Dalam codelab ini, Anda akan mempelajari cara menggunakan builder LiveData
untuk mengombinasi coroutine Kotlin dengan LiveData
di aplikasi Android. Kita juga akan menggunakan Flow Asinkron Coroutines, yang merupakan jenis dari library coroutines untuk mewakili urutan (atau aliran) asinkron nilai, guna mengimplementasikan hal yang sama.
Anda akan memulai dengan aplikasi yang ada, dibuat menggunakan Komponen Arsitektur Android, yang menggunakan LiveData
untuk mendapatkan daftar objek dari database Room
dan menampilkannya di tata letak petak RecyclerView
.
Berikut ini beberapa cuplikan kode untuk memberikan ide tentang apa yang akan Anda lakukan. Berikut adalah kode yang ada untuk membuat kueri database Room:
val plants: LiveData<List<Plant>> = plantDao.getPlants()
LiveData
akan diupdate menggunakan builder LiveData
dan coroutine dengan logika pengurutan tambahan:
val plants: LiveData<List<Plant>> = liveData<List<Plant>> {
val plantsLiveData = plantDao.getPlants()
val customSortOrder = plantsListSortOrderCache.getOrAwait()
emitSource(plantsLiveData.map { plantList -> plantList.applySort(customSortOrder) })
}
Anda juga akan mengimplementasikan logika yang sama dengan Flow
:
private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()
val plantsFlow: Flow<List<Plant>>
get() = plantDao.getPlantsFlow()
.combine(customSortFlow) { plants, sortOrder ->
plants.applySort(sortOrder)
}
.flowOn(defaultDispatcher)
.conflate()
Prasyarat
- Pengalaman dengan Komponen Arsitektur
ViewModel
,LiveData
,Repository
, danRoom
. - Pengalaman dengan sintaks Kotlin, termasuk fungsi ekstensi dan lambda.
- Pengalaman dengan Coroutine Kotlin.
- Pemahaman dasar tentang menggunakan thread pada Android, termasuk thread utama, thread latar belakang, dan callback.
Yang akan Anda lakukan
- Mengonversi
LiveData
yang ada untuk menggunakan builderLiveData
yang cocok untuk coroutine Kotlin. - Menambahkan logika dalam builder
LiveData
. - Menggunakan
Flow
untuk operasi asinkron. - Mengombinasikan
Flows
dan mentransformasi beberapa sumber asinkron. - Mengontrol secara serentak dengan
Flows
. - Mempelajari cara memilih antara
LiveData
danFlow.
Yang akan Anda butuhkan
- Android Studio 4.1 atau yang lebih baru. Codelab mungkin berfungsi dengan versi lain, namun beberapa hal mungkin hilang atau terlihat berbeda.
Jika Anda mengalami masalah (bug kode, kesalahan gramatikal, susunan kata yang tidak jelas, dll.) saat mengerjakan codelab ini, laporkan masalah tersebut melalui link "Laporkan kesalahan" di pojok kiri bawah codelab.
Download kode
Klik link berikut untuk mendownload semua kode untuk codelab ini:
... atau membuat duplikat repositori GitHub dari command line dengan menggunakan perintah berikut:
$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git
Kode untuk codelab ini ada di direktori advanced-coroutines-codelab
.
Pertanyaan umum (FAQ)
Pertama-tama, mari kita lihat bagaimana tampilan aplikasi contoh awal. Ikuti petunjuk ini untuk membuka aplikasi contoh di Android Studio.
- Jika Anda mendownload file zip
kotlin-coroutines
, ekstrak zip file tersebut. - Buka direktori
advanced-coroutines-codelab
di Android Studio. - Pastikan
start
dipilih di drop-down konfigurasi. - Klik tombol Run , lalu pilih perangkat yang diemulasi atau sambungkan perangkat Android Anda. Perangkat harus dapat menjalankan Android Lollipop (SDK minimum yang didukung adalah 21).
Saat aplikasi pertama kali dijalankan, daftar kartu akan muncul, masing-masing menampilkan nama dan gambar tanaman tertentu:
Setiap Plant
memiliki growZoneNumber
, atribut yang mewakili wilayah tempat tanaman kemungkinan besar akan tumbuh. Pengguna dapat mengetuk ikon filter untuk beralih antara menampilkan semua tanaman dan tanaman untuk zona tumbuh tertentu, yang di-hardcode ke zona 9. Tekan tombol filter beberapa kali untuk melihat cara kerjanya.
Ringkasan arsitektur
Aplikasi ini menggunakan Komponen Arsitektur untuk memisahkan kode UI dalam MainActivity
dan PlantListFragment
dari logika aplikasi di PlantListViewModel
. PlantRepository
menyediakan jembatan antara ViewModel
dan PlantDao
, yang mengakses database Room
untuk menampilkan daftar objek Plant
. UI kemudian mengambil daftar tanaman ini dan menampilkannya dalam tata letak petak RecyclerView
.
Sebelum kita mulai mengubah kode, mari lihat sekilas bagaimana data mengalir dari database ke UI. Berikut adalah bagaimana daftar tanaman dimuat di ViewModel
:
PlantListViewModel.kt
val plants: LiveData<List<Plant>> = growZone.switchMap { growZone ->
if (growZone == NoGrowZone) {
plantRepository.plants
} else {
plantRepository.getPlantsWithGrowZone(growZone)
}
}
GrowZone
adalah class inline yang hanya berisi Int
yang merepresentasikan zonanya. NoGrowZone
menunjukkan tidak adanya zona, dan hanya digunakan untuk pemfilteran.
Plant.kt
inline class GrowZone(val number: Int)
val NoGrowZone = GrowZone(-1)
growZone
dialihkan saat tombol filter diketuk. Kami menggunakan switchMap
untuk menentukan daftar tanaman yang akan ditampilkan.
Berikut adalah tampilan repositori dan Objek Akses Data (DAO) untuk mengambil data tanaman dari database:
PlantDao.kt
@Query("SELECT * FROM plants ORDER BY name")
fun getPlants(): LiveData<List<Plant>>
@Query("SELECT * FROM plants WHERE growZoneNumber = :growZoneNumber ORDER BY name")
fun getPlantsWithGrowZoneNumber(growZoneNumber: Int): LiveData<List<Plant>>
PlantRepository.kt
val plants = plantDao.getPlants()
fun getPlantsWithGrowZone(growZone: GrowZone) =
plantDao.getPlantsWithGrowZoneNumber(growZone.number)
Meskipun sebagian besar modifikasi kode berada di PlantListViewModel
dan PlantRepository
, ada baiknya Anda meluangkan waktu untuk memahami struktur project, yang berfokus pada bagaimana data tanaman ditampilkan melalui berbagai lapisan dari database ke Fragment
. Pada langkah berikutnya, kita akan memodifikasi kode untuk menambahkan pengurutan khusus menggunakan builder LiveData
.
Daftar tanaman saat ini ditampilkan dalam urutan abjad, tetapi kami ingin mengubah urutan daftar ini dengan mencantumkan tanaman tertentu terlebih dahulu, lalu sisanya dalam urutan abjad. Ini serupa dengan aplikasi belanja yang menampilkan hasil bersponsor di bagian atas daftar item yang tersedia untuk dibeli. Tim produk kami menginginkan kemampuan untuk mengubah rangkaian pengurutan secara dinamis tanpa mengirimkan versi baru aplikasi, sehingga kita akan mengambil daftar tanaman untuk diurutkan terlebih dahulu dari backend.
Berikut tampilan aplikasi dengan pengurutan khusus:
Daftar rangkaian pengurutan khusus terdiri dari empat tanaman: Jeruk, Bunga Matahari, Anggur, dan Alpukat. Perhatikan bagaimana urutan tersebut muncul pertama dalam daftar, lalu diikuti oleh tanaman lainnya dalam urutan abjad.
Sekarang, jika tombol filter ditekan (dan hanya GrowZone
9 tanaman yang ditampilkan), Bunga Matahari menghilang dari daftar karena GrowZone
-nya bukan 9. Tiga tanaman lainnya dalam daftar urutan khusus berada di GrowZone
9, sehingga akan tetap berada di bagian atas daftar. Satu-satunya tanaman lainnya di GrowZone
9 adalah Tomat, yang muncul terakhir dalam daftar ini.
Mari kita mulai menulis kode untuk mengimplementasikan pengurutan khusus.
Kita akan mulai dengan menulis fungsi penangguhan untuk mengambil rangkaian pengurutan khusus dari jaringan, lalu meng-cache-nya dalam memori.
Tambahkan kode berikut ke PlantRepository
:
PlantRepository.kt
private var plantsListSortOrderCache =
CacheOnSuccess(onErrorFallback = { listOf<String>() }) {
plantService.customPlantSortOrder()
}
plantsListSortOrderCache
digunakan sebagai cache dalam memori untuk urutan khusus. Objek ini akan kembali ke daftar kosong jika terjadi error jaringan, sehingga aplikasi kami masih dapat menampilkan data meskipun rangkaian pengurutan tidak diambil.
Kode ini menggunakan class utilitas CacheOnSuccess
yang disediakan di modul sunflower
untuk menangani penyimpanan ke cache. Dengan memisahkan detail pengimplementasian cache seperti ini, kode aplikasi dapat menjadi lebih sederhana. Karena CacheOnSuccess
sudah diuji dengan baik, kita tidak perlu menulis terlalu banyak pengujian untuk repositori kami guna memastikan perilaku yang tepat. Sebaiknya perkenalkan abstraksi tingkat tinggi yang serupa dalam kode Anda saat menggunakan kotlinx-coroutines
.
Sekarang mari kita gabungkan beberapa logika untuk menerapkan pengurutan ke daftar tanaman.
Tambahkan kode berikut ke PlantRepository:
PlantRepository.kt
private fun List<Plant>.applySort(customSortOrder: List<String>): List<Plant> {
return sortedBy { plant ->
val positionForItem = customSortOrder.indexOf(plant.plantId).let { order ->
if (order > -1) order else Int.MAX_VALUE
}
ComparablePair(positionForItem, plant.name)
}
}
Fungsi ekstensi ini akan mengatur ulang daftar, menempatkan Plants
yang ada di customSortOrder
di bagian depan daftar.
Setelah logika pengurutan diterapkan, ganti kode untuk plants
dan getPlantsWithGrowZone
dengan LiveData
builder di bawah:
PlantRepository.kt
val plants: LiveData<List<Plant>> = liveData<List<Plant>> {
val plantsLiveData = plantDao.getPlants()
val customSortOrder = plantsListSortOrderCache.getOrAwait()
emitSource(plantsLiveData.map {
plantList -> plantList.applySort(customSortOrder)
})
}
fun getPlantsWithGrowZone(growZone: GrowZone) = liveData {
val plantsGrowZoneLiveData = plantDao.getPlantsWithGrowZoneNumber(growZone.number)
val customSortOrder = plantsListSortOrderCache.getOrAwait()
emitSource(plantsGrowZoneLiveData.map { plantList ->
plantList.applySort(customSortOrder)
})
}
Sekarang jika Anda menjalankan aplikasi, daftar tanaman yang diurutkan khusus akan muncul:
Builder LiveData
memungkinkan kita menghitung nilai secara asinkron, karena liveData
didukung oleh coroutine. Di sini kita memiliki fungsi penangguhan untuk mengambil daftar LiveData
tanaman dari database, selagi memanggil fungsi penangguhan untuk mendapatkan rangkaian pengurutan khusus. Kami kemudian menggabungkan kedua nilai ini untuk mengurutkan daftar tanaman dan menampilkan nilai, semuanya dalam builder.
Coroutine memulai eksekusi ketika diamati, dan dibatalkan ketika coroutine berhasil diselesaikan atau jika panggilan database atau jaringan gagal.
Pada langkah berikutnya, kita akan mempelajari variasi getPlantsWithGrowZone
menggunakan Transformasi.
Sekarang kita akan memodifikasi PlantRepository
untuk mengimplementasikan transformasi yang ditangguhkan karena setiap nilai diproses, sambil mempelajari cara membuat transformasi asinkron yang kompleks di LiveData
. Sebagai prasyarat, mari kita buat versi algoritma pengurutan yang aman digunakan di thread utama. Kita dapat menggunakan withContext
untuk beralih ke petugas operator lain hanya untuk lambda dan melanjutkan di petugas operator yang sudah digunakan sejak awal.
Tambahkan kode berikut ke PlantRepository
:
PlantRepository.kt
@AnyThread
suspend fun List<Plant>.applyMainSafeSort(customSortOrder: List<String>) =
withContext(defaultDispatcher) {
this@applyMainSafeSort.applySort(customSortOrder)
}
Kemudian kita dapat menggunakan pengurutan utama yang aman ini dengan builder LiveData
. Update blok untuk menggunakan switchMap
, yang memungkinkan Anda menunjuk ke LiveData
baru setiap kali nilai baru diterima.
PlantRepository.kt
fun getPlantsWithGrowZone(growZone: GrowZone) =
plantDao.getPlantsWithGrowZoneNumber(growZone.number)
.switchMap { plantList ->
liveData {
val customSortOrder = plantsListSortOrderCache.getOrAwait()
emit(plantList.applyMainSafeSort(customSortOrder))
}
}
Dibandingkan dengan versi sebelumnya, setelah rangkaian pengurutan khusus diterima dari jaringan, penyortiran khusus ini dapat digunakan dengan applyMainSafeSort
utama baru yang aman. Hasil ini kemudian ditampilkan ke switchMap
sebagai nilai baru yang ditampilkan oleh getPlantsWithGrowZone
.
Mirip dengan LiveData plants
di atas, coroutine memulai eksekusi ketika teridentifikasi dan dihentikan baik saat penyelesaian atau jika panggilan database atau jaringan gagal. Perbedaannya di sini adalah aman karena melakukan panggilan jaringan di peta karena di-cache.
Sekarang mari kita lihat bagaimana kode ini diimplementasikan dengan Flow, dan bandingkan penerapannya.
Kita akan membuat logika yang sama menggunakan Flow dari kotlinx-coroutines
. Sebelum melakukannya, mari kita lihat apa itu flow dan bagaimana Anda bisa memasukkannya ke dalam aplikasi Anda.
Flow adalah versi asinkron dari Urutan, yaitu jenis kumpulan yang nilainya dibuat dengan sangat lambat. Sama seperti urutan, flow menghasilkan setiap nilai sesuai permintaan setiap kali nilai dibutuhkan, dan flow dapat berisi jumlah nilai yang tak terbatas.
Jadi, mengapa Kotlin memperkenalkan jenis Flow
baru, dan apa bedanya dengan urutan reguler? Jawabannya ada pada keajaiban asinkronik. Flow
mencakup dukungan penuh untuk coroutine. Artinya, Anda dapat membuat, mentransformasi, dan memakai Flow
dengan coroutine. Anda juga dapat mengontrol konkurensi, yang berarti mengoordinasikan eksekusi beberapa coroutine secara deklaratif dengan Flow
.
Cara ini membuka banyak kemungkinan yang menarik.
Flow
dapat digunakan dengan gaya pemrograman yang sepenuhnya reaktif. Jika Anda pernah menggunakan fitur seperti RxJava
sebelumnya, Flow
menyediakan fungsi yang serupa. Logika aplikasi dapat digambarkan secara ringkas dengan mentransformasi flow dengan operator fungsional seperti map
, flatMapLatest
, combine
, dan seterusnya.
Flow
juga mendukung fungsi penangguhan pada sebagian besar operator. Ini memungkinkan Anda melakukan tugas asinkron berurutan di dalam operator seperti map
. Dengan menggunakan operasi penangguhan di dalam flow, seringkali menghasilkan kode yang lebih pendek dan lebih mudah dibaca daripada kode setara dalam gaya yang sepenuhnya reaktif.
Dalam codelab ini, kita akan mempelajari perbandingan menggunakan kedua pendekatan tersebut.
Bagaimana flow berjalan
Agar terbiasa dengan cara Flow menghasilkan nilai sesuai permintaan (atau secara lambat), lihat flow berikut yang menampilkan nilai (1, 2, 3)
dan dicetak sebelum, selama, dan setelah masing-masing item diproduksi.
fun makeFlow() = flow {
println("sending first value")
emit(1)
println("first value collected, sending another value")
emit(2)
println("second value collected, sending a third value")
emit(3)
println("done")
}
scope.launch {
makeFlow().collect { value ->
println("got $value")
}
println("flow is completed")
}
Jika Anda menjalankan ini, akan menghasilkan output berikut:
sending first value got 1 first value collected, sending another value got 2 second value collected, sending a third value got 3 done flow is completed
Anda dapat melihat bagaimana eksekusi memantul antara lambda collect
dan builder flow
. Setiap kali builder flow memanggil emit
, akan suspends
hingga elemen selesai diproses. Kemudian, ketika nilai lain diminta dari flow, nilai resumes
akan dibiarkan dari posisi terakhir hingga panggilan menyala lagi. Saat builder flow
selesai, Flow
dibatalkan dan collect
dilanjutkan, memungkinkan dan coroutine yang memanggil akan mencetak "flow selesai".
Panggilan ke collect
sangat penting. Flow
menggunakan operator yang menangguhkan seperti collect
alih-alih memaparkan antarmuka Iterator
sehingga selalu mengetahui saat digunakan secara aktif. Yang lebih penting, operator mengetahui kapan pemanggil tidak dapat meminta nilai lebih sehingga dapat membersihkan resource.
Kapan flow berjalan
Flow
pada contoh di atas mulai berjalan saat operator collect
berjalan. Membuat Flow
baru dengan memanggil builder flow
atau API lain tidak akan menyebabkan eksekusi apa pun. Operator yang menangguhkan collect
disebut operator terminal di Flow
. Ada operator terminal lain yang menangguhkan seperti toList
, first
dan single
yang dikirim dengan kotlinx-coroutines
, dan Anda dapat membuatnya sendiri.
Secara default, Flow
akan menjalankan:
- Setiap kali operator terminal diterapkan (dan setiap pemanggilan baru terpisah dari yang dimulai sebelumnya)
- Sampai coroutine yang sedang dijalankan dibatalkan
- Saat nilai terakhir telah diproses sepenuhnya, dan nilai lain telah diminta
Karena aturan ini, Flow
dapat berpartisipasi dalam konkurensi terstruktur, dan aman untuk memulai coroutine yang berjalan lama dari Flow
. Tidak mungkin Flow
akan membocorkan resource, karena resource selalu bersih menggunakan aturan pembatalan kerja sama coroutine saat pemanggil dibatalkan.
Mari ubah flow di atas untuk hanya melihat dua elemen pertama menggunakan operator take
, lalu kumpulkan dua kali.
scope.launch {
val repeatableFlow = makeFlow().take(2) // we only care about the first two elements
println("first collection")
repeatableFlow.collect()
println("collecting again")
repeatableFlow.collect()
println("second collection completed")
}
Menjalankan kode ini, Anda akan melihat output ini:
first collection sending first value first value collected, sending another value collecting again sending first value first value collected, sending another value second collection completed
flow
lambda dimulai dari atas setiap kali collect
dipanggil. Hal ini penting jika flow melakukan pekerjaan yang mahal seperti membuat permintaan jaringan. Selain itu, karena kita menerapkan operator take(2)
, flow hanya akan menghasilkan dua nilai. Operator tidak akan melanjutkan lambda flow lagi setelah panggilan kedua ke emit
, sehingga baris "nilai kedua yang dikumpulkan..." tidak akan dicetak.
Oke, jadi Flow
lambat seperti Sequence
, tetapi bagaimana kode ini juga asinkron? Mari kita lihat contoh urutan asinkron–yang mengamati perubahan ke database.
Dalam contoh ini, kita perlu mengkoordinasikan data yang dihasilkan pada kumpulan thread database dengan observer yang aktif di thread lain, seperti thread utama atau UI thread. Dan, karena kita akan memublikasikan hasil secara berulang saat data berubah, skenario ini cocok untuk pola urutan asinkron.
Bayangkan Anda diberi tugas untuk menulis integrasi Room
untuk Flow
. Jika Anda memulai dengan dukungan kueri penangguhan yang ada di Room
, Anda dapat menulis seperti ini:
// This code is a simplified version of how Room implements flow
fun <T> createFlow(query: Query, tables: List<Tables>): Flow<T> = flow {
val changeTracker = tableChangeTracker(tables)
while(true) {
emit(suspendQuery(query))
changeTracker.suspendUntilChanged()
}
}
Kode ini bergantung pada dua fungsi penangguhan imajiner untuk membuat Flow
:
suspendQuery
– fungsi utama yang aman yang menjalankan kueri penangguhanRoom
regulersuspendUntilChanged
– fungsi yang menangguhkan coroutine sampai salah satu tabel berubah
Saat dikumpulkan, flow awalnya emits
nilai pertama kueri. Setelah nilai tersebut diproses, flow akan dilanjutkan dan memanggil suspendUntilChanged
, yang akan berfungsi seperti yang dilakukannya–menangguhkan flow sampai salah satu tabel berubah. Pada titik ini, tidak ada yang terjadi dalam sistem sampai salah satu tabel berubah dan flow dilanjutkan.
Saat flow dilanjutkan, flow akan membuat kueri aman utama lainnya, dan emits
hasilnya. Proses ini berlanjut selamanya dalam loop tak terbatas.
Flow dan konkurensi terstruktur
Tapi tunggu dulu–kami tidak ingin membocorkan pekerjaan. Coroutine tidak terlalu mahal, namun berulang kali menyala sendiri untuk melakukan kueri database. Ini adalah hal yang cukup mahal apabila terjadi kebocoran.
Meskipun kami telah membuat loop tak terbatas, Flow
membantu kami dengan mendukung konkurensi terstruktur.
Satu-satunya cara untuk menggunakan nilai atau melakukan iterasi pada flow adalah menggunakan operator terminal. Karena semua operator terminal adalah fungsi yang ditangguhkan, pekerjaan terikat dengan masa berlaku cakupan yang memanggilnya. Saat cakupan dibatalkan, flow akan otomatis dibatalkan dengan sendirinya menggunakan aturan pembatalan kerja sama coroutine reguler. Jadi, meskipun kita telah menulis loop tak terbatas dalam builder flow, kita dapat menggunakannya dengan aman tanpa kebocoran karena konkurensi terstruktur.
Pada langkah ini, Anda akan mempelajari cara menggunakan Flow
dengan Room
dan mentransfernya ke UI.
Langkah ini umum untuk banyak penggunaan Flow
. Jika digunakan dengan cara ini, Flow
dari Room
beroperasi sebagai kueri database yang dapat diobservasi, yang mirip dengan LiveData
.
Update Dao
Untuk memulai, buka PlantDao.kt
, dan tambahkan dua kueri baru yang menampilkan Flow<List<Plant>>
:
PlantDao.kt
@Query("SELECT * from plants ORDER BY name")
fun getPlantsFlow(): Flow<List<Plant>>
@Query("SELECT * from plants WHERE growZoneNumber = :growZoneNumber ORDER BY name")
fun getPlantsWithGrowZoneNumberFlow(growZoneNumber: Int): Flow<List<Plant>>
Perhatikan bahwa kecuali untuk jenis nilai yang ditampilkan, fungsi ini identik dengan versi LiveData
. Namun, kami akan mengembangkannya secara berdampingan untuk membandingkannya.
Dengan menentukan jenis nilai yang ditampilkan Flow
, Room
menjalankan kueri dengan karakteristik berikut:
- Main-safety – Kueri dengan jenis nilai yang ditampilkan
Flow
selalu berjalan di eksekutorRoom
, sehingga kueri tersebut selalu aman. Anda tidak perlu melakukan apa pun dalam kode untuk membuatnya menjalankan thread utama. - Mengamati perubahan –
Room
secara otomatis mengamati perubahan dan memunculkan nilai baru ke flow. - Urutan asinkron –
Flow
memunculkan seluruh hasil kueri pada setiap perubahan, dan tidak akan menampilkan buffer. Jika Anda menampilkanFlow<List<T>>
, flow akan memunculkanList<T>
yang berisi semua baris dari hasil kueri. Flow akan berjalan seperti urutan – memunculkan satu hasil kueri pada satu waktu dan menangguhkannya hingga diminta untuk hasil berikutnya. - Dapat dibatalkan – Jika cakupan yang mengumpulkan flow ini dibatalkan,
Room
akan membatalkan pengamatan ini.
Jika digabungkan, ini akan membuat Flow
menjadi jenis nilai yang ditampilkan yang bagus untuk mengamati database dari lapisan UI.
Update repositori
Untuk melanjutkan penyiapan nilai pengembalian yang baru ke UI, buka PlantRepository.kt
, dan tambahkan kode berikut:
PlantRepository.kt
val plantsFlow: Flow<List<Plant>>
get() = plantDao.getPlantsFlow()
fun getPlantsWithGrowZoneFlow(growZoneNumber: GrowZone): Flow<List<Plant>> {
return plantDao.getPlantsWithGrowZoneNumberFlow(growZoneNumber.number)
}
Untuk saat ini, kita hanya meneruskan nilai Flow
ke pemanggil. Ini sama persis dengan saat kami memulai codelab ini dengan meneruskan LiveData
ke ViewModel
.
Update ViewModel
Di PlantListViewModel.kt
, mari kita mulai dengan cara sederhana dan cukup tampilkan plantsFlow
. Kami akan kembali dan menambahkan zona pertumbuhan yang beralih ke versi flow dalam beberapa langkah berikutnya.
PlantListViewModel.kt
// add a new property to plantListViewModel
val plantsUsingFlow: LiveData<List<Plant>> = plantRepository.plantsFlow.asLiveData()
Sekali lagi, kami akan tetap menggunakan versi LiveData
(val plants
) untuk perbandingan.
Karena kita ingin menyimpan LiveData
dalam lapisan UI untuk codelab ini, kita akan menggunakan fungsi ekstensi asLiveData
untuk mengonversi Flow
menjadi LiveData
. Sama seperti builder LiveData
, ini menambahkan waktu tunggu yang dapat dikonfigurasi untuk LiveData
yang dihasilkan. Ini bagus karena mencegah kita memulai ulang kueri setiap kali konfigurasi berubah (misalnya dari rotasi perangkat).
Karena flow menawarkan main-safety dan kemampuan untuk membatalkan, Anda dapat memilih untuk meneruskan Flow
ke lapisan UI tanpa mengonversinya ke LiveData
. Namun, untuk codelab ini, kami akan tetap menggunakan LiveData
di lapisan UI.
Selain itu, di ViewModel
, tambahkan update cache ke blok init
. Langkah ini bersifat opsional untuk saat ini, tetapi jika Anda mengosongkan cache dan tidak menambahkan panggilan ini, Anda tidak akan melihat data apa pun di aplikasi.
PlantListViewModel.kt
init {
clearGrowZoneNumber() // keep this
// fetch the full plant list
launchDataLoad { plantRepository.tryUpdateRecentPlantsCache() }
}
Update Fragmen
Buka PlantListFragment.kt
, dan ubah fungsi subscribeUi
agar mengarah ke plantsUsingFlow
LiveData
baru.
PlantListFragment.kt
private fun subscribeUi(adapter: PlantAdapter) {
viewModel.plantsUsingFlow.observe(viewLifecycleOwner) { plants ->
adapter.submitList(plants)
}
}
Menjalankan aplikasi dengan Flow
Jika menjalankan aplikasi lagi, Anda akan melihat bahwa Anda sedang memuat data menggunakan Flow
. Karena kita belum menerapkan switchMap
, opsi filter tidak melakukan apa pun.
Di langkah berikutnya, kita akan membahas transformasi data dalam Flow
.
Pada langkah ini, Anda akan menerapkan rangkaian pengurutan ke plantsFlow
. Kami akan melakukannya menggunakan API deklaratif dari flow
.
Dengan menggunakan transformasi seperti map
, combine
, atau mapLatest
, kita dapat mengekspresikan bagaimana kita ingin mengubah setiap elemen saat bergerak melalui flow secara deklaratif. Itu bahkan memungkinkan kita untuk mengekspresikan konkurensi secara deklaratif, yang benar-benar bisa menyederhanakan kode. Di bagian ini, Anda akan melihat cara menggunakan operator untuk memberi tahu Flow
agar meluncurkan dua coroutine dan menggabungkan hasilnya secara deklaratif.
Untuk memulai, buka PlantRepository.kt
dan tentukan flow pribadi baru yang disebut customSortFlow
:
PlantRepository.kt
private val customSortFlow = flow { emit(plantsListSortOrderCache.getOrAwait()) }
Ini menentukan Flow
yang, saat dikumpulkan, akan memanggil getOrAwait
dan emit
rangkaian pengurutan.
Karena flow ini hanya memunculkan nilai tunggal, Anda juga dapat membuatnya langsung dari fungsi getOrAwait
menggunakan asFlow
.
// Create a flow that calls a single function
private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()
Kode ini membuat Flow
baru yang memanggil getOrAwait
dan menampilkan hasilnya sebagai nilai pertama dan satu-satunya. Hal ini dilakukan dengan mereferensikan metode getOrAwait menggunakan ::
dan memanggil asFlow
pada objek Function
yang dihasilkan.
Kedua flow ini akan melakukan hal yang sama, memanggil getOrAwait
dan menampilkan hasilnya sebelum selesai.
Menggabungkan beberapa flow secara deklaratif
Sekarang kita memiliki dua flow, customSortFlow
dan plantsFlow
, mari kita gabungkan secara deklaratif!
Tambahkan operator combine
ke plantsFlow
:
PlantRepository.kt
private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()
val plantsFlow: Flow<List<Plant>>
get() = plantDao.getPlantsFlow()
// When the result of customSortFlow is available,
// this will combine it with the latest value from
// the flow above. Thus, as long as both `plants`
// and `sortOrder` are have an initial value (their
// flow has emitted at least one value), any change
// to either `plants` or `sortOrder` will call
// `plants.applySort(sortOrder)`.
.combine(customSortFlow) { plants, sortOrder ->
plants.applySort(sortOrder)
}
Operator combine
menggabungkan dua flow secara bersamaan. Kedua flow akan berjalan di coroutine masing-masing, kemudian setiap flow menghasilkan nilai baru, transformasi akan dipanggil dengan nilai terbaru dari salah satu flow.
Dengan menggunakan combine
, kita dapat menggabungkan pencarian jaringan dalam cache dengan kueri database. Keduanya akan berjalan di coroutine yang berbeda secara bersamaan. Itu berarti bahwa saat Room memulai permintaan jaringan, Retrofit dapat memulai kueri jaringan. Kemudian, setelah hasilnya tersedia untuk kedua flow tersebut, pemroses akan memanggil combine
lambda tempat kita menerapkan rangkaian pengurutan yang dimuat untuk tanaman yang dimuat.
Untuk menjelajahi cara kerja operator combine
, ubah customSortFlow
untuk menampilkan dua kali dengan penundaan yang cukup lama di onStart
seperti ini:
// Create a flow that calls a single function
private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()
.onStart {
emit(listOf())
delay(1500)
}
Transformasi onStart
akan terjadi ketika observer mendengarkan sebelum operator lain, dan dapat memunculkan nilai placeholder. Jadi di sini kita menampilkan daftar kosong, menunda pemanggilan getOrAwait
dengan 1500 md, lalu melanjutkan flow yang asli. Jika Anda menjalankan aplikasi sekarang, Anda akan melihat bahwa kueri database Room langsung dikembalikan, dikombinasikan dengan daftar kosong (yang berarti sekarang akan diurutkan berdasarkan abjad). Lalu, sekitar 1500 md kemudian, akan diterapkan penyortiran khusus.
Sebelum melanjutkan dengan codelab, hapus transformasi onStart
dari customSortFlow
.
Flow dan main-safety
Flow
dapat memanggil fungsi main-safe, seperti yang kita lakukan di sini, dan akan mempertahankan jaminan main-safety normal dari coroutine. Baik Room
maupun Retrofit
akan memberikan main-safety kepada kami, dan kami tidak perlu melakukan apa pun untuk membuat permintaan jaringan atau kueri database dengan Flow.
Flow ini menggunakan thread berikut yang sudah ada:
plantService.customPlantSortOrder
berjalan di thread Retrofit (panggilan iniCall.enqueue
)getPlantsFlow
akan menjalankan kueri di Eksekutor RoomapplySort
akan berjalan pada petugas operator pengumpul (dalam kasus iniDispatchers.Main
)
Jadi, jika yang kami lakukan hanyalah memanggil fungsi penangguhan di Retrofit
dan menggunakan flow Room
, kami tidak perlu merumitkan kode ini dengan masalah main-safety.
Namun, seiring bertambahnya ukuran set data, panggilan ke applySort
mungkin menjadi cukup lambat untuk memblokir thread utama. Flow
menawarkan API deklaratif yang disebut flowOn
untuk mengontrol thread yang menjalankan flow.
Tambahkan flowOn
ke plantsFlow
seperti ini:
PlantRepository.kt
private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()
val plantsFlow: Flow<List<Plant>>
get() = plantDao.getPlantsFlow()
.combine(customSortFlow) { plants, sortOrder ->
plants.applySort(sortOrder)
}
.flowOn(defaultDispatcher)
.conflate()
Memanggil flowOn
memiliki dua efek penting pada cara kode dieksekusi:
- Luncurkan coroutine baru di
defaultDispatcher
(dalam kasus ini,Dispatchers.Default
) untuk menjalankan dan mengumpulkan flow sebelum panggilan keflowOn
. - Memperkenalkan buffer untuk mengirim hasil dari coroutine baru ke panggilan berikutnya.
- Lepaskan nilai dari buffer tersebut ke dalam
Flow
setelahflowOn
. Dalam hal ini,asLiveData
adalah diViewModel
.
Hal ini sangat mirip dengan cara kerja withContext
untuk beralih petugas operator, tetapi hal ini memperkenalkan buffer di tengah transformasi kami yang mengubah cara kerja flow. Coroutine yang diluncurkan oleh flowOn
diizinkan untuk memberikan hasil yang lebih cepat daripada yang digunakan pemanggil, dan akan membuat buffer dalam jumlah besar secara default.
Dalam hal ini, kami berencana mengirimkan hasil ke UI, jadi kami hanya akan peduli dengan hasil terbaru. Itulah yang dilakukan operator conflate
–ini memodifikasi buffer flowOn
untuk menyimpan hasil terakhir saja. Jika hasil lain muncul sebelum hasil sebelumnya dibaca, hasil akan ditimpa.
Jalankan aplikasi
Jika menjalankan aplikasi lagi, Anda akan melihat bahwa Anda sedang memuat data dan menerapkan rangkaian pengurutan khusus menggunakan Flow
. Karena kita belum menerapkan switchMap
, opsi filter tidak melakukan apa pun.
Pada langkah berikutnya, kita akan melihat cara lain untuk memberikan main-safety menggunakan flow
.
Untuk menyelesaikan versi flow API ini, buka PlantListViewModel.kt
, tempat kita akan beralih di antara flow berdasarkan GrowZone
seperti yang kita lakukan di versi LiveData
.
Tambahkan kode berikut di bawah plants
liveData
:
PlantListViewModel.kt
private val growZoneFlow = MutableStateFlow<GrowZone>(NoGrowZone)
val plantsUsingFlow: LiveData<List<Plant>> = growZoneFlow.flatMapLatest { growZone ->
if (growZone == NoGrowZone) {
plantRepository.plantsFlow
} else {
plantRepository.getPlantsWithGrowZoneFlow(growZone)
}
}.asLiveData()
Pola ini menunjukkan cara mengintegrasikan peristiwa (perubahan zona pertumbuhan) ke dalam flow. Ini melakukan hal yang sama persis seperti versi LiveData.switchMap
–beralih antara dua sumber data berdasarkan peristiwa.
Melewati kode
PlantListViewModel.kt
private val growZoneFlow = MutableStateFlow<GrowZone>(NoGrowZone)
Ini menentukan MutableStateFlow
baru dengan nilai awal NoGrowZone
. Ini adalah jenis khusus dari pemegang nilai Flow yang hanya berisi nilai terakhir yang diberikan. Ini adalah primitif konkurensi thread-safe, jadi Anda bisa menulis dari beberapa thread sekaligus (dan mana saja yang dianggap "terakhir" akan menang).
Anda juga dapat berlangganan untuk mendapatkan informasi terbaru tentang nilai saat ini. Secara keseluruhan, ini memiliki perilaku yang mirip dengan LiveData
–hanya menyimpan nilai terakhir dan memungkinkan Anda mengamati perubahan pada nilai tersebut.
PlantListViewModel.kt
val plantsUsingFlow: LiveData<List<Plant>> = growZoneFlow.flatMapLatest { growZone ->
StateFlow
juga merupakan Flow
biasa, jadi Anda dapat menggunakan semua operator seperti biasa.
Di sini kami menggunakan operator flatMapLatest
yang sama persis dengan switchMap
dari LiveData
. Setiap kali growZone
mengubah nilainya, lambda ini akan diterapkan dan harus menampilkan Flow
. Kemudian, Flow
yang ditampilkan akan digunakan sebagai Flow
untuk semua operator downstream.
Pada dasarnya, ini memungkinkan kita beralih di antara flow yang berbeda berdasarkan nilai growZone
.
PlantListViewModel.kt
if (growZone == NoGrowZone) {
plantRepository.plantsFlow
} else {
plantRepository.getPlantsWithGrowZoneFlow(growZone)
}
Di dalam flatMapLatest
, kami beralih berdasarkan growZone
. Kode ini cukup mirip dengan versi LiveData.switchMap
, dengan satu-satunya perbedaan adalah bahwa kode tersebut menampilkan Flows
, bukan LiveDatas
.
PlantListViewModel.kt
}.asLiveData()
Dan terakhir, kami mengonversi Flow
menjadi LiveData
, karena Fragment
kami akan menampilkan LiveData
dari ViewModel
.
Mengubah nilai StateFlow
Untuk memberi tahu aplikasi tentang perubahan filter, kita dapat menyetel MutableStateFlow.value
. Inilah cara mudah untuk mengomunikasikan peristiwa ke dalam coroutine seperti yang kita lakukan di sini.
PlantListViewModel.kt
fun setGrowZoneNumber(num: Int) {
growZone.value = GrowZone(num)
growZoneFlow.value = GrowZone(num)
launchDataLoad {
plantRepository.tryUpdateRecentPlantsForGrowZoneCache(GrowZone(num)) }
}
fun clearGrowZoneNumber() {
growZone.value = NoGrowZone
growZoneFlow.value = NoGrowZone
launchDataLoad {
plantRepository.tryUpdateRecentPlantsCache()
}
}
Menjalankan kembali aplikasi
Jika Anda menjalankan aplikasi lagi, filter sekarang berfungsi untuk versi LiveData
dan versi Flow
.
Pada langkah berikutnya, kami akan menerapkan penyortiran khusus untuk getPlantsWithGrowZoneFlow
.
Salah satu fitur yang paling menarik dari Flow
adalah dukungan kelas satu untuk fungsi penangguhan. Builder flow
dan hampir setiap transformasi memperlihatkan operator suspend
yang dapat memanggil fungsi penangguhan apa pun. Akibatnya, main-safety untuk panggilan jaringan dan database serta mengatur beberapa operasi asinkron dapat dilakukan menggunakan panggilan ke fungsi penangguhan reguler dari dalam flow.
Akibatnya, ini memungkinkan Anda untuk secara alami menggabungkan transformasi deklaratif dengan kode penting. Seperti yang akan Anda lihat dalam contoh ini, di dalam operator peta reguler, Anda dapat mengatur beberapa operasi asinkron tanpa menerapkan transformasi tambahan. Di banyak tempat, hal ini dapat menghasilkan kode yang jauh lebih sederhana daripada pendekatan deklaratif sepenuhnya.
Menggunakan fungsi menangguhkan untuk mengatur kerja asinkron
Untuk mengakhiri penjelajahan Flow
, kami akan menerapkan penyortiran khusus menggunakan operator yang ditangguhkan.
Buka PlantRepository.kt
dan tambahkan transformasi peta ke getPlantsWithGrowZoneNumberFlow
.
PlantRepository.kt
fun getPlantsWithGrowZoneFlow(growZone: GrowZone): Flow<List<Plant>> {
return plantDao.getPlantsWithGrowZoneNumberFlow(growZone.number)
.map { plantList ->
val sortOrderFromNetwork = plantsListSortOrderCache.getOrAwait()
val nextValue = plantList.applyMainSafeSort(sortOrderFromNetwork)
nextValue
}
}
Dengan mengandalkan fungsi menangguhkan reguler untuk menangani pekerjaan asinkron, operasi peta ini menjadi main-safe meskipun menggabungkan dua operasi asinkron.
Karena setiap hasil dari database dikembalikan, kita akan mendapatkan rangkaian pengurutan dalam cache–dan jika belum siap, tindakan ini akan menunggu di permintaan jaringan asinkron. Setelah kita selesai mengurutkan, akan aman untuk memanggil applyMainSafeSort
, yang akan menjalankan penyortiran pada petugas operator default.
Kode ini sekarang sepenuhnya main-safe dengan menunda masalah main-safety ke fungsi penangguhan reguler. Ini sedikit lebih sederhana daripada transformasi yang sama yang diterapkan di plantsFlow
.
Namun, perlu diperhatikan bahwa ini akan dijalankan sedikit berbeda. Nilai yang disimpan dalam cache akan diambil setiap kali database memunculkan nilai baru. Ini tidak masalah karena kami menyimpannya dengan benar di plantsListSortOrderCache
, tetapi jika permintaan jaringan baru dimulai, penerapan ini akan membuat banyak permintaan jaringan yang tidak diperlukan. Selain itu, pada versi .combine
, permintaan jaringan dan kueri database dijalankan secara bersamaan, sedangkan dalam versi ini, permintaan tersebut dijalankan secara berurutan.
Karena perbedaan ini, tidak ada aturan yang jelas untuk menyusun kode ini. Umumnya, bukan masalah apabila menggunakan transformasi penangguhan seperti apa yang kita lakukan di sini, yang akan membuat semua operasi asinkron berbentuk berurutan. Namun, dalam kasus lain, sebaiknya gunakan operator untuk mengontrol konkurensi dan memberikan main-safety.
Anda hampir selesai! Sebagai langkah terakhir (opsional), mari memindahkan permintaan jaringan ke dalam coroutine berbasis flow.
Dengan melakukannya, kami akan menghapus logika untuk melakukan panggilan jaringan dari pengendali yang dipanggil oleh onClick
dan mengarahkannya dari growZone
. Ini membantu kami membuat satu sumber kebenaran dan menghindari duplikasi kode–tidak mungkin kode apa pun dapat mengubah filter tanpa menyegarkan cache.
Buka PlantListViewModel.kt
, dan tambahkan ke blok init:
PlantListViewModel.kt
init {
clearGrowZoneNumber()
growZone.mapLatest { growZone ->
_spinner.value = true
if (growZone == NoGrowZone) {
plantRepository.tryUpdateRecentPlantsCache()
} else {
plantRepository.tryUpdateRecentPlantsForGrowZoneCache(growZone)
}
}
.onEach { _spinner.value = false }
.catch { throwable -> _snackbar.value = throwable.message }
.launchIn(viewModelScope)
}
Kode ini akan meluncurkan coroutine baru untuk mengamati nilai yang dikirim ke growZoneChannel
. Anda dapat mengomentari panggilan jaringan dalam metode di bawah sekarang karena hanya diperlukan untuk versi LiveData
.
PlantListViewModel.kt
fun setGrowZoneNumber(num: Int) {
growZone.value = GrowZone(num)
growZoneFlow.value = GrowZone(num)
// launchDataLoad {
// plantRepository.tryUpdateRecentPlantsForGrowZoneCache(GrowZone(num))
// }
}
fun clearGrowZoneNumber() {
growZone.value = NoGrowZone
growZoneFlow.value = NoGrowZone
// launchDataLoad {
// plantRepository.tryUpdateRecentPlantsCache()
// }
}
Menjalankan kembali aplikasi
Jika menjalankan aplikasi lagi sekarang, Anda akan melihat bahwa penyegaran jaringan sekarang dikontrol oleh growZone
. Kami telah meningkatkan kode secara nyata, karena lebih banyak cara untuk mengubah filter yang ada di saluran berfungsi sebagai satu sumber kebenaran untuk filter yang aktif. Dengan demikian, permintaan jaringan dan filter saat ini tidak dapat disinkronkan.
Melewati kode
Mari kita ikuti semua fungsi baru yang digunakan satu per satu, mulai dari luar:
PlantListViewModel.kt
growZone
// ...
.launchIn(viewModelScope)
Kali ini, kami menggunakan operator launchIn
untuk mengumpulkan flow di dalam ViewModel
kami.
Operator launchIn
membuat coroutine baru dan mengumpulkan setiap nilai dari flow. Coroutine akan diluncurkan dalam CoroutineScope
yang disediakan–dalam hal ini, viewModelScope
. Ini sangat bagus, karena artinya jika ViewModel
ini dihapus, koleksi akan dibatalkan.
Tanpa menyediakan operator lainnya, ini tidak terlalu besar–namun karena Flow
menyediakan penangguhan lambda di semua operatornya untuk memudahkan tindakan asinkron berdasarkan setiap nilai.
PlantListViewModel.kt
.mapLatest { growZone ->
_spinner.value = true
if (growZone == NoGrowZone) {
plantRepository.tryUpdateRecentPlantsCache()
} else {
plantRepository.tryUpdateRecentPlantsForGrowZoneCache(growZone)
}
}
Di sinilah letak keajaibannya–mapLatest
akan menerapkan fungsi peta ini untuk setiap nilai. Namun, tidak seperti map
yang reguler, ini akan meluncurkan coroutine baru untuk setiap panggilan ke transformasi peta. Kemudian, jika nilai baru dikeluarkan oleh growZoneChannel
sebelum coroutine sebelumnya selesai, nilai tersebut akan membatalkannya sebelum memulai yang baru.
Kita dapat menggunakan mapLatest
untuk mengontrol konkurensi bagi kita. Alih-alih membuat sendiri logika pembatalan/memulai ulang, transformasi flow dapat menanganinya. Kode ini menyimpan banyak kode dan kerumitan dibandingkan dengan menulis logika pembatalan yang sama secara manual.
Pembatalan Flow
mengikuti aturan pembatalan kerja sama normal dari coroutine.
PlantListViewModel.kt
.onEach { _spinner.value = false }
.catch { throwable -> _snackbar.value = throwable.message }
onEach
akan dipanggil setiap kali flow di atas memunculkan nilai. Di sini kami menggunakannya untuk menyetel ulang spinner setelah pemrosesan selesai.
Operator catch
akan menangkap setiap pengecualian yang ditampilkan di atasnya di flow. Ini dapat memunculkan nilai baru ke flow seperti status error, menggabungkan kembali pengecualian ke flow, atau melakukan pekerjaan seperti yang kita lakukan di sini.
Saat terjadi error, kami hanya meminta _snackbar
untuk menampilkan pesan error.
Menyelesaikan
Langkah ini menunjukkan cara mengontrol serentak menggunakan Flow
, serta menggunakan Flows
dalam ViewModel
tanpa bergantung pada observer UI.
Sebagai langkah tantangan, coba tentukan fungsi untuk enkapsulasi pemuatan data flow ini dengan tanda tangan berikut:
fun <T> loadDataFor(source: StateFlow<T>, block: suspend (T) -> Unit) {