Mem-build Lapisan Data

1. Sebelum memulai

Codelab ini mengajarkan Anda tentang lapisan data dan cara menyesuaikannya dengan arsitektur aplikasi secara keseluruhan.

Lapisan data sebagai lapisan bawah di bawah lapisan UI dan domain.

Gambar 1. Diagram yang menunjukkan lapisan data sebagai lapisan tempat lapisan UI dan domain bergantung.

Anda akan membangun lapisan data untuk aplikasi pengelolaan tugas. Anda akan membuat sumber data untuk database lokal dan layanan jaringan, serta repositori yang menampilkan, memperbarui, dan menyinkronkan data.

Prasyarat

Yang akan Anda pelajari

Dalam codelab ini, Anda akan mempelajari cara:

  • Membuat repositori, sumber data, dan model data untuk manajemen data yang efektif dan skalabel.
  • Mengekspos data ke lapisan arsitektur lainnya.
  • Menangani pembaruan data asinkron dan tugas yang kompleks atau berjalan lama.
  • Menyinkronkan data antara beberapa sumber data.
  • Membuat pengujian yang memverifikasi perilaku repositori dan sumber data Anda.

Yang akan Anda build

Anda akan membangun aplikasi pengelolaan tugas yang memungkinkan Anda menambahkan tugas dan menandainya sebagai selesai.

Anda tidak akan menulis aplikasi dari awal. Namun, Anda akan mengerjakan aplikasi yang sudah memiliki lapisan UI. Lapisan UI di aplikasi ini berisi layar dan holder status tingkat layar yang diterapkan menggunakan ViewModels.

Selama codelab, Anda akan menambahkan lapisan data, lalu menghubungkannya ke lapisan UI yang sudah ada sehingga aplikasi dapat berfungsi penuh.

Layar daftar tugas.

Layar detail tugas.

Gambar 2. Screenshot layar daftar tugas.

Gambar 3. Screenshot layar detail tugas.

2. Memulai persiapan

  1. Mendownload kode:

https://github.com/android/architecture-samples/archive/refs/heads/data-codelab-start.zip

  1. Atau, Anda dapat membuat clone repositori GitHub untuk kode tersebut:
git clone https://github.com/android/architecture-samples.git
git checkout data-codelab-start
  1. Buka Android Studio dan muat project architecture-samples.

Struktur folder

  • Buka Project explorer dalam tampilan Android.

Di bawah folder java/com.example.android.architecture.blueprints.todoapp terdapat beberapa folder.

Jendela Project explorer Android Studio dalam tampilan Android.

Gambar 4. Screenshot yang menunjukkan jendela Project explorer Android Studio dalam tampilan Android.

  • <root> berisi class tingkat aplikasi, misalnya untuk navigasi, aktivitas utama, dan class aplikasi.
  • addedittask berisi fitur UI yang memungkinkan pengguna menambah dan mengedit tugas.
  • data berisi lapisan data. Anda akan lebih banyak bekerja dengan folder ini.
  • di berisi modul Hilt untuk injeksi dependensi.
  • tasks berisi fitur UI yang memungkinkan pengguna melihat dan memperbarui daftar tugas.
  • util berisi class utilitas.

Ada juga dua folder pengujian, yang ditandai dengan teks dalam tanda kurung di akhir nama folder.

  • androidTest mengikuti struktur yang sama dengan <root> tetapi berisi uji instrumentasi.
  • test mengikuti struktur yang sama dengan <root> tetapi berisi uji lokal.

Menjalankan project

  • Klik ikon putar hijau di toolbar atas.

Konfigurasi run, perangkat target, dan tombol run Android Studio.

Gambar 5. Screenshot yang menunjukkan konfigurasi run, perangkat target, dan tombol run Android Studio.

Anda akan melihat layar Daftar Tugas dengan indikator lingkaran berputar pemuatan yang tidak pernah menghilang.

Aplikasi dalam keadaan awal dengan indikator lingkaran berputar pemuatan yang tidak pernah menghilang.

Gambar 6. Screenshot aplikasi dalam keadaan awal dengan indikator lingkaran berputar pemuatan yang tidak pernah menghilang.

Di akhir codelab, daftar tugas akan ditampilkan di layar ini.

Anda dapat melihat kode akhir di codelab dengan memeriksa cabang data-codelab-final.

git checkout data-codelab-final

Ingatlah untuk menyimpan perubahan Anda terlebih dahulu.

3. Mempelajari lapisan data

Dalam codelab ini, Anda akan mem-build lapisan data untuk aplikasi.

Lapisan data, seperti namanya, adalah lapisan arsitektur yang mengelola data aplikasi Anda. Lapisan data juga berisi logika bisnis—aturan bisnis di dunia nyata yang menentukan cara data aplikasi harus dibuat, disimpan, dan diubah. Pemisahan fokus ini membuat lapisan data dapat digunakan kembali, memungkinkannya ditampilkan di beberapa layar, berbagi informasi di antara berbagai bagian aplikasi, dan mereproduksi logika bisnis di luar UI untuk pengujian unit.

Jenis komponen utama yang membentuk lapisan data adalah model data, sumber data, dan repositori.

Jenis komponen di lapisan data, termasuk dependensi antara model data, sumber data, dan repositori.

Gambar 7. Diagram yang menunjukkan jenis komponen di lapisan data, termasuk dependensi antara model data, sumber data, dan repositori.

Model data

Data aplikasi biasanya direpresentasikan sebagai model data. Model data adalah representasi data dalam memori.

Karena aplikasi ini adalah aplikasi pengelolaan tugas, Anda memerlukan model data untuk tugas. Berikut ini class Task:

data class Task(
    val id: String
    val title: String = "",
    val description: String = "",
    val isCompleted: Boolean = false,
) { ... }

