1. Pengantar
Dalam codelab ini, Anda akan mempelajari konsep lanjutan terkait API State dan Side Effects di Jetpack Compose. Anda akan melihat cara membuat holder status untuk composable stateful yang logikanya tidak sederhana, cara membuat coroutine dan memanggil fungsi penangguhan dari kode Compose, serta cara memicu efek samping untuk menyelesaikan berbagai kasus penggunaan.
Untuk mendapatkan dukungan lebih lanjut saat Anda mempelajari codelab ini, lihat video tutorial coding berikut:
Yang akan Anda pelajari
- Cara mengamati aliran data dari kode Compose untuk mengupdate UI.
- Cara membuat holder status untuk composable stateful.
- API efek samping seperti
LaunchedEffect
,rememberUpdatedState
,DisposableEffect
,produceState
, danderivedStateOf
. - Cara membuat coroutine dan memanggil fungsi penangguhan dalam composable menggunakan
rememberCoroutineScope
API.
Yang akan Anda butuhkan
- Android Studio Terbaru
- Pengalaman dengan sintaksis Kotlin, termasuk lambda.
- Pengalaman dasar dengan Compose. Sebaiknya baca codelab Dasar-dasar Jetpack Compose sebelum codelab ini.
- Konsep status dasar dalam Compose seperti Unidirectional Data Flow (UDF), ViewModels, pengangkatan status, composable stateless/stateful, API Slot, serta API status
remember
danmutableStateOf
. Untuk mendapatkan pengetahuan ini, sebaiknya baca Dokumentasi Status dan Jetpack Compose atau selesaikan codelab Menggunakan Status dalam Jetpack Compose. - Pengetahuan dasar tentang coroutine Kotlin.
- Pemahaman dasar tentang siklus proses composable.
Yang akan Anda bangun
Dalam codelab ini, Anda akan memulai dari aplikasi yang belum selesai, yaitu aplikasi Studi materi Crane, dan menambahkan fitur untuk mengoptimalkan aplikasi.
2. Mempersiapkan
Mendapatkan kode
Kode untuk codelab ini dapat ditemukan di repositori GitHub android-compose-codelabs. Untuk melakukan clone kode ini, jalankan:
$ git clone https://github.com/android/codelab-android-compose
Atau, Anda dapat mendownload repositori sebagai file ZIP:
Melihat aplikasi contoh
Kode yang baru saja Anda download berisi kode untuk semua codelab Compose yang tersedia. Untuk menyelesaikan codelab ini, buka project AdvancedStateAndSideEffectsCodelab
di dalam Android Studio.
Sebaiknya Anda memulai dengan kode di cabang utama dan mengikuti codelab langkah demi langkah sesuai kemampuan Anda.
Selama codelab, Anda akan melihat cuplikan kode yang harus ditambahkan ke project. Di beberapa tempat, Anda juga harus menghapus kode yang disebutkan secara eksplisit dalam komentar pada cuplikan kode.
Memahami kode dan menjalankan aplikasi contoh
Luangkan waktu sejenak untuk mempelajari struktur project dan menjalankan aplikasi.
Saat menjalankan aplikasi dari cabang utama, Anda akan melihat bahwa beberapa fungsi seperti panel samping atau pemuatan tujuan penerbangan tidak berfungsi. Itulah yang akan Anda lakukan pada langkah berikutnya di codelab.
Pengujian UI
Aplikasi tercakup dalam pengujian UI sangat dasar yang tersedia di folder androidTest
. Keduanya harus selalu lulus cabang main
dan end
.
[Opsional] Menampilkan peta di layar detail
Sama sekali tidak perlu menampilkan peta kota pada layar detail. Namun, jika ingin melihatnya, Anda perlu mendapatkan kunci API pribadi seperti yang dijelaskan dalam dokumentasi Maps. Sertakan kunci tersebut dalam file local.properties
sebagai berikut:
// local.properties file
google.maps.key={insert_your_api_key_here}
Solusi untuk codelab
Untuk mendapatkan cabang end
menggunakan git, gunakan perintah ini:
$ git clone -b end https://github.com/android/codelab-android-compose
Atau, Anda dapat mendownload kode solusi dari sini:
Pertanyaan umum (FAQ)
3. Pipeline produksi status UI
Seperti yang mungkin telah Anda lihat saat menjalankan aplikasi dari cabang main
, daftar tujuan penerbangan kosong!
Untuk memperbaikinya, Anda harus menyelesaikan dua langkah:
- Tambahkan logika di
ViewModel
untuk menghasilkan status UI. Dalam kasus Anda, ini adalah daftar tujuan yang disarankan. - Gunakan status UI dari UI, yang akan menampilkan UI di layar.
Di bagian ini, Anda akan menyelesaikan langkah pertama.
Arsitektur yang baik untuk aplikasi diatur secara berlapis untuk mematuhi praktik desain sistem dasar yang baik, seperti pemisahan fokus dan kemampuan pengujian.
Produksi Status UI mengacu pada proses saat aplikasi mengakses lapisan data, menerapkan aturan bisnis jika diperlukan, dan mengekspos status UI yang akan digunakan dari UI.
Lapisan data dalam aplikasi ini sudah diimplementasikan. Sekarang, Anda akan menghasilkan status (daftar tujuan yang disarankan) sehingga UI dapat menggunakannya.
Ada beberapa API yang dapat digunakan untuk menghasilkan status UI. Alternatifnya dirangkum dalam dokumentasi Jenis output dalam pipeline produksi status. Secara umum, sebaiknya gunakan StateFlow
Kotlin untuk menghasilkan status UI.
Untuk menghasilkan status UI, ikuti langkah-langkah ini:
- Buka
home/MainViewModel.kt
. - Tentukan variabel
_suggestedDestinations
pribadi dari jenisMutableStateFlow
untuk mewakili daftar tujuan yang disarankan, lalu tetapkan daftar kosong sebagai nilai awal.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
- Tentukan variabel
suggestedDestinations
kedua yang tidak dapat diubah dari jenisStateFlow
. Ini adalah variabel hanya baca publik yang dapat digunakan dari UI. Mengekspos variabel hanya baca saat menggunakan variabel yang dapat diubah secara internal adalah praktik yang baik. Dengan melakukan hal ini, Anda memastikan status UI tidak dapat diubah kecuali melaluiViewModel
, yang menjadikannya satu sumber tepercaya. Fungsi ekstensiasStateFlow
mengonversi flow dari dapat berubah menjadi tidak dapat diubah.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
- Di blok init
ViewModel
, tambahkan panggilan daridestinationsRepository
untuk mendapatkan tujuan dari lapisan data.
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
init {
_suggestedDestinations.value = destinationsRepository.destinations
}
- Terakhir, hapus tanda komentar tentang penggunaan variabel internal
_suggestedDestinations
yang ada di class ini, sehingga dapat diperbarui dengan benar menggunakan peristiwa yang berasal dari UI.
Selesai– langkah pertama sudah selesai! Sekarang, ViewModel
dapat menghasilkan status UI. Pada langkah berikutnya, Anda akan menggunakan status ini dari UI.
4. Memakai Flow secara aman dari ViewModel
Daftar tujuan penerbangan masih kosong. Pada langkah sebelumnya, Anda telah menghasilkan status UI di MainViewModel
. Sekarang, Anda akan menggunakan status UI yang diekspos oleh MainViewModel
untuk ditampilkan di UI.
Buka file home/CraneHome.kt
dan lihat composable CraneHomeContent
.
Ada komentar TODO di atas definisi suggestedDestinations
yang ditetapkan untuk daftar kosong yang diingat. Inilah yang ditampilkan di layar: daftar kosong! Pada langkah ini, Anda akan memperbaikinya dan menampilkan tujuan yang disarankan yang diekspos oleh MainViewModel
.
Buka home/MainViewModel.kt
dan lihat StateFlow suggestedDestinations
yang diinisialisasi ke destinationsRepository.destinations
, lalu dapatkan update saat fungsi updatePeople
atau toDestinationChanged
dipanggil.
Anda ingin UI dalam composable CraneHomeContent
diperbarui setiap kali ada item baru yang dimunculkan ke dalam aliran data suggestedDestinations
. Anda dapat menggunakan fungsi collectAsStateWithLifecycle()
. collectAsStateWithLifecycle()
mengumpulkan nilai dari StateFlow
dan menampilkan nilai terbaru melalui State API Compose dengan cara yang mendukung siklus proses. Ini akan membuat kode Compose yang membaca nilai status tersebut mengomposisi ulang pada emisi baru.
Untuk mulai menggunakan collectAsStateWithLifecycle
API, tambahkan dependensi berikut di app/build.gradle
terlebih dahulu. Variabel lifecycle_version
sudah ditentukan di project dengan versi yang sesuai.
dependencies {
implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}
Kembali ke composable CraneHomeContent
dan ganti baris yang menetapkan suggestedDestinations
dengan panggilan ke collectAsStateWithLifecycle
di properti suggestedDestinations
ViewModel
:
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun CraneHomeContent(
onExploreItemClicked: OnExploreItemClicked,
openDrawer: () -> Unit,
modifier: Modifier = Modifier,
viewModel: MainViewModel = viewModel(),
) {
val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
// ...
}
Jika menjalankan aplikasi, Anda akan melihat daftar tujuan terisi dan berubah setiap kali Anda mengetuk jumlah orang yang melakukan perjalanan.
5. LaunchedEffect dan rememberUpdatedState
Dalam project, ada file home/LandingScreen.kt
yang tidak digunakan saat ini. Anda ingin menambahkan halaman landing ke aplikasi tersebut, yang berpotensi dapat digunakan untuk memuat semua data yang diperlukan di latar belakang.
Halaman landing akan menempati seluruh layar dan menampilkan logo aplikasi di bagian tengah layar. Idealnya, Anda akan menampilkan layar, dan—setelah semua data dimuat—Anda akan memberi tahu pemanggil bahwa halaman landing dapat ditutup menggunakan callback onTimeout
.
Coroutine Kotlin adalah cara yang direkomendasikan untuk melakukan operasi asinkron di Android. Aplikasi biasanya akan menggunakan coroutine untuk memuat sesuatu di latar belakang saat dimulai. Jetpack Compose menawarkan API yang menjadikan penggunaan coroutine aman dalam lapisan UI. Karena aplikasi ini tidak berkomunikasi dengan backend, Anda akan menggunakan fungsi delay
coroutine untuk menyimulasikan pemuatan berbagai hal di latar belakang.
Efek samping pada Compose adalah perubahan pada status aplikasi yang terjadi di luar cakupan fungsi composable. Perubahan status untuk menampilkan/menyembunyikan halaman landing akan terjadi di callback onTimeout
dan karena sebelum memanggil onTimeout
Anda perlu memuat sesuatu menggunakan coroutine, perubahan status harus terjadi dalam konteks coroutine.
Untuk memanggil fungsi penangguhan secara aman dari dalam composable, gunakan LaunchedEffect
API, yang memicu efek samping cakupan coroutine dalam Compose.
Saat memasuki Komposisi, LaunchedEffect
akan meluncurkan coroutine dengan blok kode yang diteruskan sebagai parameter. Coroutine akan dibatalkan jika LaunchedEffect
keluar dari komposisi.
Meskipun kode berikutnya salah, mari kita lihat cara menggunakan API ini dan diskusikan mengapa kode berikut salah. Anda akan memanggil composable LandingScreen
nanti dalam langkah ini.
// home/LandingScreen.kt file
import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// Start a side effect to load things in the background
// and call onTimeout() when finished.
// Passing onTimeout as a parameter to LaunchedEffect
// is wrong! Don't do this. We'll improve this code in a sec.
LaunchedEffect(onTimeout) {
delay(SplashWaitTime) // Simulates loading things
onTimeout()
}
Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
}
}
Beberapa API efek samping seperti LaunchedEffect
menggunakan sejumlah variabel kunci sebagai parameter yang digunakan untuk memulai ulang efek setiap kali salah satu kunci tersebut berubah. Apakah Anda menemukan error tersebut? Kita tidak ingin memulai ulang LaunchedEffect
jika pemanggil ke fungsi composable ini meneruskan nilai lambda onTimeout
yang berbeda. Hal itu akan membuat delay
dimulai lagi dan Anda tidak akan memenuhi persyaratannya.
Mari kita perbaiki. Untuk memicu efek samping hanya sekali selama siklus proses composable ini, gunakan konstanta sebagai kunci, misalnya LaunchedEffect(Unit) { ... }
. Namun, sekarang ada masalah lain.
Jika onTimeout
berubah saat efek samping dalam proses, tidak ada jaminan bahwa onTimeout
terakhir akan dipanggil saat efek selesai. Untuk menjamin bahwa onTimeout
terakhir dipanggil, ingat onTimeout
menggunakan rememberUpdatedState
API. API ini menangkap dan mengupdate nilai terbaru:
// home/LandingScreen.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay
@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
// This will always refer to the latest onTimeout function that
// LandingScreen was recomposed with
val currentOnTimeout by rememberUpdatedState(onTimeout)
// Create an effect that matches the lifecycle of LandingScreen.
// If LandingScreen recomposes or onTimeout changes,
// the delay shouldn't start again.
LaunchedEffect(Unit) {
delay(SplashWaitTime)
currentOnTimeout()
}
Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
}
}
Anda harus menggunakan rememberUpdatedState
jika lambda yang berumur panjang atau ekspresi objek mereferensikan parameter atau nilai yang dihitung selama komposisi, yang mungkin umum saat bekerja dengan LaunchedEffect
.
Menampilkan halaman landing
Sekarang Anda perlu menampilkan halaman landing saat aplikasi dibuka. Buka file home/MainActivity.kt
dan lihat composable MainScreen
yang pertama kali dipanggil.
Pada composable MainScreen
, Anda cukup menambahkan status internal yang melacak apakah landing harus ditampilkan atau tidak:
// home/MainActivity.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
Surface(color = MaterialTheme.colors.primary) {
var showLandingScreen by remember { mutableStateOf(true) }
if (showLandingScreen) {
LandingScreen(onTimeout = { showLandingScreen = false })
} else {
CraneHome(onExploreItemClicked = onExploreItemClicked)
}
}
}
Jika menjalankan aplikasi sekarang, Anda akan melihat LandingScreen
muncul dan menghilang setelah 2 detik.
6. rememberCoroutineScope
Pada langkah ini, Anda akan membuat panel navigasi berfungsi. Untuk saat ini, tidak ada yang terjadi jika Anda mencoba mengetuk menu tiga garis.
Buka file home/CraneHome.kt
dan lihat composable CraneHome
untuk mengetahui di mana Anda harus membuka panel navigasi: di callback openDrawer
.
Di CraneHome
, Anda memiliki scaffoldState
yang berisi DrawerState
. DrawerState
memiliki metode untuk membuka dan menutup panel navigasi secara terprogram. Akan tetapi, jika Anda mencoba menulis scaffoldState.drawerState.open()
dalam callback openDrawer
, Anda akan mendapatkan error. Hal tersebut dikarenakan fungsi open
adalah fungsi penangguhan. Kita berada di ranah coroutine lagi.
Selain API untuk membuat panggilan coroutine aman dari lapisan UI, beberapa API Compose merupakan fungsi penangguhan. Salah satu contohnya adalah API untuk membuka panel navigasi. Selain dapat menjalankan kode asinkron, fungsi penangguhan juga membantu menampilkan konsep yang terjadi dari waktu ke waktu. Membuka panel samping memerlukan waktu, gerakan, dan animasi potensial, dan hal ini tercermin sempurna dengan fungsi penangguhan, yang akan menangguhkan eksekusi coroutine tempatnya dipanggil hingga selesai dan melanjutkan eksekusi.
scaffoldState.drawerState.open()
harus dipanggil dalam coroutine. Apa yang dapat Anda lakukan? openDrawer
adalah fungsi callback sederhana, sehingga:
- Anda tidak bisa begitu saja memanggil fungsi penangguhan di dalamnya karena
openDrawer
tidak dieksekusi dalam konteks coroutine. - Anda tidak dapat menggunakan
LaunchedEffect
seperti sebelumnya, karena kita tidak dapat memanggil composable diopenDrawer
. Kita tidak sedang berada di dalam Komposisi.
Anda ingin meluncurkan coroutine. Cakupan mana yang harus kita gunakan? Idealnya, Anda ingin CoroutineScope
yang mengikuti siklus proses situs panggilannya. Menggunakan rememberCoroutineScope
API akan menampilkan CoroutineScope
yang terikat ke titik dalam Komposisi tempat Anda memanggilnya. Cakupan akan otomatis dibatalkan setelah keluar dari Komposisi. Dengan cakupan tersebut, Anda dapat memulai coroutine saat tidak berada di Komposisi, misalnya, di callback openDrawer
.
// home/CraneHome.kt file
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch
@Composable
fun CraneHome(
onExploreItemClicked: OnExploreItemClicked,
modifier: Modifier = Modifier,
) {
val scaffoldState = rememberScaffoldState()
Scaffold(
scaffoldState = scaffoldState,
modifier = Modifier.statusBarsPadding(),
drawerContent = {
CraneDrawer()
}
) {
val scope = rememberCoroutineScope()
CraneHomeContent(
modifier = modifier,
onExploreItemClicked = onExploreItemClicked,
openDrawer = {
scope.launch {
scaffoldState.drawerState.open()
}
}
)
}
}
Jika Anda menjalankan aplikasi, Anda akan melihat panel navigasi terbuka saat Anda mengetuk ikon menu tiga garis.
LaunchedEffect vs rememberCoroutineScope
Penggunaan LaunchedEffect
dalam kasus ini tidak memungkinkan karena Anda perlu memicu panggilan untuk membuat coroutine dalam callback biasa yang berada di luar Komposisi.
Melihat kembali langkah halaman landing yang menggunakan LaunchedEffect
, dapatkah Anda menggunakan rememberCoroutineScope
dan memanggil scope.launch { delay(); onTimeout(); }
, bukan menggunakan LaunchedEffect
?
Anda dapat melakukannya dan sepertinya akan berhasil, tetapi hasilnya tidak akan benar. Seperti yang dijelaskan dalam dokumentasi Paradigma Compose, composable dapat dipanggil oleh Compose kapan saja. LaunchedEffect
menjamin bahwa efek samping akan dieksekusi saat panggilan ke composable tersebut membuatnya masuk ke Komposisi. Jika Anda menggunakan rememberCoroutineScope
dan scope.launch
dalam isi LandingScreen
, coroutine akan dieksekusi setiap kali LandingScreen
dipanggil oleh Compose terlepas dari apakah panggilan tersebut membuatnya masuk ke Komposisi atau tidak. Oleh karena itu, Anda akan menyia-nyiakan resource dan tidak akan mengeksekusi efek samping ini di lingkungan yang terkontrol.
7. Membuat holder status
Apakah Anda memperhatikan bahwa jika Anda mengetuk Choose Destination, Anda dapat mengedit kolom ini dan memfilter kota berdasarkan input penelusuran? Anda mungkin juga memperhatikan bahwa setiap kali memodifikasi Choose Destination, gaya teks akan berubah.
Buka file base/EditableUserInput.kt
. Composable stateful CraneEditableUserInput
memerlukan beberapa parameter seperti hint
dan caption
yang sesuai dengan teks opsional di samping ikon. Misalnya, caption
To muncul saat Anda menelusuri tujuan.
// base/EditableUserInput.kt file - code in the main branch
@Composable
fun CraneEditableUserInput(
hint: String,
caption: String? = null,
@DrawableRes vectorImageId: Int? = null,
onInputChanged: (String) -> Unit
) {
// TODO Codelab: Encapsulate this state in a state holder
var textState by remember { mutableStateOf(hint) }
val isHint = { textState == hint }
...
}
Mengapa?
Logika untuk memperbarui textState
dan menentukan apakah yang ditampilkan sudah sesuai dengan petunjuk atau belum semuanya ada di dalam isi composable CraneEditableUserInput
. Hal ini memiliki beberapa kekurangan:
- Nilai
TextField
tidak diangkat sehingga tidak dapat dikontrol dari luar, yang membuat pengujian menjadi lebih sulit. - Logika composable ini dapat menjadi lebih kompleks dan status internal dapat dengan mudahnya menjadi tidak sinkron.
Dengan membuat holder status yang bertanggung jawab atas status internal composable ini, Anda dapat memusatkan semua perubahan status di satu tempat. Dengan tindakan ini, status menjadi lebih sulit asinkron, dan logika terkait dikelompokkan bersama dalam satu class. Selain itu, status ini dapat mudah diangkat dan dapat digunakan dari pemanggil composable ini.
Dalam hal ini, pengangkatan status adalah ide bagus karena merupakan komponen UI tingkat rendah yang mungkin digunakan kembali di bagian lain aplikasi. Dengan demikian, semakin fleksibel dan dapat dikontrol suatu status, semakin baik hasilnya.
Membuat holder status
Karena CraneEditableUserInput
adalah komponen yang dapat digunakan kembali, buat class reguler sebagai holder status bernama EditableUserInputState
di file yang sama yang terlihat seperti berikut:
// base/EditableUserInput.kt file
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
private set
fun updateText(newText: String) {
text = newText
}
val isHint: Boolean
get() = text == hint
}
Class harus memiliki ciri berikut:
text
adalah status yang dapat berubah dari jenisString
, seperti yang Anda miliki diCraneEditableUserInput
. Penting untuk menggunakanmutableStateOf
agar Compose melacak perubahan pada nilai dan mengomposisi ulang saat terjadi perubahan.text
adalahvar
, denganset
pribadi sehingga tidak dapat diubah secara langsung dari luar class. Daripada membuat variabel ini bersifat publik, Anda dapat mengekspos peristiwaupdateText
untuk mengubahnya, yang menjadikan class sebagai satu sumber tepercaya.- Class ini mengambil
initialText
sebagai dependensi yang digunakan untuk menginisialisasitext
. - Logika untuk mengetahui apakah
text
adalah petunjuk atau bukan ada di propertiisHint
yang melakukan pemeriksaan sesuai permintaan.
Jika logika menjadi lebih kompleks pada masa mendatang, Anda hanya perlu membuat perubahan pada satu class: EditableUserInputState
.
Mengingat holder status
Holder status harus selalu diingat untuk menjaganya tetap dalam Komposisi dan tidak membuat yang baru setiap saat. Sebaiknya buat metode dalam file yang sama dengan yang digunakan untuk menghapus boilerplate dan menghindari kesalahan yang mungkin terjadi. Dalam file base/EditableUserInput.kt
, tambahkan kode ini:
// base/EditableUserInput.kt file
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
remember(hint) {
EditableUserInputState(hint, hint)
}
Jika Anda hanya remember
(mengingat) status ini, status ini dapat berubah jika terjadi pembuatan ulang aktivitas. Untuk mencapainya, Anda dapat menggunakan rememberSaveable
API yang berperilaku mirip dengan remember
, tetapi nilai yang tersimpan juga tidak akan tersimpan jika terjadi pembuatan ulang aktivitas dan proses. Secara internal, status ini menggunakan mekanisme status instance yang disimpan.
rememberSaveable
melakukan semua ini tanpa tugas tambahan untuk objek yang dapat disimpan di dalam Bundle
. Hal ini tidak berlaku untuk class EditableUserInputState
yang Anda buat di project Anda. Oleh karena itu, Anda perlu memberi tahu rememberSaveable
cara menyimpan dan memulihkan instance class ini menggunakan Saver
.
Membuat saver kustom
Saver
menjelaskan cara objek dapat dikonversi menjadi sesuatu yang Saveable
(dapat disimpan). Implementasi Saver
perlu mengganti dua fungsi:
save
untuk mengonversi nilai asli ke nilai yang dapat disimpan.restore
untuk mengonversi nilai yang dipulihkan ke instance class asli.
Untuk kasus ini, daripada membuat implementasi kustom Saver
untuk class EditableUserInputState
, Anda dapat menggunakan beberapa API Compose yang ada seperti listSaver
atau mapSaver
(yang menyimpan nilai untuk disimpan dalam List
atau Map
) untuk mengurangi jumlah kode yang perlu Anda tulis.
Sebaiknya tempatkan definisi Saver
di dekat class yang digunakan. Karena perlu diakses secara statis, tambahkan Saver
untuk EditableUserInputState
di companion object
. Dalam file base/EditableUserInput.kt
, tambahkan implementasi Saver
:
// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver
class EditableUserInputState(private val hint: String, initialText: String) {
var text by mutableStateOf(initialText)
val isHint: Boolean
get() = text == hint
companion object {
val Saver: Saver<EditableUserInputState, *> = listSaver(
save = { listOf(it.hint, it.text) },
restore = {
EditableUserInputState(
hint = it[0],
initialText = it[1],
)
}
)
}
}
Dalam hal ini, Anda menggunakan listSaver
sebagai detail implementasi untuk menyimpan dan memulihkan instance EditableUserInputState
di saver.
Sekarang, Anda dapat menggunakan saver ini di rememberSaveable
(bukan remember
) dalam metode rememberEditableUserInputState
yang Anda buat sebelumnya:
// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable
@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
rememberSaveable(hint, saver = EditableUserInputState.Saver) {
EditableUserInputState(hint, hint)
}
Dengan penggunaan saver ini, status yang diingat EditableUserInput
akan tetap ada meskipun terjadi pembuatan ulang aktivitas dan proses.
Menggunakan holder status
Anda akan menggunakan EditableUserInputState
, bukan text
dan isHint
, tetapi Anda tidak ingin menggunakannya sebagai status internal di CraneEditableUserInput
karena tidak ada cara bagi composable pemanggil untuk mengontrol status. Sebagai gantinya, Anda ingin mengangkat EditableUserInputState
agar pemanggil dapat mengontrol status CraneEditableUserInput
. Jika Anda mengangkat statusnya, composable dapat digunakan dalam pratinjau dan diuji dengan lebih mudah karena Anda dapat memodifikasi statusnya dari pemanggil.
Untuk melakukan ini, Anda perlu mengubah parameter fungsi composable dan memberinya nilai default jika diperlukan. Karena Anda mungkin ingin mengizinkan CraneEditableUserInput
dengan petunjuk kosong, tambahkan argumen default:
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) { /* ... */ }
Anda mungkin telah mengetahui bahwa parameter onInputChanged
sudah tidak ada lagi. Karena status dapat diangkat, jika pemanggil ingin mengetahui apakah input berubah, mereka dapat mengontrol status dan meneruskan status tersebut ke fungsi ini.
Selanjutnya, Anda perlu menyesuaikan isi fungsi untuk menggunakan status yang diangkat, bukan status internal yang digunakan sebelumnya. Setelah pemfaktoran ulang, fungsinya akan terlihat seperti ini:
@Composable
fun CraneEditableUserInput(
state: EditableUserInputState = rememberEditableUserInputState(""),
caption: String? = null,
@DrawableRes vectorImageId: Int? = null
) {
CraneBaseUserInput(
caption = caption,
tintIcon = { !state.isHint },
showCaption = { !state.isHint },
vectorImageId = vectorImageId
) {
BasicTextField(
value = state.text,
onValueChange = { state.updateText(it) },
textStyle = if (state.isHint) {
captionTextStyle.copy(color = LocalContentColor.current)
} else {
MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
},
cursorBrush = SolidColor(LocalContentColor.current)
)
}
}
Pemanggil holder status
Karena Anda mengubah API CraneEditableUserInput
, Anda harus memeriksa di semua tempat yang dipanggil untuk memastikan Anda meneruskan parameter yang sesuai.
Satu-satunya tempat dalam project yang menjadi tempat Anda memanggil API ini adalah dalam file home/SearchUserInput.kt
. Buka dan arahkan ke fungsi composable ToDestinationUserInput
; Anda akan melihat error build di sana. Karena petunjuk sekarang menjadi bagian dari holder status, dan Anda menginginkan petunjuk khusus untuk instance CraneEditableUserInput
ini dalam Komposisi, Anda perlu mengingat status pada level ToDestinationUserInput
dan meneruskannya ke CraneEditableUserInput
:
// home/SearchUserInput.kt file
import androidx.compose.samples.crane.base.rememberEditableUserInputState
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
CraneEditableUserInput(
state = editableUserInputState,
caption = "To",
vectorImageId = R.drawable.ic_plane
)
}
snapshotFlow
Kode di atas tidak memiliki fungsi untuk memberi tahu pemanggil ToDestinationUserInput
saat input berubah. Sehubungan dengan cara penyusunan aplikasi, Anda tidak ingin mengangkat EditableUserInputState
lebih tinggi dalam hierarki. Anda tidak ingin menggabungkan composable lain seperti FlySearchContent
dengan status ini. Bagaimana Anda dapat memanggil lambda onToDestinationChanged
dari ToDestinationUserInput
dan tetap mempertahankan agar composable ini dapat digunakan kembali?
Anda dapat memicu efek samping menggunakan LaunchedEffect
setiap kali input berubah dan memanggil lambda onToDestinationChanged
:
// home/SearchUserInput.kt file
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
CraneEditableUserInput(
state = editableUserInputState,
caption = "To",
vectorImageId = R.drawable.ic_plane
)
val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
LaunchedEffect(editableUserInputState) {
snapshotFlow { editableUserInputState.text }
.filter { !editableUserInputState.isHint }
.collect {
currentOnDestinationChanged(editableUserInputState.text)
}
}
}
Anda sudah menggunakan LaunchedEffect
dan rememberUpdatedState
sebelumnya, tetapi kode di atas juga menggunakan API baru. snapshotFlow
API mengonversi objek State<T>
Compose menjadi Flow. Saat status yang dibaca di dalam snapshotFlow
berubah, Flow akan memunculkan nilai baru ke kolektor. Dalam hal ini, Anda akan mengonversi status menjadi alur untuk menggunakan dukungan operator alur. Dengan demikian, Anda melakukan filter
saat text
(teks) bukanlah hint
(petunjuk), dan collect
(mengumpulkan) item yang dimunculkan untuk memberi tahu induk bahwa tujuan saat ini berubah.
Tidak ada perubahan visual pada langkah codelab ini, tetapi Anda telah meningkatkan kualitas bagian kode ini. Jika menjalankan aplikasi sekarang, Anda akan melihat semuanya berfungsi seperti sebelumnya.
8. DisposableEffect
Saat Anda mengetuk tujuan, layar detail akan terbuka dan Anda dapat melihat lokasi kota pada peta. Kode tersebut ada dalam file details/DetailsActivity.kt
. Dalam composable CityMapView
, Anda memanggil fungsi rememberMapViewWithLifecycle
. Jika Anda membuka fungsi ini, yang sudah tersedia dalam file details/MapViewUtils.kt
, Anda akan melihat fungsi tersebut tidak terhubung ke siklus proses apa pun. Fungsi tersebut hanya mengingat MapView
dan memanggil onCreate
di dalamnya:
// details/MapViewUtils.kt file - code in the main branch
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
// TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
return remember {
MapView(context).apply {
id = R.id.map
onCreate(Bundle())
}
}
}
Meskipun aplikasi berjalan dengan baik, ini menjadi masalah karena MapView
tidak mengikuti siklus proses yang benar. Oleh karena itu, aplikasi tidak akan mengetahui kapan dipindahkan ke latar belakang, kapan View harus dijeda, dll. Ayo kita perbaiki!
Karena MapView
adalah View dan bukan composable, Anda ingin mengikuti siklus proses Aktivitas yang digunakannya, begitu juga siklus proses Komposisi. Artinya, Anda perlu membuat LifecycleEventObserver
untuk memproses peristiwa siklus proses dan memanggil metode yang tepat di MapView
. Kemudian, Anda perlu menambahkan observer ini ke siklus proses aktivitas saat ini.
Mulai dengan membuat fungsi yang menampilkan LifecycleEventObserver
yang memanggil metode terkait dalam MapView
jika terjadi peristiwa tertentu:
// details/MapViewUtils.kt file
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
Lifecycle.Event.ON_START -> mapView.onStart()
Lifecycle.Event.ON_RESUME -> mapView.onResume()
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
Lifecycle.Event.ON_STOP -> mapView.onStop()
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
else -> throw IllegalStateException()
}
}
Sekarang, Anda perlu menambahkan observer ini ke siklus proses saat ini, yang dapat Anda gunakan dengan LifecycleOwner
saat ini dengan lokal komposisi LocalLifecycleOwner
. Namun, tidak cukup hanya dengan menambahkan observer; Anda juga harus dapat menghapusnya. Anda memerlukan efek samping yang memberi tahu Anda saat efek keluar dari Komposisi sehingga Anda dapat melakukan beberapa kode pembersihan. API efek samping yang Anda cari adalah DisposableEffect
.
DisposableEffect
dimaksudkan untuk efek samping yang perlu dibersihkan setelah kunci berubah atau composable keluar dari Komposisi. Kode rememberMapViewWithLifecycle
akhir akan benar-benar melakukan hal tersebut. Implementasikan baris berikut dalam project Anda:
// details/MapViewUtils.kt file
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner
@Composable
fun rememberMapViewWithLifecycle(): MapView {
val context = LocalContext.current
val mapView = remember {
MapView(context).apply {
id = R.id.map
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(key1 = lifecycle, key2 = mapView) {
// Make MapView follow the current lifecycle
val lifecycleObserver = getMapLifecycleObserver(mapView)
lifecycle.addObserver(lifecycleObserver)
onDispose {
lifecycle.removeObserver(lifecycleObserver)
}
}
return mapView
}
Observer ditambahkan ke lifecycle
saat ini, dan akan dihapus setiap kali siklus proses saat ini berubah atau composable ini keluar dari Komposisi. Dengan key
dalam DisposableEffect
, jika lifecycle
atau mapView
berubah, observer akan dihapus dan ditambahkan lagi ke lifecycle
yang tepat.
Dengan perubahan yang baru saja Anda buat, MapView
akan selalu mengikuti lifecycle
dari LifecycleOwner
saat ini dan perilakunya akan sama seperti jika digunakan di lingkup View.
Jalankan aplikasi dan buka layar detail untuk memastikan MapView
masih dirender dengan benar. Tidak ada perubahan visual dalam langkah ini.
9. produceState
Di bagian ini, Anda akan meningkatkan cara layar detail dimulai. Composable DetailsScreen
dalam file details/DetailsActivity.kt
mendapatkan cityDetails
secara sinkron dari ViewModel dan memanggil DetailsContent
jika hasilnya sukses.
Namun, cityDetails
dapat berubah jadi lebih mahal untuk dimuat pada UI thread dan dapat menggunakan coroutine untuk memindahkan pemuatan data ke thread lain. Anda akan meningkatkan kode ini untuk menambahkan layar pemuatan dan menampilkan DetailsContent
saat data sudah siap.
Salah satu cara untuk memodelkan status layar adalah dengan class berikut yang mencakup semua kemungkinan: data yang akan ditampilkan di layar serta sinyal pemuatan dan error. Tambahkan class DetailsUiState
ke file DetailsActivity.kt
:
// details/DetailsActivity.kt file
data class DetailsUiState(
val cityDetails: ExploreModel? = null,
val isLoading: Boolean = false,
val throwError: Boolean = false
)
Anda dapat memetakan hal yang perlu ditampilkan di layar dan UiState
di lapisan ViewModel menggunakan aliran data, StateFlow
dari jenis DetailsUiState
, yang diperbarui ViewModel saat informasi sudah siap dan yang dikumpulkan Compose dengan collectAsStateWithLifecycle()
API yang sudah Anda ketahui.
Namun, untuk latihan ini, Anda akan mengimplementasikan sebuah alternatif. Jika ingin memindahkan logika pemetaan uiState
ke lingkup Compose, Anda dapat menggunakan produceState
API.
produceState
memungkinkan Anda mengonversi status non-Compose ke State Compose. Ini akan meluncurkan coroutine yang tercakup dalam Komposisi yang dapat mendorong nilai menjadi State
yang ditampilkan menggunakan properti value
. Seperti halnya LaunchedEffect
, produceState
juga menggunakan kunci untuk membatalkan dan memulai ulang komputasi.
Untuk kasus penggunaan Anda, Anda dapat menggunakan produceState
untuk memunculkan update uiState
dengan nilai awal DetailsUiState(isLoading = true)
seperti berikut:
// details/DetailsActivity.kt file
import androidx.compose.runtime.produceState
@Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
// In a coroutine, this can call suspend functions or move
// the computation to different Dispatchers
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
// TODO: ...
}
Selanjutnya, Anda menampilkan data, menampilkan layar pemuatan, atau melaporkan error, bergantung pada uiState
. Berikut adalah kode lengkap untuk composable DetailsScreen
:
// details/DetailsActivity.kt file
import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator
@Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
}
uiState.isLoading -> {
Box(modifier.fillMaxSize()) {
CircularProgressIndicator(
color = MaterialTheme.colors.onSurface,
modifier = Modifier.align(Alignment.Center)
)
}
}
else -> { onErrorLoading() }
}
}
Jika menjalankan aplikasi, Anda akan melihat bagaimana indikator lingkaran berputar pemuatan muncul sebelum menampilkan detail kota.
10. derivedStateOf
Pengoptimalan terakhir pada Crane adalah menampilkan tombol Scroll to top setiap kali Anda men-scroll dalam daftar tujuan penerbangan setelah meneruskan elemen pertama layar. Mengetuk tombol tersebut akan membawa Anda ke elemen pertama dalam daftar.
Buka file base/ExploreSection.kt
yang berisi kode ini. Composable ExploreSection
sesuai dengan yang Anda lihat di tampilan latar scaffold.
Untuk menghitung apakah pengguna telah meneruskan item pertama, gunakan LazyListState
dari LazyColumn
dan periksa apakah listState.firstVisibleItemIndex > 0
.
Penerapan yang naif akan terlihat seperti berikut:
// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0
Solusi ini tidak seefisien yang dimungkinkan, karena fungsi composable yang membaca showButton
merekomposisi sesering perubahan firstVisibleItemIndex
- yang sering terjadi saat men-scroll. Sebagai gantinya, Anda ingin fungsi hanya merekomposisi ketika kondisi berubah antara true
dan false
.
Ada API yang memungkinkan Anda melakukan hal ini: derivedStateOf
API.
listState
adalah State
Compose yang dapat diamati. Kalkulasi Anda, showButton
, juga harus berupa State
Compose karena Anda ingin UI merekomposisi saat nilainya berubah, dan menampilkan atau menyembunyikan tombol.
Gunakan derivedStateOf
saat Anda menginginkan State
Compose yang berasal dari State
lain. Blok kalkulasi derivedStateOf
dieksekusi setiap kali status internal berubah, tetapi fungsi composable hanya merekomposisi saat hasil kalkulasi berbeda dengan yang terakhir. Ini meminimalkan frekuensi fungsi membaca rekomposisi showButton
.
Dalam hal ini, menggunakan derivedStateOf
API adalah alternatif yang lebih baik dan lebih efisien. Anda juga akan menggabungkan panggilan dengan remember
API, sehingga nilai yang dihitung bertahan dari rekomposisi.
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
Kode baru untuk composable ExploreSection
seharusnya sudah tidak asing bagi Anda. Anda menggunakan Box
untuk menempatkan Button
yang ditampilkan secara bersyarat di atas ExploreList
. Dan Anda menggunakan rememberCoroutineScope
untuk memanggil fungsi penangguhan listState.scrollToItem
di dalam callback onClick
Button
.
// base/ExploreSection.kt file
import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch
@Composable
fun ExploreSection(
modifier: Modifier = Modifier,
title: String,
exploreList: List<ExploreModel>,
onItemClicked: OnExploreItemClicked
) {
Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
Text(
text = title,
style = MaterialTheme.typography.caption.copy(color = crane_caption)
)
Spacer(Modifier.height(8.dp))
Box(Modifier.weight(1f)) {
val listState = rememberLazyListState()
ExploreList(exploreList, onItemClicked, listState = listState)
// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
derivedStateOf {
listState.firstVisibleItemIndex > 0
}
}
if (showButton) {
val coroutineScope = rememberCoroutineScope()
FloatingActionButton(
backgroundColor = MaterialTheme.colors.primary,
modifier = Modifier
.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(bottom = 8.dp),
onClick = {
coroutineScope.launch {
listState.scrollToItem(0)
}
}
) {
Text("Up!")
}
}
}
}
}
}
Jika menjalankan aplikasi, Anda akan melihat tombol muncul di bagian bawah setelah men-scroll dan meneruskan elemen pertama layar.
11. Selamat!
Selamat, Anda berhasil menyelesaikan codelab ini dan mempelajari konsep lanjutan API status dan efek samping dalam aplikasi Jetpack Compose.
Anda telah mempelajari cara membuat holder status, API efek samping seperti LaunchedEffect
, rememberUpdatedState
, DisposableEffect
, produceState
, dan derivedStateOf
, serta cara menggunakan coroutine di Jetpack Compose.
Apa selanjutnya?
Lihat codelab lainnya di pembelajaran Compose, dan contoh kode lainnya, termasuk Crane.
Dokumentasi
Untuk mendapatkan informasi selengkapnya dan panduan tentang topik ini, lihat dokumentasi berikut: