ค้นพบประโยชน์ของสถาปัตยกรรมสะอาดของ Android

เผยแพร่แล้ว: 2022-03-11

คุณต้องการอะไร: การเพิ่มคุณสมบัติใหม่ให้กับแอพที่ใช้งานได้ดีด้วยสถาปัตยกรรมที่แย่มาก หรือแก้ไขจุดบกพร่องในแอปพลิเคชัน Android ที่ออกแบบมาอย่างดี แต่มีบั๊กกี้ โดยส่วนตัวแล้วฉันจะเลือกตัวเลือกที่สองอย่างแน่นอน การเพิ่มคุณสมบัติใหม่ แม้จะเป็นแบบธรรมดา อาจเป็นเรื่องยากมากในแอป เมื่อพิจารณาถึงการพึ่งพาทั้งหมดจากทุกสิ่งในทุกชั้นเรียน ฉันจำหนึ่งในโปรเจ็กต์ Android ของฉันได้ ซึ่งผู้จัดการโปรเจ็กต์ขอให้ฉันเพิ่มฟีเจอร์เล็กๆ น้อยๆ เช่น การดาวน์โหลดข้อมูลและแสดงบนหน้าจอใหม่ เป็นแอพที่เขียนโดยเพื่อนร่วมงานคนหนึ่งของฉันซึ่งได้งานใหม่ คุณลักษณะนี้ไม่ควรใช้เวลาเกินครึ่งวันทำการ ฉันมองโลกในแง่ดีมาก…

หลังจากเจ็ดชั่วโมงของการตรวจสอบวิธีการทำงานของแอป มีโมดูลใดบ้าง และสื่อสารกันอย่างไร ฉันได้ทดลองใช้งานคุณลักษณะนี้ มันเป็นนรก การเปลี่ยนแปลงเล็กน้อยในโมเดลข้อมูลบังคับให้มีการเปลี่ยนแปลงครั้งใหญ่ในหน้าจอการเข้าสู่ระบบ การเพิ่มคำขอเครือข่ายจำเป็นต้องเปลี่ยนแปลงการใช้งานหน้าจอเกือบทั้งหมดและคลาส GodOnlyKnowsWhatThisClassDoes การเปลี่ยนสีปุ่มทำให้เกิดพฤติกรรมแปลก ๆ เมื่อบันทึกข้อมูลลงในฐานข้อมูลหรือแอปขัดข้องทั้งหมด ผ่านไปครึ่งทางของวันถัดไป ฉันบอกผู้จัดการโครงการของฉันว่า "เรามีสองวิธีในการปรับใช้คุณลักษณะนี้ อย่างแรก ฉันสามารถใช้เวลาอีกสามวันกับมัน และในที่สุดก็จะใช้งานมันในทางที่สกปรก และเวลาการใช้งานของทุกฟีเจอร์หรือการแก้ไขข้อบกพร่องถัดไปจะเพิ่มขึ้นแบบทวีคูณ หรือจะเขียนแอปใหม่ก็ได้ ฉันจะใช้เวลาสองหรือสามสัปดาห์ แต่เราจะประหยัดเวลาสำหรับการเปลี่ยนแปลงแอปในอนาคต”” โชคดีที่เขายอมรับตัวเลือกที่สอง หากฉันเคยสงสัยว่าเหตุใดสถาปัตยกรรมซอฟต์แวร์ที่ดีในแอป (แม้แต่แอปที่เล็กมาก) จึงมีความสำคัญ แอปนี้จึงขจัดสิ่งเหล่านี้โดยสิ้นเชิง แต่เราควรใช้รูปแบบสถาปัตยกรรม Android แบบใดเพื่อหลีกเลี่ยงปัญหาดังกล่าว

ในบทความนี้ ฉันต้องการแสดงตัวอย่างสถาปัตยกรรมที่สะอาดในแอป Android อย่างไรก็ตาม แนวคิดหลักของรูปแบบนี้สามารถปรับให้เข้ากับทุกแพลตฟอร์มและทุกภาษา สถาปัตยกรรมที่ดีควรเป็นอิสระจากรายละเอียด เช่น แพลตฟอร์ม ภาษา ระบบฐานข้อมูล อินพุต หรือเอาต์พุต

แอพตัวอย่าง

เราจะสร้างแอพ Android อย่างง่ายเพื่อลงทะเบียนตำแหน่งของเราด้วยคุณสมบัติดังต่อไปนี้:

  • ผู้ใช้สามารถสร้างบัญชีด้วยชื่อ
  • ผู้ใช้สามารถแก้ไขชื่อบัญชี
  • ผู้ใช้สามารถลบบัญชี
  • ผู้ใช้สามารถเลือกบัญชีที่ใช้งานอยู่
  • ผู้ใช้สามารถบันทึกตำแหน่ง
  • ผู้ใช้สามารถดูรายการสถานที่สำหรับผู้ใช้
  • ผู้ใช้สามารถดูรายชื่อผู้ใช้

สถาปัตยกรรมสะอาด

เลเยอร์เป็นแกนหลักของสถาปัตยกรรมที่สะอาด ในแอปของเรา เราจะใช้สามเลเยอร์: การนำเสนอ โดเมน และโมเดล แต่ละชั้นควรแยกออกจากกันและไม่จำเป็นต้องรู้ชั้นอื่นๆ ควรมีอยู่ในโลกของตัวเองและอย่างน้อยที่สุดก็ใช้อินเทอร์เฟซขนาดเล็กร่วมกันในการสื่อสาร