Poin utama tentang model ini adalah bahwa model ini tidak dapat diubah. Lapisan lain tidak dapat mengubah properti tugas. Lapisan tersebut harus menggunakan lapisan data jika ingin membuat perubahan pada tugas.

Model data internal dan eksternal

Task adalah contoh dari model data eksternal. Model data ini diekspos secara eksternal oleh lapisan data dan dapat diakses oleh lapisan lain. Lain waktu, Anda akan menentukan model data internal yang hanya digunakan di dalam lapisan data.

Menentukan model data untuk setiap representasi model bisnis adalah praktik yang baik. Di aplikasi ini, ada tiga model data.

Nama model

Eksternal atau internal untuk lapisan data?

Merepresentasikan

Sumber data terkait

Task

Eksternal

Tugas yang dapat digunakan di mana saja di aplikasi, hanya disimpan di memori atau saat menyimpan status aplikasi

T/A

LocalTask

Internal

Tugas yang disimpan dalam database lokal

TaskDao

NetworkTask

Internal

Tugas yang telah diambil dari server jaringan

NetworkTaskDataSource

Sumber data

Sumber data adalah class yang bertanggung jawab untuk membaca dan menulis data ke satu sumber seperti database atau layanan jaringan.

Di aplikasi ini, ada dua sumber data:

  • TaskDao adalah sumber data lokal yang membaca dan menulis ke database.
  • NetworkTaskDataSource adalah sumber data jaringan yang membaca dan menulis ke server jaringan.

Repositori

Repositori harus mengelola model data tunggal. Di aplikasi ini, Anda akan membuat repositori yang mengelola model Task. Repositori:

  • Mengekspos daftar model Task.
  • Menyediakan metode untuk membuat dan memperbarui model Task.
  • Menjalankan logika bisnis, seperti membuat ID unik untuk setiap tugas.
  • Menggabungkan atau memetakan model data internal dari sumber data ke dalam model Task.
  • Menyinkronkan sumber data.

Bersiaplah untuk membuat kode.

  • Beralih ke tampilan Android dan luaskan paket com.example.android.architecture.blueprints.todoapp.data:

Jendela Project explorer menunjukkan folder dan file.

Gambar 8. Jendela Project explorer menunjukkan folder dan file.

Class Task sudah dibuat sehingga bagian lain aplikasi dapat dikompilasi. Mulai sekarang, Anda membuat sebagian besar class lapisan data dari awal dengan menambahkan implementasi ke file .kt kosong yang disediakan.

4. Menyimpan data secara lokal

Pada langkah ini, Anda akan membuat sumber data dan model data untuk database Room yang menyimpan tugas secara lokal di perangkat.

Hubungan antara repositori tugas, model, sumber data, dan database.

Gambar 9. Diagram yang menunjukkan hubungan antara repositori tugas, model, sumber data, dan database.

Membuat model data

Untuk menyimpan data di database Room, Anda perlu membuat entity database.

  • Buka file LocalTask.kt dalam data/source/local, lalu tambahkan kode berikut ke dalamnya:
@Entity(
    tableName = "task"
)
data class LocalTask(
    @PrimaryKey val id: String,
    var title: String,
    var description: String,
    var isCompleted: Boolean,
)

Class LocalTask mewakili data yang disimpan dalam tabel bernama task di database Room. Class ini sangat terkait dengan Room dan tidak boleh digunakan untuk sumber data lain seperti DataStore.

Awalan Local dalam nama class digunakan untuk menunjukkan bahwa data ini disimpan secara lokal. Awalan tersebut juga digunakan untuk membedakan class ini dari model data Task, yang diekspos ke lapisan lain di aplikasi. Dengan kata lain, LocalTask bersifat internal untuk lapisan data, dan Task bersifat eksternal untuk lapisan data.

Membuat sumber data

Sekarang, setelah Anda memiliki model data, buat sumber data untuk membuat, membaca, memperbarui, dan menghapus ( CRUD) model LocalTask. Karena menggunakan Room, Anda dapat menggunakan Objek Akses Data (anotasi @Dao) sebagai sumber data lokal.

  • Buat antarmuka Kotlin baru di file bernama TaskDao.kt.
@Dao
interface TaskDao {

    @Query("SELECT * FROM task")
    fun observeAll(): Flow<List<LocalTask>>

    @Upsert
    suspend fun upsert(task: LocalTask)

    @Upsert
    suspend fun upsertAll(tasks: List<LocalTask>)

    @Query("UPDATE task SET isCompleted = :completed WHERE id = :taskId")
    suspend fun updateCompleted(taskId: String, completed: Boolean)

    @Query("DELETE FROM task")
    suspend fun deleteAll()
}

Metode untuk membaca data diawali dengan observe. Metode ini adalah fungsi non-penangguhan yang menampilkan Flow. Setiap kali data yang mendasarinya berubah, item baru akan ditampilkan ke aliran data. Fitur yang berguna dari library Room (dan banyak library penyimpanan data lainnya) berarti Anda dapat memproses perubahan data, bukan mengumpulkan database untuk data baru.

Metode untuk menulis data menangguhkan fungsi karena metode tersebut melakukan operasi I/O.

Memperbarui skema database

Hal berikutnya yang perlu Anda lakukan adalah memperbarui database agar dapat menyimpan model LocalTask.

  1. Buka ToDoDatabase.kt dan ubah BlankEntity ke LocalTask.
  2. Hapus BlankEntity dan pernyataan import yang berulang.
  3. Tambahkan metode untuk menampilkan DAO bernama taskDao.

Class yang diperbarui akan terlihat seperti ini:

@Database(entities = [LocalTask::class], version = 1, exportSchema = false)
abstract class ToDoDatabase : RoomDatabase() {

    abstract fun taskDao(): TaskDao
}

Memperbarui konfigurasi Hilt

