Developer Relations Engineer
Die Snapshot-Laufzeit, die automatische Abhängigkeitsverfolgung und die atomaren Updates von Compose müssen nicht auf der UI-Ebene enden. Mit Jetpack AppState können Sie den Compose-Status vorgelagert verschieben. So wird optimiert, was auf Ihren Bildschirmen angezeigt wird, indem der Status mit dem Snapshot-System hochgestuft und abgeleitet wird, bevor er die Benutzeroberfläche erreicht.
In dieser Sitzung lernen Sie die beiden grundlegenden Bibliotheken von AppState kennen. Wir sehen uns StateStore für das Hoisting von Zuständen und Transform für die Verwendung von Kompositionen an, um Zustände mit einfachem Kotlin-Kontrollfluss abzuleiten. Dadurch werden komplexe Flow-Kombinationsketten vermieden und die Benutzeroberfläche bleibt mühelos synchron.
combine und unterstützt gleichzeitig vorzeitige Rückgaben und verschachtelte Zustandsableitungen.Transkript
Hallo und herzlich willkommen zu „Compose-Zustand mit Jetpack AppState über die Benutzeroberfläche hinaus nutzen“.
Ich heiße Ben und bin Developer Relations Engineer. Ich arbeite an den Jetpack-Architektur-Bibliotheken.
Ich bin Elif und eine der Entwicklerinnen, die an den Bibliotheken für die App-Architektur arbeiten.
Heute möchten wir mit Ihnen über eine Zukunft sprechen, in der Compose über die UI hinausgeht und auch in den Daten- und Domain-Ebenen von Anwendungen eingesetzt wird.
Wie Sie sicher alle wissen, ist Compose das empfohlene Toolkit für die UI-Entwicklung unter Android.
Von Smartphones und Tablets bis hin zu Widgets, Smartwatches, Fernsehern und XR-Geräten – Compose unterstützt alles.
Wenn Ihre App jedoch auf diesen UI-Oberflächen und Hintergrundkomponenten wächst, wird die Verwaltung des Zustands dahinter schwieriger, insbesondere im Hinblick auf zwei häufige Pain Points.
Die erste ist das Ableiten komplexer Zustände.
Das Zusammenführen mehrerer Datenstreams, lokaler UI-Eingaben und des Umgebungsstatus, z. B. der Netzwerkverbindung, führt oft zu einem Gewirr von Operatoren wie „combine“, „flatMapLatest“ und „distinctUntilChanged“.
Der zweite Punkt ist die gemeinsame Nutzung des Status über Komponenten hinweg: Aktivitäten, Dienste, App-Widgets und mehr werden synchronisiert und der Status kann bei Bedarf auch nach einer Prozessbeendigung wiederhergestellt werden.
Als wir uns diese Herausforderungen genauer angesehen haben, haben wir uns auch mit der Struktur von Compose beschäftigt.
Die meisten Nutzer betrachten Compose als UI-Toolkit, aber es besteht eigentlich aus zwei verschiedenen Ebenen: Compose-UI oben und Compose-Laufzeit darunter.
Während Compose UI Layout, Zeichnen und Eingabe übernimmt, ist die Compose-Laufzeit eine allgemeine Engine für die Statusverwaltung und Neuzusammensetzung.
Es besteht keine Abhängigkeit von der Benutzeroberfläche.
Diese Erkenntnis hat uns dazu veranlasst, Jetpack AppState zu entwickeln.
Es handelt sich um eine Familie von Bibliotheken, die darauf ausgerichtet sind, den Compose-Status über die UI hinaus optimal zu nutzen.
Bevor wir uns mit AppState befassen, sollten wir uns darüber im Klaren sein, was wir mit Compose-Zustand meinen.
Die Antwort lautet 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
}Und die Benutzeroberfläche, mit der Sie wahrscheinlich vertrauter sind: 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
}Im Grunde sind das nur einfache Container.
Damit sie tatsächlich funktionieren, ist das Compose-Snapshot-System erforderlich.
Wenn wir also von Compose-Zustand sprechen, meinen wir in der Regel den Snapshot-Zustand.
Wenn Sie also mutableStateOf aufrufen, erstellen Sie einen Container, der Lese- und Schreibvorgänge über das Snapshot-System weiterleitet.
Und wenn Ihr Code in einem Snapshot-Observer wie einer Composition ausgeführt wird, erhalten Sie zwei Eigenschaften, die ein Stream wie ein Flow nicht bietet.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}Die erste ist die automatische und dynamische Leseverfolgung.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}„Automatisch“ bedeutet also, dass keine Abonnements oder Erfassungen erforderlich sind.
.value ist das Abo.
Wenn wir uns hier „Counter“ ansehen, wird count.value direkt im Composable gelesen und an die Komposition gemeldet. „Counter“ wird in der Liste der Leser registriert.
Und jetzt sieh dir AddButton an.
@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")
}
}Es wird nur geschrieben, um den Klick zu zählen.
Es ist also kein Reader.
Wenn ein Nutzer auf AddButton klickt und die Anzahl ändert, wird dieser Schreibvorgang vom Snapshot-System erfasst. Die Komposition weiß dann, dass Counter ungültig gemacht werden muss, AddButton jedoch nicht.
„Dynamisch“ bedeutet, dass das Lesetracking zur Laufzeit erfolgt, je nachdem, welche Codepfade tatsächlich ausgeführt werden.
@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++ })
}
}Hier wird der Zählertext hinter einem isVisible-Flag geschützt.
Wenn isVisible „true“ ist, werden sowohl isVisible als auch „count“ gelesen.
Counter ist also als Leser für beide registriert.
Wenn isVisible „false“ ist, wird der „if“-Zweig nicht ausgeführt und „count“ wird nicht gelesen. „Counter“ wird also nur als Leser von isVisible registriert, nicht von „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++ })
}
}Wenn AddButton also in „count“ schreibt, gibt es nichts mehr, was ungültig gemacht werden müsste.
Die zweite Eigenschaft ist, dass Sie sich nicht zwischen Konsistenz und Granularität entscheiden müssen.
Konsistenz bedeutet, dass beim Aktualisieren mehrerer Werte in einem Snapshot diese Änderungen isoliert bleiben, während Ihr Code ausgeführt wird, auch wenn ein anderer Thread gleichzeitig in sie schreibt.
Diese Aktualisierungen werden alle auf einmal sichtbar, wenn der Snapshot fertig ist.
Granularität bedeutet, dass separate Properties einzeln deklariert und verfolgt werden können, insbesondere wenn sie nicht miteinander zusammenhängen.
Wenn Sie also eine Property aktualisieren, werden nur die Leser ungültig, die tatsächlich von ihr abhängen.
Sehen wir uns das in einem echten Code an.
Sehen wir uns zum Beispiel ein Spiel mit drei Status an: Levelname, Überlebenszeit und Punktzahl.
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0Sehen wir uns zuerst eine Flow-Implementierung an, die Konsistenz auf Kosten der Granularität wählt, indem alles in einer GameUiState-Klasse gebündelt wird.
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)Das bedeutet, dass bei jeder Ausführung von updateScore die gesamte GameUiState kopiert wird, sodass sowohl timeSurvived als auch die Punktzahl synchron aktualisiert werden können.
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
)
}
}In der Benutzeroberfläche erfasst GameScreen den UI-Status und liest daraus, um die relevanten Eigenschaften an Header und ScoreBadge weiterzugeben.
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
)
}
}Da GameScreen jeden Status liest, wird GameScreen bei jedem Aufruf von updateScore ungültig, obwohl sich die Daten des Headers nie geändert haben.
Sie könnten jede Property in einen eigenen Ablauf aufteilen, um eine höhere Granularität zu erreichen. Dann würde jedoch die Konsistenz verloren gehen, da ein anderer Thread einen Wert aktualisieren könnte, während der andere Aktualisierungsblock ausgeführt wird.
Sehen wir uns nun eine alternative zustandsbasierte Implementierung an.
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}In diesem Fall verwenden wir wieder ein GameUiState.
In diesem Fall ist es jedoch etwas anders, da jede Property an mutableStateOf delegiert.
Hier werden die Schreibvorgänge von updateScore in withMutableSnapshot verpackt, was für Konsistenz bei den Schreibvorgängen sorgt.
@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
}
}In der UI übergibt GameScreen den Status direkt an Header und 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)
}
}Es liest keine der Properties. Wenn updateScore ausgeführt wird, ist GameScreen also kein Leser.
Nur die eigentliche Komponente, in diesem Fall ScoreBadge, wird ungültig.
Und schließlich ist nichts davon an die Benutzeroberfläche gebunden.
Die Benutzeroberfläche ist nur ein Ort, an dem diese Eigenschaften wirklich glänzen.
Vielen Dank, dass Sie sich unseren Vortrag angehört haben.
Okay, Spaß beiseite. Ich übergebe an Elif, die uns durch die erste Bibliothek, Transform, führt.
Okay.
Danke, Ben.
Wie Ben gerade erwähnt hat, bietet der Compose-Snapshot-Status automatisches und dynamisches Lesetracking, Konsistenz und Granularität. Das bringt mich zu der Frage: Was wäre, wenn wir unsere Datenflüsse auch von einem Compose-State-Snapshot – einem Snapshot-Status – steuern lassen könnten?
Und wenn der Zustand unser Datenmodell ist, wie berechnen und transformieren wir dann einen Zustand für alle seine Nutzer?
Hier kommt die neue Jetpack AppState Transform-Bibliothek ins Spiel.
Bevor wir uns die Bibliothek selbst ansehen, müssen wir darüber sprechen, wie die Bibliothek entstanden ist.
Die Idee, die Compose-Engine, also die Laufzeit und den Compiler, zu nutzen, um Zustandswerte in einer Headless-Komposition zu erzeugen, ist also nicht neu.
Viele von Ihnen verwenden die Molecule-Bibliothek möglicherweise bereits oder sind mit ihr vertraut.
„Headless“ bedeutet hier, dass es keine Benutzeroberfläche gibt.
Molecule war also ein Pionier, der die Leistungsfähigkeit der Compose-Laufzeit demonstrierte. Es zeigte uns in der Praxis, dass eine zusammensetzbare Funktion keine UI-Knoten ausgeben muss und tatsächlich verwendet werden kann, um eine zusammensetzbare Funktion zu schreiben, die ein reines Datenmodell zurückgibt.
Im Fall von Molecule war dies ein Kotlin Flow oder ein StateFlow.
Das bedeutete, dass Sie statt der Verkettung von reaktiven Operatoren wie „combine“ normalen Kotlin-Code schreiben konnten.
Was wäre, wenn Ihr Datenmodell nicht mit Abläufen, sondern mit Status arbeiten würde, da Ihre Benutzeroberfläche bereits in Compose ist?
Bei Transform ist Ihr Datenmodell der State.
Sehen wir uns die eigentliche API an.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>Das ist die gesamte API.
Es ist sehr einfach, nur eine Funktion.
Es wird ein einfacher beobachtbarer State zurückgegeben, kein Flow und kein StateFlow.
Und wer auch immer sie konsumiert, weiß nicht oder es ist ihm egal, dass sie von einer Transformation erzeugt wurde.
Besonders interessant ist dieser Block.
Es ist zusammensetzbar.
Das bedeutet, dass alles, was Sie bereits kennen, darin funktioniert.
Denken Sie daran: LaunchedEffect, collectAsState, vorzeitige Rückgaben, einfache „if“-Anweisungen – alles, was Sie innerhalb einer zusammensetzbaren Funktion lesen können, können Sie in einer Transformation ableiten.
Alle APIs, die wir heute in dieser Präsentation sehen, befinden sich in der Alphaphase und können sich ändern. Sehen wir uns trotzdem ein Beispiel an.
Nehmen wir eine sehr einfache, kleine Timer-App.
Sie wählen eine Aufgabe aus, stellen einen Timer auf 25 Minuten und die Zeit läuft ab.
Jedes Mal, wenn Sie einen einzelnen Bildschirm erstellen, selbst wenn es sich um einen einfachen Bildschirm wie diesen handelt, müssen Sie wahrscheinlich mehrere unabhängige Datenstreams kombinieren und verwalten, um den Status im Hintergrund zusammenzustellen.
Für den Fokus-Bildschirm haben wir zwei: die Fokus-Sitzung selbst, die die aktuelle Phase anzeigt, also ob pausiert oder nicht, wie viele Sekunden noch übrig sind, und all das kommt von einer Timer-Engine als StateFlow, die sagen wir jede Sekunde tickt.
Der andere Datenstream ist die aktuelle Aufgabe, an der der Nutzer arbeitet. Sie wird als Flow aus einer Room-Datenbank stammen.
Also zwei Datenstreams und ein Bildschirmstatus am Ende.
In der ViewModel werden sie zusammengeführt. Die Ausgabe, die wir als Zusammenfassung bezeichnen, ist der Code, den wir als Nächstes schreiben.
Normalerweise müssten Sie diese Abläufe in Ihrem ViewModel mit verketteten Operatoren kombinieren.
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,
)
}Kombinieren Sie also die beiden Abläufe zu Streams, verzweigen Sie innerhalb des Lambdas, also mit „if else“, und fügen Sie am Ende einen Status hinzu.
Mit Transform können wir den gesamten Boilerplate-Code weglassen.
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(...)Dieser Aufruf sieht vertraut aus, da wir einen Anfangswert in einem Bereich übergeben, aber es gibt nichts Vorgelagertes, das verkettet werden könnte. Die Ableitung ist der Block selbst.
Im Gegensatz zu stateIn, wo Sie wahrscheinlich ein WhileSubscribed-Zeitlimit konfigurieren müssten, um Upstream-Streams zu pausieren und fortzusetzen, wird eine Transformation während der gesamten Lebensdauer dieses Bereichs kontinuierlich ausgeführt.
Unsere Quellen sind also Flows, und Flows sind willkommen.
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(...)
}
}Wir verbinden die einzelnen Elemente mit collectAsState. Die Sitzung ist ein StateFlow, daher benötigen wir keinen Anfangswert. Der Room Flow ist jedoch kalt, also geben wir ihm einen.
Das ist alles, was Sie über Abos wissen müssen.
Der Lesevorgang ist das, was uns abonniert.
Das ist das automatische Lesetracking, von dem Ben vorhin im Beispiel gesprochen hat, aber in einem ViewModel.
Wir können sogar vorzeitige Rückgaben wie diese hier einführen, wenn wir uns im inaktiven Zustand befinden.
Wenn wir keine neuen Aufgaben haben, verarbeiten wir den Rest nicht einmal.
Auch hier zahlt sich das dynamische Lesetracking wieder aus.
Im inaktiven Zustand wird der Raumfluss überhaupt nicht erfasst.
Schließlich geben wir die Zusammenfassung aus.
Wenn der Nutzer also die Aufgabe pausiert oder der Timer tickt, wird alles automatisch aktualisiert.
Es wird also etwas komplizierter, wenn wir das Beispiel mit nur einem ViewModel verlassen.
Angenommen, dieselbe Sitzung muss auf zwei weiteren Oberflächen angezeigt werden: einer Mini-Timerleiste, die sich auf jedem Bildschirm unten auf dem Bildschirm der App befindet, und einem Widget auf dem Startbildschirm.
Beide benötigen dieselben abgeleiteten Informationen zur Zusammenfassung der Sitzung, zur Phase, ob sie pausiert ist oder nicht und wie viel Zeit noch auf dem Timer übrig ist.
Das bedeutet, dass jede einzelne Oberfläche ihre eigene Kopie erstellen müsste. Das wäre dreimal dieselbe Ableitung an drei verschiedenen Stellen, die alle miteinander übereinstimmen müssten.
Stattdessen müssen wir die Zusammenfassung einmal im Anwendungsbereich ableiten und dann wird sie von allen nachgelagerten Elementen verwendet.
Wie können wir so etwas umsetzen?
Für unsere Implementierung benötigen wir wahrscheinlich drei Teile.
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}Ein Sitzungsmanager im Anwendungsbereich, in dem die Zusammenfassung abgeleitet wird, ein Mini-Timer (ViewModel) und ein Glance-Widget, die beide die in diesem Fokus-Sitzungsmanager abgeleitete Zusammenfassung nutzen.
Wir erstellen beide zuerst auf herkömmliche Weise und dann mit Transform.
Traditionell würden wir die Zusammenfassung einmal im Anwendungsbereich als StateFlow ableiten.
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
)
}Es sind dieselben Aufrufe von „combine“ und stateIn, die wir zuvor im Beispiel ViewModel gesehen haben, nur eine Ebene höher.
Mit Transform können wir auch dieselbe Deklaration aus dem ViewModel wiederverwenden, den wir zuvor gesehen haben.
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(...)
}
}Die einzige Änderung ist, dass sie sich jetzt im Anwendungsbereich befindet.
Als Nächstes haben wir den ViewModel des Mini-Timers.
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 } }
}Nehmen wir an, wir möchten uns auch hier eine kleine Herausforderung stellen.
Angenommen, wir möchten im Mini-Timer zusätzlich zu den beiden bereits vorhandenen Datenstreams, StateFlow und Room Flow, auch erfassen, ob die Timerleiste maximiert ist oder nicht.
Dafür verwenden wir ein MutableStateFlow.
Dazu müssen wir einen weiteren Combine-Operator verwenden, um den Zusammenfassungs-Flow und die isExpanded StateFlow zu verfolgen.
Und wir erhalten einen zweiten Zustand.
Mit Transform müssen wir keine dieser verketteten Operatoren mehr verwenden, um die Synchronisierung zwischen Datenstreams zu erreichen.
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 } }
}Was wir tun können, und das ist wirklich cool, sind verschachtelte Transformationen.
Hier liest die ViewModel die Zusammenfassung, die die Ausgabe der vorgelagerten Transformation ist, wie jeden anderen Zustand, und sammelt dann isExpanded mit collectAsState.
Wir können also einen Upstream-Zustand und einen Downstream-Ablauf im StateFlow im selben Block kombinieren.
Denken Sie an den Detaillierungsgrad.
Wenn Sie also isExpanded aktivieren, wird nur dieser Block noch einmal ausgeführt.
Die Blockierung auf App-Ebene und das Widget müssen das nicht einmal bemerken.
Als Letztes haben wir das Widget.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Da Glance auf Compose basiert, können wir den StateFlow mit collectAsState in einem provideContent wie jedes andere Composable erfassen.
Mit Transform ist das noch einfacher.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Der Fokusmanager stellt uns einen Compose-Status bereit, sodass wir hier nichts konvertieren müssen.
Und letztendlich können die Mini-Timer-Leiste und das Startbildschirm-Widget nie unterschiedliche Informationen anzeigen, da sie denselben Status auslesen.
Darüber hinaus werden alle Änderungen an diesem Status an alle Nutzer dieses Status weitergegeben.
Was bedeutet das?
Wenn Sie also das Widget hier pausieren, wird das über den Manager an den Sitzungsstream und dann an die Zusammenfassung weitergegeben. Alle drei Bildschirme werden synchron aktualisiert.
Nachdem wir uns nun ein wenig mit der Verwendung von „Transformieren“ beschäftigt haben, wollen wir uns die Funktion genauer ansehen.
Die Transform API, die wir uns zuvor angesehen haben, ist eigentlich eine Hilfsfunktion, mit der eine einfache verwaltete Transformation erstellt wird.
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
}In Wirklichkeit werden eine TransformScope- und eine launchTransform-Erweiterungsfunktion verwendet, die eine zusammensetzbare Transformation in diesem TransformScope starten und einen verwerfbaren Zustand zurückgeben.
Wenn Sie ihn von oben nach unten lesen, werden Sie feststellen, dass er alles für Sie erledigt.
Es erstellt die TransformScope und startet die Transformation darin. Das ist der Zustand, den Sie zurückerhalten.
Anschließend wird „run“ in der TransformScope auf dem von Ihnen angegebenen Dispatcher gestartet, sodass die Lebensdauer des Bereichs und der Thread Ihnen gehören.
Und schließlich wird die Transformation verworfen, wenn der Bereich endet.
Das ist der gesamte Lebenszyklus von Transform.
Sehen wir uns die TransformScope-Oberfläche kurz an.
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()
}Wir sehen, dass sie eine createTransform-Funktion hat, die eine Transformation erstellt, die an diese TransformScope angehängt wird.
Außerdem gibt es eine aussetzende Ausführungsfunktion, die den TransformScope startet und den Prozess der Aktualisierung der Transformationen des aktuellen Zustands beginnt.
Was bedeutet das?
„run“ richtet also den Recomposer und die Komposition ein.
Und jede Transformation im Bereich befindet sich in dieser Komposition und hat ihren eigenen Rekompositionsbereich.
Deshalb möchten wir den Umfang teilen und haben TransformScope.
Wenn sich ein Status ändert, werden nur die Transformationen, die ihn lesen, noch einmal ausgeführt.
Wie bereits erwähnt, ist „run“ eine Suspend-Funktion.
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
}
}Sie wird also in der Koroutine ausgeführt, von der aus sie aufgerufen wird, bis diese Koroutine abgebrochen wird.
Sie fragen sich vielleicht, woher ein Recomposer weiß, wann er einen Frame erzeugen muss. Dazu benötigt er eine Frame-Uhr.
In der Compose-UI ist das die Anzeige, aber wie bereits erwähnt, befinden wir uns in einer monitorlosen Umgebung, sodass es keine UI gibt.
Wenn Sie uns also keine Uhr zur Verfügung stellen, erstellen wir unsere eigene.
Durch die Entkopplung der Neuzusammensetzung von der Frame-Uhr führt Transform alle Transformationen und Zustandsableitungen außerhalb des UI-Threads aus.
Um dies zu ermöglichen, haben wir eine spezielle HeadlessTransformClock entwickelt.
Transformations-Frame-Uhr.
Da ein Widget, ein Dienst oder eine andere nachgelagerte Transformation möglicherweise auf einen Wert aus einer Transformation wartet, ist unsere Uhr „eager“.
Das bedeutet, dass sofort ein neuer Frame erzeugt wird, wenn sich ein Zustand ändert, den eine Transformation verwendet.
Es handelt sich um eine kleine, sperrfreie Zustandsmaschine, sodass das Anfordern eines Frames ein einfacher Vergleichs- und Setzvorgang ist.
Wenn sich am Status nichts geändert hat, wird die Uhr geparkt.
Eine Transformation, die einen Zustand verbraucht, der überhaupt nicht geändert wurde, kostet Sie also nichts.
Bevor ich wieder an Ben übergebe, möchte ich noch erwähnen, dass „Transform“ jetzt „rememberSaveable“ unterstützt, damit Ihre Daten bei Konfigurationsänderungen gespeichert werden.
Dies geschieht über die neue SavedStateContainer API, die jetzt der Baustein ist, auf dem SaveableStateRegistry, SavedStateHandle und SavedStateRegistry von Compose basieren.
Sie können jeden dieser Container in einen SavedStateContainer umwandeln oder untergeordnete Container erstellen und an Ihre Transformation übergeben, damit Ihre Daten gespeichert werden.
Abschließend möchten wir Sie noch einmal daran erinnern, dass sich alle APIs, über die wir heute sprechen, in der Alphaphase befinden und sich daher ändern können.
Damit übergebe ich wieder an Ben.
Okay.
Alles klar, danke, Elif.
Wir haben gesehen, wie leistungsstark der Compose-Zustand ist und wie wir ihn mit Transform außerhalb der UI ableiten können. Vielleicht ist Ihnen im Beispiel bisher aufgefallen, dass unsere Eingaben Flows waren, die wir mit collectAsState überbrückt haben.
Aber ist das immer notwendig?
Warum verwenden wir den Compose-Status nicht direkt in unseren State Holdern?
Um diese Frage zu beantworten, ist es hilfreich, den Status in drei grobe Kategorien zu unterteilen.
Der erste ist der Live-Status, der nur im Arbeitsspeicher gespeichert wird.
Dazu gehören beispielsweise der Netzwerk- oder Bluetooth-Verbindungsstatus.
Sie möchten keinen persistenten Wert verwenden.
Der zweite ist der vorübergehende Zustand, der die Prozessbeendigung überstehen sollte, aber nicht unbegrenzt gespeichert werden muss.
Dazu gehören der UI-Status, z. B. ob eine Karte maximiert ist oder nicht, und der Sitzungsstatus, z. B. der Back Stack für die Navigation oder ein Formularentwurf.
Der dritte ist der permanente Zustand, der unbegrenzt gespeichert wird.
Dazu gehören beispielsweise Nutzereinstellungen oder eine Mediathek in einer Datenbank.
Beginnen wir damit, wie Sie den Compose-Status sowohl für den Live- als auch für den vorübergehenden Status verwenden können.
Bisher wurde der Compose-Zustand in unserer Architektur-Anleitung in der UI-Ebene oder manchmal in den ViewModels gespeichert. Für alles andere haben wir Flow empfohlen.
Aber warum war das so?
Das liegt daran, dass Sie eine State-Property, die Sie mit einem Flow kombinieren wollten, mit „snapshotFlow“ zurückkonvertieren mussten.
Mit Transform ist diese Konvertierung jedoch nicht erforderlich.
Sehen wir uns an, wie das aussieht oder was das bedeutet, beginnend mit einem ViewModel.
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}Für lokale Bildschirmeingaben wie selectedTaskIds können Sie die einfache Property mutableStateOf anstelle von MutableStateFlow deklarieren.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}Und da dies etwas ist, das nicht unbedingt über die Prozessbeendigung hinaus erhalten bleiben muss, ist es in Ordnung, dass es nur mutableStateOf verwendet.
Für Dinge, die die Prozessbeendigung überstehen sollen, wie die Suchanfrage, können Sie den änderbaren Status direkt mit savedStateHandle.saveable sichern.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}Und wie bei updateScore im Spielbeispiel von vorhin: Wenn eine Methode mehrere Statuswerte gleichzeitig aktualisiert, sorgt das Verpacken der Schreibvorgänge in withMutableSnapshot für Konsistenz.
@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()
}
}
}Um searchQuery mit einem Room-Flow zu kombinieren, lesen Sie ihn einfach direkt in der Transformation, während Sie den Flow erfassen.
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)
}Wie sieht es mit dem Live-Status wie der Netzwerkverbindung aus?
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}Diese befindet sich normalerweise in einem Singleton, da sie von mehreren Bildschirmen oder sogar von mehreren App-Komponenten wie Diensten gelesen wird.
Das bedeutet aber nicht, dass Sie den Compose-Status nicht zum Speichern verwenden können.
Tatsächlich ist sie oft einfacher als eine flussbasierte Version.
Sie benötigen keinen Callback-Ablauf oder Ähnliches, um die Anpassung vorzunehmen. Sie aktualisieren einfach den Wert von „dot“ direkt in den Callbacks.
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}Und wenn ein Nutzer doch einen Flow benötigt, kannst du snapshotFlow verwenden, um ihn zurückzuleiten.
@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)
}
}Sehen wir uns also an, wie das in der ViewModel aussieht.
Sie können isOnline direkt in einer Transformation lesen. Die Transformation wird dann neu zusammengesetzt, wenn sich der Wert ändert.
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)
}Und das alles geschieht direkt. Sie müssen keinen Anwendungs- oder Koroutinenbereich angeben, um den Status zu aktualisieren. Es ist einfach eine direkte, synchrone Aktualisierung.
Mit Transform kann Ihr ViewModel also den Status von Singleton-Managern, den Status in einem ViewModel und Abläufe aus Repositories in einem einzigen Transform-Block kombinieren.
Kommen wir nun zurück zum vorübergehenden Zustand.
Ich habe bereits zwei Typen erwähnt: UI-Status und Sitzungsstatus.
Für den UI-Status und sogar den Sitzungsstatus, der sich innerhalb einer einzelnen Aktivität befindet, funktionieren ViewModel und SavedState hervorragend.
Genau dafür wurden sie entwickelt.
Probleme treten jedoch auf, wenn dieser Sitzungsstatus von vielen verschiedenen Benutzeroberflächen und App-Komponenten gemeinsam genutzt werden muss.
Wenn dieser Zustand außerhalb eines ViewModel liegt, weisen unsere vorhandenen APIs und Anleitungen eine kleine Lücke auf.
Zuerst speichern die SavedState APIs in einem Bundle einer Aktivität, das aber in anderen App-Komponenten wie einem Dienst nicht verfügbar ist.
Zweitens: mutableStateOf in einem Singleton, das nur im Arbeitsspeicher vorhanden ist und daher nicht überlebt, wenn der Prozess beendet wird.
Und genau diese Lücke schließt Jetpack AppState, StateStore.
Es handelt sich um eine typsichere Zuordnung des Compose-Status, die von App-Komponenten gemeinsam genutzt werden kann und von DataStore unterstützt wird, um die Prozessbeendigung zu überstehen.
Sie könnten DataStore selbst in jedes Singleton einbinden, aber das würde bedeuten, dass Sie für jedes Attribut Boilerplate-Code zum Speichern und Wiederherstellen schreiben müssten.
Bei der StateStore DataStore Integration müssen Sie DataStore nur einmal einbinden und dann jeden kleinen Teil des Zustands aktivieren, den Sie persistent speichern möchten.
Lassen Sie uns das in unserer Fokus-Timer-App erstellen.
Wir beginnen mit dem In-Memory-Speicher und fügen dann die DataStore-Persistenz hinzu.
Zuerst modellieren wir einen FocusSession als versiegeltes Interface mit den Status „Inaktiv“, „Wird ausgeführt“ und „Pausiert“.
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
}Um diesen Status in StateStore zu speichern, haben wir eine StateStoreKey mit dem Typ FocusSession und dem Standardwert „idle“ definiert.
sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)Anschließend stellen wir in Hilt ein Singleton-StateStore bereit, das wir in verschiedene Komponenten einfügen können, in denen wir es benötigen, z. B. in den Focus Session Manager.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}Ähnlich wie der ViewModel SavedStateHandle einfügt.
Okay.
Wir haben jetzt also einen StateStore. Aber wie verwenden wir ihn?
Gut, dass es eine kleine API ist.
Es gibt nur drei Methoden, die wir uns nacheinander ansehen.
Der erste ist setState, mit dem ein neuer Wert für einen Schlüssel geschrieben wird.
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)
}Wenn eine Sitzung beginnt, wird sie auf „Wird ausgeführt“ gesetzt, und wenn die Sitzung endet, wird sie auf „Inaktiv“ gesetzt.
Das war's.
Zweitens gibt es weiterhin updateState, mit dem Sie einen neuen Wert basierend auf dem aktuellen Wert berechnen können, z. B. die verbleibende Zeit beim Wechsel zwischen „Ausgeführt“ und „Pausiert“.
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)
}
}Und schließlich gibt es noch getState, das einen Lesezugriff-Status für einen Schlüssel zurückgibt. Hier haben Sie die Möglichkeit, den Standardwert zu überschreiben, wenn Sie ihn an einer bestimmten Aufrufstelle ändern möchten.
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}Wenn der FocusSession-Status in diesem Manager verfügbar gemacht wird, müssen Nutzer nie direkt auf StateStore oder die zugehörigen Schlüssel zugreifen.
Sie können die Schreib- und Lesekapselung beibehalten.
Anstelle von Consumern wird dann einfach der Focus Session Manager eingefügt, der wahrscheinlich ohnehin schon vorhanden ist, weil die Sitzung gesteuert werden muss, und dann kann der State gelesen werden.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}Diese Reader können den State dann entweder direkt wieder verfügbar machen oder mit Transform einen neuen, aktualisierten State erstellen, wie wir es vorhin beim Beispiel mit dem Mini-Timer ViewModel gesehen haben.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}Die UI-Kompositionen können diesen Status dann wie jeden anderen Compose-Status lesen und bei Bedarf neu zusammensetzen.
@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)
}
}Bisher existiert die FocusSession jedoch nur im Arbeitsspeicher.
Sehen wir uns also an, was nötig ist, um eine Prozessbeendigung zu überstehen.
Zuerst müssen wir eine einmalige Einrichtung vornehmen, indem wir beim Erstellen des Stores den syncToDataStore starten.
@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) }
}
}Anschließend aktivieren wir jeden Schlüssel, den wir beibehalten möchten.
@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),
PersistToDataStoreWir müssen nur dafür sorgen, dass der Typ serialisierbar ist und der Schlüssel die Schnittstelle PersistToDataStore implementiert.
Sie fragen sich jetzt vielleicht: „Okay, das klingt einfach.
Was passiert also, um den Schlüssel zu speichern?
Im Hintergrund verwendet syncToDataStore also die Listener API von Transform. Das ist im Grunde genau dasselbe wie Transform, nur dass nichts zurückgegeben wird. Es wird nur für Beobachtungen und Nebenwirkungen verwendet.
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}Und dann liest er innerhalb dieses Listeners jeden einzelnen Schlüssel im Store und prüft, ob einer davon ein PersistToDataStore-Schlüssel ist.
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
}
}
}
}
}In diesem Fall werden diese Werte mit LaunchedEffect wieder mit DataStore synchronisiert, wenn sie sich ändern.
Ich möchte hier darauf hinweisen, dass wir diese Drop-in-Integrationen nur anbieten können, weil wir einen Standardcontainer wie StateStore haben.
DataStore ist nur die erste, die wir uns ansehen.
Wenn wir uns die Zustandskategorien noch einmal ansehen, haben wir jetzt ein ziemlich klares Bild davon, wie Sie Compose State in Ihrer gesamten Anwendung verwenden können.
Von mutableStateOf für den Live-Zustand über die SavedState APIs für den bildschirmlokalen Präsentationszustand und StateStore für den komponentenübergreifenden Sitzungszustand bis hin zu collectAsState für die Interoperabilität mit Flows.
Die Interoperabilität ist jedoch nicht auf eine einzelne Kategorie beschränkt. Sie können diese Hilfsprogramme wie collectAsState immer dann verwenden, wenn Sie beobachtbare Typen in den Compose-Status überführen müssen.
Für den permanenten Zustand prüfen wir jedoch auch, wie wir mit Compose oder für DataStore und Room zusammenarbeiten können, um den Compose-Zustand direkt verfügbar zu machen, damit Sie dafür keine eigene, bessere Syntax schreiben müssen.
Ich freue mich, ankündigen zu können, dass Transform, StateStore und die Integration von DataStore ab heute in der Alphaversion verfügbar sind. Außerdem haben wir die gesamte Dokumentation auf developer.android.com veröffentlicht und ein AppState Recipes-Repository auf GitHub eingerichtet, das Beispiele für die Verwendung von StateStore und Transform enthält.
[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" }Probieren Sie sie am besten gleich in Ihrer App aus.
Feedback geben
Wir befinden uns in der Alpha-Phase. Das ist der richtige Zeitpunkt, um Änderungen vorzunehmen, damit die Funktionen für Ihre Anwendungsfälle geeignet sind.
/**
* 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" }