ความรับผิดชอบของเลเยอร์:

  • โดเมน: มีกฎเกณฑ์ทางธุรกิจของแอปของเรา ควรมีกรณีการใช้งานที่สะท้อนถึงคุณสมบัติของแอพของเรา
  • การนำเสนอ: นำเสนอข้อมูลแก่ผู้ใช้และรวบรวมข้อมูลที่จำเป็น เช่น ชื่อผู้ใช้ นี่คือชนิดของอินพุต/เอาท์พุต
  • รุ่น: ให้ข้อมูลสำหรับแอปของเรา มีหน้าที่รับข้อมูลจากแหล่งภายนอกและบันทึกลงในฐานข้อมูล เซิร์ฟเวอร์คลาวด์ ฯลฯ

ชั้นใดควรรู้เกี่ยวกับชั้นอื่นๆ วิธีที่ง่ายที่สุดในการหาคำตอบคือการคิดถึงการเปลี่ยนแปลง มาดูเลเยอร์การนำเสนอกัน—เราจะนำเสนอบางสิ่งแก่ผู้ใช้ ถ้าเราเปลี่ยนแปลงบางอย่างในการนำเสนอ เราควรจะทำการเปลี่ยนแปลงในเลเยอร์โมเดลด้วยหรือไม่ ลองนึกภาพว่าเรามีหน้าจอ "ผู้ใช้" ที่มีชื่อผู้ใช้และตำแหน่งสุดท้าย หากเราต้องการนำเสนอสถานที่สองแห่งสุดท้ายของผู้ใช้แทนที่จะเป็นเพียงแห่งเดียว โมเดลของเราไม่ควรได้รับผลกระทบ ดังนั้นเราจึงมีหลักการแรก: เลเยอร์การนำเสนอไม่รู้เกี่ยวกับเลเยอร์โมเดล

และสิ่งที่ตรงกันข้าม—เลเยอร์โมเดลควรรู้เกี่ยวกับเลเยอร์การนำเสนอหรือไม่ อีกครั้ง—ไม่ เพราะหากเราเปลี่ยน เช่น แหล่งที่มาของข้อมูลจากฐานข้อมูลเป็นเครือข่าย ไม่ควรเปลี่ยนแปลงสิ่งใดใน UI (ถ้าคุณคิดจะเพิ่มตัวโหลดที่นี่—ใช่ แต่เราอาจมีตัวโหลด UI ได้เช่นกัน เมื่อใช้ฐานข้อมูล) ดังนั้นทั้งสองชั้นจึงแยกออกจากกันโดยสิ้นเชิง ยอดเยี่ยม!

แล้วชั้นโดเมนล่ะ? เป็นสิ่งสำคัญที่สุดเพราะมีตรรกะทางธุรกิจหลักทั้งหมด นี่คือที่ที่เราต้องการประมวลผลข้อมูลของเราก่อนที่จะส่งต่อไปยังชั้นแบบจำลองหรือนำเสนอต่อผู้ใช้ มันควรจะเป็นอิสระจากเลเยอร์อื่น—มันไม่รู้อะไรเลยเกี่ยวกับฐานข้อมูล เครือข่าย หรือส่วนต่อประสานผู้ใช้ เนื่องจากนี่คือแกนหลัก เลเยอร์อื่นๆ จะสื่อสารกับเลเยอร์นี้เท่านั้น ทำไมเราต้องการให้สิ่งนี้เป็นอิสระอย่างสมบูรณ์? กฎเกณฑ์ทางธุรกิจอาจจะเปลี่ยนแปลงน้อยกว่าการออกแบบ UI หรือบางอย่างในฐานข้อมูลหรือที่เก็บข้อมูลเครือข่าย เราจะสื่อสารกับเลเยอร์นี้ผ่านอินเทอร์เฟซที่ให้มา ไม่ใช้แบบจำลองที่เป็นรูปธรรมหรือการใช้งาน UI นี่คือรายละเอียด และอย่าลืม—รายละเอียดจะเปลี่ยนไป สถาปัตยกรรมที่ดีไม่ได้ผูกมัดกับรายละเอียด

ทฤษฎีเพียงพอสำหรับตอนนี้ มาเริ่มการเข้ารหัสกันเถอะ! บทความนี้เกี่ยวกับโค้ด ดังนั้น เพื่อความเข้าใจที่ดีขึ้น คุณควรดาวน์โหลดโค้ดจาก GitHub และตรวจสอบว่ามีอะไรอยู่ข้างใน มีการสร้างแท็ก Git สามแท็ก—architecture_v1, architecture_v2 และ architecture_v3 ซึ่งสอดคล้องกับส่วนต่าง ๆ ของบทความ

เทคโนโลยีแอพ

ในแอป ฉันใช้ Kotlin และ Dagger 2 สำหรับการฉีดพึ่งพา ไม่จำเป็นต้องใช้ Kotlin และ Dagger 2 ที่นี่ แต่มันทำให้สิ่งต่างๆ ง่ายขึ้นมาก คุณอาจแปลกใจที่ฉันไม่ได้ใช้ RxJava (หรือ RxKotlin) แต่ฉันไม่พบว่ามันใช้งานได้ที่นี่ และฉันไม่ชอบใช้ห้องสมุดใด ๆ เพียงเพราะมันอยู่ด้านบนสุดและมีคนบอกว่ามันเป็นสิ่งจำเป็น อย่างที่ฉันพูด ภาษาและห้องสมุดคือรายละเอียด คุณจึงใช้สิ่งที่คุณต้องการได้ ไลบรารีทดสอบหน่วย Android บางตัวก็ใช้เช่นกัน: JUnit, Robolectric และ Mockito

