Temukan Manfaat Arsitektur Bersih Android

Diterbitkan: 2022-03-11

Apa yang Anda pilih: menambahkan fitur baru ke aplikasi yang bekerja sangat baik dengan arsitektur yang buruk, atau memperbaiki bug di aplikasi Android yang dirancang dengan baik, tetapi bermasalah? Secara pribadi, saya pasti akan memilih opsi kedua. Menambahkan fitur baru, bahkan yang sederhana, bisa menjadi sangat melelahkan dalam aplikasi, mengingat semua dependensi dari semua yang ada di setiap kelas. Saya ingat salah satu proyek Android saya, di mana seorang manajer proyek meminta saya untuk menambahkan fitur kecil—seperti mengunduh data dan menampilkannya di layar baru. Itu adalah aplikasi yang ditulis oleh salah satu rekan saya yang menemukan pekerjaan baru. Fitur tidak boleh memakan waktu lebih dari setengah hari kerja. saya sangat optimis…

Setelah tujuh jam menyelidiki cara kerja aplikasi, modul apa yang ada, dan bagaimana mereka berkomunikasi satu sama lain, saya membuat beberapa implementasi uji coba fitur tersebut. Itu adalah neraka. Perubahan kecil pada model data memaksa perubahan besar pada layar login. Menambahkan permintaan jaringan memerlukan perubahan implementasi hampir semua layar dan kelas GodOnlyKnowsWhatThisClassDoes . Perubahan warna tombol menyebabkan perilaku aneh saat menyimpan data ke database atau aplikasi mogok total. Di tengah hari berikutnya, saya memberi tahu manajer proyek saya, “Kami memiliki dua cara untuk mengimplementasikan fitur tersebut. Pertama, saya dapat menghabiskan tiga hari lagi untuk itu dan akhirnya akan mengimplementasikannya dengan cara yang sangat kotor, dan waktu implementasi setiap fitur atau perbaikan bug berikutnya akan tumbuh secara eksponensial. Atau, saya dapat menulis ulang aplikasi. Ini akan memakan waktu dua atau tiga minggu, tetapi kami akan menghemat waktu untuk perubahan aplikasi di masa mendatang.”” Untungnya, dia menyetujui opsi kedua. Jika saya pernah memiliki keraguan mengapa arsitektur perangkat lunak yang baik dalam suatu aplikasi (bahkan yang sangat kecil) penting, aplikasi ini menghilangkannya sepenuhnya. Tetapi pola arsitektur Android mana yang harus kita gunakan untuk menghindari masalah seperti itu?

Dalam artikel ini, saya ingin menunjukkan kepada Anda contoh arsitektur bersih di aplikasi Android. Ide utama dari pola ini, bagaimanapun, dapat disesuaikan dengan setiap platform dan bahasa. Arsitektur yang baik harus independen dari detail seperti platform, bahasa, sistem database, input, atau output.

Contoh Aplikasi

Kami akan membuat aplikasi Android sederhana untuk mendaftarkan lokasi kami dengan fitur-fitur berikut:

  • Pengguna dapat membuat akun dengan nama.
  • Pengguna dapat mengedit nama akun.
  • Pengguna dapat menghapus akun.
  • Pengguna dapat memilih akun yang aktif.
  • Pengguna dapat menyimpan lokasi.
  • Pengguna dapat melihat daftar lokasi untuk pengguna.
  • Pengguna dapat melihat daftar pengguna.

Arsitektur Bersih

Lapisan adalah inti utama dari arsitektur yang bersih. Di aplikasi kami, kami akan menggunakan tiga lapisan: presentasi, domain, dan model. Setiap lapisan harus dipisahkan dan tidak perlu tahu tentang lapisan lain. Itu harus ada di dunianya sendiri dan, paling banyak, berbagi antarmuka kecil untuk berkomunikasi.

Tanggung jawab lapisan:

  • Domain: Berisi aturan bisnis aplikasi kami. Ini harus menyediakan kasus penggunaan yang mencerminkan fitur aplikasi kita.
  • Presentasi: Menyajikan data kepada pengguna dan juga mengumpulkan data yang diperlukan seperti nama pengguna. Ini adalah semacam input/output.
  • Model: Menyediakan data untuk aplikasi kita. Bertanggung jawab untuk mendapatkan data dari sumber eksternal dan menyimpannya ke database, server cloud, dll.

