Panduan arsitektur aplikasi

Panduan ini mencakup praktik terbaik dan arsitektur yang direkomendasikan untuk membuat aplikasi yang tangguh dan berkualitas produksi.

Halaman ini mengasumsikan Anda telah memiliki pengetahuan dasar tentang Framework Android. Jika Anda baru mengenal pengembangan aplikasi Android, lihat Panduan developer kami untuk memulai dan mempelajari konsep yang disebutkan dalam panduan ini lebih lanjut.

Jika Anda tertarik dengan arsitektur aplikasi, dan ingin melihat materi dalam panduan ini dari perspektif pemrograman Kotlin, lihat kursus Mengembangkan Aplikasi Android dengan Kotlin di Udacity.

Pengalaman pengguna aplikasi seluler

Biasanya, aplikasi desktop memiliki satu titik masuk dari peluncur desktop atau program, lalu berjalan sebagai proses monolitik tunggal. Di sisi lain, aplikasi Android memiliki struktur yang jauh lebih rumit. Aplikasi Android standar memuat beberapa komponen aplikasi, termasuk aktivitas, fragmen, layanan, penyedia konten, dan penerima siaran.

Anda mendeklarasikan sebagian besar komponen aplikasi ini di manifes aplikasi. Android OS kemudian menggunakan file ini untuk menentukan cara mengintegrasikan aplikasi Anda ke dalam seluruh pengalaman pengguna di perangkat. Mengingat bahwa aplikasi Android yang dibuat dengan tepat berisi beberapa komponen, dan bahwa pengguna sering berinteraksi dengan beberapa aplikasi dalam rentang waktu yang singkat, aplikasi perlu beradaptasi dengan berbagai jenis alur kerja dan tugas yang dikelola pengguna.

Sebagai contoh, pertimbangkan apa yang terjadi saat Anda membagikan foto di aplikasi jaringan sosial favorit Anda:

  1. Aplikasi tersebut memicu intent kamera. Selanjutnya, Android OS meluncurkan aplikasi kamera untuk menangani permintaan. Pada tahap ini, pengguna telah keluar dari aplikasi jaringan sosial, tetapi pengalaman penggunaan aplikasi mereka tetap berjalan mulus.
  2. Aplikasi kamera dapat memicu intent lain, seperti meluncurkan pemilih file, yang dapat meluncurkan aplikasi lain lagi.
  3. Akhirnya, pengguna kembali ke aplikasi jaringan sosial dan membagikan fotonya.

Kapan saja selama proses ini, pengguna dapat diganggu oleh panggilan telepon atau notifikasi. Setelah merespons gangguan tersebut, pengguna berharap dapat kembali ke aplikasi dan melanjutkan proses berbagi foto ini. Perilaku melompat antar-aplikasi ini sangatlah umum di perangkat seluler, sehingga aplikasi Anda harus menangani alur-alur ini dengan tepat.

Ingatlah bahwa ada keterbatasan resource pada perangkat seluler. Jadi, sewaktu-waktu, sistem operasi mungkin perlu menghentikan beberapa aplikasi agar aplikasi lain dapat berjalan.

Mengingat kondisi lingkungan ini, bisa jadi komponen aplikasi Anda diluncurkan satu per satu dan tidak berurutan, dan sistem operasi atau pengguna dapat merusak proses ini kapan saja. Karena peristiwa ini tidak berada di bawah kontrol Anda, sebaiknya Anda tidak menyimpan data atau status aplikasi di komponen aplikasi, dan komponen aplikasi Anda sebaiknya tidak bergantung satu sama lain.

Prinsip arsitektur umum

Jika Anda tidak disarankan menggunakan komponen aplikasi untuk menyimpan data dan status aplikasi, bagaimana caranya Anda mendesain aplikasi?

Pemisahan fokus

Prinsip paling penting yang perlu diikuti adalah pemisahan fokus. Kesalahan umum yang biasa dilakukan adalah menulis semua kode dalam sebuah Activity atau Fragment. Class berbasis UI ini hanya boleh memuat logika yang menangani UI dan interaksi sistem operasi. Dengan menjaga class tetap seramping mungkin, Anda dapat menghindari banyak masalah yang terkait dengan siklus proses.

Perlu diingat bahwa Anda tidak memiliki implementasi Activity dan Fragment; sebaliknya, keduanya adalah class perekat yang merepresentasikan kontrak antara Android OS dan aplikasi Anda. OS dapat mengakhiri class tersebut kapan saja berdasarkan interaksi pengguna atau karena kondisi sistem seperti memori yang rendah. Untuk memberikan pengalaman pengguna yang memuaskan dan pengalaman pemeliharaan aplikasi yang lebih mudah dikelola, sebaiknya minimalkan dependensi Anda pada class tersebut.

Menjalankan UI dari model

Prinsip penting lainnya adalah sebaiknya Anda menjalankan UI dari suatu model, terutama model persisten. Model adalah komponen yang bertanggung jawab menangani data untuk aplikasi. Model tidak terikat dengan objek View dan komponen aplikasi dalam aplikasi Anda, sehingga tidak terpengaruh oleh siklus proses aplikasi dan masalah terkaitnya.