โดเมน

เลเยอร์ที่สำคัญที่สุดในการออกแบบสถาปัตยกรรมแอปพลิเคชัน Android ของเราคือเลเยอร์โดเมน เริ่มกันเลย นี่คือที่ที่ตรรกะทางธุรกิจของเราและส่วนต่อประสานเพื่อสื่อสารกับเลเยอร์อื่นจะเป็น แกนหลักคือ UseCase ซึ่งสะท้อนถึงสิ่งที่ผู้ใช้สามารถทำได้กับแอปของเรา มาเตรียมนามธรรมสำหรับพวกเขา:

 abstract class UseCase<out Type, in Params> { private var job: Deferred<OneOf<Failure, Type>>? = null abstract suspend fun run(params: Params): OneOf<Failure, Type> fun execute(params: Params, onResult: (OneOf<Failure, Type>) -> Unit) { job?.cancel() job = async(CommonPool) { run(params) } launch(UI) { val result = job!!.await() onResult(result) } } open fun cancel() { job?.cancel() } open class NoParams }

ฉันตัดสินใจใช้คอรูทีนของ Kotlin ที่นี่ UseCase แต่ละอันต้องใช้วิธีการรันเพื่อให้ข้อมูล วิธีการนี้ถูกเรียกบนเธรดพื้นหลัง และหลังจากได้รับผลลัพธ์ จะถูกส่งบนเธรด UI ประเภทที่ส่งคืนคือ OneOf<F, T> —เราสามารถส่งคืนข้อผิดพลาดหรือความสำเร็จด้วยข้อมูล:

 sealed class OneOf<out E, out S> { data class Error<out E>(val error: E) : OneOf<E, Nothing>() data class Success<out S>(val data: S) : OneOf<Nothing, S>() val isSuccess get() = this is Success<S> val isError get() = this is Error<E> fun <E> error(error: E) = Error(error) fun <S> success(data: S) = Success(data) fun oneOf(onError: (E) -> Any, onSuccess: (S) -> Any): Any = when (this) { is Error -> onError(error) is Success -> onSuccess(data) } }

เลเยอร์ของโดเมนต้องการเอนทิตีของตัวเอง ดังนั้นขั้นตอนต่อไปคือการกำหนดพวกมัน ขณะนี้มีสองหน่วยงาน: User และ UserLocation :

 data class User(var id: Int? = null, val name: String, var isActive: Boolean = false) data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int)

ตอนนี้เราทราบข้อมูลที่จะส่งคืนแล้ว เราต้องประกาศอินเทอร์เฟซของผู้ให้บริการข้อมูลของเรา สิ่งเหล่านี้จะเป็น IUsersRepository และ ILocationsRepository จะต้องดำเนินการในเลเยอร์โมเดล:

 interface IUsersRepository { fun setActiveUser(userId: Int): OneOf<Failure, User> fun getActiveUser(): OneOf<Failure, User?> fun createUser(user: User): OneOf<Failure, User> fun removeUser(userId: Int): OneOf<Failure, User?> fun editUser(user: User): OneOf<Failure, User> fun users(): OneOf<Failure, List<User>> } interface ILocationsRepository { fun locations(userId: Int): OneOf<Failure, List<UserLocation>> fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> }

ชุดการดำเนินการนี้ควรเพียงพอที่จะให้ข้อมูลที่จำเป็นสำหรับแอป ในขั้นตอนนี้ เราไม่ได้ตัดสินใจว่าจะจัดเก็บข้อมูลอย่างไร นี่คือรายละเอียดที่เราต้องการให้เป็นอิสระจากกัน สำหรับตอนนี้ เลเยอร์โดเมนของเราไม่รู้ด้วยซ้ำว่าอยู่บน Android เราจะพยายามรักษาสถานะนี้ไว้ (เช่น ฉันจะอธิบายในภายหลัง)

ขั้นตอนสุดท้าย (หรือเกือบสุดท้าย) คือการกำหนดการใช้งานสำหรับ UseCase ของเรา ซึ่งจะถูกใช้โดยข้อมูลการนำเสนอ ทั้งหมดนั้นเรียบง่ายมาก (เช่นเดียวกับแอพและข้อมูลของเรา)—การดำเนินการจะถูกจำกัดให้เรียกวิธีการที่เหมาะสมจากที่เก็บ เช่น:

 class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<List<UserLocation>, UserIdParams>() { override suspend fun run(params: UserIdParams): OneOf<Failure, List<UserLocation>> = repository.locations(params.userId) }

สิ่งที่เป็นนามธรรมของ Repository ทำให้ UseCases ของเราทดสอบได้ง่ายมาก—เราไม่จำเป็นต้องสนใจเกี่ยวกับเครือข่ายหรือฐานข้อมูล มันสามารถเยาะเย้ยได้ไม่ว่าด้วยวิธีใด ดังนั้นการทดสอบหน่วยของเราจะทดสอบกรณีการใช้งานจริง ไม่ใช่คลาสอื่นๆ ที่ไม่เกี่ยวข้อง ซึ่งจะทำให้การทดสอบหน่วยของเราง่ายและรวดเร็ว:

 @RunWith(MockitoJUnitRunner::class) class GetLocationsTests { private lateinit var getLocations: GetLocations private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1)) @Mock private lateinit var locationsRepository: ILocationsRepository @Before fun setUp() { getLocations = GetLocations(locationsRepository) } @Test fun `should call getLocations locations`() { runBlocking { getLocations.run(UserIdParams(1)) } verify(locationsRepository, times(1)).locations(1) } @Test fun `should return locations obtained from locationsRepository`() { given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations)) val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) } returnedLocations shouldEqual OneOf.Success(locations) } }