Lapisan mana yang harus diketahui tentang yang lain? Cara paling sederhana untuk mendapatkan jawabannya adalah dengan memikirkan perubahan. Mari kita ambil lapisan presentasi—kita akan menyajikan sesuatu kepada pengguna. Jika kita mengubah sesuatu dalam presentasi, haruskah kita juga membuat perubahan pada layer model? Bayangkan kita memiliki layar "Pengguna" dengan nama pengguna dan lokasi terakhir. Jika kami ingin menampilkan dua lokasi terakhir pengguna alih-alih hanya satu, model kami tidak akan terpengaruh. Jadi, kami memiliki prinsip pertama: Lapisan presentasi tidak tahu tentang lapisan model.

Dan sebaliknya—haruskah layer model mengetahui tentang presentation layer? Sekali lagi—tidak, karena jika kita mengubah, misalnya, sumber data dari database ke jaringan, itu tidak akan mengubah apa pun di UI (jika Anda berpikir untuk menambahkan loader di sini—ya, tetapi kita juga dapat memiliki UI loader saat menggunakan basis data). Jadi kedua lapisan itu benar-benar terpisah. Besar!

Bagaimana dengan lapisan domain? Ini adalah yang paling penting karena mengandung semua logika bisnis utama. Di sinilah kami ingin memproses data kami sebelum meneruskannya ke lapisan model atau menyajikannya kepada pengguna. Itu harus independen dari lapisan lain — itu tidak tahu apa-apa tentang database, jaringan, atau antarmuka pengguna. Karena ini adalah intinya, lapisan lain hanya akan berkomunikasi dengan yang ini. Mengapa kita ingin memiliki ini sepenuhnya independen? Aturan bisnis mungkin akan lebih jarang berubah daripada desain UI atau sesuatu di database atau penyimpanan jaringan. Kami akan berkomunikasi dengan lapisan ini melalui beberapa antarmuka yang disediakan. Itu tidak menggunakan model konkret atau implementasi UI. Ini adalah detail, dan ingat—detail berubah. Arsitektur yang baik tidak terikat pada detail.

Cukup teori untuk saat ini. Mari kita mulai coding! Artikel ini membahas tentang kode, jadi—untuk pemahaman yang lebih baik—Anda harus mengunduh kode dari GitHub dan memeriksa apa yang ada di dalamnya. Ada tiga tag Git yang dibuat—architecture_v1, architecture_v2, dan architecture_v3, yang sesuai dengan bagian artikel.

Teknologi Aplikasi

Di aplikasi, saya menggunakan Kotlin dan Dagger 2 untuk injeksi ketergantungan. Baik Kotlin maupun Dagger 2 tidak diperlukan di sini, tetapi itu membuat segalanya jauh lebih mudah. Anda mungkin terkejut bahwa saya tidak menggunakan RxJava (atau RxKotlin), tetapi saya tidak menemukannya dapat digunakan di sini, dan saya tidak suka menggunakan perpustakaan apa pun hanya karena itu ada di atas dan seseorang mengatakan itu adalah suatu keharusan. Seperti yang saya katakan—bahasa dan perpustakaan adalah detail, jadi Anda bisa menggunakan apa yang Anda inginkan. Beberapa library pengujian unit Android juga digunakan: JUnit, Robolectric, dan Mockito.

Domain

Lapisan paling penting dalam desain arsitektur aplikasi Android kami adalah lapisan domain. Mari kita mulai dengan itu. Di sinilah logika bisnis kita dan antarmuka untuk berkomunikasi dengan lapisan lain. Inti utamanya adalah UseCase s, yang mencerminkan apa yang dapat dilakukan pengguna dengan aplikasi kita. Mari kita siapkan abstraksi untuk mereka:

 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 }

Saya memutuskan untuk menggunakan coroutine Kotlin di sini. Setiap UseCase harus mengimplementasikan metode run untuk menyediakan data. Metode ini dipanggil di utas latar belakang, dan setelah hasilnya diterima, hasilnya dikirim di utas UI. Jenis yang dikembalikan adalah OneOf<F, T> —kita dapat mengembalikan kesalahan atau keberhasilan dengan data:

 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) } }