Persistensi ideal karena alasan berikut:

  • Pengguna Anda tidak kehilangan data jika Android OS mengakhiri aplikasi untuk mengosongkan resource.
  • Aplikasi Anda tetap berfungsi saat koneksi jaringan tidak stabil atau tidak tersedia.

Dengan mendasarkan aplikasi Anda pada class model yang memiliki tanggung jawab pengelolaan data yang jelas, aplikasi Anda dapat lebih mudah diuji dan konsisten.

Pada bagian ini, kami akan menunjukkan cara membuat struktur aplikasi menggunakan Komponen Arsitektur melalui kasus penggunaan menyeluruh.

Bayangkan kita sedang membuat UI yang menampilkan profil pengguna. Kita menggunakan backend pribadi dan REST API guna mengambil data untuk profil yang ditentukan.

Ringkasan

Untuk memulai, perhatikan diagram berikut, yang menunjukkan bagaimana semua modul harus berinteraksi satu sama lain setelah mendesain aplikasi:

Perhatikan bahwa setiap komponen hanya bergantung pada komponen yang berada satu level di bawahnya. Misalnya, aktivitas dan fragmen hanya bergantung pada model tampilan. Repositori adalah satu-satunya class yang bergantung pada beberapa class lainnya; dalam contoh ini, repositori bergantung pada model data persisten dan sumber data backend jarak jauh.

Desain ini menciptakan pengalaman pengguna yang konsisten dan menyenangkan. Terlepas dari apakah pengguna mengakses kembali aplikasi beberapa menit atau beberapa hari setelah mereka terakhir kali menutupnya, mereka akan langsung melihat informasi pengguna yang dipertahankan oleh aplikasi secara lokal. Jika data ini usang, modul repositori aplikasi akan mulai memperbarui data di latar belakang.

Membuat antarmuka pengguna

UI terdiri dari fragmen, UserProfileFragment, dan file tata letak yang terkait, user_profile_layout.xml.

Untuk menjalankan UI, model data harus menyimpan elemen data berikut:

  • ID Pengguna: Pengidentifikasi untuk pengguna. Sebaiknya teruskan informasi ini ke fragmen menggunakan argumen fragmen. Jika Android OS mengakhiri proses, informasi ini tetap dipertahankan, sehingga ID ini tersedia saat aplikasi dimulai ulang.
  • Objek pengguna: Class data yang menyimpan detail tentang pengguna.

Kami menggunakan UserProfileViewModel, berdasarkan komponen arsitektur ViewModel, untuk mempertahankan informasi ini.

Objek ViewModel menyediakan data untuk komponen UI tertentu, seperti fragmen atau aktivitas, dan berisi logika bisnis penanganan data untuk berkomunikasi dengan model. Misalnya, ViewModel dapat memanggil komponen lain untuk memuat data dan meneruskan permintaan pengguna untuk mengubah data tersebut. ViewModel tidak mengetahui komponen UI, sehingga tidak terpengaruh oleh perubahan konfigurasi, seperti pembuatan ulang aktivitas saat merotasi layar.

Sekarang kita telah mendefinisikan file-file berikut:

  • user_profile.xml: Definisi tata letak UI untuk layar.
  • UserProfileFragment: Pengontrol UI yang menampilkan data.
  • UserProfileViewModel: Class yang menyiapkan data untuk ditampilkan di UserProfileFragment dan bereaksi terhadap interaksi pengguna.

Cuplikan kode berikut menampilkan konten awal untuk file-file ini. (File tata letak dihilangkan agar lebih praktis.)

UserProfileViewModel

class UserProfileViewModel : ViewModel() {
       val userId : String = TODO()
       val user : User = TODO()
    }
    

UserProfileFragment

    class UserProfileFragment : Fragment() {
       // To use the viewModels() extension function, include
       // "androidx.fragment:fragment-ktx:latest-version" in your app
       // module's build.gradle file.
       private val viewModel: UserProfileViewModel by viewModels()

       override fun onCreateView(
           inflater: LayoutInflater, container: ViewGroup?,
           savedInstanceState: Bundle?
       ): View {
           return inflater.inflate(R.layout.main_fragment, container, false)
       }
    }
    

Setelah kita memiliki modul-modul kode ini, bagaimana cara menghubungkannya? Lagi pula, saat kolom user ditetapkan dalam class UserProfileViewModel, kita memerlukan cara untuk memberi tahu UI.

Untuk mendapatkan user, ViewModel kita perlu mengakses argumen Fragmen. Kita dapat meneruskannya dari Fragmen, atau lebih baik lagi jika kita menggunakan modul SavedState agar ViewModel membaca argumen secara langsung:

// UserProfileViewModel
    class UserProfileViewModel(
       savedStateHandle: SavedStateHandle
    ) : ViewModel() {
       val userId : String = savedStateHandle["uid"] ?:
              throw IllegalArgumentException("missing user id")
       val user : User = TODO()
    }

    // UserProfileFragment
    private val viewModel: UserProfileViewModel by viewModels(
       factoryProducer = { SavedStateVMFactory(this) }
       ...
    )
    

Sekarang kita perlu memberi tahu Fragmen saat objek pengguna diperoleh. Di sinilah komponen arsitektur LiveData berperan.