Project ini menggunakan Hilt untuk injeksi dependensi. Hilt perlu mengetahui cara membuat TaskDao sehingga dapat dimasukkan ke class yang menggunakannya.

  • Buka di/DataModules.kt dan tambahkan metode berikut ke DatabaseModule:
    @Provides
    fun provideTaskDao(database: ToDoDatabase) : TaskDao = database.taskDao()

Anda sekarang memiliki semua bagian yang diperlukan untuk membaca dan menulis tugas ke database lokal.

5. Menguji sumber data lokal

Pada langkah terakhir, Anda akan menulis cukup banyak kode, tetapi bagaimana Anda tahu kode itu bekerja dengan benar? Sangat mudah untuk membuat kesalahan dengan semua kueri SQL di TaskDao. Buat pengujian untuk memverifikasi bahwa TaskDao berperilaku sebagaimana mestinya.

Pengujian bukan bagian dari aplikasi, sehingga harus ditempatkan di folder yang berbeda. Ada dua folder pengujian yang ditandai dengan teks dalam tanda kurung di akhir nama paket:

Folder pengujian dan androidTest di Project explorer.

Gambar 10. Screenshot yang menunjukkan folder pengujian dan androidTest di Project explorer.

  • androidTest berisi pengujian yang dijalankan pada emulator atau perangkat Android. Pengujian ini dikenal sebagai uji instrumentasi.
  • test berisi pengujian yang dijalankan pada mesin host Anda, juga dikenal sebagai uji lokal.

TaskDao memerlukan database Room (yang hanya dapat dibuat di perangkat Android). Jadi, untuk mengujinya, Anda perlu membuat uji instrumentasi.

Membuat class pengujian

  • Luaskan folder androidTest dan buka TaskDaoTest.kt. Di dalamnya, buat class kosong bernama TaskDaoTest.
class TaskDaoTest {

}

Menambahkan database pengujian

  • Tambahkan ToDoDatabase dan lakukan inisialisasi sebelum setiap pengujian.
    private lateinit var database: ToDoDatabase

    @Before
    fun initDb() {
        database = Room.inMemoryDatabaseBuilder(
            getApplicationContext(),
            ToDoDatabase::class.java
        ).allowMainThreadQueries().build()
    }

Tindakan ini akan membuat database dalam memori sebelum setiap pengujian. Database dalam memori jauh lebih cepat daripada database berbasis disk. Pilihan ini baik untuk pengujian otomatis dengan data yang tidak perlu bertahan lebih lama dari pengujian.

Menambahkan pengujian

Tambahkan pengujian yang memverifikasi bahwa LocalTask dapat dimasukkan, dan bahwa LocalTask yang sama dapat dibaca menggunakan TaskDao.

Semua pengujian dalam codelab ini mengikuti struktur kondisi, jika, maka:

Kondisi

Database yang kosong

Jika

Tugas dimasukkan, dan Anda mulai mengamati aliran tugas

Maka

Item pertama dalam aliran tugas cocok dengan tugas yang dimasukkan

  1. Mulailah dengan membuat pengujian yang gagal. Tindakan ini akan memastikan bahwa pengujian benar-benar berjalan dan bahwa objek yang benar serta dependensinya telah diuji.
@Test
fun insertTaskAndGetTasks() = runTest {

    val task = LocalTask(
        title = "title",
        description = "description",
        id = "id",
        isCompleted = false,
    )
    database.taskDao().upsert(task)

    val tasks = database.taskDao().observeAll().first()

    assertEquals(0, tasks.size)
}
  1. Jalankan pengujian dengan mengklik Play di sebelah pengujian di ruang kosong

Tombol Play pengujian di ruang kosong editor kode.

Gambar 11. Screenshot yang menunjukkan tombol Play pengujian di ruang kosong editor kode.

Dalam jendela hasil pengujian, Anda akan melihat pengujian gagal dengan pesan expected:<0> but was:<1>. Hasil ini sudah diduga karena jumlah tugas dalam database adalah satu, bukan nol.

Pengujian yang gagal.

Gambar 12. Screenshot yang menunjukkan pengujian yang gagal.

  1. Hapus pernyataan assertEquals yang sudah ada.
  2. Tambahkan kode untuk menguji bahwa satu-satunya tugas disediakan oleh sumber data tersebut dan tugas ini sama dengan yang dimasukkan.

Urutan parameter untuk assertEquals harus selalu berupa nilai yang diharapkan, kemudian nilai sebenarnya**.**

assertEquals(1, tasks.size)
assertEquals(task, tasks[0])
  1. Jalankan pengujian lagi. Dalam jendela hasil pengujian, Anda akan melihat pengujian berhasil.

Pengujian yang berhasil.

Gambar 13. Screenshot yang menunjukkan pengujian yang berhasil.

6. Membuat sumber data jaringan

Tugas yang dapat disimpan secara lokal di perangkat adalah hal yang bagus, tetapi bagaimana jika Anda juga ingin menyimpan dan memuat tugas tersebut ke layanan jaringan? Mungkin aplikasi Android Anda hanyalah salah satu cara yang dapat dipakai pengguna untuk menambahkan tugas ke daftar TODO mereka. Tugas juga dapat dikelola melalui situs atau aplikasi desktop. Atau, mungkin Anda hanya ingin menyediakan cadangan data online sehingga pengguna dapat memulihkan data aplikasi meskipun mereka mengganti perangkat mereka.

Dalam skenario ini, Anda biasanya memiliki layanan berbasis jaringan yang dapat digunakan semua klien, termasuk aplikasi Android Anda, untuk memuat dan menyimpan data.

Pada langkah berikutnya, Anda akan membuat sumber data untuk berkomunikasi dengan layanan jaringan ini. Untuk tujuan codelab ini, langkah ini adalah layanan simulasi yang tidak terhubung ke layanan jaringan live, tetapi memberi Anda gambaran tentang cara penerapannya di aplikasi nyata.