Lapisan domain membutuhkan entitasnya sendiri, jadi langkah selanjutnya adalah mendefinisikannya. Kami memiliki dua entitas untuk saat ini: User dan 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)

Sekarang kita tahu data apa yang harus dikembalikan, kita harus mendeklarasikan antarmuka penyedia data kita. Ini akan menjadi IUsersRepository dan ILocationsRepository . Mereka harus diimplementasikan di lapisan model:

 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> }

Kumpulan tindakan ini harus cukup untuk menyediakan data yang diperlukan untuk aplikasi. Pada tahap ini, kami tidak memutuskan bagaimana data akan disimpan—ini adalah detail yang ingin kami bebaskan. Untuk saat ini, lapisan domain kami bahkan tidak tahu bahwa itu ada di Android. Kami akan mencoba untuk menjaga keadaan ini (Semacam. Saya akan jelaskan nanti).

Langkah terakhir (atau hampir terakhir) adalah mendefinisikan implementasi untuk UseCase s kita, yang akan digunakan oleh data presentasi. Semuanya sangat sederhana (seperti aplikasi dan data kami sederhana)—operasinya terbatas untuk memanggil metode yang tepat dari repositori, misalnya:

 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) }

Abstraksi Repository membuat UseCases kami sangat mudah untuk diuji—kami tidak perlu peduli dengan jaringan atau database. Itu dapat diejek dengan cara apa pun, jadi pengujian unit kami akan menguji kasus penggunaan aktual dan bukan kelas lain yang tidak terkait. Ini akan membuat pengujian unit kami sederhana dan cepat:

 @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) } }

Untuk saat ini, lapisan domain sudah selesai.

Model

Sebagai pengembang Android, Anda mungkin akan memilih Room, perpustakaan Android baru untuk menyimpan data. Tapi mari kita bayangkan bahwa manajer proyek bertanya apakah Anda dapat menunda keputusan tentang database karena manajemen mencoba memutuskan antara Room, Realm, dan beberapa perpustakaan penyimpanan baru yang super cepat. Kami memerlukan beberapa data untuk mulai bekerja dengan UI, jadi kami hanya akan menyimpannya di memori untuk saat ini:

 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) } }

Presentasi

Dua tahun lalu, saya menulis artikel tentang MVP sebagai struktur aplikasi yang sangat bagus untuk Android. Ketika Google mengumumkan Komponen Arsitektur yang hebat, yang membuat pengembangan aplikasi Android jauh lebih mudah, MVP tidak lagi diperlukan dan dapat digantikan oleh MVVM; namun, beberapa ide dari pola ini masih sangat berguna—seperti ide tentang pandangan bodoh. Mereka seharusnya hanya peduli tentang menampilkan data. Untuk mencapai ini, kami akan menggunakan ViewModel dan LiveData.

Desain aplikasi kami sangat sederhana—satu aktivitas dengan navigasi bawah, di mana dua entri menu menunjukkan fragmen locations atau fragmen users . Dalam tampilan ini kami menggunakan ViewModels, yang pada gilirannya menggunakan UseCase s dari lapisan domain, menjaga komunikasi tetap rapi dan sederhana. Misalnya, ini adalah 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 } }

Sedikit penjelasan bagi yang belum familiar dengan ViewModels—data kita disimpan di variabel lokasi. Saat kami memperoleh data dari kasus penggunaan getLocations , data tersebut diteruskan ke nilai LiveData . Perubahan ini akan memberi tahu pengamat sehingga mereka dapat bereaksi dan memperbarui data mereka. Kami menambahkan pengamat untuk data dalam sebuah fragmen:

 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() } }

Pada setiap perubahan lokasi, kami hanya meneruskan data baru ke adaptor yang ditetapkan ke tampilan pendaur ulang—dan di situlah aliran Android normal untuk menampilkan data dalam tampilan pendaur ulang.

Karena kami menggunakan ViewModel dalam tampilan kami, perilakunya juga mudah untuk diuji—kami hanya dapat mengolok-olok ViewModels dan tidak peduli dengan sumber data, jaringan, atau faktor lainnya:

 @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" } }

