Aplikasi Android yang Lebih Baik Menggunakan MVVM dengan Arsitektur Bersih
Diterbitkan: 2022-03-11Jika Anda tidak memilih arsitektur yang tepat untuk proyek Android Anda, Anda akan kesulitan mempertahankannya saat basis kode Anda tumbuh dan tim Anda berkembang.
Ini bukan hanya tutorial Android MVVM. Pada artikel ini, kita akan menggabungkan MVVM (Model-View-ViewModel atau terkadang bergaya "pola ViewModel") dengan Arsitektur Bersih. Kita akan melihat bagaimana arsitektur ini dapat digunakan untuk menulis kode yang dipisahkan, dapat diuji, dan dipelihara.
Mengapa MVVM dengan Arsitektur Bersih?
MVVM memisahkan tampilan Anda (yaitu Activity
s dan Fragment
s) dari logika bisnis Anda. MVVM cukup untuk proyek kecil, tetapi ketika basis kode Anda menjadi besar, ViewModel
Anda mulai membengkak. Memisahkan tanggung jawab menjadi sulit.
MVVM dengan Arsitektur Bersih cukup bagus dalam kasus seperti itu. Ini melangkah lebih jauh dalam memisahkan tanggung jawab basis kode Anda. Ini dengan jelas mengabstraksi logika tindakan yang dapat dilakukan di aplikasi Anda.
Catatan: Anda juga dapat menggabungkan Arsitektur Bersih dengan arsitektur model-view-presenter (MVP). Tetapi karena Komponen Arsitektur Android sudah menyediakan kelas ViewModel
, kita akan menggunakan MVVM melalui MVP—tidak diperlukan kerangka kerja MVVM!
Keuntungan Menggunakan Arsitektur Bersih
- Kode Anda bahkan lebih mudah diuji daripada dengan MVVM biasa.
- Kode Anda selanjutnya dipisahkan (keuntungan terbesar.)
- Struktur paket bahkan lebih mudah dinavigasi.
- Proyek ini bahkan lebih mudah untuk dipelihara.
- Tim Anda dapat menambahkan fitur baru dengan lebih cepat.
Kekurangan Arsitektur Bersih
- Ini memiliki kurva belajar yang sedikit curam. Bagaimana semua lapisan bekerja bersama mungkin membutuhkan waktu untuk dipahami, terutama jika Anda berasal dari pola seperti MVVM atau MVP sederhana.
- Itu menambahkan banyak kelas tambahan, jadi itu tidak ideal untuk proyek dengan kompleksitas rendah.
Aliran data kami akan terlihat seperti ini:
Logika bisnis kami sepenuhnya dipisahkan dari UI kami. Itu membuat kode kami sangat mudah dipelihara dan diuji.
Contoh yang akan kita lihat cukup sederhana. Ini memungkinkan pengguna untuk membuat posting baru dan melihat daftar posting yang dibuat oleh mereka. Saya tidak menggunakan perpustakaan pihak ketiga (seperti Dagger, RxJava, dll.) dalam contoh ini demi kesederhanaan.
Lapisan MVVM dengan Arsitektur Bersih
Kode ini dibagi menjadi tiga lapisan terpisah:
- Lapisan Presentasi
- Lapisan Domain
- Lapisan Data
Kami akan membahas lebih detail tentang setiap lapisan di bawah ini. Untuk saat ini, struktur paket yang kami hasilkan terlihat seperti ini:
Bahkan dalam arsitektur aplikasi Android yang kami gunakan, ada banyak cara untuk menyusun hierarki file/folder Anda. Saya suka mengelompokkan file proyek berdasarkan fitur. Saya merasa rapi dan ringkas. Anda bebas memilih struktur proyek apa pun yang cocok untuk Anda.
Lapisan Presentasi
Ini termasuk Activity
s, Fragment
s, dan ViewModel
kami. Suatu Activity
harus sebodoh mungkin. Jangan pernah memasukkan logika bisnis Anda ke dalam Activity
s.
Activity
akan berbicara dengan ViewModel
dan ViewModel
akan berbicara dengan lapisan domain untuk melakukan tindakan. ViewModel
tidak pernah berbicara dengan lapisan data secara langsung.
Di sini kami meneruskan UseCaseHandler
dan dua UseCase
s ke ViewModel
kami. Kita akan membahasnya lebih detail segera, tetapi dalam arsitektur ini, UseCase
adalah tindakan yang mendefinisikan bagaimana ViewModel
berinteraksi dengan lapisan data.
Berikut tampilan kode Kotlin kami:
class PostListViewModel( val useCaseHandler: UseCaseHandler, val getPosts: GetPosts, val savePost: SavePost): ViewModel() { fun getAllPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { val requestValue = GetPosts.RequestValues(userId) useCaseHandler.execute(getPosts, requestValue, object : UseCase.UseCaseCallback<GetPosts.ResponseValue> { override fun onSuccess(response: GetPosts.ResponseValue) { callback.onPostsLoaded(response.posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } fun savePost(post: Post, callback: PostDataSource.SaveTaskCallback) { val requestValues = SavePost.RequestValues(post) useCaseHandler.execute(savePost, requestValues, object : UseCase.UseCaseCallback<SavePost.ResponseValue> { override fun onSuccess(response: SavePost.ResponseValue) { callback.onSaveSuccess() } override fun onError(t: Throwable) { callback.onError(t) } }) } }
Lapisan Domain
Lapisan domain berisi semua kasus penggunaan aplikasi Anda. Dalam contoh ini, kami memiliki UseCase
, sebuah kelas abstrak. Semua UseCase
s kami akan memperluas kelas ini.
abstract class UseCase<Q : UseCase.RequestValues, P : UseCase.ResponseValue> { var requestValues: Q? = null var useCaseCallback: UseCaseCallback<P>? = null internal fun run() { executeUseCase(requestValues) } protected abstract fun executeUseCase(requestValues: Q?) /** * Data passed to a request. */ interface RequestValues /** * Data received from a request. */ interface ResponseValue interface UseCaseCallback<R> { fun onSuccess(response: R) fun onError(t: Throwable) } }
Dan UseCaseHandler
menangani eksekusi UseCase
. Kita tidak boleh memblokir UI saat mengambil data dari database atau server jarak jauh kita. Ini adalah tempat di mana kami memutuskan untuk menjalankan UseCase
kami di utas latar belakang dan menerima respons di utas utama.
class UseCaseHandler(private val mUseCaseScheduler: UseCaseScheduler) { fun <T : UseCase.RequestValues, R : UseCase.ResponseValue> execute( useCase: UseCase<T, R>, values: T, callback: UseCase.UseCaseCallback<R>) { useCase.requestValues = values useCase.useCaseCallback = UiCallbackWrapper(callback, this) mUseCaseScheduler.execute(Runnable { useCase.run() }) } private fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mUseCaseScheduler.notifyResponse(response, useCaseCallback) } private fun <V : UseCase.ResponseValue> notifyError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mUseCaseScheduler.onError(useCaseCallback, t) } private class UiCallbackWrapper<V : UseCase.ResponseValue>( private val mCallback: UseCase.UseCaseCallback<V>, private val mUseCaseHandler: UseCaseHandler) : UseCase.UseCaseCallback<V> { override fun onSuccess(response: V) { mUseCaseHandler.notifyResponse(response, mCallback) } override fun onError(t: Throwable) { mUseCaseHandler.notifyError(mCallback, t) } } companion object { private var INSTANCE: UseCaseHandler? = null fun getInstance(): UseCaseHandler { if (INSTANCE == null) { INSTANCE = UseCaseHandler(UseCaseThreadPoolScheduler()) } return INSTANCE!! } } }
Seperti namanya, GetPosts
UseCase
bertanggung jawab untuk mendapatkan semua posting dari pengguna.
class GetPosts(private val mDataSource: PostDataSource) : UseCase<GetPosts.RequestValues, GetPosts.ResponseValue>() { protected override fun executeUseCase(requestValues: GetPosts.RequestValues?) { mDataSource.getPosts(requestValues?.userId ?: -1, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { val responseValue = ResponseValue(posts) useCaseCallback?.onSuccess(responseValue) } override fun onError(t: Throwable) { // Never use generic exceptions. Create proper exceptions. Since // our use case is different we will go with generic throwable useCaseCallback?.onError(Throwable("Data not found")) } }) } class RequestValues(val userId: Int) : UseCase.RequestValues class ResponseValue(val posts: List<Post>) : UseCase.ResponseValue }
Tujuan UseCase
s adalah menjadi mediator antara ViewModel
s dan Repository
Anda.

Katakanlah di masa depan Anda memutuskan untuk menambahkan fitur "edit posting". Yang harus Anda lakukan adalah menambahkan UseCase
EditPost
baru dan semua kodenya akan benar-benar terpisah dan dipisahkan dari UseCase
lain. Kita semua telah melihatnya berkali-kali: Fitur baru diperkenalkan dan mereka secara tidak sengaja merusak sesuatu dalam kode yang sudah ada sebelumnya. Membuat UseCase
terpisah sangat membantu dalam menghindarinya.
Tentu saja, Anda tidak bisa menghilangkan kemungkinan itu 100 persen, tetapi Anda pasti bisa meminimalkannya. Inilah yang membedakan Arsitektur Bersih dari pola lain: Kode ini sangat dipisahkan sehingga Anda dapat memperlakukan setiap lapisan sebagai kotak hitam.
Lapisan Data
Ini memiliki semua repositori yang dapat digunakan oleh lapisan domain. Lapisan ini memaparkan API sumber data ke kelas luar:
interface PostDataSource { interface LoadPostsCallback { fun onPostsLoaded(posts: List<Post>) fun onError(t: Throwable) } interface SaveTaskCallback { fun onSaveSuccess() fun onError(t: Throwable) } fun getPosts(userId: Int, callback: LoadPostsCallback) fun savePost(post: Post) }
PostDataRepository
mengimplementasikan PostDataSource
. Ini memutuskan apakah kita mengambil data dari database lokal atau server jauh.
class PostDataRepository private constructor( private val localDataSource: PostDataSource, private val remoteDataSource: PostDataSource): PostDataSource { companion object { private var INSTANCE: PostDataRepository? = null fun getInstance(localDataSource: PostDataSource, remoteDataSource: PostDataSource): PostDataRepository { if (INSTANCE == null) { INSTANCE = PostDataRepository(localDataSource, remoteDataSource) } return INSTANCE!! } } var isCacheDirty = false override fun getPosts(userId: Int, callback: PostDataSource.LoadPostsCallback) { if (isCacheDirty) { getPostsFromServer(userId, callback) } else { localDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { getPostsFromServer(userId, callback) } }) } } override fun savePost(post: Post) { localDataSource.savePost(post) remoteDataSource.savePost(post) } private fun getPostsFromServer(userId: Int, callback: PostDataSource.LoadPostsCallback) { remoteDataSource.getPosts(userId, object : PostDataSource.LoadPostsCallback { override fun onPostsLoaded(posts: List<Post>) { refreshCache() refreshLocalDataSource(posts) callback.onPostsLoaded(posts) } override fun onError(t: Throwable) { callback.onError(t) } }) } private fun refreshLocalDataSource(posts: List<Post>) { posts.forEach { localDataSource.savePost(it) } } private fun refreshCache() { isCacheDirty = false } }
Kode ini sebagian besar cukup jelas. Kelas ini memiliki dua variabel, localDataSource
dan remoteDataSource
. Jenisnya adalah PostDataSource
, jadi kami tidak peduli bagaimana mereka sebenarnya diimplementasikan di bawah tenda.
Dalam pengalaman pribadi saya, arsitektur ini terbukti sangat berharga. Di salah satu aplikasi saya, saya memulai dengan Firebase di bagian belakang yang sangat bagus untuk membangun aplikasi Anda dengan cepat. Saya tahu akhirnya saya harus pindah ke server saya sendiri.
Ketika saya melakukannya, yang harus saya lakukan hanyalah mengubah implementasi di RemoteDataSource
. Saya tidak perlu menyentuh kelas lain bahkan setelah perubahan besar seperti itu. Itulah keuntungan dari decoupled code. Mengubah kelas tertentu tidak akan memengaruhi bagian lain dari kode Anda.
Beberapa kelas tambahan yang kami miliki adalah:
interface UseCaseScheduler { fun execute(runnable: Runnable) fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) } class UseCaseThreadPoolScheduler : UseCaseScheduler { val POOL_SIZE = 2 val MAX_POOL_SIZE = 4 val TIMEOUT = 30 private val mHandler = Handler() internal var mThreadPoolExecutor: ThreadPoolExecutor init { mThreadPoolExecutor = ThreadPoolExecutor(POOL_SIZE, MAX_POOL_SIZE, TIMEOUT.toLong(), TimeUnit.SECONDS, ArrayBlockingQueue(POOL_SIZE)) } override fun execute(runnable: Runnable) { mThreadPoolExecutor.execute(runnable) } override fun <V : UseCase.ResponseValue> notifyResponse(response: V, useCaseCallback: UseCase.UseCaseCallback<V>) { mHandler.post { useCaseCallback.onSuccess(response) } } override fun <V : UseCase.ResponseValue> onError( useCaseCallback: UseCase.UseCaseCallback<V>, t: Throwable) { mHandler.post { useCaseCallback.onError(t) } } }
UseCaseThreadPoolScheduler
bertanggung jawab untuk menjalankan tugas secara asinkron menggunakan ThreadPoolExecuter
.
class ViewModelFactory : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { if (modelClass == PostListViewModel::class.java) { return PostListViewModel( Injection.provideUseCaseHandler() , Injection.provideGetPosts(), Injection.provideSavePost()) as T } throw IllegalArgumentException("unknown model class $modelClass") } companion object { private var INSTANCE: ViewModelFactory? = null fun getInstance(): ViewModelFactory { if (INSTANCE == null) { INSTANCE = ViewModelFactory() } return INSTANCE!! } } }
Ini adalah ViewModelFactory
kami. Anda harus membuat ini untuk meneruskan argumen di konstruktor ViewModel
Anda.
Injeksi Ketergantungan
Saya akan menjelaskan injeksi ketergantungan dengan sebuah contoh. Jika Anda melihat kelas PostDataRepository
kami, ia memiliki dua dependensi, LocalDataSource
dan RemoteDataSource
. Kami menggunakan kelas Injection
untuk menyediakan dependensi ini ke kelas PostDataRepository
.
Ketergantungan menyuntikkan memiliki dua keuntungan utama. Salah satunya adalah Anda bisa mengontrol instantiasi objek dari tempat sentral alih-alih menyebarkannya ke seluruh basis kode. Hal lain adalah bahwa ini akan membantu kita menulis pengujian unit untuk PostDataRepository
karena sekarang kita hanya dapat meneruskan versi tiruan dari LocalDataSource
dan RemoteDataSource
ke konstruktor PostDataRepository
alih-alih nilai sebenarnya.
object Injection { fun providePostDataRepository(): PostDataRepository { return PostDataRepository.getInstance(provideLocalDataSource(), provideRemoteDataSource()) } fun provideViewModelFactory() = ViewModelFactory.getInstance() fun provideLocalDataSource(): PostDataSource = LocalDataSource.getInstance() fun provideRemoteDataSource(): PostDataSource = RemoteDataSource.getInstance() fun provideGetPosts() = GetPosts(providePostDataRepository()) fun provideSavePost() = SavePost(providePostDataRepository()) fun provideUseCaseHandler() = UseCaseHandler.getInstance() }
Catatan: Saya lebih suka menggunakan Dagger 2 untuk injeksi ketergantungan dalam proyek yang kompleks. Tetapi dengan kurva belajarnya yang sangat curam, itu di luar cakupan artikel ini. Jadi jika Anda tertarik untuk masuk lebih dalam, saya sangat merekomendasikan pengenalan Hari Vignesh Jayapalan untuk Dagger 2.
MVVM dengan Arsitektur Bersih: Kombinasi Yang Solid
Tujuan kami dengan proyek ini adalah untuk memahami MVVM dengan Arsitektur Bersih, jadi kami melewatkan beberapa hal yang dapat Anda coba untuk meningkatkannya lebih lanjut:
- Gunakan LiveData atau RxJava untuk menghapus panggilan balik dan membuatnya sedikit lebih rapi.
- Gunakan status untuk mewakili UI Anda. (Untuk itu, lihat pembicaraan luar biasa ini oleh Jake Wharton.)
- Gunakan Dagger 2 untuk menyuntikkan dependensi.
Ini adalah salah satu arsitektur terbaik dan paling terukur untuk aplikasi Android. Saya harap Anda menikmati artikel ini, dan saya berharap dapat mendengar bagaimana Anda menggunakan pendekatan ini di aplikasi Anda sendiri!