LiveData adalah penyimpan data yang dapat diamati. Komponen lain dalam aplikasi Anda dapat memantau perubahan pada objek menggunakan penyimpan ini tanpa membuat jalur dependensi yang eksplisit dan kaku antar-objek. Komponen LiveData juga mengikuti status siklus proses komponen aplikasi Anda—seperti aktivitas, fragmen, dan layanan—dan mencakup logika pembersihan untuk mencegah kebocoran objek dan konsumsi memori yang berlebihan.

Untuk menyertakan komponen LiveData ke dalam aplikasi, kami mengubah jenis kolom di UserProfileViewModel menjadi LiveData<User>. Sekarang, UserProfileFragment akan diberi tahu saat data diperbarui. Selanjutnya, karena kolom LiveData ini berbasis siklus proses, kolom ini akan otomatis membersihkan referensi setelah tidak diperlukan lagi.

UserProfileViewModel

class UserProfileViewModel(
       savedStateHandle: SavedStateHandle
    ) : ViewModel() {
       val userId : String = savedStateHandle["uid"] ?:
              throw IllegalArgumentException("missing user id")
       val user : LiveData<User> = TODO()
    }
    

Sekarang kita akan memodifikasi UserProfileFragment untuk mengamati data dan mengupdate UI:

UserProfileFragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
       super.onViewCreated(view, savedInstanceState)
       viewModel.user.observe(viewLifecycleOwner) {
           // update UI
       }
    }
    

Setiap kali data profil pengguna diperbarui, callback onChanged() dipanggil, dan UI di-refresh.

Jika Anda sudah terbiasa dengan library lain yang menggunakan callback yang dapat diamati, Anda mungkin menyadari bahwa kita tidak harus mengganti metode onStop() fragmen untuk berhenti mengamati data. Langkah ini tidak diperlukan untuk LiveData karena sudah berbasis siklus proses, yang berarti LiveData tidak akan memanggil callback onChanged() kecuali jika fragmen berstatus aktif; artinya, fragmen telah menerima onStart() tetapi belum menerima onStop()). LiveData juga otomatis menghapus pengamat saat metode onDestroy() fragmen diaktifkan.

Kita juga tidak menambahkan logika apa pun untuk menangani perubahan konfigurasi, seperti pengguna yang merotasi layar perangkat. UserProfileViewModel otomatis dipulihkan saat konfigurasi berubah, jadi segera setelah fragmen baru dibuat, fragmen akan menerima instance ViewModel yang sama, dan callback segera dijalankan menggunakan data terbaru. Mengingat objek ViewModel diharapkan aktif lebih lama daripada objek View terkait yang diperbaruinya, sebaiknya Anda tidak menyertakan referensi langsung ke objek View dalam implementasi ViewModel. Untuk informasi selengkapnya tentang masa aktif ViewModel yang terkait dengan siklus proses komponen UI, lihat Siklus proses ViewModel.

Mengambil data

Setelah kita menggunakan LiveData untuk menghubungkan UserProfileViewModel ke UserProfileFragment, bagaimana cara mengambil data profil pengguna?

Dalam contoh ini, kami berasumsi bahwa backend menyediakan REST API. Kami menggunakan library Retrofit untuk mengakses backend, tetapi Anda dapat menggunakan library yang berbeda yang fungsinya sama.

Berikut adalah definisi Webservice yang berkomunikasi dengan backend:

Webservice

interface Webservice {
       /**
        * @GET declares an HTTP GET request
        * @Path("user") annotation on the userId parameter marks it as a
        * replacement for the {user} placeholder in the @GET path
        */
       @GET("/users/{user}")
       fun getUser(@Path("user") userId: String): Call<User>
    }
    

Ide pertama untuk mengimplementasikan ViewModel mungkin melibatkan panggilan langsung Webservice dengan tujuan mengambil data dan menetapkan data ini ke objek LiveData. Desain ini dapat digunakan, tetapi dengan menggunakannya, aplikasi akan semakin sulit dipelihara seiring perkembangannya. Desain ini memberikan tanggung jawab yang terlalu banyak pada class UserProfileViewModel, dan ini melanggar prinsip pemisahan fokus. Selain itu, cakupan ViewModel terikat dengan siklus proses Activity atau Fragment, yang berarti data dari Webservice akan hilang saat siklus proses objek UI yang terkait berakhir. Perilaku ini menciptakan pengalaman pengguna yang tidak diinginkan.

Sebaliknya, ViewModel kami mendelegasikan proses pengambilan data ke modul baru, yaitu repositori.

Modul repositori menangani operasi data. Modul ini menyajikan API yang bersih sehingga seluruh aplikasi dapat mengambil data ini dengan mudah. Repositori mengetahui asal data dan panggilan API mana yang harus dilakukan saat data diperbarui. Anda dapat menganggap repositori sebagai mediator antara sumber data yang berbeda-beda, seperti model persisten, layanan web, dan cache.

Class UserRepository, ditampilkan dalam cuplikan kode berikut, menggunakan instance WebService untuk mengambil data pengguna:

UserRepository

class UserRepository {
       private val webservice: Webservice = TODO()
       // ...
       fun getUser(userId: String): LiveData<User> {
           // This isn't an optimal implementation. We'll fix it later.
           val data = MutableLiveData<User>()
           webservice.getUser(userId).enqueue(object : Callback<User> {
               override fun onResponse(call: Call<User>, response: Response<User>) {
                   data.value = response.body()
               }
               // Error case is left out for brevity.
               override fun onFailure(call: Call<User>, t: Throwable) {
                   TODO()
               }
           })
           return data
       }
    }
    

Meskipun tampaknya tidak diperlukan, modul repositori memiliki fungsi penting: memisahkan sumber data dari bagian lain dalam aplikasi. Sekarang, UserProfileViewModel tidak mengetahui cara data diambil, sehingga kita dapat memberikan model tampilan dengan data yang diperoleh dari implementasi pengambilan data yang berbeda-beda.

Mengelola dependensi antarkomponen

Class UserRepository di atas memerlukan instance Webservice untuk mengambil data pengguna. Class ini dapat langsung membuat instance, tetapi untuk melakukannya, class ini juga perlu mengetahui dependensi class Webservice. Selain itu, UserRepository mungkin bukan satu-satunya class yang memerlukan Webservice. Situasi ini mengharuskan kita menduplikasi kode, karena setiap class yang memerlukan referensi ke Webservice perlu mengetahui cara menyusunnya dan dependensinya. Jika setiap class membuat WebService baru, maka resource yang digunakan akan sangat banyak.

Anda dapat menggunakan pola desain berikut untuk mengatasi masalah ini:

  • Injeksi dependensi (DI): Injeksi dependensi memungkinkan class untuk menentukan dependensi tanpa perlu menyusunnya. Saat waktu proses, class lain bertanggung jawab menyediakan dependensi ini. Kami merekomendasikan library Dagger 2 untuk mengimplementasikan injeksi dependensi pada aplikasi Android. Dagger 2 otomatis menyusun objek dengan cara menelusuri hierarki dependensi, dan menyediakan jaminan waktu kompilasi pada dependensi.
  • Pencari lokasi: Pola pencari lokasi menyediakan registry tempat class dapat memperoleh dependensinya, bukan menyusunnya.

Mengimplementasikan registry layanan lebih mudah daripada menggunakan DI; karena itu, jika Anda tidak terbiasa dengan DI, gunakan pola pencari lokasi saja.

Pola-pola ini memungkinkan Anda untuk menerapkan kode Anda dalam skala luas karena keduanya memberikan pola yang jelas untuk mengelola dependensi tanpa menduplikasi kode atau menambahkan kerumitan. Selain itu, keduanya memungkinkan Anda beralih dengan cepat antara implementasi pengujian dan pengambilan data produksi.

Aplikasi contoh kami menggunakan Dagger 2 untuk mengelola dependensi objek Webservice.

Menghubungkan ViewModel dan repositori

Sekarang, kita akan memodifikasi UserProfileViewModel untuk menggunakan objek UserRepository:

UserProfileViewModel

class UserProfileViewModel @Inject constructor(
       savedStateHandle: SavedStateHandle,
       userRepository: UserRepository
    ) : ViewModel() {
       val userId : String = savedStateHandle["uid"] ?:
              throw IllegalArgumentException("missing user id")
       val user : LiveData<User> = userRepository.getUser(userId)
    }
    

Meng-cache data

Implementasi UserRepository memisahkan panggilan ke objek Webservice, tetapi karena hanya mengandalkan satu sumber data, implementasi tersebut tidak terlalu fleksibel.

Masalah inti pada implementasi UserRepository adalah setelah data diambil dari backend, data tersebut tidak disimpan di mana pun. Oleh karena itu, jika pengguna keluar dari UserProfileFragment, kemudian kembali, aplikasi harus mengambil kembali data, meski data tidak berubah.

Desain ini kurang optimal karena alasan berikut:

  • Desain ini menyia-nyiakan bandwidth jaringan yang berharga.
  • Desain ini memaksa pengguna untuk menunggu hingga kueri baru diselesaikan.

Untuk mengatasi masalah ini, kami menambahkan sumber data baru ke UserRepository, yang berfungsi meng-cache objek User di memori:

UserRepository

// Informs Dagger that this class should be constructed only once.
    @Singleton
    class UserRepository @Inject constructor(
       private val webservice: Webservice,
       // Simple in-memory cache. Details omitted for brevity.
       private val userCache: UserCache
    ) {
       fun getUser(userId: String): LiveData<User> {
           val cached = userCache.get(userId)
           if (cached != null) {
               return cached
           }
           val data = MutableLiveData<User>()
           userCache.put(userId, data)
           // This implementation is still suboptimal but better than before.
           // A complete implementation also handles error cases.
           webservice.getUser(userId).enqueue(object : Callback<User> {
               override fun onResponse(call: Call<User>, response: Response<User>) {
                   data.value = response.body()
               }

               // Error case is left out for brevity.
               override fun onFailure(call: Call<User>, t: Throwable) {
                   TODO()
               }
           })
           return data
       }
    }
    

Mempertahankan data

Dengan implementasi kita saat ini, jika pengguna merotasi perangkat atau keluar dan segera kembali lagi ke aplikasi, UI yang ada langsung terlihat karena repositori mengambil data dari cache dalam memori.

Namun, bagaimana jika pengguna keluar dari aplikasi, dan baru kembali beberapa jam kemudian, setelah Android OS mengakhiri proses? Jika dalam kasus tersebut kita mengandalkan implementasi saat ini, kita perlu mengambil kembali data dari jaringan. Proses pengambilan data kembali ini tidak hanya mengakibatkan pengalaman pengguna yang buruk, tetapi juga memboroskan kuota internet yang berharga.

Anda dapat memperbaiki masalah ini dengan meng-cache permintaan web, tetapi hal itu akan menciptakan masalah baru: Bagaimana jika data pengguna yang sama muncul dari jenis permintaan lain, seperti mengambil daftar teman? Aplikasi akan menampilkan data yang tidak konsisten, yang sangat membingungkan. Misalnya, aplikasi mungkin menampilkan dua versi berbeda dari data pengguna yang sama jika pengguna membuat permintaan daftar teman dan permintaan satu pengguna pada waktu yang berbeda. Aplikasi kita perlu mencari tahu cara menggabungkan data yang tidak konsisten ini.

Cara yang tepat untuk menangani situasi seperti ini adalah menggunakan model persisten. Dalam hal inilah library persistensi Room dapat membantu.

Room adalah library pemetaan objek yang menyediakan persistensi data lokal dengan kode boilerplate yang minimal. Saat mengompilasi, Room memvalidasi setiap kueri terhadap skema data Anda, sehingga kueri SQL yang rusak akan menghasilkan error waktu kompilasi, bukan kegagalan waktu proses. Room memisahkan beberapa detail implementasi pokok tentang menangani tabel dan kueri SQL mentah. Hal ini juga memungkinkan Anda untuk mengamati perubahan pada data database, termasuk kueri penggabung dan koleksi, serta memperlihatkan perubahan tersebut menggunakan objek LiveData. Room bahkan secara eksplisit menetapkan batasan eksekusi yang mengatasi masalah threading umum, seperti mengakses penyimpanan pada thread utama.

Untuk menggunakan Room, kita perlu mendefinisikan skema lokal. Pertama-tama, kita harus menambahkan anotasi @Entity ke class model data User, dan anotasi @PrimaryKey ke kolom id class. Anotasi ini menandai User sebagai tabel dalam database dan id sebagai kunci utama tabel:

Pengguna

@Entity
    data class User(
       @PrimaryKey private val id: String,
       private val name: String,
       private val lastName: String
    )
    

Selanjutnya, kita membuat class database dengan mengimplementasikan RoomDatabase untuk aplikasi kita:

UserDatabase

@Database(entities = [User::class], version = 1)
    abstract class UserDatabase : RoomDatabase()
    

Perhatikan bahwa UserDatabase bersifat abstrak. Room otomatis menyediakan implementasinya. Untuk penjelasan selengkapnya, baca dokumentasi Room.

Sekarang kita memerlukan cara untuk memasukkan data pengguna ke dalam database. Untuk tugas ini, kita akan membuat objek akses data (DAO).

UserDao

@Dao
    interface UserDao {
       @Insert(onConflict = REPLACE)
       fun save(user: User)

       @Query("SELECT * FROM user WHERE id = :userId")
       fun load(userId: String): LiveData<User>
    }
    

Perhatikan bahwa metode load menampilkan objek berjenis LiveData<User>. Room tahu saat database diubah dan otomatis memberi tahu semua pengamat aktif saat data berubah. Karena Room menggunakan LiveData, operasi ini efisien; Room mengupdate data hanya saat ada minimal satu pengamat aktif.

Setelah menetapkan class UserDao, selanjutnya kita akan mereferensikan DAO dari class database kita:

UserDatabase

@Database(entities = [User::class], version = 1)
    abstract class UserDatabase : RoomDatabase() {
       abstract fun userDao(): UserDao
    }
    

Sekarang kita dapat memodifikasi UserRepository untuk menerapkan sumber data Room:

// Informs Dagger that this class should be constructed only once.
    @Singleton
    class UserRepository @Inject constructor(
       private val webservice: Webservice,
       // Simple in-memory cache. Details omitted for brevity.
       private val executor: Executor,
       private val userDao: UserDao
    ) {
       fun getUser(userId: String): LiveData<User> {
           refreshUser(userId)
           // Returns a LiveData object directly from the database.
           return userDao.load(userId)
       }

       private fun refreshUser(userId: String) {
           // Runs in a background thread.
           executor.execute {
               // Check if user data was fetched recently.
               val userExists = userDao.hasUser(FRESH_TIMEOUT)
               if (!userExists) {
                   // Refreshes the data.
                   val response = webservice.getUser(userId).execute()

                   // Check for errors here.

                   // Updates the database. The LiveData object automatically
                   // refreshes, so we don't need to do anything else here.
                   userDao.save(response.body()!!)
               }
           }
       }

       companion object {
           val FRESH_TIMEOUT = TimeUnit.DAYS.toMillis(1)
       }
    }
    

