Mempelajari coroutine lanjutan dengan Flow dan LiveData Kotlin

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 Roomdan 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, dan Room.
  • 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 builder LiveData 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 dan Flow.

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:

Download zip

... 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.

  1. Jika Anda mendownload file zip kotlin-coroutines, ekstrak zip file tersebut.
  2. Buka direktori advanced-coroutines-codelab di Android Studio.
  3. Pastikan start dipilih di drop-down konfigurasi.
  4. Klik tombol Run execute.png, 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:

2faf7cd0b97434f5.png

Setiap Plant memiliki growZoneNumber, atribut yang mewakili wilayah tempat tanaman kemungkinan besar akan tumbuh. Pengguna dapat mengetuk ikon filter ee1895257963ae84.png 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.

8e150fb2a41417ab.png

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:

ca3c67a941933bd9.png

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.

50efd3b656d4b97.png

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:

ca3c67a941933bd9.png

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 penangguhan Room reguler
  • suspendUntilChanged – 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 eksekutor Room, 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 menampilkan Flow<List<T>>, flow akan memunculkan List<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.customPlantSortOrderberjalan di thread Retrofit (panggilan ini Call.enqueue)
  • getPlantsFlow akan menjalankan kueri di Eksekutor Room
  • applySort akan berjalan pada petugas operator pengumpul (dalam kasus ini Dispatchers.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:

  1. Luncurkan coroutine baru di defaultDispatcher (dalam kasus ini, Dispatchers.Default) untuk menjalankan dan mengumpulkan flow sebelum panggilan ke flowOn.
  2. Memperkenalkan buffer untuk mengirim hasil dari coroutine baru ke panggilan berikutnya.
  3. Lepaskan nilai dari buffer tersebut ke dalam Flow setelah flowOn. Dalam hal ini, asLiveData adalah di ViewModel.

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) {