Anda mungkin memperhatikan bahwa lapisan presentasi juga dipisahkan menjadi lapisan yang lebih kecil dengan batas yang jelas. Tampilan seperti activities , fragments , ViewHolders , dll. hanya bertanggung jawab untuk menampilkan data. Mereka hanya mengetahui tentang lapisan ViewModel—dan hanya menggunakannya untuk mendapatkan atau mengirim pengguna dan lokasi. Ini adalah ViewModel yang berkomunikasi dengan domain. Implementasi ViewModel sama untuk tampilan seperti UseCase untuk domain. Untuk parafrase, arsitektur bersih seperti bawang—memiliki lapisan, dan lapisan juga dapat memiliki lapisan.

Injeksi Ketergantungan

Kami telah membuat semua kelas untuk arsitektur kami, tetapi ada satu hal lagi yang harus dilakukan—kami membutuhkan sesuatu yang menghubungkan semuanya bersama-sama. Lapisan presentasi, domain, dan model tetap bersih, tetapi kita membutuhkan satu modul yang akan menjadi modul kotor dan akan mengetahui segalanya tentang segalanya—dengan pengetahuan ini, ia akan dapat menghubungkan lapisan kita. Cara terbaik untuk membuatnya adalah menggunakan salah satu pola desain umum (salah satu prinsip kode bersih yang didefinisikan dalam SOLID)—injeksi dependensi, yang membuat objek yang tepat untuk kita dan menyuntikkannya ke dependensi yang diinginkan. Saya menggunakan Dagger 2 di sini (di tengah proyek, saya mengubah versi ke 2.16, yang memiliki lebih sedikit boilerplate), tetapi Anda dapat menggunakan mekanisme apa pun yang Anda suka. Baru-baru ini, saya bermain sedikit dengan perpustakaan Koin, dan saya pikir itu juga patut dicoba. Saya ingin menggunakannya di sini, tetapi saya memiliki banyak masalah dengan mengejek ViewModels saat pengujian. Saya harap saya menemukan cara untuk menyelesaikannya dengan cepat — jika demikian, saya dapat menyajikan perbedaan untuk aplikasi ini saat menggunakan Koin dan Dagger 2.

Anda dapat memeriksa aplikasi untuk tahap ini di GitHub dengan tag architecture_v1.

Perubahan

Kami menyelesaikan lapisan kami, menguji aplikasi—semuanya berfungsi! Kecuali satu hal—kita masih perlu tahu database apa yang ingin digunakan oleh PM kita. Asumsikan mereka datang kepada Anda dan mengatakan bahwa manajemen setuju untuk menggunakan Room, tetapi mereka masih ingin memiliki kemungkinan untuk menggunakan perpustakaan supercepat terbaru di masa mendatang, jadi Anda perlu mengingat kemungkinan perubahan. Juga, salah satu pemangku kepentingan bertanya apakah data dapat disimpan di cloud dan ingin mengetahui biaya perubahan seperti itu. Jadi, inilah saatnya untuk memeriksa apakah arsitektur kita bagus dan apakah kita dapat mengubah sistem penyimpanan data tanpa perubahan pada presentasi atau lapisan domain.

Ubah 1

Hal pertama saat menggunakan Room adalah mendefinisikan entitas untuk database. Kami sudah memiliki beberapa: User dan UserLocation . Yang harus kita lakukan adalah menambahkan anotasi seperti @PrimaryKey @Entity dan kemudian kita dapat menggunakannya di layer model kita dengan database. Besar! Ini adalah cara terbaik untuk melanggar semua aturan arsitektur yang ingin kami pertahankan. Sebenarnya, entitas domain tidak dapat dikonversi ke entitas database dengan cara ini. Bayangkan saja kita juga ingin mendownload data dari sebuah jaringan. Kita bisa menggunakan beberapa kelas lagi untuk menangani respons jaringan—mengonversi entitas sederhana kita agar berfungsi dengan database dan jaringan. Itu adalah jalan terpendek menuju bencana di masa depan (dan menangis, "Siapa yang menulis kode ini?"). Kami membutuhkan kelas entitas terpisah untuk setiap jenis penyimpanan data yang kami gunakan. Tidak membutuhkan banyak biaya, jadi mari kita definisikan entitas Room dengan benar:

 @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 )

Seperti yang Anda lihat, mereka hampir sama dengan entitas domain, jadi ada godaan besar untuk menggabungkannya. Ini hanya kebetulan—dengan data yang lebih rumit, kemiripannya akan lebih kecil.

