Developer Relations Engineer
Runtime snapshot Compose, pelacakan dependensi otomatis, dan update atomik tidak harus berhenti di lapisan UI. Jetpack AppState memungkinkan Anda memindahkan status Compose ke upstream—menyederhanakan apa yang ditampilkan di layar dengan mengangkat dan memperoleh status menggunakan sistem snapshot sebelum mencapai UI.
Dalam sesi ini, Anda akan mempelajari dua library dasar AppState. Kita akan membahas StateStore untuk mengangkat status, dan Transform untuk menggunakan komposisi guna memperoleh status dengan alur kontrol Kotlin biasa—menghilangkan rantai penggabungan Flow yang kompleks dan menjaga UI Anda tetap sinkron dengan mudah.
combine sekaligus mendukung pengembalian awal dan turunan status bertingkat.Transkrip
Halo, dan selamat datang di Menggunakan Status Compose di Luar UI dengan AppState Jetpack.
Nama saya Ben, dan saya adalah developer relations engineer yang mengerjakan library arsitektur Jetpack.
Saya Elif, dan saya salah satu engineer yang mengerjakan library arsitektur aplikasi.
Hari ini, kami akan membahas masa depan Compose yang melampaui UI dan masuk ke lapisan data dan domain aplikasi.
Jadi, seperti yang pasti sudah Anda ketahui, Compose adalah toolkit yang direkomendasikan untuk pengembangan UI di Android.
Mulai dari ponsel dan tablet hingga widget, smartwatch, TV, dan perangkat XR, Compose mendukung semuanya.
Namun, seiring berkembangnya aplikasi Anda di seluruh platform UI dan komponen latar belakang ini, mengelola status di baliknya akan menjadi lebih sulit, terutama terkait dua poin masalah umum.
Yang pertama adalah memperoleh status yang kompleks.
Menggabungkan beberapa aliran data, input UI lokal, dan status sekitar, seperti konektivitas jaringan, yang sering kali berubah menjadi jalinan operator seperti combine, flatMapLatest, dan distinctUntilChanged.
Lalu yang kedua adalah berbagi status di seluruh komponen: menjaga aktivitas, layanan, widget aplikasi, dan lainnya tetap sinkron, serta memastikan bahwa status tersebut dapat bertahan dari penghentian proses latar depan jika diperlukan.
Saat kami mundur untuk melihat tantangan ini, kami mulai melihat cara Compose dibuat.
Kebanyakan orang menganggap Compose sebagai toolkit UI, tetapi sebenarnya Compose terdiri dari dua lapisan yang berbeda: Compose UI di atas dan Compose Runtime di bawahnya.
Meskipun Compose UI menangani tata letak, gambar, dan input, Compose Runtime adalah mesin tujuan umum untuk pengelolaan status dan rekomposisi.
Class ini tidak memiliki dependensi pada UI.
Dan kesadaran itulah yang membuat kami membangun Jetpack AppState.
Ini adalah rangkaian library yang berfokus untuk memaksimalkan status Compose di luar UI.
Namun, sebelum membahas AppState, mari kita pastikan bahwa kita sepaham saat membahas apa yang dimaksud dengan status Compose.
Jawabannya adalah androidx.compose.runtime.State.
/**
* A value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value.
*/
@Stable
public interface State<out T> {
public val value: T
}Dan antarmuka yang mungkin lebih Anda kenal, MutableState.
/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*/
@Stable
public interface MutableState<T> : State<T> {
override var value: T
public operator fun component1(): T
public operator fun component2(): (T) -> Unit
}Pada dasarnya, ini hanyalah container sederhana.
Yang membuatnya benar-benar berfungsi adalah sistem snapshot Compose.
Jadi, saat kita mengatakan status Compose, yang sebenarnya kita maksud biasanya adalah Snapshot State.
Jadi, saat Anda memanggil mutableStateOf, Anda membuat container yang merutekan pembacaan dan penulisan melalui sistem snapshot.
Dan setiap kali kode Anda berjalan di dalam observer snapshot, seperti komposisi, Anda akan mendapatkan dua properti yang tidak diberikan oleh aliran data seperti Flow.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}Pertama, pelacakan bacaan otomatis dan dinamis.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}Jadi, otomatis berarti tidak ada proses berlangganan atau pengumpulan.
Hanya membaca .value adalah langganan.
Jadi, jika kita melihat Counter di sini, membaca count.value langsung di dalam composable akan melaporkan pembacaan tersebut ke komposisi, dan mendaftarkan Counter dalam daftar pembacanya.
Dan sekarang lihat AddButton.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}Tag ini hanya menulis untuk menghitung klik.
Jadi, ini bukan pembaca.
Saat pengguna mengklik AddButton dan mengubah jumlah, sistem snapshot akan melacak penulisan tersebut, yang memungkinkan komposisi mengetahui untuk membatalkan Counter, tetapi tidak untuk AddButton.
Dinamis berarti pelacakan pembacaan terjadi saat runtime berdasarkan jalur kode yang benar-benar berjalan.
@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}Jadi di sini, kita melindungi teks penghitung di balik flag isVisible.
Jika isVisible bernilai benar, isVisible dan jumlah akan dibaca.
Jadi, Counter terdaftar sebagai pembaca keduanya.
Dan jika isVisible bernilai salah, jika cabang if tidak berjalan, count tidak akan dibaca, sehingga Counter hanya terdaftar sebagai pembaca isVisible, bukan count.
@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(false) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}Jadi, saat AddButton menulis ke jumlah, tidak ada lagi yang perlu dibatalkan.
Sekarang, properti kedua adalah tidak harus memilih antara konsistensi dan perincian.
Konsistensi berarti bahwa saat Anda memperbarui beberapa nilai dalam snapshot, perubahan tersebut tetap terisolasi saat kode Anda berjalan, meskipun thread lain menulis ke nilai tersebut pada saat yang sama.
Dan update tersebut akan terlihat sekaligus saat snapshot selesai.
Granularitas berarti properti terpisah dapat dideklarasikan dan dilacak satu per satu, terutama jika tidak terkait.
Jadi, memperbarui satu properti hanya akan membatalkan pembaca yang benar-benar bergantung padanya.
Mari kita lihat hal ini dalam beberapa kode nyata.
Misalnya, kita akan melihat game yang memiliki tiga bagian status: nama level, waktu bertahan, dan skor.
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0Jadi, untuk memulai, mari kita lihat implementasi Flow yang memilih konsistensi dengan mengorbankan perincian dengan menggabungkan semuanya ke dalam class GameUiState.
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)Artinya, setiap kali updateScore berjalan, Anda menyalin seluruh GameUiState sehingga timeSurvived dan skor dapat diperbarui bersama-sama secara sinkron.
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}Di UI, GameScreen mengumpulkan status UI dan membacanya untuk meneruskan properti yang relevan ke Header dan ScoreBadge.
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header(levelName = state.levelName)
ScoreBadge(
timeSurvived = state.timeSurvived,
score = state.score
)
}
}Karena GameScreen membaca setiap status, setiap panggilan ke updateScore akan membatalkan GameScreen meskipun data header tidak pernah berubah.
Sekarang, Anda dapat membagi setiap properti menjadi Flow-nya sendiri untuk mendapatkan perincian, tetapi Anda akan kehilangan konsistensi karena thread lain dapat memperbarui nilai saat blok update lain berjalan.
Sekarang, mari kita pertimbangkan implementasi berbasis status alternatif.
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}Dalam contoh ini, kita menggunakan GameUiState lagi.
Namun, dalam contoh ini, sedikit berbeda karena setiap properti didelegasikan ke mutableStateOf.
Di sini, updateScore menggabungkan penulisannya dalam withMutableSnapshot, dan inilah yang memberi kita konsistensi di seluruh penulisan.
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}Kemudian di UI, GameScreen meneruskan State langsung ke Header dan ScoreBadge.
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state = viewModel.uiState
Column {
Header(state = state)
ScoreBadge(state = state)
}
}Fungsi ini tidak membaca properti apa pun, sehingga saat updateScore berjalan, GameScreen bukan pembaca.
Hanya komponen sebenarnya, dalam hal ini, ScoreBadge, yang dibatalkan.
Terakhir, perhatikan bahwa tidak ada satu pun dari hal ini yang terkait dengan UI.
UI hanyalah salah satu tempat di mana properti ini benar-benar terlihat.
Terima kasih telah menghadiri presentasi kami.
Oke, cukup bercandanya. Saya akan menyerahkan kepada Elif untuk membahas library pertama, Transform.
Oke.
Terima kasih, Ben.
Jadi, seperti yang baru saja disampaikan Ben, status snapshot Compose menawarkan pelacakan pembacaan, konsistensi, dan perincian yang otomatis dan dinamis, yang membawa saya ke pertanyaan, bagaimana jika kita juga dapat mengarahkan aliran data kita dengan Snapshot Status Compose-- Status Snapshot?
Dan jika status adalah model data kita, bagaimana cara kita menghitung dan mengubah status untuk semua konsumennya?
Di sinilah library Jetpack AppState Transform yang baru berperan.
Sebelum membahas library itu sendiri, kita perlu membahas bagaimana library tersebut dibuat.
Jadi, ide untuk memanfaatkan mesin Compose, yang merupakan runtime dan compiler di sini, untuk menghasilkan nilai status dalam komposisi Headless, bukanlah hal baru.
Banyak dari Anda yang mungkin sudah menggunakan atau mungkin sudah memahami library Molecule.
Headless di sini berarti tidak ada UI.
Jadi, Molecule adalah pelopor dalam mendemonstrasikan keandalan penggunaan Compose Runtime, dan melakukannya dengan menunjukkan kepada kita bahwa fungsi composable tidak harus memunculkan node UI, dan sebenarnya dapat digunakan untuk menulis fungsi composable yang menampilkan model data murni.
Dalam kasus Molecule, ini adalah Kotlin Flow atau StateFlow.
Artinya, Anda dapat menulis Kotlin biasa, alih-alih merangkai operator reaktif seperti combine.
Namun, alih-alih menangani flow, bagaimana jika model data Anda adalah State, karena UI Anda sudah ada di Compose?
Dengan Transform, model data Anda adalah State.
Mari kita lihat API yang sebenarnya.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>Ini adalah keseluruhan API.
Sangat sederhana, satu fungsi.
Fungsi ini menampilkan State biasa yang dapat diamati, bukan Flow, bukan StateFlow.
Dan siapa pun yang menggunakannya tidak tahu atau peduli bahwa Transform yang memproduksinya.
Yang menarik di sini adalah blok ini.
Composable.
Artinya, semua yang sudah Anda ketahui berfungsi di dalamnya.
Ingat LaunchedEffect, collectAsState, early return, if biasa, apa pun yang dapat Anda baca dari dalam Composable, dapat Anda turunkan dalam Transform.
Perlu diperhatikan bahwa semua API yang kita lihat hari ini dalam presentasi ini masih dalam versi alfa, dan dapat berubah sewaktu-waktu, tetapi mari kita lihat contohnya.
Jadi, mari kita ambil contoh aplikasi timer fokus yang sangat sederhana dan kecil.
Anda memilih tugas, menyetel timer 25 menit, dan jam akan menghitung mundur.
Setiap kali Anda membuat satu layar, bahkan saat Anda membuat satu layar sederhana seperti ini, Anda mungkin perlu menggabungkan dan mengelola beberapa aliran data independen untuk menyusun status di balik layar.
Untuk layar fokus, misalnya kita memiliki dua: sesi fokus itu sendiri, yang akan menjadi fase kita, jadi dijeda atau tidak, berapa detik yang tersisa, dan semua ini akan berasal dari mesin timer sebagai StateFlow yang akan berdetak, misalnya setiap satu detik.
Aliran data lainnya adalah tugas saat ini yang sedang dikerjakan pengguna yang akan masuk dari database Room sebagai Flow.
Jadi, dua aliran data dan satu status layar di akhir.
ViewModel akan menjadi tempat penggabungan keduanya, dan output-nya, yang akan kita sebut ringkasan, akan menjadi bagian kode yang akan kita tulis berikutnya.
Biasanya, hal ini berarti bahwa dalam ViewModel, Anda harus menggabungkan flow ini menggunakan operator berantai.
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: StateFlow<FocusSummary> =
combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle,
)
}Jadi, gabungkan kedua zip, kedua flow menjadi stream, percabangan di dalam lambda, jadi if else di sana, lalu status di akhir.
Dengan Transform, kita dapat menghapus seluruh kode boilerplate ini.
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)Panggilan ini terlihat agak familier karena kita memiliki nilai awal dalam cakupan yang diteruskan, tetapi tidak ada apa pun di upstream untuk dirantai, turunannya adalah blok itu sendiri.
Tidak seperti stateIn saat Anda mungkin perlu mengonfigurasi waktu tunggu WhileSubscribed untuk menjeda dan melanjutkan streaming upstream, Transform berjalan terus-menerus dalam masa aktif cakupan tersebut.
Sekali lagi, sumber kita adalah flow, dan flow dapat digunakan.
class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}Kita akan menjembatani masing-masing dengan collectAsState, dan sesinya adalah StateFlow, jadi kita tidak memerlukan nilai awal, tetapi Room Flow bersifat cold, jadi kita memberinya nilai awal.
Dan itulah keseluruhan cerita langganan.
Pembacaan adalah hal yang membuat kita berlangganan.
Ini adalah pelacakan pembacaan otomatis yang dibahas Ben sebelumnya dalam contoh yang kita lihat, tetapi dalam ViewModel.
Kita bahkan dapat memperkenalkan pengembalian awal seperti ini di sini saat kita berada dalam status tidak ada aktivitas.
Misalnya, kita tidak memiliki tugas baru, kita bahkan tidak akan memproses sisanya.
Sekali lagi, ini adalah manfaat dari pelacakan pembacaan dinamis.
Saat kita tidak aktif, Room Flow tidak dikumpulkan sama sekali.
Kemudian, kita akan menampilkan ringkasannya.
Jadi, jika pengguna menjeda tugas atau timer berdetak, semuanya akan otomatis diperbarui.
Jadi, semuanya menjadi sedikit lebih rumit saat kita melangkah keluar dari contoh ViewModel tunggal.
Misalnya, sesi yang sama ini perlu ditampilkan di dua platform tambahan, yaitu panel timer mini yang berada di bagian bawah layar aplikasi di setiap layar, dan widget layar utama.
Keduanya akan memerlukan informasi turunan yang sama tentang ringkasan sesi, fase, dijeda atau tidak, berapa-- berapa banyak waktu yang tersisa di timer.
Artinya, jika setiap platform harus menyusun salinannya sendiri, akan ada tiga turunan yang sama di tiga tempat berbeda yang semuanya harus saling sesuai.
Jadi, yang perlu kita lakukan adalah memperoleh ringkasan sekali dalam cakupan aplikasi, lalu semua yang ada di downstream akan menggunakannya.
Jadi, bagaimana cara kita menerapkan hal seperti ini?
Implementasi kita mungkin akan memerlukan tiga bagian.
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}Jadi, pengelola sesi dalam cakupan aplikasi yang akan menjadi tempat ringkasan diperoleh, ViewModel timer mini, dan Widget Glance, yang keduanya akan menggunakan ringkasan yang diperoleh di pengelola sesi fokus ini.
Mari kita buat masing-masing dengan cara tradisional, lalu dengan Transform.
Jadi, secara tradisional, sekali lagi, kita akan memulai dengan memperoleh ringkasan sekali dalam cakupan aplikasi sebagai StateFlow.
class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}Ini adalah panggilan combine dan stateIn yang sama dengan yang kita lihat sebelumnya dalam contoh ViewModel, tetapi hanya naik satu level.
Dengan Transform, kita juga dapat menggunakan kembali deklarasi yang sama dari ViewModel yang kita lihat sebelumnya.
class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
) {
val session by sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow
.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}Satu-satunya hal yang berubah adalah, sekali lagi, sekarang berada dalam cakupan aplikasi.
Selanjutnya, kita memiliki ViewModel timer mini.
class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: StateFlow<MiniTimerUiState> =
combine(
focusManager.summary,
isExpanded
) { summary, expanded ->
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MiniTimerUiState()
)
fun toggleExpanded() { isExpanded.update { !it } }
}Misalnya, kita juga ingin memberikan sedikit tantangan di sini.
Misalnya, di timer mini, selain dua aliran data yang sudah kita miliki, StateFlow dan Room Flow, kita juga ingin melacak apakah panel timer diperluas atau tidak.
Dan kita menggunakan MutableStateFlow untuk itu.
Untuk melakukannya, kita harus menggunakan combine lain untuk melacak flow ringkasan dan isExpanded StateFlow.
Dan kita mendapatkan status kedua.
Dengan Transform, kita tidak perlu menggunakan operator berantai ini lagi untuk mencapai sinkronisasi antara aliran data.
class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}Yang dapat kita lakukan, dan ini sangat keren, adalah kita dapat melakukan Transformasi bertingkat.
Jadi, di sini ViewModel membaca ringkasan, yang merupakan output dari Transformasi upstream, seperti status lainnya, lalu mengumpulkan isExpanded dengan collectAsState.
Jadi, kita dapat menggabungkan status upstream dan flow downstream di StateFlow tepat di blok yang sama.
Jangan lupa tingkat perincian.
Jadi, jika Anda mengaktifkan isExpanded, hanya blok ini yang akan dijalankan ulang.
Blok tingkat aplikasi dan widget bahkan tidak perlu mengetahuinya.
Terakhir, kita memiliki widget.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Karena Glance adalah Compose, kita dapat mengumpulkan StateFlow dengan collectAsState di dalam provideContent seperti Composable lainnya.
Dengan Transform, proses ini menjadi lebih mudah.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Pengelola fokus memberi kita Compose State, jadi tidak ada yang perlu dikonversi di sini.
Dan pada akhirnya, panel timer mini dan widget layar utama tidak akan pernah berbeda karena keduanya membaca status yang sama.
Selain itu, setiap perubahan yang dilakukan pada status tersebut akan diterapkan ke semua konsumen status tersebut.
Apa artinya?
Jadi, jika Anda menekan jeda pada widget di sini, widget akan melewati pengelola ke dalam streaming sesi, kembali melalui ringkasan, dan ketiga layar ini akan diperbarui secara sinkron.
Setelah kita melihat sedikit cara menggunakan Transform, mari kita pelajari lebih dalam cara kerjanya.
Transform API yang kita lihat sebelumnya sebenarnya adalah fungsi bantuan yang digunakan untuk membuat Transform terkelola dasar.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}Pada kenyataannya, fungsi ini menggunakan fungsi ekstensi TransformScope dan launchTransform, yang meluncurkan transformasi Composable di TransformScope ini dan menampilkan status sekali pakai.
Saat Anda membacanya dari atas ke bawah, Anda akan melihat bahwa kode ini melakukan semuanya untuk Anda.
Fungsi ini membuat TransformScope dan meluncurkan Transform ke dalamnya, yang akan menjadi status yang Anda dapatkan kembali.
Kemudian, fungsi ini juga meluncurkan run di TransformScope pada dispatcher yang Anda sediakan, sehingga masa aktif cakupan dan thread menjadi milik Anda.
Kemudian, fungsi ini akan membuang transformasi Anda saat cakupan berakhir.
Ini adalah keseluruhan kisah siklus proses Transform.
Jadi, mari kita lihat antarmuka TransformScope di sini dengan cepat.
public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
public abstract class TransformScope {
public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}
public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>
public interface DisposableState<T> : State<T> {
public fun dispose()
}Kita dapat melihat bahwa fungsi ini memiliki fungsi createTransform, yang akan membuat Transform yang dilampirkan ke TransformScope ini.
Fungsi ini juga memiliki fungsi run penangguhan, yang memulai TransformScope dan memulai proses pembaruan transformasi status saat ini.
Apa artinya?
Jadi, run sebenarnya menyiapkan Recomposer dan komposisi.
Dan setiap Transform dalam cakupan berada di dalam komposisi tersebut dan memiliki cakupan rekomposisinya sendiri.
Inilah alasan kami ingin membagikan cakupan dan alasan kami memiliki TransformScope.
Jadi, setiap kali status berubah, hanya Transformasi yang membacanya yang akan dijalankan ulang.
Seperti yang telah kami sebutkan sebelumnya, run adalah fungsi penangguhan.
override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}Jadi, fungsi ini berjalan di coroutine mana pun yang Anda panggil hingga coroutine tersebut dibatalkan.
Sekarang, Anda mungkin bertanya-tanya, Recomposer memerlukan jam frame untuk memberi tahu kapan harus menghasilkan frame.
Di Compose UI, itulah tampilannya, tetapi seperti yang telah kami sebutkan sebelumnya, kita berada di lingkungan headless, sehingga tidak ada UI.
Jadi, kecuali jika Anda memberi kami jam, kami akan membuat jam kami sendiri.
Dengan memisahkan rekomposisi dari clock frame tampilan, Transform menjalankan semua transformasi dan turunan statusnya di luar UI thread.
Untuk memungkinkan hal ini, kami membuat HeadlessTransformClock khusus.
Mengubah clock frame.
Karena widget, layanan, atau Transform lain di downstream mungkin menunggu nilai dari Transform, jam kita bersifat eager.
Artinya, saat status apa pun yang digunakan Transform berubah, frame baru akan segera dibuat.
Ini adalah mesin status kecil tanpa kunci, sehingga meminta frame adalah operasi perbandingan dan penetapan yang sederhana.
Dan jika tidak ada yang berubah dalam status, clock akan diparkir.
Jadi, Transform yang menggunakan status yang tidak diubah sama sekali tidak akan dikenai biaya.
Sebelum saya menyerahkannya kembali kepada Ben, perlu diperhatikan juga bahwa Transform kini mendukung rememberSaveable untuk memastikan data Anda disimpan di seluruh perubahan konfigurasi.
Hal ini dilakukan melalui SavedStateContainer API baru, yang kini menjadi elemen penyusun untuk SaveableStateRegistry, SavedStateHandle, dan SavedStateRegistry Compose.
Anda dapat mengubah salah satu dari ini menjadi SavedStateContainer atau membuat penampung turunan dan meneruskannya ke Transform untuk memastikan data Anda disimpan.
Terakhir, kami ingin mengingatkan kembali bahwa semua API yang kita bahas hari ini masih dalam versi alfa, sehingga dapat berubah sewaktu-waktu.
Sekarang, saya kembalikan kepada Ben.
Oke.
Baiklah, terima kasih, Elif.
Jadi, setelah kita melihat keandalan status Compose dan cara Transform memungkinkan kita memperolehnya di luar UI, satu hal yang mungkin Anda perhatikan dalam contoh sejauh ini adalah input kita adalah Flow yang kita hubungkan menggunakan collectAsState.
Namun, apakah hal itu selalu diperlukan?
Mengapa tidak menggunakan status Compose secara langsung di holder status kita?
Jadi, untuk menjawab pertanyaan tersebut, sebaiknya pikirkan status dalam tiga kategori umum.
Yang pertama adalah status aktif yang hanya disimpan dalam memori.
Misalnya, status koneksi jaringan atau Bluetooth.
Anda tidak ingin menggunakan nilai yang dipertahankan.
Kemudian, yang kedua adalah status sementara yang harus bertahan dari penghentian proses, tetapi tidak perlu disimpan tanpa batas waktu.
Pikirkan hal-hal seperti status UI, seperti apakah kartu diperluas atau tidak, dan status sesi, seperti data sebelumnya navigasi atau draf formulir.
Kemudian, yang ketiga adalah status permanen, yang disimpan tanpa batas waktu.
Pikirkan hal-hal seperti preferensi pengguna atau galeri media yang ada di database.
Jadi, mari kita mulai dengan cara menggunakan status Compose untuk status aktif dan sementara.
Hingga saat ini, panduan arsitektur kami menyimpan status Compose di lapisan UI, atau terkadang, di ViewModel, dan kami menyarankan Anda untuk menggunakan Flow di tempat lain.
Namun, mengapa demikian?
Hal ini karena begitu Anda perlu menggabungkan properti status dengan Flow, Anda harus mengonversinya kembali menggunakan snapshotFlow.
Namun, dengan Transform, konversi tersebut tidak diperlukan.
Jadi, mari kita lihat tampilan atau arti dari hal ini, dimulai dari ViewModel.
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}Untuk input layar lokal, seperti selectedTaskIds di sini, Anda dapat mendeklarasikan properti mutableStateOf biasa, bukan MutableStateFlow.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}Karena ini adalah sesuatu yang tidak perlu dipertahankan di seluruh penghentian proses, tidak masalah jika hanya menggunakan mutableStateOf.
Untuk hal-hal yang ingin Anda pertahankan dari penghentian proses, seperti kueri penelusuran, Anda dapat mendukung status yang dapat diubah secara langsung dengan savedStateHandle.saveable.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}Kemudian, seperti updateScore dalam contoh game sebelumnya, saat metode memperbarui beberapa nilai status sekaligus, menggabungkan penulisan dalam withMutableSnapshot akan memastikan konsistensi.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
fun updateSearchQuery(query: String) {
Snapshot.withMutableSnapshot {
searchQuery = query
selectedTaskIds = emptySet()
}
}
}Kemudian, untuk menggabungkan searchQuery dengan Room Flow, Anda cukup membacanya langsung di dalam Transform, sambil mengumpulkan Flow.
val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}Jadi, bagaimana dengan status aktif seperti konektivitas jaringan?
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}Hal ini biasanya ada di Singleton karena dibaca di beberapa layar atau bahkan oleh beberapa komponen aplikasi, seperti layanan.
Jadi, bukan berarti Anda tidak dapat menggunakan Compose State untuk menyimpannya.
Bahkan, sering kali lebih sederhana daripada versi berbasis alur.
Anda tidak memerlukan alur callback atau apa pun untuk melakukan adaptasi, cukup perbarui nilai titik secara langsung di callback.
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}Dan, jika konsumen masih memerlukan flow, Anda dapat menggunakan snapshotFlow untuk menjembatani kembali.
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { _isOnline.value = true }
override fun onLost(network: Network) { _isOnline.value = false }
}
init {
connectivityManager.registerDefaultNetworkCallback(callback)
}
}Mari kita lihat tampilannya di ViewModel.
Anda dapat membaca isOnline langsung di Transform, dan akan merekomposisi saat nilainya berubah.
val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
if (!networkMonitor.isOnline.value) {
return@transform TaskListState.Offline
}
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}Dan, semua ini terjadi secara langsung, tidak ada cakupan aplikasi, atau cakupan coroutine yang perlu Anda sediakan untuk memperbarui status, ini hanya, Anda tahu, pembaruan langsung dan sinkron.
Jadi, dengan Transform, ViewModel Anda dapat menggabungkan State dari pengelola Singleton, State di ViewModel, dan Flow dari Repositori, semuanya dalam satu blok Transform.
Sekarang, mari kita kembali ke Status sementara.
Sebelumnya, saya menyebutkan dua jenis: status UI dan status sesi.
Untuk status UI, dan bahkan status sesi yang ada di dalam satu Aktivitas, ViewModel dan SavedState berfungsi dengan baik.
Itulah tujuan desainnya.
Namun, masalah mulai muncul saat status sesi tersebut perlu dibagikan ke berbagai UI dan komponen aplikasi yang berbeda.
Setelah status tersebut keluar dari ViewModel, API dan panduan yang ada akan meninggalkan sedikit celah.
Jadi, pertama-tama SavedState API menyimpan ke dalam Bundle Aktivitas, tetapi tidak tersedia di komponen aplikasi lain, seperti layanan.
Kedua, mutableStateOf di Singleton, yang hanya ada di memori, sehingga tidak bertahan dari penghentian proses.
Dan, itulah celah yang diisi oleh Jetpack AppState, StateStore.
Ini adalah peta Compose State yang aman dari jenis yang dapat dibagikan di seluruh komponen aplikasi dan didukung oleh DataStore untuk bertahan dari penghentian proses.
Anda dapat menghubungkan DataStore ke setiap Singleton sendiri, tetapi itu berarti menulis boilerplate simpan dan pulihkan untuk setiap properti.
Dengan Integrasi StateStore DataStore, Anda menghubungkan DataStore sekali, lalu memilih setiap bagian status yang ingin dipertahankan.
Jadi, mari kita buat fitur tersebut di aplikasi timer fokus kita.
Kita akan memulai dalam memori terlebih dahulu, lalu menambahkan persistensi DataStore setelahnya.
Untuk menyiapkan tahap, kita akan membuat model FocusSession sebagai antarmuka tertutup dengan status tidak ada aktivitas, berjalan, dan dijeda.
sealed interface FocusSession {
data object Idle : FocusSession
data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}Kemudian, untuk menyimpan status ini di StateStore, kita menentukan StateStoreKey dengan jenisnya, FocusSession, dan nilai default, idle.
sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)Kemudian, kita menyediakan Singleton StateStore di Hilt yang dapat kita injeksikan ke berbagai komponen yang kita perlukan, seperti Focus Session Manager.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}Jadi, mirip dengan cara ViewModel menginjeksikan SavedStateHandle.
Oke.
Jadi, sekarang kita sudah memiliki StateStore, tetapi bagaimana cara menggunakannya?
Untungnya, API ini kecil.
Class ini hanya memiliki tiga metode, jadi kita akan membahasnya satu per satu.
Yang pertama adalah setState, yang menulis nilai baru untuk kunci.
fun startFocus(taskId: String, durationMinutes: Int = 25) {
val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
stateStore.setState(
FocusSessionKey,
FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
)
}
fun endFocus() {
stateStore.setState(FocusSessionKey, FocusSession.Idle)
}Saat sesi dimulai, kita menyetelnya ke berjalan, dan saat sesi berakhir, kita menyetelnya ke tidak ada aktivitas.
Selesai.
Kedua, kita masih memiliki updateState, yang memungkinkan Anda menghitung nilai baru berdasarkan nilai saat ini, seperti menghitung sisa waktu saat beralih antara berjalan dan dijeda.
fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Running) return@updateState session
FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
}
}
fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Paused) return@updateState session
FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
}
}Terakhir, ada getState, yang menampilkan State hanya baca untuk kunci, dan memiliki opsi untuk mengganti nilai default, jika Anda ingin mengubahnya di situs panggilan tertentu.
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}Selain itu, mengekspos Status FocusSession di pengelola ini berarti konsumen tidak perlu mengakses StateStore atau kuncinya secara langsung.
Anda dapat mempertahankan enkapsulasi tulis dan baca.
Kemudian, alih-alih konsumen, mereka hanya memasukkan Focus Session Manager, yang mungkin sudah mereka miliki karena mereka perlu mengontrol sesi, lalu mereka dapat membaca State.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}Kemudian, pembaca tersebut dapat langsung mengekspos ulang State, atau menggunakan Transform untuk membuat State baru yang diperbarui seperti yang kita lihat pada contoh Mini-Timer ViewModel sebelumnya.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}Kemudian, Composable UI dapat membaca State tersebut seperti State Compose lainnya dan melakukan rekomposisi jika diperlukan.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}
@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
val session by viewModel.session
if (session !is FocusSession.Idle) {
TimerReadout(session = session)
}
}Namun, sejauh ini, FocusSession hanya ada di memori.
Jadi, mari kita lihat apa yang diperlukan untuk bertahan dari penghentian proses.
Jadi, pertama-tama, kita harus melakukan beberapa penyiapan satu kali dengan meluncurkan syncToDataStore saat membuat penyimpanan.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): StateStore = StateStore().apply {
val path = context.dataStoreFile("app_state.json").absolutePath
scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
}
}Kemudian, setelah itu, kita mengaktifkan setiap kunci yang ingin kita pertahankan.
@Serializable
sealed interface FocusSession {
@Serializable data object Idle : FocusSession
@Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
@Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
PersistToDataStoreKita hanya perlu memastikan bahwa jenisnya dapat diserialisasi dan kuncinya mengimplementasikan antarmuka PersistToDataStore.
Lalu, Anda mungkin bertanya-tanya, oke, sepertinya mudah.
Jadi, apa yang sebenarnya dilakukan untuk mempertahankan kunci?
Jadi, di balik layar, syncToDataStore menggunakan API pemroses Transform, yang pada dasarnya sama persis dengan Transform, hanya saja tidak menampilkan apa pun, dan hanya digunakan untuk pengamatan dan efek samping.
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}Kemudian, dalam pemroses tersebut, kode ini membaca setiap kunci dalam penyimpanan, dan memeriksa apakah salah satunya adalah kunci PersistToDataStore.
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener {
for (key in keys) {
if (key is PersistToDataStore) {
val value = getState(key).value
LaunchedEffect(key) {
// Restore saved value from dataStore on first access
}
LaunchedEffect(key, value) {
// Flush updated value to dataStore on change
}
}
}
}
}Jika ya, library ini akan menggunakan LaunchedEffect untuk menyinkronkan nilai tersebut kembali ke DataStore setiap kali ada perubahan.
Hal yang ingin kami tunjukkan di sini adalah bahwa memiliki container standar seperti StateStore memungkinkan kami menyediakan integrasi drop-in ini.
DataStore hanyalah yang pertama kita lihat.
Jadi, kembali ke kategori status, sekarang kita memiliki gambaran yang cukup jelas tentang cara menggunakan Status Compose di seluruh aplikasi.
Dari mutableStateOf untuk status aktif, ke SavedState API untuk status presentasi lokal layar, dan StateStore untuk status sesi lintas komponen, dan terakhir, kita memiliki collectAsState untuk interop dengan Flow.
Namun, interop tidak terbatas pada satu kategori saja, Anda dapat menggunakan helper seperti collectAsState setiap kali Anda perlu menjembatani jenis yang dapat diamati ke dalam Status Compose.
Namun, untuk status permanen, hal lain yang kami pertimbangkan adalah cara untuk bekerja dengan Compose atau agar DataStore dan Room mengekspos Status Compose secara langsung, sehingga Anda tidak perlu menulis sintaksis sendiri yang lebih baik untuk melakukannya.
Dengan demikian, kami dengan senang hati mengumumkan bahwa Transform, StateStore, dan integrasi DataStore semuanya tersedia dalam versi alfa sekarang, dan kami juga telah memublikasikan semua dokumentasi di developer.android.com, dan kami memiliki repositori AppState Recipes di GitHub yang menunjukkan contoh penggunaan StateStore dan Transform.
[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }Dan, ya, silakan coba di aplikasi Anda.
Berikan masukan kepada kami.
Misalnya, kita sedang dalam tahap alfa, jadi inilah saatnya kita benar-benar dapat melakukan iterasi dan membuat perubahan yang akan memastikan bahwa perubahan tersebut berfungsi untuk kasus penggunaan Anda.
/**
* A value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value.
*/
@Stable
public interface State<out T> {
public val value: T
}/**
* A mutable value holder where reads to the [value] property during the execution of a [Composable]
* function, the current [RecomposeScope] will be subscribed to changes of that value. When the
* [value] property is written to and changed, a recomposition of any subscribed [RecomposeScope]s
* will be scheduled. If [value] is written to with the same value, no recompositions will be
* scheduled.
*/
@Stable
public interface MutableState<T> : State<T> {
override var value: T
public operator fun component1(): T
public operator fun component2(): (T) -> Unit
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}
@Composable
fun AddButton(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Add")
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(true) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}@Composable
fun Counter() {
val isVisible = remember { mutableStateOf(false) }
val count = remember { mutableStateOf(0) }
Column {
if (isVisible.value) {
Text("Count: ${count.value}")
} else {
Text("Hidden")
}
AddButton(onClick = { count.value++ })
}
}val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)
class GameViewModel : ViewModel() {
val uiState = MutableStateFlow(GameUiState())
fun updateScore() = uiState.update {
it.copy(
timeSurvived = it.timeSurvived + 1,
score = it.score + 10 + .01 * it.timeSurvived
)
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Column {
Header(levelName = state.levelName)
ScoreBadge(
timeSurvived = state.timeSurvived,
score = state.score
)
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}
class GameViewModel : ViewModel() {
val uiState = GameUiState()
fun updateScore() = Snapshot.withMutableSnapshot {
uiState.timeSurvived += 1
uiState.score += 10 + .01 * uiState.timeSurvived
}
}
@Composable
fun GameScreen(viewModel: GameViewModel = viewModel()) {
val state = viewModel.uiState
Column {
Header(state = state)
ScoreBadge(state = state)
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: StateFlow<FocusSummary> =
combine(focusManager.sessionFlow, taskRepository.tasksFlow) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle,
)
}class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle) return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find { it.id == session.activeTaskId }
FocusSummary.Active(...)class FocusScreenViewModel(
focusManager: FocusSessionManager,
taskRepository: TaskRepository
) : ViewModel() {
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val session by focusManager.sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: State<FocusSummary> =
transform<FocusSummary>(
initialValue = FocusSummary.Idle,
scope = scope,
container = handle.getOrCreateContainer("transform")
) {
val session by sessionFlow.collectAsState()
if (session.phase == FocusPhase.Idle)
return@transform FocusSummary.Idle
val tasks by taskRepository.tasksFlow
.collectAsState(emptyList())
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: StateFlow<MiniTimerUiState> =
combine(
focusManager.summary,
isExpanded
) { summary, expanded ->
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MiniTimerUiState()
)
fun toggleExpanded() { isExpanded.update { !it } }
}class MiniTimerViewModel(
focusManager: FocusSessionManager
) : ViewModel() {
val isExpanded = MutableStateFlow(false)
val uiState: State<MiniTimerUiState> =
transform(
initialValue = MiniTimerUiState(),
scope = viewModelScope,
container = handle.getOrCreateContainer("transform")
) {
val summary by focusManager.summary
val expanded by isExpanded.collectAsState()
MiniTimerUiState(
summary = summary,
showControls = expanded && summary is FocusSummary.Active
)
}
fun toggleExpanded() { isExpanded.update { !it } }
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R> {
val transformScope = TransformScope()
val transform = transformScope
.launchTransform(initialValue, onUpdate)
scope.launch(
dispatcher,
start = CoroutineStart.UNDISPATCHED
) {
try { transformScope.run() } finally {
transform.dispose()
}
}
return transform
}public fun TransformScope(context: CoroutineContext = Dispatchers.Default): TransformScope
public abstract class TransformScope {
public fun <T> createTransform(initialState: T, onUpdate: @Composable (T) -> T): DisposableState<T>
public abstract suspend fun run()
}
public fun <T> TransformScope.launchTransform(
initialState: T,
onUpdate: @Composable (T) -> T,
): DisposableState<T>
public interface DisposableState<T> : State<T> {
public fun dispose()
}override suspend fun run() {
val context = currentCoroutineContext()
val clock = context[MonotonicFrameClock] ?: HeadlessTransformClock()
...
val recomposer = Recomposer(context)
val composition = Composition(UnitApplier, recomposer)
try {
withContext(clock) {
if (clock is HeadlessTransformClock) {
launch(start = CoroutineStart.UNDISPATCHED) {
clock.runClock()
}
}
...
}
} finally {
// dispose of composition, cancel the clock, close the recomposer
}
}@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
fun updateSearchQuery(query: String) {
Snapshot.withMutableSnapshot {
searchQuery = query
selectedTaskIds = emptySet()
}
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { _isOnline.value = true }
override fun onLost(network: Network) { _isOnline.value = false }
}
init {
connectivityManager.registerDefaultNetworkCallback(callback)
}
}val tasksState: State<TaskListState> = transform(
initialValue = TaskListState.Loading,
scope = viewModelScope,
) {
if (!networkMonitor.isOnline.value) {
return@transform TaskListState.Offline
}
val tasks = remember(searchQuery) {
tasksRepository.getTasks(searchQuery)
}.collectAsState(initial = null).value
?: return@transform TaskListState.Loading
TaskListState.Success(tasks = tasks)
}sealed interface FocusSession {
data object Idle : FocusSession
data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}fun startFocus(taskId: String, durationMinutes: Int = 25) {
val endTime = System.currentTimeMillis() + durationMinutes * 60_000L
stateStore.setState(
FocusSessionKey,
FocusSession.Running(taskId = taskId, endTimeMillis = endTime),
)
}
fun endFocus() {
stateStore.setState(FocusSessionKey, FocusSession.Idle)
}fun pauseFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Running) return@updateState session
FocusSession.Paused(session.taskId, session.endTimeMillis - nowMillis)
}
}
fun resumeFocus(nowMillis: Long = System.currentTimeMillis()) {
stateStore.updateState(FocusSessionKey) { session ->
if (session !is FocusSession.Paused) return@updateState session
FocusSession.Running(session.taskId, nowMillis + session.remainingMillis)
}
}@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}
@Composable
fun MiniTimerBar(viewModel: MiniTimerViewModel = hiltViewModel()) {
val session by viewModel.session
if (session !is FocusSession.Idle) {
TimerReadout(session = session)
}
}@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(
@ApplicationContext context: Context,
@ApplicationScope scope: CoroutineScope,
): StateStore = StateStore().apply {
val path = context.dataStoreFile("app_state.json").absolutePath
scope.launch { syncToDataStore(path = path, coroutineScope = scope) }
}
}@Serializable
sealed interface FocusSession {
@Serializable data object Idle : FocusSession
@Serializable data class Running(val taskId: String, val endTimeMillis: Long) : FocusSession
@Serializable data class Paused(val taskId: String, val remainingMillis: Long) : FocusSession
}
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle),
PersistToDataStoresuspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener {
for (key in keys) {
if (key is PersistToDataStore) {
val value = getState(key).value
LaunchedEffect(key) {
// Restore saved value from dataStore on first access
}
LaunchedEffect(key, value) {
// Flush updated value to dataStore on change
}
}
}
}
}[versions]
appstate = "1.0.0-alpha01"
...
[libraries]
...
transform = { module = "androidx.appstate.transform:transform", version.ref = "appstate" }
transform-testing = { module = "androidx.appstate.transform:transform-testing", version.ref = "appstate" }
statestore = { module = "androidx.appstate.statestore:statestore", version.ref = "appstate" }
statestore-datastore = { module = "androidx.appstate.statestore:statestore-datastore", version.ref = "appstate" }