Tentang layanan jaringan

Dalam contoh, API jaringan sangat sederhana. API itu hanya menjalankan dua operasi:

  • Menyimpan semua tugas, menimpa data yang ditulis sebelumnya.
  • Memuat semua tugas, yang menyediakan daftar semua tugas yang saat ini disimpan di layanan jaringan.

Membuat model data jaringan

Saat mendapatkan data dari API jaringan, biasanya data tersebut direpresentasikan secara berbeda dari data lokal. Representasi jaringan tugas mungkin memiliki kolom tambahan, atau mungkin menggunakan jenis atau nama kolom yang berbeda untuk mewakili nilai yang sama.

Untuk memperhitungkan perbedaan ini, buat model data khusus untuk jaringan.

  • Buka file NetworkTask.kt yang ada di data/source/network, lalu tambahkan kode berikut untuk mewakili kolom:
data class NetworkTask(
    val id: String,
    val title: String,
    val shortDescription: String,
    val priority: Int? = null,
    val status: TaskStatus = TaskStatus.ACTIVE
) {
    enum class TaskStatus {
        ACTIVE,
        COMPLETE
    }
}

Berikut ini perbedaan antara LocalTask dan NetworkTask:

  • Deskripsi tugas diberi nama shortDescription, bukan description.
  • Kolom isCompleted direpresentasikan sebagai enum status, yang memiliki dua kemungkinan nilai: ACTIVE dan COMPLETE.
  • Enum ini berisi kolom priority tambahan, yang merupakan bilangan bulat.

Membuat sumber data jaringan

  • Buka TaskNetworkDataSource.kt, lalu buat class bernama TaskNetworkDataSource dengan konten berikut:
class TaskNetworkDataSource @Inject constructor() {

    // A mutex is used to ensure that reads and writes are thread-safe.
    private val accessMutex = Mutex()
    private var tasks = listOf(
        NetworkTask(
            id = "PISA",
            title = "Build tower in Pisa",
            shortDescription = "Ground looks good, no foundation work required."
        ),
        NetworkTask(
            id = "TACOMA",
            title = "Finish bridge in Tacoma",
            shortDescription = "Found awesome girders at half the cost!"
        )
    )

    suspend fun loadTasks(): List<NetworkTask> = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        return tasks
    }

    suspend fun saveTasks(newTasks: List<NetworkTask>) = accessMutex.withLock {
        delay(SERVICE_LATENCY_IN_MILLIS)
        tasks = newTasks
    }
}

private const val SERVICE_LATENCY_IN_MILLIS = 2000L

Objek ini menyimulasikan interaksi dengan server, termasuk simulasi penundaan dua detik setiap kali loadTasks atau saveTasks dipanggil. Objek ini dapat mewakili latensi respons jaringan atau server.

Objek ini juga mencakup beberapa data pengujian yang Anda gunakan nanti untuk memastikan bahwa tugas berhasil dimuat dari jaringan.

Jika API server asli Anda menggunakan HTTP, pertimbangkan untuk menggunakan library seperti Ktor atau Retrofit untuk membangun sumber data jaringan Anda.

7. Membuat repositori tugas

Bagian-bagian telah menyatu.

Dependensi DefaultTaskRepository.

Gambar 14. Diagram yang menunjukkan dependensi DefaultTaskRepository.

Kita memiliki dua sumber data—satu untuk data lokal (TaskDao) dan satu untuk data jaringan (TaskNetworkDataSource). Masing-masing memungkinkan untuk membaca dan menulis, serta memiliki representasi tugasnya sendiri (LocalTask dan NetworkTask).

Sekarang saatnya membuat repositori yang menggunakan sumber data ini dan menyediakan API sehingga lapisan arsitektur lain dapat mengakses data tugas ini.

Mengekspos data

  1. Buka DefaultTaskRepository.kt di paket data, lalu buat class bernama DefaultTaskRepository, yang mengambil TaskDao dan TaskNetworkDataSource sebagai dependensi.
class DefaultTaskRepository @Inject constructor(
    private val localDataSource: TaskDao,
    private val networkDataSource: TaskNetworkDataSource,
) {

}

Data harus diekspos menggunakan flow. Hal ini memungkinkan pemanggil diberi tahu tentang perubahan data tersebut dari waktu ke waktu.

  1. Tambahkan metode bernama observeAll, yang menampilkan aliran model Task menggunakan Flow.
fun observeAll() : Flow<List<Task>> {
    // TODO add code to retrieve Tasks
}

Repositori harus mengekspos data dari satu sumber kebenaran. Artinya, data harus berasal dari satu sumber data saja. Sumber ini bisa berupa cache dalam memori, server jarak jauh, atau, dalam hal ini, database lokal.

Tugas dalam database lokal dapat diakses menggunakan TaskDao.observeAll, yang dengan mudah menampilkan flow. Tetapi, ini adalah flow model LocalTask, dengan LocalTask adalah model internal yang tidak boleh diekspos ke lapisan arsitektur lainnya.

Anda perlu mengubah LocalTask menjadi Task. Model ini adalah model eksternal yang merupakan bagian dari API lapisan data.

Memetakan model internal ke model eksternal

Untuk melakukan konversi ini, Anda perlu memetakan kolom dari LocalTask ke kolom di Task.

  1. Buat fungsi ekstensi untuk tindakan ini di LocalTask.
// Convert a LocalTask to a Task
fun LocalTask.toExternal() = Task(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)

// Convenience function which converts a list of LocalTasks to a list of Tasks
fun List<LocalTask>.toExternal() = map(LocalTask::toExternal) // Equivalent to map { it.toExternal() }

Sekarang, kapan pun Anda perlu mengubah LocalTask menjadi Task, Anda hanya perlu memanggil toExternal.

  1. Gunakan fungsi toExternal yang baru Anda buat di dalam observeAll:
fun observeAll(): Flow<List<Task>> {
    return localDataSource.observeAll().map { tasks ->
        tasks.toExternal()
    }
}

Setiap kali data tugas berubah di database lokal, daftar model LocalTask baru akan ditampilkan ke dalam aliran. Kemudian, setiap LocalTask dipetakan ke Task.

Bagus. Sekarang lapisan lain dapat menggunakan observeAll untuk mendapatkan semua model Task dari database lokal Anda dan diberi tahu setiap kali model Task tersebut berubah.

Memperbarui data

Aplikasi TODO tidak terlalu bagus jika Anda tidak dapat membuat dan memperbarui tugas. Anda sekarang menambahkan metode untuk melakukannya.

Metode untuk membuat, memperbarui, atau menghapus data adalah operasi sekali pakai, dan harus diimplementasikan menggunakan fungsi suspend.

  1. Tambahkan metode bernama create, yang mengambil title dan description sebagai parameter dan menampilkan ID tugas yang baru dibuat.
suspend fun create(title: String, description: String): String {
}

Perhatikan bahwa API lapisan data tidak memungkinkan Task dibuat oleh lapisan lain dengan hanya menyediakan metode create yang menerima parameter individual, bukan Task. Pendekatan ini merangkum:

  • Logika bisnis untuk membuat ID tugas unik.
  • Tempat tugas disimpan setelah pembuatan awal.
  1. Tambahkan metode untuk membuat ID tugas
// This method might be computationally expensive
private fun createTaskId() : String {
    return UUID.randomUUID().toString()
}
  1. Buat ID tugas menggunakan metode createTaskId yang baru ditambahkan
suspend fun create(title: String, description: String): String {
    val taskId = createTaskId()
}

Jangan memblokir thread utama

Tapi tunggu! Bagaimana jika pembuatan ID tugas mahal secara komputasi? Mungkin prosesnya menggunakan kriptografi untuk membuat kunci hash bagi ID, yang memakan waktu beberapa detik. Hal ini dapat menyebabkan UI mengalami jank jika dipanggil di thread utama.

Lapisan data memiliki tanggung jawab untuk memastikan tugas yang rumit atau berjalan lama tidak memblokir thread utama.

Untuk memperbaikinya, tentukan operator coroutine yang akan digunakan untuk menjalankan instruksi ini.

  1. Pertama, tambahkan CoroutineDispatcher sebagai dependensi untuk DefaultTaskRepository. Gunakan penentu @DefaultDispatcher yang sudah dibuat (didefinisikan dalam di/CoroutinesModule.kt) untuk memberi tahu Hilt agar memasukkan dependensi ini dengan Dispatchers.Default. Operator Default ditentukan karena dioptimalkan untuk tugas intensif CPU. Baca selengkapnya tentang dispatcher coroutine di sini.
class DefaultTaskRepository @Inject constructor(
   private val localDataSource: TaskDao,
   private val networkDataSource: TaskNetworkDataSource,
   @DefaultDispatcher private val dispatcher: CoroutineDispatcher,
)
  1. Sekarang, tempatkan panggilan ke UUID.randomUUID().toString() di dalam blok withContext.
val taskId = withContext(dispatcher) {
    createTaskId()
}

Baca selengkapnya tentang threading di lapisan data.

Membuat dan menyimpan tugas

  1. Sekarang setelah Anda memiliki ID tugas, gunakan bersama dengan parameter yang disediakan untuk membuat Task yang baru.
suspend fun create(title: String, description: String): String {
    val taskId = withContext(dispatcher) {
        createTaskId()
    }
    val task = Task(
        title = title,
        description = description,
        id = taskId,
    )
}

Sebelum memasukkan tugas ke sumber data lokal, Anda perlu memetakannya ke LocalTask.

  1. Tambahkan fungsi ekstensi berikut ke akhir LocalTask. Fungsi ini adalah fungsi pemetaan terbalik ke LocalTask.toExternal, yang telah Anda buat sebelumnya.
fun Task.toLocal() = LocalTask(
    id = id,
    title = title,
    description = description,
    isCompleted = isCompleted,
)
  1. Gunakan fungsi ini di dalam create untuk memasukkan tugas ke sumber data lokal, lalu tampilkan taskId.
suspend fun create(title: String, description: String): Task {
    ...
    localDataSource.upsert(task.toLocal())
    return taskId
}

Menyelesaikan tugas

  • Buat metode tambahan, complete, yang menandai Task sebagai selesai.
suspend fun complete(taskId: String) {
    localDataSource.updateCompleted(taskId, true)
}

Anda sekarang memiliki beberapa metode yang berguna untuk membuat dan menyelesaikan tugas.

Menyinkronkan data

Di aplikasi ini, sumber data jaringan digunakan sebagai cadangan online yang diperbarui setiap kali data ditulis secara lokal. Data dimuat dari jaringan setiap kali pengguna meminta pemuatan ulang.

Diagram berikut merangkum perilaku untuk setiap jenis operasi.

Jenis operasi

Metode repositori

Langkah

Pergerakan data

Muat

observeAll

Muat data dari database lokal

Aliran data dari sumber data lokal ke repositori tugas.Gambar 15. Diagram yang menampilkan aliran data dari sumber data lokal ke repositori tugas.

Simpan

createcomplete

1. Tulis data ke local database2. Salin semua data ke jaringan, timpa semuanya

Aliran data dari repositori tugas ke sumber data lokal, kemudian ke sumber data jaringan.Gambar 16. Diagram yang menunjukkan aliran data dari repositori tugas ke sumber data lokal, kemudian ke sumber data jaringan.

Muat ulang

refresh

1. Muat data dari network2. Salin ke database lokal, timpa semuanya

Aliran data dari sumber data jaringan ke sumber data lokal, kemudian ke repositori tugas.Gambar 17. Diagram yang menunjukkan aliran data dari sumber data jaringan ke sumber data lokal, kemudian ke repositori tugas.

Menyimpan dan memuat ulang data jaringan

Repositori Anda sudah memuat tugas dari sumber data lokal. Untuk menyelesaikan algoritma sinkronisasi, Anda perlu membuat metode untuk menyimpan dan memuat ulang data dari sumber data jaringan.

  1. Pertama, buat fungsi pemetaan dari LocalTask ke NetworkTask dan sebaliknya di dalam NetworkTask.kt. Menempatkan fungsi di dalam LocalTask.kt juga sama validnya.
fun NetworkTask.toLocal() = LocalTask(
    id = id,
    title = title,
    description = shortDescription,
    isCompleted = (status == NetworkTask.TaskStatus.COMPLETE),
)

fun List<NetworkTask>.toLocal() = map(NetworkTask::toLocal)

fun LocalTask.toNetwork() = NetworkTask(
    id = id,
    title = title,
    shortDescription = description,
    status = if (isCompleted) { NetworkTask.TaskStatus.COMPLETE } else { NetworkTask.TaskStatus.ACTIVE }
)

fun List<LocalTask>.toNetwork() = map(LocalTask::toNetwork)

Di sini Anda dapat melihat keuntungan memiliki model terpisah untuk setiap sumber data—pemetaan satu jenis data ke jenis data lainnya dienkapsulasi ke dalam fungsi terpisah.

  1. Tambahkan metode refresh di akhir DefaultTaskRepository.
suspend fun refresh() {
    val networkTasks = networkDataSource.loadTasks()
    localDataSource.deleteAll()
    val localTasks = withContext(dispatcher) {
        networkTasks.toLocal()
    }
    localDataSource.upsertAll(networkTasks.toLocal())
}

Tindakan ini akan menggantikan semua tugas lokal dengan tugas dari jaringan. withContext digunakan untuk operasi toLocal massal karena ada tugas yang jumlahnya tidak diketahui, dan setiap operasi pemetaan mungkin mahal secara komputasi.

  1. Tambahkan metode saveTasksToNetwork di akhir DefaultTaskRepository.
private suspend fun saveTasksToNetwork() {
    val localTasks = localDataSource.observeAll().first()
    val networkTasks = withContext(dispatcher) {
        localTasks.toNetwork()
    }
    networkDataSource.saveTasks(networkTasks)
}

Tindakan ini akan menggantikan semua tugas jaringan dengan tugas dari sumber data lokal.

  1. Sekarang perbarui metode yang ada, yang memperbarui tugas create dan complete sehingga data lokal disimpan ke jaringan saat data itu berubah.
    suspend fun create(title: String, description: String): String {
        ...
        saveTasksToNetwork()
        return taskId
    }

     suspend fun complete(taskId: String) {
        localDataSource.updateCompleted(taskId, true)
        saveTasksToNetwork()
    }

Jangan membuat pemanggil menunggu

Jika Anda menjalankan kode ini, Anda akan melihat bahwa saveTasksToNetwork memblokir. Hal ini berarti pemanggil dari create dan complete terpaksa menunggu sampai data disimpan ke jaringan sebelum pemanggil tersebut dapat memastikan bahwa operasi telah selesai. Dalam simulasi sumber data jaringan, waktu yang dibutuhkan hanya dua detik, tetapi dalam aplikasi sebenarnya mungkin lebih lama—atau tidak pernah jika tidak ada koneksi jaringan.

Kondisi ini terlalu membatasi dan kemungkinan besar akan menyebabkan pengalaman pengguna yang buruk—tidak ada yang mau menunggu untuk membuat tugas, terutama saat mereka sedang sibuk.

Solusi yang lebih baik adalah menggunakan cakupan coroutine yang berbeda untuk menyimpan data ke jaringan. Solusi ini memungkinkan operasi selesai di latar belakang tanpa membuat pemanggil menunggu hasilnya.

  1. Tambahkan cakupan coroutine sebagai parameter untuk DefaultTaskRepository.
class DefaultTaskRepository @Inject constructor(
    // ...other parameters...
    @ApplicationScope private val scope: CoroutineScope,
)

Penentu Hilt @ApplicationScope (didefinisikan dalam di/CoroutinesModule.kt) digunakan untuk memasukkan cakupan yang mengikuti siklus proses aplikasi.

  1. Gabungkan kode di dalam saveTasksToNetwork dengan scope.launch.
    private fun saveTasksToNetwork() {
        scope.launch {
            val localTasks = localDataSource.observeAll().first()
            val networkTasks = withContext(dispatcher) {
                localTasks.toNetwork()
            }
            networkDataSource.saveTasks(networkTasks)
        }
    }

Sekarang saveTasksToNetwork segera ditampilkan dan tugas disimpan ke jaringan di latar belakang.

8. Menguji repositori tugas

Wah, banyak sekali fungsi yang ditambahkan ke lapisan data Anda. Saatnya untuk memastikan semuanya berfungsi dengan membuat pengujian unit untuk DefaultTaskRepository.

Anda perlu membuat instance subjek yang diuji (DefaultTaskRepository) dengan dependensi uji untuk sumber data lokal dan jaringan. Pertama, Anda perlu membuat dependensi tersebut.

  1. Di jendela Project Explorer, perluas folder (test), lalu perluas folder source.local dan buka FakeTaskDao.kt.

File FakeTaskDao.kt dalam struktur folder Project.

Gambar 18. Screenshot yang menunjukkan FakeTaskDao.kt dalam struktur folder Project.

  1. Tambahkan konten berikut:
class FakeTaskDao(initialTasks: List<LocalTask>) : TaskDao {

    private val _tasks = initialTasks.toMutableList()
    private val tasksStream = MutableStateFlow(_tasks.toList())

    override fun observeAll(): Flow<List<LocalTask>> = tasksStream

    override suspend fun upsert(task: LocalTask) {
        _tasks.removeIf { it.id == task.id }
        _tasks.add(task)
        tasksStream.emit(_tasks)
    }

    override suspend fun upsertAll(tasks: List<LocalTask>) {
        val newTaskIds = tasks.map { it.id }
        _tasks.removeIf { newTaskIds.contains(it.id) }
        _tasks.addAll(tasks)
    }

    override suspend fun updateCompleted(taskId: String, completed: Boolean) {
        _tasks.firstOrNull { it.id == taskId }?.let { it.isCompleted = completed }
        tasksStream.emit(_tasks)
    }

    override suspend fun deleteAll() {
        _tasks.clear()
        tasksStream.emit(_tasks)
    }
}

Dalam aplikasi sebenarnya, Anda juga akan membuat dependensi palsu untuk mengganti TaskNetworkDataSource (dengan membuat objek palsu dan objek asli mengimplementasikan antarmuka umum). Tetapi, untuk tujuan codelab ini, Anda akan menggunakannya secara langsung.

  1. Dalam DefaultTaskRepositoryTest, tambahkan hal berikut.

Aturan yang menetapkan operator utama untuk digunakan di semua pengujian.

Beberapa data uji.

Dependensi pengujian untuk sumber data lokal dan jaringan.

Subjek yang diuji: DefaultTaskRepository.

class DefaultTaskRepositoryTest {

    private var testDispatcher = UnconfinedTestDispatcher()
    private var testScope = TestScope(testDispatcher)

    private val localTasks = listOf(
        LocalTask(id = "1", title = "title1", description = "description1", isCompleted = false),
        LocalTask(id = "2", title = "title2", description = "description2", isCompleted = true),
    )

    private val localDataSource = FakeTaskDao(localTasks)
    private val networkDataSource = TaskNetworkDataSource()
    private val taskRepository = DefaultTaskRepository(
        localDataSource = localDataSource,
        networkDataSource = networkDataSource,
        dispatcher = testDispatcher,
        scope = testScope
    )
}

Bagus. Sekarang Anda dapat mulai menulis pengujian unit. Ada tiga area utama yang harus Anda uji: baca, tulis, dan sinkronisasi data.

Menguji data yang diekspos

Berikut ini cara Anda menguji apakah repositori mengekspos data dengan benar. Pengujian diberikan dalam struktur kondisi, jika, maka. Contoh:

Kondisi

Sumber data lokal memiliki beberapa tugas yang sudah ada

Jika

Aliran tugas diperoleh dari repositori menggunakan observeAll

Maka

Item pertama dalam aliran tugas cocok dengan representasi eksternal tugas di sumber data lokal

  • Buat pengujian bernama observeAll_exposesLocalData dengan konten berikut:
@Test
fun observeAll_exposesLocalData() = runTest {
    val tasks = taskRepository.observeAll().first()
    assertEquals(localTasks.toExternal(), tasks)
}

Gunakan fungsi first untuk mendapatkan item pertama dari aliran tugas.

Menguji pembaruan data

Selanjutnya, tulis pengujian yang memastikan tugas dibuat dan disimpan ke sumber data jaringan.

Kondisi

Database yang kosong

Jika

Tugas dibuat dengan memanggil create

Maka

Tugas dibuat di sumber data lokal dan jaringan

  1. Buat pengujian bernama onTaskCreation_localAndNetworkAreUpdated.
@Test
    fun onTaskCreation_localAndNetworkAreUpdated() = testScope.runTest {
        val newTaskId = taskRepository.create(
            localTasks[0].title,
            localTasks[0].description
        )

        val localTasks = localDataSource.observeAll().first()
        assertEquals(true, localTasks.map { it.id }.contains(newTaskId))

        val networkTasks = networkDataSource.loadTasks()
        assertEquals(true, networkTasks.map { it.id }.contains(newTaskId))
    }

Selanjutnya, pastikan bahwa saat tugas selesai, tugas itu ditulis dengan benar ke sumber data lokal dan disimpan ke sumber data jaringan.

Kondisi

Sumber data lokal berisi tugas

Jika

Tugas itu diselesaikan dengan memanggil complete

Maka

Data lokal dan data jaringan juga diperbarui

  1. Buat pengujian bernama onTaskCompletion_localAndNetworkAreUpdated.
    @Test
    fun onTaskCompletion_localAndNetworkAreUpdated() = testScope.runTest {
        taskRepository.complete("1")

        val localTasks = localDataSource.observeAll().first()
        val isLocalTaskComplete = localTasks.firstOrNull { it.id == "1" } ?.isCompleted
        assertEquals(true, isLocalTaskComplete)

        val networkTasks = networkDataSource.loadTasks()
        val isNetworkTaskComplete =
            networkTasks.firstOrNull { it.id == "1"} ?.status == NetworkTask.TaskStatus.COMPLETE
        assertEquals(true, isNetworkTaskComplete)
    }

Menguji pemuatan ulang data

Terakhir, uji apakah operasi pemuatan ulang berhasil.

Kondisi

Sumber data jaringan berisi data

Jika

refresh dipanggil.

Maka

data lokal sama dengan data jaringan

  • Buat pengujian bernama onRefresh_localIsEqualToNetwork
@Test
    fun onRefresh_localIsEqualToNetwork() = runTest {
        val networkTasks = listOf(
            NetworkTask(id = "3", title = "title3", shortDescription = "desc3"),
            NetworkTask(id = "4", title = "title4", shortDescription = "desc4"),
        )
        networkDataSource.saveTasks(networkTasks)

        taskRepository.refresh()

        assertEquals(networkTasks.toLocal(), localDataSource.observeAll().first())
    }

Selesai. Jalankan pengujian, dan semuanya harus berhasil.

9. Memperbarui lapisan UI

Sekarang Anda tahu bahwa lapisan data berfungsi. Saatnya menghubungkannya ke lapisan UI.

Memperbarui model tampilan untuk layar daftar tugas

Diawali dengan TasksViewModel. Model ini adalah model tampilan untuk menampilkan layar pertama di aplikasi—daftar semua tugas yang sedang aktif.

  1. Buka class ini dan tambahkan DefaultTaskRepository sebagai parameter konstruktor.
@HiltViewModel
class TasksViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
) : ViewModel () { /* ... */ }
  1. Lakukan inisialisasi variabel tasksStream menggunakan repositori.
private val tasksStream = taskRepository.observeAll()

Model tampilan Anda sekarang memiliki akses ke semua tugas yang disediakan oleh repositori, dan akan menerima daftar tugas baru setiap kali data berubah—hanya dalam satu baris kode.

  1. Yang tersisa hanyalah menghubungkan tindakan pengguna ke metode yang sesuai di repositori. Temukan metode complete dan perbarui ke:
fun complete(task: Task, completed: Boolean) {
    viewModelScope.launch {
        if (completed) {
            taskRepository.complete(task.id)
            showSnackbarMessage(R.string.task_marked_complete)
        } else {
            ...
       }
    }
}
  1. Lakukan hal yang sama dengan refresh.
    fun refresh() {
        _isLoading.value = true
        viewModelScope.launch {
            taskRepository.refresh()
            _isLoading.value = false
        }
    }

Memperbarui model tampilan untuk layar tambahkan tugas

  1. Buka AddEditTaskViewModel dan tambahkan DefaultTaskRepository sebagai parameter konstruktor, sama seperti yang Anda lakukan pada langkah sebelumnya.
class AddEditTaskViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val taskRepository: DefaultTaskRepository,
)
  1. Perbarui metode create menjadi yang berikut:
    private fun createNewTask() = viewModelScope.launch {
        taskRepository.create(uiState.value.title, uiState.value.description)
        _uiState.update {
            it.copy(isTaskSaved = true)
        }
    }

Menjalankan aplikasi

  1. Inilah saat yang Anda tunggu-tunggu—waktu untuk menjalankan aplikasi. Anda akan melihat layar yang menunjukkan Anda tidak memiliki tugas..

Layar tugas aplikasi ketika tidak ada tugas.

Gambar 19. Screenshot layar tugas aplikasi ketika tidak ada tugas.

  1. Ketuk tiga titik di pojok kanan atas, lalu tekan Refresh.

Layar tugas aplikasi dengan menu tindakan ditampilkan.

Gambar 20. Screenshot layar tugas aplikasi dengan menu tindakan ditampilkan.

Anda akan melihat indikator lingkaran berputar muncul selama dua detik, lalu tugas pengujian yang Anda tambahkan sebelumnya akan muncul.

Layar tugas aplikasi dengan dua tugas ditampilkan.

Gambar 21. Screenshot layar tugas aplikasi dengan dua tugas ditampilkan.

  1. Sekarang ketuk tanda tambah di pojok kanan bawah untuk menambahkan tugas baru. Isi kolom judul dan deskripsi.

Layar tambahkan tugas aplikasi.

Gambar 22. Screenshot layar tambahkan tugas aplikasi.

  1. Ketuk tombol centang di pojok kanan bawah untuk menyimpan tugas.

Layar tugas aplikasi setelah tugas ditambahkan.

Gambar 23. Screenshot layar tugas aplikasi setelah tugas ditambahkan.

  1. Centang kotak di samping tugas untuk menandai tugas sebagai selesai.

Layar tugas aplikasi menampilkan tugas yang telah diselesaikan.

Gambar 24. Screenshot layar tugas aplikasi yang menampilkan tugas yang telah diselesaikan.

10. Selamat!

Anda telah berhasil membuat lapisan data untuk aplikasi.

Lapisan data membentuk bagian penting dari arsitektur aplikasi Anda. Bagian ini adalah fondasi tempat lapisan lain dapat dibangun. Jadi, melakukannya dengan benar memungkinkan aplikasi Anda menyesuaikan dengan kebutuhan pengguna dan bisnis Anda.

Yang telah Anda pelajari

  • Peran lapisan data dalam arsitektur aplikasi Android.
  • Cara membuat sumber dan model data.
  • Peran repositori, dan caranya mengekspos data serta menyediakan metode sekali pakai untuk memperbarui data.
  • Kapan harus mengganti operator coroutine, dan mengapa penting untuk melakukannya.
  • Sinkronisasi data menggunakan beberapa sumber data.
  • Cara membuat pengujian unit dan pengujian berinstrumen untuk class lapisan data umum.

Tantangan selanjutnya

Jika Anda menginginkan tantangan lain, terapkan fitur-fitur berikut:

  • Aktifkan kembali tugas setelah ditandai sebagai selesai.
  • Edit judul dan deskripsi tugas dengan mengetuknya.

Tidak ada instruksi yang diberikan. Semua terserah Anda. Jika Anda menemui kesulitan, lihat aplikasi yang berfungsi penuh di cabang main.

git checkout main

Langkah berikutnya

Untuk mempelajari lebih lanjut lapisan data, lihat dokumentasi resmi dan panduan untuk aplikasi offline-first. Anda juga dapat mempelajari lapisan arsitektur lainnya, lapisan UI dan lapisan domain.

Untuk contoh di dunia nyata yang lebih kompleks, lihat aplikasi Now in Android.