Selanjutnya, kita harus mengimplementasikan UserDAO dan UserLocationsDAO , AppDatabase , dan terakhir—implementasi untuk IUsersRepository dan ILocationsRepository . Ada masalah kecil di sini— ILocationsRepository harus mengembalikan UserLocation , tetapi menerima UserLocationEntity dari database. Hal yang sama berlaku untuk kelas terkait User . Di arah yang berlawanan, kami melewati UserLocation ketika database membutuhkan UserLocationEntity . Untuk mengatasi ini, kami membutuhkan Mappers antara domain dan entitas data kami. Saya menggunakan salah satu fitur Kotlin favorit saya—ekstensi. Saya membuat file bernama Mapper.kt , dan meletakkan semua metode untuk pemetaan antar kelas di sana (tentu saja, itu ada di lapisan model—domain tidak membutuhkannya):

 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())

Kebohongan kecil yang saya sebutkan sebelumnya adalah tentang entitas domain. Saya menulis bahwa mereka tidak tahu apa-apa tentang Android, tapi ini tidak sepenuhnya benar. Saya menambahkan anotasi @Parcelize ke entitas User dan memperluas Parcelable di sana, sehingga memungkinkan untuk meneruskan entitas ke sebuah fragmen. Untuk struktur yang lebih rumit, kita harus menyediakan kelas data lapisan tampilan itu sendiri, dan membuat pembuat peta seperti antara model domain dan data. Menambahkan Parcelable ke entitas domain adalah risiko kecil yang berani saya ambil—Saya menyadari hal itu, dan jika ada perubahan entitas User , saya akan membuat kelas data terpisah untuk presentasi dan menghapus Parcelable dari lapisan domain.

Hal terakhir yang harus dilakukan adalah mengubah modul injeksi ketergantungan kita untuk menyediakan implementasi Repository yang baru dibuat alih-alih MemoryRepository sebelumnya. Setelah kita membangun dan menjalankan aplikasi, kita bisa pergi ke PM untuk menunjukkan aplikasi yang berfungsi dengan database Room. Kami juga dapat memberi tahu PM bahwa menambahkan jaringan tidak akan memakan banyak waktu, dan kami terbuka untuk perpustakaan penyimpanan apa pun yang diinginkan manajemen. Anda dapat memeriksa file apa yang telah diubah—hanya yang ada di lapisan model. Arsitektur kami sangat rapi! Setiap jenis penyimpanan berikutnya dapat dibangun dengan cara yang sama, hanya dengan memperluas repositori kami dan menyediakan implementasi yang tepat. Tentu saja, ternyata kita membutuhkan banyak sumber data, seperti database dan jaringan. Lalu bagaimana? Tidak ada apa-apa, kita hanya perlu membuat tiga implementasi repositori—satu untuk jaringan, satu untuk database, dan yang utama, di mana sumber data yang benar akan dipilih (misalnya, jika kita memiliki jaringan, muat dari jaringan, dan jika tidak, muat dari database).

Anda dapat memeriksa aplikasi untuk tahap ini di GitHub dengan tag architecture_v2.

Jadi, hari hampir selesai — Anda sedang duduk di depan komputer Anda dengan secangkir kopi, aplikasi siap dikirim ke Google Play, ketika tiba-tiba manajer proyek datang kepada Anda dan bertanya “Bisakah Anda menambahkan fitur yang dapat menyimpan lokasi pengguna saat ini dari GPS?”

Ubah 2

Semuanya berubah… terutama perangkat lunaknya. Inilah mengapa kita membutuhkan kode yang bersih dan arsitektur yang bersih. Namun, bahkan hal yang paling bersih pun bisa menjadi kotor jika kita membuat kode tanpa berpikir. Pikiran pertama ketika menerapkan mendapatkan lokasi dari GPS akan menambahkan semua kode lokasi-sadar dalam aktivitas, menjalankannya di SaveLocationDialogFragment kami dan membuat UserLocation baru dengan data yang sesuai. Ini bisa menjadi cara tercepat. Tetapi bagaimana jika PM gila kita datang kepada kita dan meminta kita untuk mengubah mendapatkan lokasi dari GPS ke penyedia lain (misalnya, sesuatu seperti Bluetooth atau jaringan)? Perubahan akan segera lepas kendali. Bagaimana kita bisa melakukannya dengan cara yang bersih?