Perlu diperhatikan bahwa meski kita mengubah asal data di UserRepository, kita tidak perlu mengubah UserProfileViewModel atau UserProfileFragment. Update berskala kecil ini menunjukkan fleksibilitas yang diberikan oleh arsitektur aplikasi kita. Fleksibilitas ini juga bagus untuk pengujian, karena kita dapat memberikan UserRepository palsu dan menguji UserProfileViewModel produksi kita pada saat bersamaan.

Jika pengguna menunggu beberapa hari sebelum kembali ke aplikasi yang menggunakan arsitektur ini, kemungkinan mereka akan melihat informasi yang sudah tidak berlaku hingga repositori dapat mengambil informasi terbaru. Bergantung pada kasus penggunaan, Anda mungkin tidak ingin menampilkan informasi yang sudah tidak berlaku ini. Sebagai gantinya, Anda dapat menampilkan data placeholder, yang menampilkan nilai tiruan dan menunjukkan bahwa aplikasi Anda saat ini mengambil dan memuat informasi terbaru.

Sumber ketepatan tunggal

Sangatlah umum bagi endpoint REST API yang berbeda untuk menampilkan data yang sama. Misalnya, jika backend kita memiliki endpoint lain yang menampilkan daftar teman, objek pengguna yang sama dapat berasal dari dua endpoint API berbeda, bahkan mungkin menggunakan tingkat perincian yang berbeda. Jika UserRepository harus menampilkan respons dari permintaan Webservice apa adanya, tanpa memeriksa konsistensi, UI kita dapat menampilkan informasi yang membingungkan karena versi dan format data dari repositori akan bergantung pada endpoint yang terakhir dipanggil.

Karena alasan ini, implementasi UserRepository kita menyimpan respons layanan web ke database. Perubahan pada database ini selanjutnya akan memicu callback pada objek LiveData aktif. Dengan model ini, database berfungsi sebagai satu-satunya sumber ketepatan, dan bagian lain dari aplikasi mengaksesnya menggunakan UserRepository kita. Terlepas dari apakah Anda menggunakan cache disk, kami merekomendasikan agar repositori Anda menetapkan satu sumber data sebagai satu-satunya sumber ketepatan untuk seluruh aplikasi Anda.

Menampilkan operasi yang sedang berlangsung

Dalam beberapa kasus penggunaan, seperti operasi tarik untuk refresh, UI harus menunjukkan kepada pengguna bahwa saat ini ada operasi jaringan yang sedang berlangsung. Memisahkan tindakan UI ini dari data aktual merupakan praktik yang baik karena data mungkin diperbarui karena berbagai alasan. Misalnya, jika kita mengambil daftar teman, pengguna yang sama mungkin akan diambil kembali secara terprogram, dan ini akan memicu pembaruan LiveData<User>. Dari perspektif UI, adanya permintaan yang sedang berlangsung ini hanyalah titik data lain, sama seperti penggalan data lain mana pun dalam objek User itu sendiri.

Kita dapat menggunakan salah satu strategi berikut untuk menampilkan status pembaruan data yang konsisten di UI, dari mana pun permintaan pembaruan data itu berasal:

  • Mengubah getUser() untuk menampilkan objek berjenis LiveData. Objek ini akan menyertakan status operasi jaringan.
    Sebagai contoh, lihat implementasi NetworkBoundResource pada project GitHub android-architecture-components.
  • Menyediakan fungsi publik lain di class UserRepository yang dapat menampilkan status muat ulang User. Opsi ini lebih baik jika Anda ingin menampilkan status jaringan di UI hanya ketika proses pengambilan data berasal dari tindakan pengguna yang eksplisit, seperti operasi tarik untuk muat ulang.

Menguji setiap komponen

Dalam bagian pemisahan fokus, kami menyebutkan bahwa salah satu manfaat utama dari mengikuti prinsip ini adalah kemudahan untuk diuji.

Daftar berikut mencantumkan cara menguji setiap modul kode dari contoh lengkap kita:

  • Antarmuka dan interaksi pengguna: Gunakan Pengujian instrumentasi UI Android. Cara terbaik untuk membuat pengujian ini adalah dengan menggunakan library Espresso. Anda dapat membuat fragmen dan memberinya UserProfileViewModel palsu. Karena fragmen hanya berkomunikasi dengan UserProfileViewModel, memalsukan satu class ini saja sudah memadai untuk menguji UI aplikasi Anda sepenuhnya.
  • ViewModel: Anda dapat menguji class UserProfileViewModel menggunakan Pengujian JUnit. Anda hanya perlu membuat tiruan satu class, UserRepository.
  • UserRepository: Anda juga dapat menguji UserRepository menggunakan pengujian JUnit. Anda perlu memalsukan Webservice dan UserDao. Dalam pengujian ini, verifikasi perilaku berikut:
    • Repositori membuat panggilan layanan web yang benar.
    • Repositori menyimpan hasil ke dalam database.
    • Repositori tidak membuat permintaan yang tidak perlu jika data di-cache dan sudah terbaru.
  • Karena baik Webservice maupun UserDao merupakan antarmuka, Anda dapat memalsukan keduanya atau membuat implementasi palsu untuk kasus pengujian yang lebih kompleks.
  • UserDao: Uji class DAO menggunakan pengujian instrumentasi. Karena pengujian instrumentasi tidak memerlukan komponen UI, pengujian akan berjalan cepat. Untuk setiap pengujian, buat database dalam memori untuk memastikan bahwa pengujian tidak memiliki efek samping, seperti perubahan file database di disk.

    Perhatian:Room memungkinkan penentuan implementasi database sehingga Anda dapat menguji DAO dengan menyediakan implementasi JUnit dari SupportSQLiteOpenHelper. Namun, pendekatan ini tidak direkomendasikan, karena versi SQLite yang ada di perangkat mungkin berbeda dengan versi SQLite pada perangkat pengembangan Anda.

  • Webservice: Dalam pengujian ini, hindari membuat panggilan jaringan ke backend Anda. Penting bagi semua pengujian, terutama yang berbasis web, untuk bebas dari pengaruh lingkungan luar. Beberapa library, termasuk MockWebServer, dapat membantu Anda membuat server lokal palsu untuk pengujian ini.

  • Artefak Pengujian: Komponen Arsitektur menyediakan artefak maven untuk mengontrol thread latar belakangnya. Artefak androidx.arch.core:core-testing berisi aturan JUnit berikut:

    • InstantTaskExecutorRule: Gunakan aturan ini untuk langsung menjalankan operasi latar belakang apa pun pada thread panggilan.
    • CountingTaskExecutorRule: Gunakan aturan ini untuk menunggu operasi latar belakang Komponen Arsitektur. Anda juga dapat mengaitkan aturan ini dengan Espresso sebagai resource nonaktif.

Praktik terbaik

Pemrograman adalah bidang kreatif, begitu juga pengembangan aplikasi Android. Ada banyak cara untuk menyelesaikan masalah, baik dengan mengomunikasikan data antara berbagai aktivitas atau fragmen, mengambil data jarak jauh dan mempertahankannya secara lokal untuk mode offline, atau sejumlah skenario umum lainnya yang ditemukan oleh aplikasi yang tidak umum.

Meski rekomendasi berikut tidak wajib diikuti, pengalaman kami menunjukkan bahwa dengan mengikuti rekomendasi ini, code base Anda akan menjadi lebih tangguh, mudah diuji, dan mudah dipelihara dalam jangka panjang:

Hindari menetapkan titik masuk aplikasi Anda—seperti aktivitas, layanan, dan penerima siaran—sebagai sumber data.

Komponen ini seharusnya hanya berkoordinasi dengan komponen lain untuk mengambil subkumpulan data yang relevan dengan titik masuk tersebut. Setiap komponen aplikasi memiliki masa aktif yang singkat, bergantung pada interaksi pengguna dengan perangkatnya dan respons keseluruhan saat ini dari sistem.

Buat batasan tanggung jawab yang jelas antara berbagai modul aplikasi Anda.

Misalnya, jangan menyebarkan kode yang memuat data dari jaringan di beberapa class atau paket pada code base Anda. Demikian pula, jangan menetapkan beberapa tanggung jawab yang tidak terkait—seperti data caching dan data binding—ke dalam class yang sama.

Perlihatkan sesedikit mungkin dari setiap modul.

Jangan tergoda untuk membuat pintasan "itu saja" yang memperlihatkan detail implementasi internal dari satu modul. Dalam jangka pendek, Anda mungkin menghemat banyak waktu, tetapi Anda akan menanggung utang teknis berkali-kali lipat seiring berkembangnya codebase Anda.

Pertimbangkan cara untuk menjadikan setiap modul mudah diuji secara terpisah.

Misalnya, memiliki API yang didefinisikan dengan baik untuk mengambil data dari jaringan akan mempermudah pengujian modul yang mempertahankan data tersebut di database lokal. Sebaliknya, jika Anda mencampur logika dari kedua modul ini di satu tempat, atau mendistribusikan kode jaringan Anda di seluruh code base, pengujian akan menjadi jauh lebih sulit, bahkan mustahil, dilakukan.

Fokuskan pada inti unik aplikasi Anda agar lebih menonjol dibanding aplikasi lain.

Jangan memulai dari awal dengan menuliskan kode boilerplate yang sama berulang-ulang. Sebaliknya, fokuskan waktu dan energi Anda pada hal yang membuat aplikasi Anda unik, dan biarkan Komponen Arsitektur Android dan rekomendasi library lainnya menangani boilerplate yang berulang.

Pertahankan sebanyak mungkin data yang relevan dan baru.

Dengan demikian, pengguna dapat menikmati fungsionalitas aplikasi Anda meski perangkat mereka dalam mode offline. Ingat, tidak semua pengguna Anda menyukai konektivitas berkecepatan tinggi dan konstan.

Tetapkan satu sumber data sebagai sumber ketepatan tunggal.

Kapan pun aplikasi Anda perlu mengakses potongan data ini, data harus selalu berasal dari satu-satunya sumber ketepatan ini.

Tambahan: memperlihatkan status jaringan

Di bagian arsitektur aplikasi yang direkomendasikan di atas, kita mengabaikan error jaringan dan status pemuatan agar cuplikan kode lebih ringkas.

Bagian ini menunjukkan cara memperlihatkan status jaringan menggunakan class Resource yang mencakup data dan status data sekaligus.

Cuplikan kode berikut menunjukkan contoh implementasi Resource:

Resource

// A generic class that contains data and status about loading this data.
    sealed class Resource<T>(
       val data: T? = null,
       val message: String? = null
    ) {
       class Success<T>(data: T) : Resource<T>(data)
       class Loading<T>(data: T? = null) : Resource<T>(data)
       class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
    }
    

Karena memuat data dari jaringan sambil menampilkan salinan disk dari data tersebut merupakan praktik yang umum, sebaiknya buat class penunjang yang dapat Anda gunakan kembali di beberapa tempat. Untuk contoh ini, kami membuat kelas yang disebut NetworkBoundResource.

Diagram berikut menunjukkan hierarki keputusan untuk NetworkBoundResource:

Prosesnya dimulai dengan mengamati database untuk resource. Ketika entri pertama kali dimuat dari database, NetworkBoundResource memeriksa apakah hasilnya cukup bagus untuk dikirim, atau harus diambil ulang dari jaringan. Perlu diperhatikan bahwa kedua situasi ini dapat terjadi secara bersamaan, mengingat bahwa Anda mungkin ingin menampilkan data yang di-cache sambil memperbaruinya dari jaringan.

Jika panggilan jaringan sukses, maka panggilan tersebut akan menyimpan respons ke dalam database dan menginisialisasi ulang aliran. Jika permintaan jaringan gagal, NetworkBoundResource akan langsung mengirimkan kegagalan.

Catatan: Setelah menyimpan data baru ke disk, kami menginisialisasi ulang aliran ini dari database. Namun, biasanya kami tidak perlu melakukan langkah ini karena database dengan sendirinya mengirimkan perubahan.

Perlu diingat bahwa mengandalkan database untuk mengirimkan perubahan berarti mengandalkan efek samping yang terkait, dan ini bukan praktik yang baik karena perilaku yang tidak diketahui dari efek samping tersebut dapat muncul jika database tidak mengirimkan perubahan karena data tidak berubah.

Selain itu, jangan mengirim hasil yang diterima dari jaringan karena hal itu akan melanggar prinsip sumber ketepatan tunggal. Lagi pula, bisa jadi database menyertakan pemicu yang mengubah nilai data selama operasi "penyimpanan". Demikian pula, jangan mengirim 'SUCCESS' tanpa data baru, karena klien akan menerima versi data yang salah.

Cuplikan kode berikut menampilkan API publik yang disediakan oleh class NetworkBoundResource untuk subclass-nya:

NetworkBoundResource.kt

// ResultType: Type for the Resource data.
    // RequestType: Type for the API response.
    abstract class NetworkBoundResource<ResultType, RequestType> {
       // Called to save the result of the API response into the database
       @WorkerThread
       protected abstract fun saveCallResult(item: RequestType)

       // Called with the data in the database to decide whether to fetch
       // potentially updated data from the network.
       @MainThread
       protected abstract fun shouldFetch(data: ResultType?): Boolean

       // Called to get the cached data from the database.
       @MainThread
       protected abstract fun loadFromDb(): LiveData<ResultType>

       // Called to create the API call.
       @MainThread
       protected abstract fun createCall(): LiveData<ApiResponse<RequestType>>

       // Called when the fetch fails. The child class may want to reset components
       // like rate limiter.
       protected open fun onFetchFailed() {}

       // Returns a LiveData object that represents the resource that's implemented
       // in the base class.
       fun asLiveData(): LiveData<ResultType> = TODO()
    }

    

Perhatikan detail penting berikut tentang definisi kelas:

  • Ada dua jenis parameter yang ditetapkan, ResultType dan RequestType, karena jenis data yang ditampilkan dari API mungkin tidak cocok dengan jenis data yang digunakan secara lokal.
  • Class ApiResponse digunakan untuk permintaan jaringan. ApiResponse adalah wrapper ringkas untuk class Retrofit2..Call yang mengonversi respons terhadap instance LiveData.

Implementasi lengkap class NetworkBoundResource muncul sebagai bagian dari project GitHub android-architecture-components.

Setelah membuat NetworkBoundResource, kita dapat menggunakannya untuk menulis implementasi terikat disk dan terikat jaringan User di class UserRepository:

UserRepository

// Informs Dagger that this class should be constructed only once.
    @Singleton
    class UserRepository @Inject constructor(
       private val webservice: Webservice,
       private val userDao: UserDao
    ) {
       fun getUser(userId: String): LiveData<User> {
           return object : NetworkBoundResource<User, User>() {
               override fun saveCallResult(item: User) {
                   userDao.save(item)
               }

               override fun shouldFetch(data: User?): Boolean {
                   return rateLimiter.canFetch(userId) && (data == null || !isFresh(data))
               }

               override fun loadFromDb(): LiveData<User> {
                   return userDao.load(userId)
               }

               override fun createCall(): LiveData<ApiResponse<User>> {
                   return webservice.getUser(userId)
               }
           }.asLiveData()
       }
    }