สำหรับตอนนี้ เลเยอร์โดเมนเสร็จสิ้นแล้ว

แบบอย่าง

ในฐานะนักพัฒนา Android คุณอาจเลือก Room ซึ่งเป็นไลบรารี Android ใหม่สำหรับจัดเก็บข้อมูล แต่ลองนึกภาพว่าผู้จัดการโครงการถามว่าคุณสามารถชะลอการตัดสินใจเกี่ยวกับฐานข้อมูลได้หรือไม่ เพราะฝ่ายบริหารกำลังพยายามตัดสินใจระหว่าง Room, Realm และไลบรารีจัดเก็บข้อมูลใหม่ที่รวดเร็วเป็นพิเศษ เราต้องการข้อมูลบางอย่างเพื่อเริ่มทำงานกับ UI ดังนั้นตอนนี้เราจะเก็บไว้ในหน่วยความจำ:

 class MemoryLocationsRepository @Inject constructor(): ILocationsRepository { private val locations = mutableListOf<UserLocation>() override fun locations(userId: Int): OneOf<Failure, List<UserLocation>> = OneOf.Success(locations.filter { it.userId == userId }) override fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> { val addedLocation = location.copy(id = locations.size + 1) locations.add(addedLocation) return OneOf.Success(addedLocation) } }

การนำเสนอ

เมื่อสองปีที่แล้ว ฉันเขียนบทความเกี่ยวกับ MVP ว่าเป็นโครงสร้างแอปที่ดีมากสำหรับ Android เมื่อ Google ประกาศส่วนประกอบสถาปัตยกรรมที่ยอดเยี่ยม ซึ่งทำให้การพัฒนาแอปพลิเคชัน Android ง่ายขึ้นมาก ไม่จำเป็นต้องใช้ MVP และ MVVM สามารถแทนที่ได้ อย่างไรก็ตาม แนวคิดบางอย่างจากรูปแบบนี้ยังคงมีประโยชน์มาก เช่น แนวคิดเกี่ยวกับมุมมองที่โง่เขลา พวกเขาควรสนใจเกี่ยวกับการแสดงข้อมูลเท่านั้น เพื่อให้บรรลุสิ่งนี้ เราจะใช้ ViewModel และ LiveData

การออกแบบแอพของเรานั้นง่ายมาก—หนึ่งกิจกรรมที่มีการนำทางด้านล่าง โดยที่รายการเมนูสองรายการจะแสดงส่วนย่อย locations หรือส่วน users ในมุมมองเหล่านี้ เราใช้ ViewModels ซึ่งใช้ UseCase จากเลเยอร์โดเมน ทำให้การสื่อสารเป็นไปอย่างเรียบร้อยและเรียบง่าย ตัวอย่างเช่น นี่คือ LocationsViewModel :

 class LocationsViewModel @Inject constructor(private val getLocations: GetLocations, private val saveLocation: SaveLocation) : BaseViewModel() { var locations = MutableLiveData<List<UserLocation>>() fun loadLocations(userId: Int) { getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) } } fun saveLocation(location: UserLocation, onSaved: (UserLocation) -> Unit) { saveLocation.execute(UserLocationParams(location)) { it.oneOf(::handleError) { location -> handleLocationSave(location, onSaved) } } } private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) -> Unit) { val currentLocations = locations.value?.toMutableList() ?: mutableListOf() currentLocations.add(location) this.locations.value = currentLocations onSaved(location) } private fun handleLocationsChange(locations: List<UserLocation>) { this.locations.value = locations } }

คำอธิบายเล็กน้อยสำหรับผู้ที่ไม่คุ้นเคยกับ ViewModels—ข้อมูลของเราถูกจัดเก็บไว้ในตัวแปรตำแหน่ง เมื่อเราได้รับข้อมูลจากกรณีการใช้งาน getLocations ข้อมูลเหล่านั้นจะถูกส่งไปยังค่า LiveData การเปลี่ยนแปลงนี้จะแจ้งให้ผู้สังเกตการณ์ทราบเพื่อให้สามารถโต้ตอบและอัปเดตข้อมูลได้ เราเพิ่มผู้สังเกตการณ์สำหรับข้อมูลเป็นส่วนย่อย:

 class LocationsFragment : BaseFragment() { ... private fun initLocationsViewModel() { locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java] locationsViewModel.locations.observe(this, Observer<List<UserLocation>> { showLocations(it ?: emptyList()) }) locationsViewModel.error.observe(this, Observer<Failure> { handleError(it) }) } private fun showLocations(locations: List<UserLocation>) { locationsAdapter.locations = locations } private fun handleError(error: Failure?) { toast(R.string.user_fetch_error).show() } }

ทุกครั้งที่มีการเปลี่ยนแปลงสถานที่ เราเพียงแค่ส่งข้อมูลใหม่ไปยังอแด็ปเตอร์ที่กำหนดให้กับมุมมองผู้รีไซเคิล—และนั่นคือสิ่งที่ Android ไหลปกติสำหรับการแสดงข้อมูลในมุมมองรีไซเคิลไป

เนื่องจากเราใช้ ViewModel ในมุมมองของเรา พฤติกรรมของพวกมันจึงง่ายต่อการทดสอบ—เราสามารถล้อเลียน ViewModels และไม่สนใจแหล่งข้อมูล เครือข่าย หรือปัจจัยอื่นๆ:

 @RunWith(RobolectricTestRunner::class) @Config(application = TestRegistryRobolectricApplication::class) class LocationsFragmentTests { private var usersViewModel = mock(UsersViewModel::class.java) private var locationsViewModel = mock(LocationsViewModel::class.java) lateinit var fragment: LocationsFragment @Before fun setUp() { UsersViewModelMock.intializeMock(usersViewModel) LocationsViewModelMock.intializeMock(locationsViewModel) fragment = LocationsFragment() fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel) startFragment(fragment) } @Test fun `should getActiveUser on start`() { Mockito.verify(usersViewModel).getActiveUser() } @Test fun `should load locations from active user`() { usersViewModel.activeUserId.value = 1 Mockito.verify(locationsViewModel).loadLocations(1) } @Test fun `should display locations`() { val date = Date(1362919080000)//10-03-2013 13:38 locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1)) val recyclerView = fragment.find<RecyclerView>(R.id.locationsRecyclerView) recyclerView.measure(100, 100) recyclerView.layout(0,0, 100, 100) val adapter = recyclerView.adapter as LocationsListAdapter adapter.itemCount `should be` 1 val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder viewHolder.latitude.text `should equal` "Lat: 1.0" viewHolder.longitude.text `should equal` "Lng: 2.0" viewHolder.locationDate.text `should equal` "10-03-2013 13:38" } }

คุณอาจสังเกตเห็นว่าเลเยอร์การนำเสนอถูกแยกออกเป็นเลเยอร์ที่เล็กกว่าด้วยเส้นขอบที่ชัดเจน มุมมองเช่น activities , fragments , ViewHolders ฯลฯ มีหน้าที่แสดงข้อมูลเท่านั้น พวกเขาทราบเพียงเกี่ยวกับเลเยอร์ ViewModel และใช้ข้อมูลนั้นเพื่อรับหรือส่งผู้ใช้และสถานที่เท่านั้น เป็น ViewModel ที่สื่อสารกับโดเมน การใช้งาน ViewModel นั้นเหมือนกันสำหรับมุมมองเนื่องจาก UseCases มีไว้สำหรับโดเมน ในการถอดความ สถาปัตยกรรมที่สะอาดเหมือนหัวหอม—มีชั้น และชั้นสามารถมีชั้นได้เช่นกัน

การฉีดพึ่งพา

เราได้สร้างคลาสทั้งหมดสำหรับสถาปัตยกรรมของเราแล้ว แต่มีอีกสิ่งหนึ่งที่ต้องทำ—เราต้องการบางสิ่งที่เชื่อมโยงทุกอย่างเข้าด้วยกัน เลเยอร์การนำเสนอ โดเมน และโมเดลนั้นสะอาดอยู่เสมอ แต่เราต้องการโมดูลหนึ่งโมดูลซึ่งจะเป็นโมดูลที่สกปรกและจะรู้ทุกอย่างเกี่ยวกับทุกสิ่ง โดยความรู้นี้ จะสามารถเชื่อมต่อเลเยอร์ของเราได้ วิธีที่ดีที่สุดในการสร้างคือการใช้รูปแบบการออกแบบทั่วไป (หนึ่งในหลักการของโค้ดสะอาดที่กำหนดไว้ใน SOLID) นั่นคือการฉีดการพึ่งพา ซึ่งจะสร้างวัตถุที่เหมาะสมสำหรับเราและส่งไปยังการขึ้นต่อกันที่ต้องการ ฉันใช้ Dagger 2 ที่นี่ (ในช่วงกลางของโปรเจ็กต์ ฉันเปลี่ยนเวอร์ชันเป็น 2.16 ซึ่งมีต้นแบบน้อยกว่า) แต่คุณสามารถใช้กลไกใดก็ได้ที่คุณชอบ เมื่อเร็ว ๆ นี้ฉันเล่นกับห้องสมุด Koin เล็กน้อยและฉันคิดว่ามันก็คุ้มค่าที่จะลอง ฉันต้องการใช้ที่นี่ แต่ฉันมีปัญหามากมายกับการล้อเลียน ViewModels เมื่อทำการทดสอบ ฉันหวังว่าฉันจะพบวิธีแก้ปัญหาอย่างรวดเร็ว—ถ้าเป็นเช่นนั้น ฉันสามารถนำเสนอข้อแตกต่างสำหรับแอปนี้เมื่อใช้ Koin และ Dagger 2

คุณสามารถตรวจสอบแอปสำหรับขั้นตอนนี้บน GitHub ด้วยแท็ก architecture_v1.

การเปลี่ยนแปลง

เราสร้างเลเยอร์เสร็จแล้ว ทดสอบแอป—ทุกอย่างใช้งานได้! ยกเว้นสิ่งหนึ่ง—เรายังจำเป็นต้องรู้ว่าฐานข้อมูลใดที่ PM ของเราต้องการใช้ สมมติว่าพวกเขามาหาคุณและบอกว่าฝ่ายบริหารตกลงที่จะใช้ Room แต่พวกเขายังต้องการใช้ห้องสมุดใหม่ล่าสุดที่เร็วสุด ๆ ในอนาคต ดังนั้นคุณต้องคำนึงถึงการเปลี่ยนแปลงที่อาจเกิดขึ้น นอกจากนี้ หนึ่งในผู้มีส่วนได้ส่วนเสียได้สอบถามว่าข้อมูลสามารถเก็บไว้ในระบบคลาวด์ได้หรือไม่ และต้องการทราบต้นทุนของการเปลี่ยนแปลงดังกล่าว ดังนั้น นี่คือเวลาที่จะตรวจสอบว่าสถาปัตยกรรมของเราดีหรือไม่ และถ้าเราสามารถเปลี่ยนแปลงระบบจัดเก็บข้อมูลโดยไม่มีการเปลี่ยนแปลงใดๆ ในการนำเสนอหรือเลเยอร์โดเมน

เปลี่ยน 1

สิ่งแรกที่เมื่อใช้ Room คือการกำหนดเอนทิตีสำหรับฐานข้อมูล เรามีอยู่แล้วบางส่วน: User และ UserLocation สิ่งที่เราต้องทำคือเพิ่มคำอธิบายประกอบเช่น @Entity และ @PrimaryKey จากนั้นเราสามารถใช้ในเลเยอร์โมเดลของเรากับฐานข้อมูลได้ ยอดเยี่ยม! นี่เป็นวิธีที่ยอดเยี่ยมในการทำลายกฎสถาปัตยกรรมทั้งหมดที่เราต้องการจะรักษา ที่จริงแล้ว เอนทิตีโดเมนไม่สามารถแปลงเป็นเอนทิตีฐานข้อมูลด้วยวิธีนี้ ลองนึกภาพว่าเราต้องการดาวน์โหลดข้อมูลจากเครือข่ายด้วย เราสามารถใช้คลาสเพิ่มเติมเพื่อจัดการกับการตอบสนองของเครือข่าย—แปลงเอนทิตีอย่างง่ายของเราเพื่อให้ทำงานกับฐานข้อมูลและเครือข่าย นั่นคือเส้นทางที่สั้นที่สุดสู่หายนะในอนาคต (และร้องไห้ "ใครเป็นคนเขียนโค้ดนี้" เราต้องการเอนทิตีแยกประเภทสำหรับการจัดเก็บข้อมูลทุกประเภทที่เราใช้ ค่าใช้จ่ายไม่มากนัก เรามากำหนดเอนทิตีของห้องให้ถูกต้องกันเถอะ:

 @Entity data class UserEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "isActive") var isActive: Boolean = false ) @Entity(foreignKeys = [ ForeignKey(entity = UserEntity::class, parentColumns = [ "id" ], childColumns = [ "userId" ], onDelete = CASCADE) ]) data class UserLocationEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "latitude") var latitude: Double, @ColumnInfo(name = "longitude") var longitude: Double, @ColumnInfo(name = "time") var time: Long, @ColumnInfo(name = "userId") var userId: Long )

