Kita
Membangun Aplikasi MVVM Dengan Kotlin, Coroutines dan Architecture Component
Repository
Sebelumnya kita udah bahas tentang pola MVVM waktu bikin aplikasi Android. Seperti
yang udah kita bahas, View akan melakukan observasi terhadap data di ViewModel dan
memiliki tanggung jawab untuk update tampilan ketika ada perubahan data di
ViewModel. Artinya View memiliki ketergantungan terhadap ViewModel. Sedangkan
ViewModel akan menyimpan data dalam bentuk LiveData yang nantinya akan
diobservasi oleh View. Dari mana asal data tersebut? Yaps benar! Repository.
Repository bertanggung jawab untuk semua data yang akan digunakan di aplikasi. Mau
itu menyimpan data, melakukan update data, menghapus data atau mencari data
serahkan semuanya kepada Repository. Kemana Repository akan menyimpan data atau
dari mana Repository akan mencari data? Biasanya aplikasi Android melakukan
penyimpanan ke dua tempat. Pertama Remote Data Store atau web service yang dimiliki.
Remote Data Store biasanya berupa cloud server atau server sik. Yang kedua adalah
Local Data Store. Kebalikan dari Remote Data Store, Local Data Store menyimpan datanya
langsung di device Android itu sendiri baik menggunakan Realm atau SQLite.
Salah satu tujuan dari Remote Data Store itu buat sinkronisasi data antar device. Jadi
kalau misal ada pengguna yang ganti device, dia cukup login pake akun yang sama
dengan device sebelumnya. Jadi data dari device sebelumnya masih bisa dipake di
device yang baru.
Kalau Local Data Store, biasanya dipake buat ningkation pengalaman pengguna saat
pake aplikasi. Mencari data pada SQLite local jauh lebih cepat dibanding melakukan
request http ke server. Belum lagi device harus terhubung ke internet.
Dengan memanfaatkan keduanya, kita bisa membuat aplikasi kita menjadi O ine First
Apps.
. . .
Yuk Bikin!
Halaman pertama dari aplikasi yang kita bangun akan menampilkan list dari pokemon
set yang tersedia. Tampilannya kurang lebih akan seperti ini.
Sebelum kita membangun repository, kita akan membangun model dari set pokemon
ini.
1 class PokemonSetRepository(
2 private val setLocalDataStore: PokemonSetDataStore,
3 private val setRemoteDataStore: PokemonSetDataStore
4 ) {
5 suspend fun getSets(): MutableList<PokemonSet>? {
6 val cache = setLocalDataStore.getSets()
7 if (cache != null) return cache
8 val response = setRemoteDataStore.getSets()
9 setLocalDataStore.addAll(response)
10 return response
11 }
12 }
Kayak yang udah dijelasin sebelumnya, Repository akan bergantung pada dua buah
Data Store. Untuk mendapatkan set pokemon yang tersedia, pertama repository bakal
cari data dari Local Data Store dulu. Kalau datanya ketemu berarti datanya langsung
dikirim. Kalau nggak ketemu, Repository bakal cari data dari Remote Data Store. Abis
dapet datanya, Repository bakal nyimpen datanya ke Local Data Store.
1 interface PokemonSetDataStore {
2 suspend fun getSets(): MutableList<PokemonSet>?
3 suspend fun addAll(sets: MutableList<PokemonSet>?)
4 }
Setelah mende nisikan interface tersebut, kita harus mende nisikan implementasinya.
Biar bisa pake tur coroutine dari kotlin, kita bikin fungsinya jadi suspend function.
Karena Repository kita membutuhkan 2 buah Data Store, maka kita akan membuat 2
class DataStore baru.
Pertama kita buat PokemonSetLocalDataStore. Data Store ini akan menyimpan data dari
web service di variabel cache. Gimana kalo misalnya kita bikin object Repository baru
dan DataStore baru, berarti variabel cache nanti bakal kosong padahal kita udah
nyimpen datanya? Yaps benar, data cache yang seharusnya udah disimpan sebelumnya
berubah lagi menjadi list baru yang masih kosong.
Solusi dari skenario itu adalah dengan memastikan object repository yang kita bikin
cuma ada satu dan kita hanya melakukan set up objectnya hanya sekali. Untuk
memastikan object Repository yang kita bangun cuma ada satu adalah dengan
mengimplementasikan Singleton Pattern.
Selanjutnya kita buat class PokemonSetRemoteDataStore. Tugas dari class ini adalah
melakukan request http menggunakan library Retro t 2. Sebenarnya penamaan class ini
bisa saja menjadi PokemonSetRetro tDataStore. Bergantung selera dan code convention
yang digunakan dalam tim.
Kalau nanti kita butuh sebuah Data Store baru yang dapat menyimpan data ke dalam
SQLite pake library Room. Kita nggak perlu ngubah class PokemonSetLocalDataStore,
kita cukup buat sebuah class baru misalnya PokemonSetRoomDataStore dan
mengimplementasikan fungsi yang dibutuhkan.
1 object RetrofitApp {
2 private const val BASE_URL = "https://api.pokemontcg.io/v1/"
3
4 private val client = Retrofit.Builder()
5 .baseUrl(BASE_URL)
6 .addConverterFactory(GsonConverterFactory.create())
7 .build()
8
9 val POKEMON_TCG_SERVICE: PokemonTcgService = client.create(PokemonTcgService::class.java)
10 }
11
12 interface PokemonTcgService {
13 @GET("cards")
14 suspend fun getCards(@Query("set") set: String): Response<PokemonCard.PokemonCardResponse>
15
16 @GET("sets")
17 suspend fun getSets(): Response<PokemonSet.PokemonSetResponse>
18 }
1 class PokemonSetRepositoryTest {
2 @Mock
3 var localDataStore: PokemonSetDataStore? = null
4
5 @Mock
6 var remoteDataStore: PokemonSetDataStore? = null
7
8 var pokemonSetRepository: PokemonSetRepository? = null
9
10 var pokemonSets = mutableListOf<PokemonSet>()
11
12 @Before
13 fun init() {
14 MockitoAnnotations.initMocks(this)
15 pokemonSetRepository = PokemonSetRepository.instance.apply {
16 init(localDataStore!!, remoteDataStore!!)
17 }
18 }
19
20 @Test
21 fun shouldNotGetPokemonsFromRemoteWhenLocalIsNotNull() {
22 runBlocking {
23 `when`(localDataStore?.getSets()).thenReturn(pokemonSets)
24 pokemonSetRepository?.getSets()
25
26 verify(remoteDataStore, never())?.getSets()
27 verify(localDataStore, never())?.addAll(pokemonSets)
28 }
29 }
30
31 @Test
32 fun shouldCallGetPokemonsFromRemoteAndSaveToLocalWhenLocalIsNull() {
33 runBlocking {
34 `when`(localDataStore?.getSets()).thenReturn(null)
35 `when`(remoteDataStore?.getSets()).thenReturn(pokemonSets)
36 pokemonSetRepository?.getSets()
37
38 verify(remoteDataStore, times(1))?.getSets()
39 verify(localDataStore, times(1))?.addAll(pokemonSets)
40 }
41 }
42
43 @Test
44 fun shouldThrowExceptionWhenRemoteThrowAnException() {
45 runBlocking {
46 `when`(localDataStore?.getSets()).thenReturn(null)
47 `when`(remoteDataStore?.getSets()).thenAnswer { throw Exception() }
48
49 try {
50 pokemonSetRepository?.getSets()
51 } catch (ex: Exception) {
52 }
Test yang kita buat bakal nguji apakah repository yang kita bangun bekerja sesuai
dengan konsep yang sudah dijelaskan atau belum.
Fungsi yang terakhir buat mastiin kalau repository akan melempar exception ketika
terjadi kegagalan waktu melakukan request http.
Repository yang kita bangun sudah sesuai dengan yang kita pengen. Selanjutnya kita
tinggal bangun Repository buat mendapatkan kartu pokemon.
Kayak yang udah dilakuin sebelumnya, pertama kita de nisiin dulu model buat kartu
pokemon.
Selanjutnya kita bakal bangun repository dari kartu pokemon dan memastikan
repository mengimplementasikan Singleton Pattern.
Ada yang ganjel? Berasa dejavu? Kita coba bandingin class PokemonCardRepository
dengan PokemonSetReporitory.
Ternyata bener, ada sesuatu yang ngulan dan sama persis di PokemonCardRepository
sama PokemonSetRepository. Kita bakal menggeneralisasi dua class ini jadi nggak akan
dejavu lagi.
Untuk mastiin perubahan ini nggak bikin repository sebelumnya error, kita jalanin lagi
unit test yang udah dibuat.
1 interface PokemonCardDataStore {
2 suspend fun getPokemons(set: String): MutableList<PokemonCard>?
3 suspend fun addAll(set: String, pokemons: MutableList<PokemonCard>?)
4 }
1 interface DataStore<Model> {
2 suspend fun findAll(): MutableList<Model>?
3 suspend fun addAll(models: MutableList<Model>?)
4 suspend fun addToFavorite(model: Model?)
5 }
6
7 class PokemonSetLocalDataStore : DataStore<Nothing, PokemonSet> {
8 private var caches = mutableListOf<PokemonSet>()
9
10 override suspend fun findAll(nothing: Nothing): MutableList<PokemonSet>? =
11 if (caches.isNotEmpty()) caches else null
12
13 override suspend fun addAll(sets: MutableList<PokemonSet>?) {
14 sets?.let { caches = it }
15 }
16
17 override suspend fun addToFavorite(model: PokemonSet?) {
18 TODO("not implemented") //To change body of created functions use File | Settings | File
19 }
20 }
21
22 class PokemonSetRemoteDataStore(private val pokemonTcgService: PokemonTcgService) :
23 DataStore<Nothing, PokemonSet> {
24 override suspend fun findAll(input: Nothing): MutableList<PokemonSet>? {
25 val response = pokemonTcgService.getSets()
26 if (response.isSuccessful) return response.body()?.sets
27
28 throw Exception("Terjadi kesalahan saat melakukan request data, status error ${response.
29 }
30
31 override suspend fun addAll(sets: MutableList<PokemonSet>?) {
32 TODO("not implemented") //To change body of created functions use File | Settings | File
33 }
34
35 override suspend fun addToFavorite(model: PokemonSet?) {
36 TODO("not implemented") //To change body of created functions use File | Settings | File
37 }
Overriding fungsi yang tidak diperlukan bukan best practice. Jadi hindari code yang
kayak gitu ya.
“A class should have only one reason to change”
Robert C. Martin
Kita balik ke PokemonCardDataStore, sekarang kita implementasikan menjadi
PokemonLocalDataStore dan PokemonRemoteDataStore.
1 class PokemonCardRepositoryTest {
2 @Mock
3 var localCardDataStore: PokemonCardDataStore? = null
4
5 @Mock
6 var remoteCardDataStore: PokemonCardDataStore? = null
7
8 var pokemonCardRepository: PokemonCardRepository? = null
9
10 @Before
11 fun init() {
12 MockitoAnnotations.initMocks(this)
13 pokemonCardRepository = PokemonCardRepository.instance.apply {
14 init(localCardDataStore!!, remoteCardDataStore!!)
15 }
16 }
17
18 @Test
19 fun shouldNotGetPokemonsFromRemoteWhenLocalIsNotNull() {
20 runBlocking {
21 `when`(localCardDataStore?.getPokemons(anyString())).thenReturn(mutableListOf())
22 pokemonCardRepository?.getPokemons(anyString())
23
24 verify(remoteCardDataStore, never())?.getPokemons(anyString())
25 verify(localCardDataStore, never())?.addAll(anyString(), any())
26 }
27 }
28
29 @Test
30 fun shouldCallGetPokemonsFromRemoteAndSaveToLocalWhenLocalIsNull() {
31 runBlocking {
32 `when`(localCardDataStore?.getPokemons(anyString())).thenReturn(null)
33 `when`(remoteCardDataStore?.getPokemons(anyString())).thenReturn(any())
34 pokemonCardRepository?.getPokemons("Test set")
35
36 verify(remoteCardDataStore, times(1))?.getPokemons(anyString())
37 verify(localCardDataStore, times(1))?.addAll(anyString(), any())
38 }
39 }
40
41 @Test
42 fun shouldThrowExceptionWhenRemoteThrowAnException() {
43 runBlocking {
44 `when`(localCardDataStore?.getPokemons(anyString())).thenReturn(null)
45 `when`(remoteCardDataStore?.getPokemons(anyString())).thenAnswer { throw Exception()
46
47 try {
48 pokemonCardRepository?.getPokemons("Test set")
49 } catch (ex: Exception) {
50 }
51 }
Dan seperti sebelumnya, fungsi pertama untuk menguji kalau repository akan
melakukan request http atau nggak kalau data dari Local Data Store tidak null. Fungsi
kedua untuk menguji apakah Repository melakukan request http kalau Local Data Store
null. Terakhir untuk menguji kalau Repository akan melempar exception ketika terjadi
kesalahan saat melakukan request http.
. . .
Finally!
Repository kita sudah siap, yay! Cukup banyak yang udah kita bahas tentang Repository.
Kita udah mengimplementasikan dua repository dan juga membuat unit test untuk
menguji dua repository tersebut. Mungkin kalau kalian sadar, kita tidak menggunakan
komponen dari framework Android (Context, Activity, Fragment dll) sama sekali selama
kita mengimplementasikan dua repository ini. Ini hal yang bagus.
. . .
Parts
Artikel ini akan dibagi ke dalam beberapa bagian. Karena saya rasa memahami tugas
dari setiap komponen jauh lebih penting daripada hanya sekedar tau bagaimana MVVM
diimplementasikan. Bagian-bagian tersebut adalah sebagai berikut:
Referensi
developer.android.com