Inżynier ds. relacji z deweloperami
Środowisko wykonawcze migawki Compose, automatyczne śledzenie zależności i aktualizacje atomowe nie muszą kończyć się na warstwie interfejsu. Jetpack AppState umożliwia przenoszenie stanu Compose w górę strumienia – usprawnia to, co dociera do ekranów, przez podnoszenie i wyodrębnianie stanu za pomocą systemu migawek, zanim trafi on do interfejsu.
Podczas tej sesji dowiesz się więcej o 2 podstawowych bibliotekach AppState. Omówimy StateStore do podnoszenia stanu i Transform do używania kompozycji do uzyskiwania stanu za pomocą prostego przepływu sterowania Kotlin – eliminując złożone łańcuchy łączenia przepływów i zapewniając bezproblemową synchronizację interfejsu.
combine, a jednocześnie obsługuje wczesne zwracanie i zagnieżdżone pochodne stanu.Transkrypcja
Cześć, witamy na szkoleniu „Przenoszenie stanu Compose poza interfejs za pomocą Jetpack AppState”.
Nazywam się Ben i jestem inżynierem ds. relacji z deweloperami, który pracuje nad bibliotekami architektury Jetpack.
A ja jestem Elif i jestem jedną z inżynierek, które pracują nad bibliotekami architektury aplikacji.
Dzisiaj chcemy porozmawiać o przyszłości, w której Compose wykracza poza interfejs użytkownika i obejmuje warstwy danych i domen aplikacji.
Jak już pewnie wiecie, Compose to zalecany zestaw narzędzi do tworzenia interfejsu na Androidzie.
Od telefonów i tabletów po widżety, zegarki, telewizory i urządzenia z XR – Compose obsługuje je wszystkie.
Jednak w miarę jak aplikacja rozrasta się o te interfejsy i komponenty działające w tle, zarządzanie stanem staje się trudniejsze, zwłaszcza w przypadku 2 częstych problemów.
Pierwszy to uzyskiwanie złożonego stanu.
Łączenie wielu strumieni danych, danych wejściowych lokalnego interfejsu i stanu otoczenia, np. połączenia sieciowego, często prowadzi do powstania plątaniny operatorów, takich jak combine, flatMapLatest i distinctUntilChanged.
Drugi to udostępnianie stanu między komponentami: synchronizowanie aktywności, usługi, widżetu aplikacji i innych elementów oraz dbanie o to, aby stan mógł przetrwać śmierć procesu, gdy jest to konieczne.
Gdy przyjrzeliśmy się tym wyzwaniom, zaczęliśmy analizować, jak jest zbudowany sam Compose.
Większość osób uważa Compose za zestaw narzędzi UI, ale w rzeczywistości składa się on z 2 odrębnych warstw: Compose UI na górze i Compose Runtime na dole.
Podczas gdy Compose UI obsługuje układ, rysowanie i wprowadzanie danych, Compose Runtime jest silnikiem ogólnego przeznaczenia do zarządzania stanem i rekompozycji.
Nie jest zależny od interfejsu.
To spostrzeżenie doprowadziło nas do stworzenia Jetpack AppState.
Jest to rodzina bibliotek, które pozwalają maksymalnie wykorzystać stan Compose poza interfejsem.
Zanim jednak przejdziemy do AppState, upewnijmy się, że rozumiemy stan Compose w ten sam sposób.
Odpowiedź to 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
}Interfejs, który prawdopodobnie znasz lepiej, to 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
}Zasadniczo są to tylko podstawowe kontenery.
To, co sprawia, że działają, to system migawek Compose.
Gdy mówimy o stanie Compose, zwykle mamy na myśli stan Snapshot.
Gdy wywołujesz mutableStateOf, tworzysz kontener, który kieruje odczyty i zapisy przez system zrzutów.
Gdy kod jest uruchamiany w obserwatorze migawki, np. w kompozycji, otrzymujesz 2 właściwości, których nie zapewnia strumień taki jak Flow.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}Pierwsza to automatyczne i dynamiczne śledzenie odczytu.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}Automatyczne oznacza, że nie trzeba niczego subskrybować ani zbierać.
Subskrypcja obejmuje tylko czytanie książki .value.
Jeśli więc spojrzymy na Counter, odczytanie count.value bezpośrednio w funkcji kompozycyjnej zgłasza ten odczyt do kompozycji i rejestruje Counter na liście czytników.
A teraz spójrz na 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")
}
}Zapisuje tylko dane o kliknięciu.
Nie jest to więc czytnik.
Gdy użytkownik kliknie AddButton i zmieni liczbę, system migawek śledzi ten zapis, dzięki czemu kompozycja wie, że należy unieważnić Counter, ale nie AddButton.
Dynamiczne oznacza, że śledzenie odczytu odbywa się w czasie działania na podstawie ścieżek kodu, które są faktycznie uruchamiane.
@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++ })
}
}W tym przypadku tekst licznika jest chroniony przez flagę isVisible.
Gdy właściwość isVisible ma wartość „true”, odczytywane są zarówno właściwość isVisible, jak i liczba.
Counter jest więc zarejestrowany jako czytnik obu tych usług.
Gdy isVisible ma wartość false, gałąź if nie jest uruchamiana, a wartość count nie jest odczytywana, więc Counter jest zarejestrowany tylko jako czytnik isVisible, a nie 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++ })
}
}Dlatego gdy AddButton zapisuje dane w liczbie, nie ma już nic do unieważnienia.
Druga właściwość to brak konieczności wyboru między spójnością a szczegółowością.
Spójność oznacza, że gdy aktualizujesz wiele wartości w migawce, zmiany te pozostają odizolowane podczas działania kodu, nawet jeśli inny wątek zapisuje je w tym samym czasie.
Te aktualizacje stają się widoczne jednocześnie po zakończeniu tworzenia zrzutu.
Szczegółowość oznacza, że oddzielne usługi można zadeklarować i śledzić indywidualnie, zwłaszcza gdy nie są ze sobą powiązane.
Dlatego aktualizacja jednej właściwości unieważnia tylko te czytniki, które od niej zależą.
Przyjrzyjmy się temu na przykładzie prawdziwego kodu.
Załóżmy, że mamy grę, która ma 3 stany: nazwę poziomu, czas przetrwania i wynik.
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0Zacznijmy od przyjrzenia się implementacji Flow, która wybiera spójność kosztem szczegółowości, łącząc wszystko w klasę GameUiState.
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)Oznacza to, że przy każdym uruchomieniu funkcji updateScore kopiujesz cały obiekt GameUiState, dzięki czemu zarówno timeSurvived, jak i wynik mogą być aktualizowane jednocześnie.
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
)
}
}W interfejsie użytkownika GameScreen zbiera stan interfejsu i odczytuje go, aby przekazać odpowiednie właściwości do komponentów Header i 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
)
}
}GameScreen odczytuje każdy stan, więc każde wywołanie updateScore unieważnia GameScreen, mimo że dane nagłówka nigdy się nie zmieniły.
Możesz teraz podzielić każdą właściwość na osobny przepływ, aby uzyskać szczegółowość, ale stracisz spójność, ponieważ inny wątek może zaktualizować wartość, gdy działa inny blok aktualizacji.
Teraz rozważmy alternatywną implementację opartą na stanach.
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}W tym przypadku ponownie używamy GameUiState.
W tym przypadku jest jednak nieco inaczej, ponieważ każda właściwość jest delegowana do mutableStateOf.
W tym przypadku updateScore opakowuje zapisy w withMutableSnapshot, co zapewnia spójność zapisów.
@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
}
}Następnie w interfejsie GameScreen przekazuje stan bezpośrednio do nagłówka i 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)
}
}Nie odczytuje żadnych właściwości, więc gdy updateScore jest uruchamiany, GameScreen nie jest czytnikiem.
Unieważniany jest tylko rzeczywisty komponent, w tym przypadku ScoreBadge.
Zwróć uwagę, że żadna z tych czynności nie jest powiązana z interfejsem.
Interfejs jest tylko jednym z miejsc, w których te właściwości naprawdę się sprawdzają.
Dziękujemy za udział w naszej prezentacji.
OK, żarty na bok. Przekazuję głos Elif, która omówi pierwszą bibliotekę, Transform.
OK.
Dzięki, Ben.
Jak zauważył Ben, stan zrzutu Compose zapewnia automatyczne i dynamiczne śledzenie odczytu, spójność i szczegółowość. To prowadzi do pytania: co by było, gdyby przepływy danych mogły być również sterowane przez zrzut stanu Compose – stan zrzutu?
Jeśli stan jest naszym modelem danych, to jak właściwie obliczamy i przekształcamy stan dla wszystkich jego konsumentów?
Właśnie w tym celu powstała nowa biblioteka Jetpack AppState Transform.
Zanim przejdziemy do samej biblioteki, musimy porozmawiać o tym, jak powstała.
Dlatego pomysł wykorzystania silnika Compose, który jest tutaj środowiskiem wykonawczym i kompilatorem, do generowania wartości stanu w kompozycji bez interfejsu nie jest nowy.
Wielu z Was może już używać biblioteki Molecule lub ją znać.
W tym przypadku „bezgłowy” oznacza brak interfejsu użytkownika.
Molecule był pionierem w demonstrowaniu możliwości środowiska wykonawczego Compose. Pokazał nam w praktyce, że funkcja typu „composable” nie musi emitować węzłów interfejsu i może być używana do pisania funkcji typu „composable”, które zwracają czysty model danych.
W przypadku Molecule był to Kotlin Flow lub StateFlow.
Oznaczało to, że zamiast łączyć operatory reaktywne, takie jak „combine”, można było pisać zwykły kod w języku Kotlin.
A gdyby zamiast przepływów użyć modelu danych State, skoro interfejs jest już w Compose?
W przypadku przekształcenia modelem danych jest stan.
Przyjrzyjmy się interfejsowi API.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>To cały interfejs API.
To bardzo proste, jedna funkcja.
Zwraca zwykły obserwowalny stan, a nie przepływ ani StateFlow.
Osoba, która z niego korzysta, nie wie, że został wygenerowany przez przekształcenie, i nie ma to dla niej znaczenia.
Najbardziej interesujący jest ten blok.
Jest kompozycyjny.
Oznacza to, że wszystko, co już wiesz, działa w tym środowisku.
Pamiętaj, że LaunchedEffect, collectAsState, wczesne zwracanie, zwykłe instrukcje if, wszystko, co można odczytać z wnętrza funkcji kompozycyjnej, można uzyskać w ramach przekształcenia.
Warto zauważyć, że wszystkie interfejsy API, które widzimy dzisiaj w tej prezentacji, są w wersji alfa i mogą ulec zmianie, ale mimo to przyjrzyjmy się przykładowi.
Weźmy na przykład bardzo prostą, niewielką aplikację z minutnikiem do skupienia uwagi.
Wybierasz zadanie, ustawiasz minutnik na 25 minut i zegar zaczyna odliczać czas.
Za każdym razem, gdy tworzysz pojedynczy ekran, nawet tak prosty jak ten, prawdopodobnie musisz połączyć wiele niezależnych strumieni danych i nimi zarządzać, aby złożyć stan pod maską.
Jeśli chodzi o ekran skupienia, załóżmy, że mamy 2 ekrany: samą sesję skupienia, która będzie określać, w jakiej fazie się znajdujemy, czyli czy jest wstrzymana, czy nie, ile sekund zostało, a wszystko to będzie pochodzić z silnika minutnika jako StateFlow, który będzie tykać powiedzmy co sekundę.
Drugi strumień danych będzie zawierał bieżące zadanie, nad którym pracuje użytkownik, i będzie pochodził z bazy danych Room jako Flow.
Ostatecznie mamy więc 2 strumienie danych i 1 stan ekranu.
ViewModel to miejsce, w którym zostaną połączone, a jego dane wyjściowe, które nazwiemy podsumowaniem, będą fragmentem kodu, który napiszemy w następnej kolejności.
Tradycyjnie oznaczałoby to, że w ViewModel musisz połączyć te przepływy za pomocą operatorów łańcuchowych.
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,
)
}Połącz więc te 2 przepływy w strumienie, rozgałęzienie wewnątrz funkcji lambda, czyli instrukcję if-else, a na końcu stan.
Dzięki funkcji Transform możemy usunąć cały ten powtarzalny kod.
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(...)To wywołanie wygląda znajomo, ponieważ mamy wartość początkową w zakresie, który jest przekazywany, ale nie ma niczego nadrzędnego do połączenia, a pochodna jest samym blokiem.
W odróżnieniu od stateIn, gdzie prawdopodobnie trzeba skonfigurować czas oczekiwania WhileSubscribed, aby wstrzymać i wznowić strumienie nadrzędne, przekształcenie działa nieprzerwanie przez cały czas istnienia tego zakresu.
Naszymi źródłami są przepływy, a przepływy są mile widziane.
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(...)
}
}Każdy z nich połączymy z collectAsState, a sesja to StateFlow, więc nie potrzebujemy wartości początkowej, ale Room Flow jest zimny, więc podajemy mu wartość.
To wszystko, co musisz wiedzieć o subskrypcjach.
Odczyt to subskrypcja.
To automatyczne śledzenie odczytu, o którym Ben mówił wcześniej w przykładzie, ale w ViewModel.
Możemy nawet wprowadzić wcześniejsze powroty, takie jak ten, gdy jesteśmy w stanie bezczynności.
Załóżmy, że nie mamy nowych zadań, więc nie będziemy nawet przetwarzać pozostałych.
To kolejny przykład na to, jak dynamiczne śledzenie odczytu przynosi korzyści.
Gdy nie korzystamy z urządzenia, dane Room Flow nie są w ogóle zbierane.
Na koniec generujemy podsumowanie.
Jeśli więc użytkownik wstrzyma zadanie lub licznik czasu się zmieni, wszystko zostanie automatycznie zaktualizowane.
Sytuacja nieco się komplikuje, gdy wyjdziemy poza przykład z jednym elementem ViewModel.
Załóżmy, że ta sama sesja musi pojawić się na 2 dodatkowych powierzchniach: na pasku minizegara, który znajduje się u dołu ekranu aplikacji na każdym ekranie, oraz w widżecie na ekranie głównym.
Oba będą potrzebować tych samych informacji o podsumowaniu sesji, fazie, wstrzymaniu lub nie, oraz o tym, ile czasu pozostało na minutniku.
Oznacza to, że gdyby każda powierzchnia miała własną kopię, byłoby to to samo wyprowadzenie w 3 różnych miejscach, które musiałyby być ze sobą zgodne.
Zamiast tego musimy wygenerować podsumowanie raz w zakresie aplikacji, a następnie wszystko, co znajduje się poniżej, będzie z niego korzystać.
Jak więc wdrożyć takie rozwiązanie?
Nasza implementacja będzie prawdopodobnie wymagać 3 elementów.
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}Menedżer sesji w zakresie aplikacji, który będzie miejscem, gdzie generowane jest podsumowanie, minizegar ViewModel i widżet podsumowania, które będą korzystać z podsumowania wygenerowanego w tym menedżerze sesji skupienia.
Utwórzmy każdy z nich w tradycyjny sposób, a następnie za pomocą funkcji Przekształć.
Tradycyjnie zaczęlibyśmy od jednorazowego wygenerowania podsumowania w zakresie aplikacji jako 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
)
}Są to te same wywołania funkcji łączenia i stateIn, które widzieliśmy wcześniej w przykładzie ViewModel, ale przeniesione o poziom wyżej.
W przypadku przekształcenia możemy też ponownie użyć tej samej deklaracji z ViewModel, którą widzieliśmy wcześniej.
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(...)
}
}Jedyną zmianą jest to, że teraz znajduje się on w zakresie aplikacji.
Następnie mamy ViewModel miniodliczania.
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 } }
}Załóżmy, że chcemy sobie trochę utrudnić zadanie.
Załóżmy, że w minutniku oprócz 2 strumieni danych, które już mamy, czyli StateFlow i Room Flow, chcemy też śledzić, czy pasek minutnika jest rozwinięty.
W tym celu używamy MutableStateFlow.
Aby to zrobić, musimy użyć innego połączenia do śledzenia przepływu podsumowania i isExpanded StateFlow.
I mamy drugi stan.
Dzięki funkcji Transform nie musimy już używać żadnych z tych połączonych operatorów, aby synchronizować strumienie danych.
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 } }
}Możemy jednak zrobić coś naprawdę fajnego – przekształcenia zagnieżdżone.
W tym przypadku ViewModel odczytuje podsumowanie, które jest wynikiem przekształcenia nadrzędnego, tak jak każdy inny stan, a następnie zbiera isExpanded za pomocą collectAsState.
Możemy więc połączyć stan nadrzędny i przepływ podrzędny w StateFlow w tym samym bloku.
Pamiętaj też o szczegółowości.
Jeśli więc przełączysz isExpanded, ponownie uruchomiony zostanie tylko ten blok.
Blokada na poziomie aplikacji i widżet nie muszą nawet tego zauważyć.
Ostatni element to widżet.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Glance to Compose, więc możemy zbierać StateFlow za pomocą collectAsState w provideContent, tak jak w przypadku każdego innego elementu Composable.
Dzięki funkcji Transform jest to jeszcze prostsze.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Nasz menedżer zaznaczenia udostępnia nam stan Compose, więc nie musimy niczego konwertować.
Ostatecznie pasek minizegara i widżet na ekranie głównym nigdy nie będą się różnić, ponieważ odczytują ten sam stan.
Co więcej, każda zmiana wprowadzona w tym stanie zostanie odzwierciedlona u wszystkich użytkowników tego stanu.
Co to znaczy?
Jeśli więc wstrzymasz odtwarzanie w widżecie, informacja o tym zostanie przekazana przez menedżera do strumienia sesji, a następnie do podsumowania. Wszystkie 3 ekrany zostaną zsynchronizowane.
Skoro już wiemy, jak korzystać z narzędzia Transform, przyjrzyjmy się mu bliżej.
Interfejs Transform API, który widzieliśmy wcześniej, jest w rzeczywistości funkcją pomocniczą używaną do tworzenia podstawowego zarządzanego przekształcenia.
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
}W rzeczywistości używa funkcji rozszerzenia TransformScope i launchTransform, która uruchamia przekształcenie Composable w tym TransformScope i zwraca stan jednorazowy.
Gdy przeczytasz go od góry do dołu, zobaczysz, że robi wszystko za Ciebie.
Tworzy TransformScope i uruchamia w nim przekształcenie, które będzie stanem, który otrzymasz z powrotem.
Następnie uruchamia też funkcję run w TransformScope w podanym przez Ciebie dyspozytorze, dzięki czemu czas życia zakresu i wątek należą do Ciebie.
Na koniec, gdy zakres się kończy, usuwa przekształcenie.
To jest cały cykl życia funkcji Transform.
Przyjrzyjmy się szybko interfejsowi TransformScope.
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()
}Widzimy, że zawiera funkcję createTransform, która utworzy przekształcenie dołączone do tego TransformScope.
Ma też funkcję zawieszania, która uruchamia TransformScope i rozpoczyna proces aktualizowania przekształceń stanu obecnego.
Co to znaczy?
Funkcja run konfiguruje więc Recomposer i kompozycję.
Każda funkcja Transform w zakresie znajduje się wewnątrz tej kompozycji i ma własny zakres rekompozycji.
Dlatego chcemy udostępniać zakres i dlatego mamy TransformScope.
Dzięki temu po zmianie stanu ponownie uruchamiane są tylko te przekształcenia, które go odczytują.
Jak wspomnieliśmy wcześniej, run jest funkcją zawieszenia.
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
}
}Działa więc w dowolnej współprogramie, z którego jest wywoływana, dopóki ten współprogram nie zostanie anulowany.
Możesz się zastanawiać, czy Recomposer potrzebuje zegara klatek, który informuje go, kiedy ma wygenerować klatkę.
W interfejsie Compose jest to wyświetlacz, ale jak wspomnieliśmy wcześniej, działamy w środowisku bez interfejsu, więc nie ma interfejsu użytkownika.
Jeśli nie dostarczysz nam zegara, utworzymy własny.
Dzięki oddzieleniu rekompozycji od zegara klatek wyświetlania Transform wykonuje wszystkie przekształcenia i pochodne stanu poza wątkiem UI.
Aby to umożliwić, stworzyliśmy specjalną HeadlessTransformClock.
Zegar klatek przekształcenia.
Ponieważ widżet, usługa lub inne przekształcenie na dalszym etapie może oczekiwać na wartość z przekształcenia, nasz zegar jest niecierpliwy.
Oznacza to, że w momencie, gdy zmieni się stan, który jest wykorzystywany przez przekształcenie, natychmiast tworzona jest nowa klatka.
Jest to niewielka maszyna stanowa bez blokad, więc żądanie ramki jest prostą operacją porównania i ustawienia.
Gdy stan się nie zmienia, zegar się zatrzymuje.
Dlatego przekształcenie, które wykorzystuje stan, który nie został zmieniony, nic nie kosztuje.
Zanim oddam głos Benowi, warto też wspomnieć, że Transform obsługuje teraz rememberSaveable, dzięki czemu dane są zapisywane podczas zmian konfiguracji.
Dzieje się tak dzięki nowemu interfejsowi API SavedStateContainer, który jest teraz podstawą, na której opierają się SaveableStateRegistry, SavedStateHandle i SavedStateRegistry w Compose.
Możesz przekształcić dowolny z nich w SavedStateContainer lub utworzyć kontenery podrzędne i przekazać je do przekształcenia, aby mieć pewność, że dane zostaną zapisane.
Na koniec przypominamy, że wszystkie interfejsy API, o których dzisiaj mówimy, są w wersji alfa, więc mogą ulec zmianie.
Teraz oddaję głos Benowi.
OK.
Dobrze, dziękuję, Elif.
Skoro już wiemy, jak potężny jest stan Compose i jak Transform pozwala nam go uzyskać poza interfejsem, warto zauważyć, że w dotychczasowym przykładzie nasze dane wejściowe były przepływami, które połączyliśmy za pomocą collectAsState.
Czy jednak zawsze jest to konieczne?
Dlaczego nie używamy stanu Compose bezpośrednio w naszych zmiennych stanu?
Aby odpowiedzieć na to pytanie, warto podzielić stan na 3 szerokie kategorie.
Pierwszy to stan aktywny, który jest przechowywany tylko w pamięci.
Może to być na przykład stan połączenia sieciowego lub Bluetooth.
Nie chcesz używać wartości trwałej.
Drugi to stan przejściowy, który powinien przetrwać śmierć procesu, ale nie musi być przechowywany bezterminowo.
Chodzi o takie elementy jak stan interfejsu, np. czy karta jest rozwinięta, oraz stan sesji, np. stos wsteczny nawigacji lub wersja robocza formularza.
Trzeci to stan stały, który jest przechowywany bezterminowo.
Mogą to być na przykład preferencje użytkownika lub biblioteka multimediów w bazie danych.
Zacznijmy od tego, jak można używać stanu Compose zarówno w przypadku stanu aktywnego, jak i przejściowego.
Do tej pory nasze wskazówki dotyczące architektury zalecały przechowywanie stanu Compose w warstwie interfejsu, a czasami w ViewModel, i używanie Flow we wszystkich innych miejscach.
Dlaczego tak się stało?
Dzieje się tak, ponieważ gdy tylko trzeba połączyć właściwość stanu z Flow, trzeba ją przekonwertować z powrotem za pomocą snapshotFlow.
Dzięki Transform nie jest to jednak konieczne.
Zobaczmy, jak to wygląda i co to oznacza, zaczynając od ViewModel.
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}W przypadku lokalnych danych wejściowych ekranu, takich jak selectedTaskIds, możesz zadeklarować zwykłą właściwość mutableStateOf zamiast MutableStateFlow.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}Ponieważ jest to coś, czego niekoniecznie chcesz zachować po śmierci procesu, nie ma problemu, że nie jest to tak, że używa tylko mutableStateOf.
W przypadku elementów, które mają przetrwać śmierć procesu, takich jak zapytanie, możesz bezpośrednio zapisać ich stan zmienny za pomocą savedStateHandle.saveable.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}Podobnie jak w przypadku updateScore we wcześniejszym przykładzie z grą, gdy metoda aktualizuje wiele wartości stanu naraz, opakowanie zapisów w withMutableSnapshot zapewnia spójność.
@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()
}
}
}Następnie, aby połączyć searchQuery z Room Flow, wystarczy odczytać go bezpośrednio w Transform podczas zbierania 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)
}A co ze stanem na żywo, np. połączeniem sieciowym?
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}Zwykle znajduje się on w klasie Singleton, ponieważ jest odczytywany na wielu ekranach, a nawet przez wiele komponentów aplikacji, takich jak usługi.
Nie oznacza to jednak, że nie możesz używać stanu Compose do przechowywania tych danych.
W rzeczywistości jest to często prostsze niż wersja oparta na przepływie.
Nie potrzebujesz przepływu wywołania zwrotnego ani niczego innego, aby przeprowadzić adaptację. Wystarczy, że zaktualizujesz wartość kropki bezpośrednio w wywołaniach zwrotnych.
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}Jeśli konsument nadal potrzebuje przepływu, możesz użyć snapshotFlow, aby wrócić do poprzedniego stanu.
@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)
}
}Zobaczmy, jak to wygląda w ViewModel.
Możesz odczytywać isOnline bezpośrednio w obiekcie Transform, a gdy wartość się zmieni, zostanie on ponownie skomponowany.
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)
}Wszystko to dzieje się bezpośrednio. Nie ma żadnego zakresu aplikacji ani zakresu współprogramu, który trzeba podać, aby zaktualizować stan. To po prostu bezpośrednia, synchroniczna aktualizacja.
Dzięki funkcji Transform ViewModel może łączyć stan z menedżerów Singleton, stan w ViewModel i przepływy z repozytoriów w jednym bloku Transform.
Wróćmy teraz do stanu przejściowego.
Wcześniej wspomnieliśmy o 2 typach: stanie interfejsu i stanie sesji.
W przypadku stanu interfejsu, a nawet stanu sesji, który znajduje się w jednej aktywności, ViewModel i SavedState sprawdzają się świetnie.
Właśnie do tego zostały stworzone.
Problemy zaczynają się, gdy stan sesji musi być udostępniany wielu różnym interfejsom i komponentom aplikacji.
Gdy ten stan wykracza poza ViewModel, nasze obecne interfejsy API i wskazówki nie są już wystarczające.
Najpierw interfejsy API SavedState zapisują dane w pakiecie aktywności, ale nie są one dostępne w innych komponentach aplikacji, takich jak usługa.
Po drugie, mutableStateOf we wzorcu Singleton, który istnieje tylko w pamięci, więc nie przetrwa śmierci procesu.
Tę lukę wypełnia Jetpack AppState, StateStore.
Jest to mapa stanu Compose z zabezpieczeniami wpisywania, którą można udostępniać między komponentami aplikacji i która jest obsługiwana przez DataStore, aby zapewnić przetrwanie śmierci procesu.
Możesz samodzielnie podłączyć DataStore do każdego obiektu Singleton, ale oznacza to konieczność pisania kodu szablonowego do zapisywania i przywracania każdej właściwości.
Dzięki integracji StateStore DataStore podłączasz DataStore raz, a potem akceptujesz każdy element stanu, który chcesz zachować.
Zbudujmy więc tę funkcję w naszej aplikacji z licznikiem czasu skupienia.
Zaczniemy od pamięci, a potem dodamy trwałość DataStore.
Na początek modelujemy FocusSession jako zapieczętowany interfejs ze stanami bezczynności, działania i wstrzymania.
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
}Aby zapisać ten stan w StateStore, zdefiniowaliśmy StateStoreKey z typem FocusSession i wartością domyślną „idle”.
sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)Następnie udostępniamy singleton StateStore w Hilt, który możemy wstrzykiwać do różnych komponentów, w których jest potrzebny, np. do menedżera sesji skupienia.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}Czyli podobnie jak ViewModel wstrzykuje SavedStateHandle.
OK.
Mamy już StateStore, ale jak go używać?
Na szczęście jest to mały interfejs API.
Ma tylko 3 metody, więc omówimy je po kolei.
Pierwsza to setState, która zapisuje nową wartość klucza.
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)
}Gdy sesja się rozpoczyna, ustawiamy jej stan na „uruchomiona”, a gdy się kończy, ustawiamy jej stan na „bezczynna”.
To wszystko.
Po drugie, nadal mamy updateState, który pozwala obliczyć nową wartość na podstawie bieżącej wartości, np. obliczyć pozostały czas podczas przełączania się między uruchomieniem a wstrzymaniem.
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)
}
}Na koniec mamy getState, która zwraca stan tylko do odczytu dla klucza i ma opcję zastąpienia wartości domyślnej, jeśli chcesz ją zmienić w określonym miejscu wywołania.
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}Udostępnienie stanu FocusSession w tym menedżerze oznacza, że konsumenci nigdy nie muszą uzyskiwać bezpośredniego dostępu do StateStore ani jego kluczy.
Możesz zachować enkapsulację zapisu i odczytu.
Następnie zamiast konsumentów wstrzykują Focus Session Manager, który prawdopodobnie już mają, ponieważ muszą kontrolować sesję, a potem mogą odczytać stan.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}Następnie te czytniki mogą bezpośrednio ponownie udostępnić stan lub użyć przekształcenia, aby utworzyć nowy, zaktualizowany stan, podobnie jak w przykładzie z minutnikiem ViewModel.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}Następnie elementy kompozycyjne interfejsu mogą odczytywać ten stan tak samo jak każdy inny stan Compose i w razie potrzeby przeprowadzać rekompozycję.
@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)
}
}Jednak do tej pory FocusSession był przechowywany tylko w pamięci.
Przyjrzyjmy się temu, co jest potrzebne, aby przetrwać śmierć procesu.
Najpierw musimy przeprowadzić jednorazową konfigurację, uruchamiając syncToDataStore podczas tworzenia sklepu.
@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) }
}
}Następnie akceptujemy każdy klucz, który chcemy trwale przechowywać.
@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),
PersistToDataStoreWystarczy upewnić się, że typ jest możliwy do serializacji, a klucz implementuje interfejs PersistToDataStore.
Możesz pomyśleć: „To wydaje się proste.
Co właściwie robi, aby zachować klucz?
W tle syncToDataStore używa interfejsu API detektora przekształcenia, który działa dokładnie tak samo jak przekształcenie, ale nic nie zwraca, tylko służy do obserwacji i efektów ubocznych.
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}Następnie w ramach tego detektora odczytuje każdy klucz w sklepie i sprawdza, czy jest to klucz 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
}
}
}
}
}Jeśli tak, używa LaunchedEffect do synchronizowania tych wartości z DataStore za każdym razem, gdy ulegną zmianie.
Chcę podkreślić, że standardowy kontener, taki jak StateStore, umożliwia nam udostępnianie tych integracji typu drop-in.
DataStore to tylko pierwszy z nich.
Wracając do kategorii stanów, mamy teraz dość jasny obraz tego, jak można używać stanu Compose w całej aplikacji.
Od mutableStateOf dla stanu aktywnego, przez interfejsy API SavedState dla stanu prezentacji lokalnej na ekranie i StateStore dla stanu sesji między komponentami, aż po collectAsState do współdziałania z przepływami.
Jednak interoperacyjność nie ogranicza się do jednej kategorii. Możesz używać tych funkcji pomocniczych, takich jak collectAsState, gdy chcesz połączyć typy obserwowalne ze stanem Compose.
Jeśli chodzi o stan stały, rozważamy też sposoby współpracy z Compose lub DataStore i Room, aby bezpośrednio udostępniać stan Compose, dzięki czemu nie trzeba będzie pisać własnej, ładniejszej składni.
Z przyjemnością ogłaszam, że Transform, StateStore i integracja z DataStore są już dostępne w wersji alfa. Opublikowaliśmy też całą dokumentację na stronie developer.android.com i mamy repozytorium AppState Recipes na GitHubie, które pokazuje przykłady użycia zarówno StateStore, jak i 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" }Wypróbuj je w swojej aplikacji.
Prześlij nam opinię.
Jesteśmy w fazie alfa, więc możemy wprowadzać zmiany, które będą odpowiadać Twoim przypadkom użycia.
/**
* 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" }