อย่างที่คุณเห็น พวกมันเกือบจะเหมือนกับเอนทิตีโดเมน ดังนั้นจึงมีสิ่งล่อใจที่จะรวมเข้าด้วยกัน นี่เป็นเพียงอุบัติเหตุ—ด้วยข้อมูลที่ซับซ้อนมากขึ้น ความคล้ายคลึงจะน้อยลง

ต่อไป เราต้องใช้ UserDAO และ UserLocationsDAO , AppDatabase ของเรา และสุดท้ายคือการใช้งานสำหรับ IUsersRepository และ ILocationsRepository มีปัญหาเล็กน้อยที่นี่— ILocationsRepository ควรส่งคืน UserLocation แต่ได้รับ UserLocationEntity จากฐานข้อมูล เช่นเดียวกับคลาสที่เกี่ยวข้องกับ User ในทิศทางตรงกันข้าม เราส่ง UserLocation เมื่อฐานข้อมูลต้องการ UserLocationEntity ในการแก้ไขปัญหานี้ เราจำเป็นต้องมี Mappers ระหว่างโดเมนและเอนทิตีข้อมูลของเรา ฉันใช้หนึ่งในคุณสมบัติ Kotlin ที่ฉันโปรดปราน—ส่วนขยาย ฉันสร้างไฟล์ชื่อ Mapper.kt และใส่วิธีการทั้งหมดสำหรับการแมประหว่างคลาสต่างๆ ที่นั่น (แน่นอนว่าอยู่ในเลเยอร์โมเดล—โดเมนไม่ต้องการ):

 fun User.toEntity() = UserEntity(id?.toLong(), name, isActive) fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive) fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong()) fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt())

คำโกหกเล็กๆ น้อยๆ ที่ฉันพูดถึงก่อนหน้านี้เป็นเรื่องเกี่ยวกับเอนทิตีของโดเมน ฉันเขียนว่าพวกเขาไม่รู้อะไรเกี่ยวกับ Android แต่นี่ไม่เป็นความจริงทั้งหมด ฉันเพิ่มคำอธิบายประกอบ @Parcelize ให้กับเอนทิตี User และขยาย Parcelable ที่นั่น ทำให้สามารถส่งเอนทิตีไปยังส่วนย่อยได้ สำหรับโครงสร้างที่ซับซ้อนมากขึ้น เราควรจัดเตรียมคลาสข้อมูลของเลเยอร์การดู และสร้าง mapper เช่น ระหว่างโดเมนและโมเดลข้อมูล การเพิ่ม Parcelable ให้กับเอนทิตีโดเมนถือเป็นความเสี่ยงเล็กน้อยที่ฉันกล้ารับ ฉันทราบดี และในกรณีที่มีการเปลี่ยนแปลงเอนทิตี User ฉันจะสร้างคลาสข้อมูลแยกต่างหากสำหรับการนำเสนอและลบ Parcelable ออกจากเลเยอร์โดเมน

สิ่งสุดท้ายที่ต้องทำคือเปลี่ยนโมดูลการฉีดพึ่งพาของเราเพื่อให้การใช้งาน Repository ที่สร้างขึ้นใหม่แทน MemoryRepository ก่อนหน้า หลังจากที่เราสร้างและเรียกใช้แอปแล้ว เราสามารถไปที่ PM เพื่อแสดงแอปที่ใช้งานได้ด้วยฐานข้อมูลของห้อง นอกจากนี้เรายังสามารถแจ้ง PM ว่าการเพิ่มเครือข่ายจะไม่ใช้เวลามากเกินไป และเราเปิดให้ใช้ไลบรารีพื้นที่เก็บข้อมูลที่ฝ่ายจัดการต้องการ คุณสามารถตรวจสอบว่าไฟล์ใดบ้างที่มีการเปลี่ยนแปลง—เฉพาะไฟล์ในเลเยอร์โมเดล สถาปัตยกรรมของเราเรียบร้อยมาก! สามารถสร้างสตอเรจประเภทถัดไปได้ในลักษณะเดียวกัน เพียงแค่ขยายที่เก็บข้อมูลของเราและจัดเตรียมการใช้งานที่เหมาะสม แน่นอน อาจกลายเป็นว่าเราต้องการแหล่งข้อมูลหลายแหล่ง เช่น ฐานข้อมูลและเครือข่าย แล้วไง? ไม่มีอะไรเกิดขึ้น เราแค่ต้องสร้างการใช้งานพื้นที่เก็บข้อมูลสามรายการ—หนึ่งรายการสำหรับเครือข่าย หนึ่งรายการสำหรับฐานข้อมูล และอีกรายการหนึ่งสำหรับฐานข้อมูล และอีกรายการหนึ่งสำหรับแหล่งข้อมูลหลัก ซึ่งจะเลือกแหล่งข้อมูลที่ถูกต้อง (เช่น หากเรามีเครือข่าย ให้โหลดจาก เครือข่าย และถ้าไม่ใช่ ให้โหลดจากฐานข้อมูล)

คุณสามารถตรวจสอบแอปสำหรับขั้นตอนนี้บน GitHub ด้วยแท็ก architecture_v2

ใกล้จะหมดวันแล้ว—คุณกำลังนั่งดื่มกาแฟอยู่หน้าคอมพิวเตอร์ แอปก็พร้อมส่งไปที่ Google Play แล้วจู่ๆ ผู้จัดการโครงการก็มาหาคุณและถามว่า “คุณเพิ่มฟีเจอร์ที่ สามารถบันทึกตำแหน่งปัจจุบันของผู้ใช้จาก GPS ได้หรือไม่”

เปลี่ยน2

ทุกอย่างเปลี่ยนไป...โดยเฉพาะซอฟต์แวร์ นี่คือเหตุผลที่เราต้องการโค้ดที่สะอาดและสถาปัตยกรรมที่สะอาด อย่างไรก็ตาม แม้แต่สิ่งที่สะอาดที่สุดก็อาจสกปรกได้หากเราเขียนโค้ดโดยไม่คิด ความคิดแรกในการดำเนินการรับตำแหน่งจาก GPS คือการเพิ่มรหัสระบุตำแหน่งทั้งหมดในกิจกรรม เรียกใช้ใน SaveLocationDialogFragment ของเรา และสร้าง UserLocation ใหม่พร้อมข้อมูลที่เกี่ยวข้อง นี่อาจเป็นวิธีที่เร็วที่สุด แต่ถ้า PM ที่บ้าคลั่งของเรามาหาเราและขอให้เราเปลี่ยนการรับตำแหน่งจาก GPS เป็นผู้ให้บริการรายอื่น (เช่นบางอย่างเช่น Bluetooth หรือเครือข่าย) การเปลี่ยนแปลงจะเกิดขึ้นในไม่ช้า เราจะทำอย่างสะอาดได้อย่างไร?

ตำแหน่งของผู้ใช้คือข้อมูล และการได้ตำแหน่งเป็น UseCase — ดังนั้นฉันคิดว่าโดเมนและเลเยอร์โมเดลของเราควรมีส่วนร่วมด้วย ดังนั้นเราจึงมี UseCase อีกหนึ่งรายการที่จะนำไปใช้— GetCurrentLocation เราต้องการบางสิ่งที่จะให้ตำแหน่งแก่เรา—อินเทอร์เฟซ ILocationProvider เพื่อทำให้ UseCase เป็นอิสระจากรายละเอียด เช่น เซ็นเซอร์ GPS:

 interface ILocationProvider { fun getLocation(): OneOf<Failure, SimpleLocation> fun cancel() } class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<SimpleLocation, UseCase.NoParams>() { override suspend fun run(params: NoParams): OneOf<Failure, SimpleLocation> = locationProvider.getLocation() override fun cancel() { super.cancel() locationProvider.cancel() } }

คุณจะเห็นว่าเรามีวิธีเพิ่มเติมหนึ่งวิธีที่นี่—ยกเลิก เนื่องจากเราต้องการวิธียกเลิกการอัปเดตตำแหน่ง GPS การใช้งาน Provider ของเรา ซึ่งกำหนดไว้ในเลเยอร์โมเดล มีดังต่อไปนี้:

 class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider { private var locationManager: LocationManager? = null private var locationListener: GPSLocationListener? = null override fun getLocation(): OneOf<Failure, SimpleLocation> = runBlocking { val grantedResult = getLocationPermissions() if (grantedResult.isError) { val error = (grantedResult as OneOf.Error<Failure>).error OneOf.Error(error) } else { getLocationFromGPS() } } private suspend fun getLocationPermissions(): OneOf<Failure, Boolean> = suspendCoroutine { Dexter.withActivity(activity) .withPermission(Manifest.permission.ACCESS_FINE_LOCATION) .withListener(PermissionsCallback(it)) .check() } private suspend fun getLocationFromGPS(): OneOf<Failure, SimpleLocation> = suspendCoroutine { locationListener?.unsubscribe() locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager?.let { manager -> locationListener = GPSLocationListener(manager, it) launch(UI) { manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener) } } } override fun cancel() { locationListener?.unsubscribe() locationListener = null locationManager = null } }