Lokasi pengguna adalah data. Dan mendapatkan lokasi adalah UseCase —jadi menurut saya domain dan lapisan model kita juga harus dilibatkan di sini. Jadi, kami memiliki satu UseCase lagi untuk diterapkan— GetCurrentLocation . Kami juga membutuhkan sesuatu yang akan menyediakan lokasi bagi kami—antarmuka ILocationProvider , untuk membuat UseCase bergantung pada detail seperti sensor 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() } }

Anda dapat melihat bahwa kami memiliki satu metode tambahan di sini—batalkan. Ini karena kami membutuhkan cara untuk membatalkan pembaruan lokasi GPS. Implementasi Provider kami, yang didefinisikan dalam lapisan model, ada di sini:

 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 } }

Penyedia ini siap bekerja dengan coroutine Kotlin. Jika Anda ingat, metode run UseCase s dipanggil pada utas latar belakang—jadi kita harus memastikan dan menandai utas kita dengan benar. Seperti yang Anda lihat, kita harus melewati suatu aktivitas di sini—sangatlah penting untuk membatalkan pembaruan dan membatalkan pendaftaran dari pendengar saat kita tidak lagi membutuhkannya untuk menghindari kebocoran memori. Karena mengimplementasikan ILocationProvider , kita dapat dengan mudah memodifikasinya di masa mendatang ke beberapa penyedia lain. Kami juga dapat dengan mudah menguji penanganan lokasi saat ini (secara otomatis atau manual), bahkan tanpa mengaktifkan GPS di telepon kami—yang harus kami lakukan adalah mengganti implementasi untuk mengembalikan lokasi yang dibangun secara acak. Untuk membuatnya berfungsi, kita harus menambahkan UseCase yang baru dibuat ke LocationsViewModel . ViewModel, pada gilirannya, harus memiliki metode baru, getCurrentLocation , yang sebenarnya akan memanggil use case. Dengan hanya sedikit perubahan UI kecil untuk memanggilnya dan mendaftarkan GPSProvider di Dagger—dan voila, aplikasi kita selesai!

Ringkasan

Saya mencoba menunjukkan kepada Anda bagaimana kami dapat mengembangkan aplikasi Android yang mudah dirawat, diuji, dan diubah. Itu juga harus mudah dipahami—jika seseorang yang baru datang ke pekerjaan Anda, mereka seharusnya tidak memiliki masalah dalam memahami aliran data atau strukturnya. Jika mereka menyadari bahwa arsitekturnya bersih, mereka dapat yakin bahwa perubahan di UI tidak akan memengaruhi apa pun dalam model, dan menambahkan fitur baru tidak akan memakan waktu lebih dari yang diperkirakan. Tapi ini bukan akhir dari perjalanan. Bahkan jika kita memiliki aplikasi yang terstruktur dengan baik, sangat mudah untuk memecahkannya dengan perubahan kode yang berantakan “hanya untuk sesaat, hanya untuk bekerja.” Ingat—tidak ada kode “hanya untuk saat ini”. Setiap kode yang melanggar aturan kami dapat bertahan dalam basis kode dan dapat menjadi sumber terobosan yang lebih besar di masa depan. Jika Anda menemukan kode itu hanya seminggu kemudian, itu akan terlihat seperti seseorang menerapkan beberapa dependensi kuat dalam kode itu dan, untuk mengatasinya, Anda harus menggali banyak bagian lain dari aplikasi. Arsitektur kode yang baik merupakan tantangan tidak hanya di awal proyek—ini adalah tantangan untuk bagian mana pun dari masa pakai aplikasi Android. Berpikir dan memeriksa kode harus diperhitungkan setiap kali sesuatu akan berubah. Untuk mengingat ini, Anda dapat, misalnya, mencetak dan menggantung diagram arsitektur Android Anda. Anda juga dapat sedikit memaksakan independensi lapisan dengan memisahkannya ke tiga modul Gradle, di mana modul domain tidak mengetahui yang lain dan modul presentasi dan model tidak saling menggunakan. Tetapi bahkan ini tidak dapat menggantikan kesadaran bahwa kekacauan dalam kode aplikasi akan membalas dendam pada kita ketika kita tidak mengharapkannya.