ผู้ให้บริการรายนี้พร้อมที่จะทำงานกับ Kotlin coroutines หากคุณจำได้ วิธีการรันของ UseCase จะถูกเรียกใช้บนเธรดพื้นหลัง—ดังนั้นเราจึงต้องตรวจสอบให้แน่ใจและทำเครื่องหมายเธรดของเราอย่างถูกต้อง อย่างที่คุณเห็น เราต้องผ่านกิจกรรมที่นี่ เป็นสิ่งสำคัญอย่างยิ่งที่จะยกเลิกการอัปเดตและยกเลิกการลงทะเบียนจากผู้ฟัง เมื่อเราไม่ต้องการมันอีกต่อไปเพื่อหลีกเลี่ยงการรั่วไหลของหน่วยความจำ เนื่องจากมันใช้ ILocationProvider เราจึงสามารถแก้ไขได้อย่างง่ายดายในอนาคตสำหรับผู้ให้บริการรายอื่น นอกจากนี้เรายังสามารถทดสอบการจัดการตำแหน่งปัจจุบันได้อย่างง่ายดาย (โดยอัตโนมัติหรือด้วยตนเอง) แม้จะไม่ได้เปิดใช้งาน GPS ในโทรศัพท์ของเรา สิ่งที่เราต้องทำคือเปลี่ยนการใช้งานเพื่อส่งคืนตำแหน่งที่สร้างขึ้นแบบสุ่ม เพื่อให้ใช้งานได้ เราต้องเพิ่ม UseCase ที่สร้างขึ้นใหม่ไปยัง LocationsViewModel ในทางกลับกัน ViewModel จะต้องมีวิธีการใหม่ getCurrentLocation ซึ่งจะเรียกใช้กรณีการใช้งานจริง ด้วยการเปลี่ยนแปลง UI เพียงเล็กน้อยเพื่อเรียกใช้และลงทะเบียน GPSProvider ใน Dagger—และ voila แอปของเราก็เสร็จเรียบร้อย!

สรุป

ฉันพยายามแสดงให้คุณเห็นว่าเราสามารถพัฒนาแอป Android ที่ดูแลรักษา ทดสอบ และเปลี่ยนแปลงได้ง่ายเพียงใด ควรเข้าใจง่ายด้วย—หากมีคนใหม่มาทำงานของคุณ พวกเขาไม่ควรมีปัญหาในการทำความเข้าใจกระแสข้อมูลหรือโครงสร้าง หากพวกเขาทราบว่าสถาปัตยกรรมนั้นสะอาด พวกเขาสามารถมั่นใจได้ว่าการเปลี่ยนแปลงใน UI จะไม่ส่งผลกระทบใดๆ ในโมเดล และการเพิ่มคุณสมบัติใหม่จะใช้เวลาไม่เกินที่คาดการณ์ไว้ แต่นี่ไม่ใช่จุดสิ้นสุดของการเดินทาง แม้ว่าเราจะมีแอปที่มีโครงสร้างสวยงาม แต่ก็สามารถทำลายมันได้โดยง่ายด้วยการเปลี่ยนแปลงโค้ดที่ยุ่งเหยิง "เพียงครู่เดียวเท่านั้น แค่ใช้งานได้" จำไว้ว่าไม่มีรหัส "แค่ตอนนี้" แต่ละรหัสที่ฝ่าฝืนกฎของเราสามารถคงอยู่ในฐานรหัสและอาจเป็นที่มาของตัวแบ่งอนาคตที่ใหญ่กว่า หากคุณมาที่โค้ดนั้นในสัปดาห์ต่อมา ดูเหมือนว่ามีคนใช้การพึ่งพาที่แข็งแกร่งในโค้ดนั้น และในการแก้ไข คุณจะต้องเจาะลึกส่วนอื่นๆ ของแอป สถาปัตยกรรมโค้ดที่ดีไม่เพียงแต่เป็นความท้าทายในช่วงเริ่มต้นของโปรเจ็กต์เท่านั้น แต่ยังเป็นความท้าทายสำหรับส่วนใดๆ ของอายุการใช้งานแอป Android การคิดและตรวจสอบรหัสควรคำนึงถึงทุกครั้งที่มีบางสิ่งกำลังจะเปลี่ยนแปลง ในการจำสิ่งนี้ คุณสามารถพิมพ์และแขวนไดอะแกรมสถาปัตยกรรม Android ของคุณ คุณยังสามารถบังคับความเป็นอิสระของเลเยอร์ได้เล็กน้อยโดยแยกพวกมันออกเป็นโมดูล Gradle สามโมดูล โดยที่โมดูลโดเมนจะไม่รับรู้ถึงโมดูลอื่นๆ และโมดูลการนำเสนอและโมเดลจะไม่ใช้กันและกัน แต่สิ่งนี้ไม่สามารถแทนที่การรับรู้ที่ยุ่งเหยิงในโค้ดของแอพที่จะแก้แค้นเราเมื่อเราคาดหวังน้อยที่สุด