Ingeniera de Relaciones con Desarrolladores
El entorno de ejecución de instantáneas, el seguimiento automático de dependencias y las actualizaciones atómicas de Compose no tienen que detenerse en la capa de la IU. Jetpack AppState te permite mover el estado de Compose hacia arriba, lo que optimiza lo que llega a tus pantallas elevando y derivando el estado con el sistema de instantáneas antes de que llegue a la IU.
En esta sesión, aprenderás sobre las dos bibliotecas fundamentales de AppState. Analizaremos StateStore para elevar el estado y Transform para usar la composición y derivar el estado con el flujo de control de Kotlin simple, lo que elimina las cadenas de combinación de Flow complejas y mantiene tu IU sincronizada sin esfuerzo.
combine, y, al mismo tiempo, admite retornos anticipados y derivaciones de estado anidadas.Transcripción
Te damos la bienvenida a la charla "Lleva el estado de Compose más allá de la IU con AppState de Jetpack".
Me llamo Ben y soy ingeniero de relaciones con desarrolladores. Trabajo en las bibliotecas de arquitectura de Jetpack.
Y yo soy Elif, una de las ingenieras que trabajan en las bibliotecas de arquitectura de apps.
Hoy, estamos aquí para hablarles sobre un futuro en el que Compose va más allá de la IU y se adentra en las capas de datos y de dominio de las aplicaciones.
Como seguramente ya saben, Compose es el kit de herramientas recomendado para el desarrollo de IU en Android.
Desde teléfonos y tablets hasta widgets, relojes, TVs y dispositivos de RE, Compose potencia todo.
Sin embargo, a medida que tu app crece en estas plataformas de IU y componentes en segundo plano, administrar el estado detrás de ellos se vuelve más difícil, especialmente en torno a dos problemas comunes.
La primera es derivar un estado complejo.
Unir varios flujos de datos, entradas de IU locales y estados ambientales, como la conectividad de red, suele convertirse en una maraña de operadores como combine, flatMapLatest y distinctUntilChanged.
Y el segundo es compartir el estado entre los componentes: mantener sincronizados una actividad, un servicio, un widget de la app y mucho más, y asegurarse de que ese estado pueda sobrevivir al cierre del proceso cuando sea necesario.
Cuando dimos un paso atrás para analizar estos desafíos, nos pusimos a ver cómo se compila Compose.
La mayoría de las personas piensan en Compose como un kit de herramientas de IU, pero en realidad son dos capas distintas: la IU de Compose en la parte superior y el entorno de ejecución de Compose en la parte inferior.
Mientras que la IU de Compose controla el diseño, el dibujo y la entrada, el entorno de ejecución de Compose es un motor de uso general para la administración de estados y la recomposición.
No depende de la IU.
Y esa es la conclusión que nos llevó a crear Jetpack AppState.
Es una familia de bibliotecas enfocadas en aprovechar al máximo el estado de Compose más allá de la IU.
Pero antes de profundizar en AppState, asegurémonos de que estemos en sintonía cuando hablemos de lo que entendemos por estado de Compose.
La respuesta es 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
}Y la interfaz con la que probablemente estés más familiarizado, 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
}En esencia, son solo contenedores básicos.
Lo que hace que funcionen es el sistema de instantáneas de Compose.
Por lo tanto, cuando decimos estado de Compose, lo que realmente queremos decir suele ser SnapshotState.
Por lo tanto, cuando llamas a mutableStateOf, creas un contenedor que enruta las lecturas y escrituras a través del sistema de instantáneas.
Y cada vez que tu código se ejecuta dentro de un observador de instantáneas, como una composición, obtienes dos propiedades que un flujo como Flow no te brinda.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}La primera es el seguimiento de lectura automático y dinámico.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}Por lo tanto, automático significa que no hay suscripción ni recopilación.
Solo leer .value es la suscripción.
Por lo tanto, si observamos Counter aquí, la lectura de count.value directamente dentro del elemento componible informa a la composición y registra Counter en su lista de lectores.
Y ahora mira a 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")
}
}Solo escribe para contar el clic.
Por lo tanto, no es un lector.
Cuando un usuario hace clic en AddButton y modifica el recuento, el sistema de instantáneas hace un seguimiento de esa escritura, lo que permite que la composición sepa que debe invalidar Counter, pero no AddButton.
Y dinámico significa que el seguimiento de lectura se produce en el entorno de ejecución en función de las instrucciones de código que se ejecutan realmente.
@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++ })
}
}Aquí, protegemos el texto del contador detrás de una marca isVisible.
Cuando isVisible es verdadero, se leen tanto isVisible como el recuento.
Por lo tanto, Counter se registra como lector de ambos.
Y cuando isVisible es falso, si la rama if no se ejecuta, no se lee count, por lo que Counter solo se registra como lector de isVisible, no de 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++ })
}
}Por lo tanto, cuando AddButton escribe en count, no queda nada para invalidar.
Y ahora, la segunda propiedad es no tener que elegir entre coherencia y nivel de detalle.
La coherencia significa que, cuando actualizas varios valores en una instantánea, esos cambios permanecen aislados mientras se ejecuta tu código, incluso si otro subproceso escribe en ellos al mismo tiempo.
Y esas actualizaciones se vuelven visibles todas a la vez cuando finaliza la instantánea.
La granularidad significa que las propiedades separadas se pueden declarar y seguir de forma individual, en especial cuando no están relacionadas.
Por lo tanto, actualizar una propiedad solo invalida los lectores que realmente dependen de ella.
Veamos esto en un código real.
Por ejemplo, veremos un juego que tiene tres partes de estado: un nombre de nivel, el tiempo de supervivencia y la puntuación.
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0Para comenzar, veamos una implementación de Flow que elige la coherencia a expensas de la granularidad agrupando todo en una clase GameUiState.
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)Esto significa que cada vez que se ejecuta updateScore, copias todo el GameUiState para que tanto timeSurvived como la puntuación se puedan actualizar juntos de forma sincronizada.
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
)
}
}En la IU, GameScreen recopila el estado de la IU y lo lee para pasar las propiedades pertinentes a Header y 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
)
}
}Como GameScreen lee cada estado, cada llamada a updateScore invalida GameScreen, aunque los datos del encabezado nunca hayan cambiado.
Ahora, podrías dividir cada propiedad en su propio flujo para obtener un nivel de detalle, pero perderías la coherencia porque otro subproceso podría actualizar un valor mientras se ejecuta el otro bloque de actualización.
Ahora, consideremos una implementación alternativa basada en el estado.
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}En este caso, volvemos a usar un GameUiState.
Pero en este caso, es un poco diferente porque cada propiedad delega en mutableStateOf.
Aquí, updateScore une sus escrituras en withMutableSnapshot, y esto es lo que nos da coherencia en todas las escrituras.
@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
}
}Luego, en la IU, GameScreen pasa el estado directamente a Header y 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)
}
}No lee ninguna de las propiedades, por lo que, cuando se ejecuta updateScore, GameScreen no es un lector.
Solo se invalida el componente real, en este caso, ScoreBadge.
Por último, ten en cuenta que nada de esto está vinculado a la IU.
La IU resulta ser un lugar donde estas propiedades realmente se destacan.
Y con eso, gracias por venir a nuestra charla.
Bien, bromas aparte, le daré la palabra a Elif para que nos guíe por la primera biblioteca, Transform.
Bien.
Gracias, Ben.
Como Ben acaba de señalar, el estado de la instantánea de Compose nos ofrece seguimiento de lectura, coherencia y nivel de detalle automáticos y dinámicos, lo que me lleva a la pregunta: ¿qué pasaría si nuestros flujos de datos también pudieran ser impulsados por una instantánea de estado de Compose, un estado de instantánea?
Y si el estado es nuestro modelo de datos, ¿cómo calculamos y transformamos un estado para todos sus consumidores?
Aquí es donde entra en juego la nueva biblioteca de transformación de AppState de Jetpack.
Antes de profundizar en la biblioteca, debemos hablar sobre cómo se creó.
Por lo tanto, la idea de aprovechar el motor de Compose, que es el entorno de ejecución y el compilador, para producir valores de estado en una composición sin interfaz gráfica no es nueva.
Es posible que muchos de ustedes ya usen la biblioteca de Molecule o estén familiarizados con ella.
En este caso, sin interfaz gráfica significa que no hay IU.
Por lo tanto, Molecule fue pionero en demostrar el poder de usar el entorno de ejecución de Compose, y lo hizo mostrándonos en acción que una función de componibilidad no tiene que emitir nodos de IU y que, en realidad, se puede usar para escribir una función de componibilidad que devuelva un modelo de datos puro.
En el caso de Molecule, se trataba de un flujo de Kotlin o un StateFlow.
Esto significaba que, en lugar de encadenar operadores reactivos como combine, podías escribir código de Kotlin común.
Pero, en lugar de lidiar con flujos, ¿qué pasaría si tu modelo de datos fuera State, ya que tu IU ya está en Compose?
Con Transform, tu modelo de datos es el estado.
Veamos la API real.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>Esta es toda la API.
Es muy simple, una función.
Devuelve un State observable simple, no un Flow ni un StateFlow.
Y quienquiera que lo consuma no sabe ni le importa que una transformación lo haya producido.
Lo que es realmente interesante aquí es este bloque.
Es componible.
Esto significa que todo lo que ya sabes funciona dentro de él.
Recuerda que LaunchedEffect, collectAsState, los retornos anticipados, las declaraciones if simples y todo lo que puedas leer desde un elemento componible se pueden derivar en una transformación.
Debemos tener en cuenta que todas las APIs que vemos hoy en esta presentación están en versión alfa y están sujetas a cambios, pero, de todos modos, veamos un ejemplo.
Veamos una app de temporizador de enfoque muy simple y pequeña.
Eliges una tarea, configuras un temporizador de 25 minutos y el reloj empieza a correr.
Cada vez que crees una sola pantalla, incluso cuando crees una pantalla simple como esta, es probable que debas combinar y administrar varios flujos de datos independientes para ensamblar tu estado de forma interna.
Para nuestra pantalla de enfoque, supongamos que tenemos dos: la sesión de enfoque en sí, que será la fase en la que nos encontramos, es decir, pausada o no, cuántos segundos quedan, y todo esto provendrá de un motor de temporizador como un StateFlow que marcará, por ejemplo, cada un segundo.
El otro flujo de datos será la tarea actual en la que trabaja el usuario, que provendrá de una base de datos de Room como un Flow.
Por lo tanto, hay dos flujos de datos y un estado de pantalla al final.
El ViewModel será donde se unan, y su resultado, que llamaremos resumen, será el fragmento de código que escribiremos a continuación.
Tradicionalmente, esto significaría que, en tu ViewModel, tendrías que combinar estos flujos con operadores encadenados.
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,
)
}Por lo tanto, combina los dos flujos en flujos juntos, la ramificación dentro de la lambda, por lo que, si no es así, y, luego, un estado al final.
Con Transform, podemos descartar todo este código estándar.
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(...)Esta llamada parece un poco familiar porque tenemos un valor inicial en un alcance que se pasa, pero no hay nada upstream para encadenar, la derivación es el bloque en sí.
Y, a diferencia de stateIn, en el que probablemente necesitarías configurar un tiempo de espera de WhileSubscribed para pausar y reanudar los flujos ascendentes, una transformación se ejecuta de forma continua durante el ciclo de vida de ese alcance.
Nuevamente, nuestras fuentes son flujos, y los flujos son bienvenidos.
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(...)
}
}Uniremos cada uno con collectAsState, y la sesión es un StateFlow, por lo que no necesitamos un valor inicial, pero el flujo de Room es frío, por lo que le damos uno.
Y esa es toda la historia de las suscripciones.
La lectura es lo que nos suscribe.
Es el seguimiento automático de lectura del que Ben habló antes en el ejemplo que vimos, pero en un ViewModel.
Incluso podemos introducir retornos anticipados como este cuando estamos en un estado inactivo.
Supongamos que no tenemos tareas nuevas, ni siquiera procesaremos el resto.
Y, de nuevo, este es el seguimiento dinámico de lectura que vuelve a dar sus frutos.
Mientras estamos inactivos, no se recopila el flujo de Room.
Por último, emitimos el resumen.
Por lo tanto, si el usuario pausa la tarea o el temporizador avanza, todo se actualiza automáticamente.
Por lo tanto, las cosas se complican un poco más cuando salimos del ejemplo de un solo ViewModel.
Supongamos que esta misma sesión debe aparecer en dos plataformas adicionales: una barra de temporizador pequeña que se encuentra en la parte inferior de la pantalla de la app en cada pantalla y un widget de la pantalla principal.
Ambos necesitarán la misma información derivada sobre el resumen de la sesión, qué fase, si está pausada o no, cuánto tiempo queda en el temporizador.
Esto significa que, si cada superficie ensamblara su propia copia, sería la misma derivación tres veces y en tres lugares diferentes que tendrían que estar de acuerdo entre sí.
Por lo tanto, lo que tendremos que hacer en su lugar es derivar el resumen una vez en el alcance de la aplicación y, luego, todo lo que esté en sentido descendente lo consumirá.
Entonces, ¿cómo implementamos algo como esto?
En este caso, nuestra implementación probablemente necesitará tres partes.
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}Por lo tanto, un administrador de sesiones en el alcance de la aplicación será donde se derive el resumen, un ViewModel de minitemporizador y un widget de Glance, que consumirán el resumen derivado en este administrador de sesiones de enfoque.
Creemos cada una de la forma tradicional y, luego, con Transform.
Por lo tanto, tradicionalmente, de nuevo, comenzaríamos derivando el resumen una vez en el alcance de la aplicación como un 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
)
}Son las mismas llamadas a combine y stateIn que vimos antes en el ejemplo de ViewModel, pero se movieron un nivel hacia arriba.
Con Transform, también podemos reutilizar la misma declaración del ViewModel que vimos antes.
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(...)
}
}Lo único que cambió es que, de nuevo, ahora se encuentra en el alcance de la aplicación.
A continuación, tenemos el ViewModel del minitemporizador.
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 } }
}Supongamos que también queremos ponernos un pequeño desafío.
Supongamos que, en el minitemporizador, además de los dos flujos de datos que ya teníamos, el StateFlow y el flujo de la sala, también queremos hacer un seguimiento de si la barra del temporizador está expandida o no.
Para eso, usamos un MutableStateFlow.
Para ello, tendremos que usar otra combinación para hacer un seguimiento del flujo de resumen y de isExpanded StateFlow.
Y obtenemos un segundo estado.
Con Transform, no tenemos que volver a usar ninguno de estos operadores encadenados para lograr la sincronización entre los flujos de datos.
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 } }
}Lo que podemos hacer, y esto es genial, es realizar transformaciones anidadas.
Aquí, ViewModel lee el resumen, que es el resultado de la transformación ascendente, como cualquier otro estado, y, luego, recopila isExpanded con collectAsState.
Por lo tanto, podemos mezclar un estado ascendente y un flujo descendente en el StateFlow en el mismo bloque.
Y recuerda el nivel de detalle.
Por lo tanto, si activas isExpanded, solo se volverá a ejecutar este bloque.
El bloque a nivel de la app y el widget ni siquiera tienen que notarlo.
Por último, tenemos el widget.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
.collectAsState()
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Como Glance es Compose, podemos recopilar el StateFlow con collectAsState dentro de un provideContent como cualquier otro elemento componible.
Con Transform, esto es aún más sencillo.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}Nuestro administrador de enfoque nos proporciona un estado de Compose, por lo que no necesitamos convertir nada aquí.
Y, en última instancia, la barra del minitemporizador y el widget de la pantalla principal nunca pueden estar en desacuerdo porque leen el mismo estado.
Además, cualquier cambio que se realice en ese estado se reflejará en todos los consumidores de ese estado.
¿Qué significa eso?
Por lo tanto, si presionas Pausa en el widget, se enviará la acción a través del administrador al flujo de la sesión, volverá a salir a través del resumen y las tres pantallas se actualizarán de forma sincronizada.
Ahora que vimos un poco cómo usar Transform, analicemos más en profundidad cómo funciona.
La API de Transform que vimos antes es, en realidad, una función auxiliar que se usa para crear una transformación administrada básica.
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
}En realidad, usa una función de extensión TransformScope y launchTransform, que inicia una transformación de componibilidad en este TransformScope y devuelve un estado desechable.
Cuando lo leas de arriba abajo, verás que hace todo por ti.
Crea el TransformScope y, luego, inicia tu Transform en él, que será el estado que obtendrás.
Y, luego, también inicia la ejecución en TransformScope en el despachador que proporcionaste, de modo que el tiempo de vida del alcance y el subproceso te pertenezcan.
Y, luego, el bloque finally elimina tu transformación cuando finaliza el alcance.
Esta es la historia completa del ciclo de vida de Transform.
Echemos un vistazo rápido a la interfaz de 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()
}Podemos ver que tiene una función createTransform, que creará una transformación adjunta a este TransformScope.
También tiene una función de ejecución de suspensión, que inicia el TransformScope y comienza el proceso de actualización de las transformaciones de estado actuales.
¿Qué significa eso?
Por lo tanto, run configura el Recomposer y la composición.
Y cada Transform en el alcance vive dentro de esa composición y tiene su propio alcance de recomposición.
Por eso queremos compartir el alcance y por eso tenemos TransformScope.
De esta manera, cada vez que cambia un estado, solo se vuelven a ejecutar las transformaciones que lo leen.
Como señalamos antes, run es una función de suspensión.
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
}
}Por lo tanto, se ejecuta en cualquier corrutina desde la que la llames hasta que se cancele esa corrutina.
Ahora, tal vez te preguntes: un Recomposer necesita un reloj de fotogramas para saber cuándo debe producir un fotograma.
En la IU de Compose, esa es la pantalla, pero, como mencionamos antes, estamos en un entorno sin interfaz gráfica, por lo que no hay IU.
Por lo tanto, a menos que nos proporciones un reloj, crearemos el nuestro.
Al desvincular la recomposición del reloj de fotogramas de la pantalla, Transform ejecuta todas sus transformaciones y derivaciones de estado fuera del subproceso de IU.
Para permitirlo, creamos un HeadlessTransformClock dedicado.
Transforma el reloj de fotogramas.
Dado que un widget, un servicio o cualquier otra transformación posterior podrían estar esperando un valor de una transformación, nuestro reloj es impaciente.
Esto significa que, en el momento en que cambia cualquier estado que consume un Transform, se produce un fotograma nuevo de inmediato.
Es una máquina de estado pequeña sin bloqueos, por lo que solicitar un fotograma es una operación simple de comparación y configuración.
Y cuando no hay nada que haya cambiado en el estado, el reloj se detiene.
Por lo tanto, un Transform que consume un estado que no se modificó no tiene costo.
Antes de volver con Ben, también debemos tener en cuenta que Transform ahora admite rememberSaveable para garantizar que tus datos se guarden en los cambios de configuración.
Lo hace a través de la nueva API de SavedStateContainer, que ahora es el componente básico sobre el que se compilan SaveableStateRegistry, SavedStateHandle y SavedStateRegistry de Compose.
Puedes convertir cualquiera de estos en un SavedStateContainer o crear contenedores secundarios y pasarlos a tu Transform para asegurarte de que se guarden tus datos.
Por último, te recordamos que todas las APIs de las que hablamos hoy están en versión alfa, por lo que están sujetas a cambios.
Ahora le cedo la palabra a Ben.
Bien.
Muy bien, gracias, Elif.
Ahora que vimos lo potente que es el estado de Compose y cómo Transform nos permite derivarlo fuera de la IU, es posible que hayas notado en el ejemplo que nuestras entradas eran flujos que conectamos con collectAsState.
¿Pero siempre es necesario?
¿Por qué no usar el estado de Compose directamente en nuestros contenedores de estado?
Para responder esa pregunta, me resulta útil pensar en el estado dentro de tres categorías amplias.
El primero es el estado en vivo que solo se almacena en la memoria.
Por ejemplo, el estado de la conexión de red o Bluetooth.
No querrás usar un valor persistente.
Y, luego, el segundo es el estado transitorio que debería sobrevivir al cierre del proceso, pero no necesita almacenarse de forma indefinida.
Piensa en cosas como el estado de la IU, por ejemplo, si una tarjeta está expandida o no, y el estado de la sesión, como la pila de actividades de navegación o un borrador de formulario.
Por último, el estado permanente, que se almacena de forma indefinida.
Piensa en cosas como las preferencias del usuario o una biblioteca de contenido multimedia que se encuentra en una base de datos.
Comencemos con cómo puedes usar el estado de Compose para el estado activo y el transitorio.
Hasta ahora, nuestra guía de arquitectura mantenía el estado de Compose en la capa de la IU o, a veces, en los ViewModels, y te indicábamos que usaras Flow en todos los demás lugares.
¿Por qué?
Esto se debe a que, en cuanto necesitaste combinar una propiedad de estado con un flujo, tuviste que volver a convertirla con snapshotFlow.
Sin embargo, con Transform, esa conversión no es necesaria.
Veamos cómo se ve esto o qué significa, comenzando en un ViewModel.
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}Para las entradas de pantalla locales, como selectedTaskIds aquí, puedes declarar la propiedad mutableStateOf simple en lugar de MutableStateFlow.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}Y, como esto es algo que no necesariamente quieres que persista tras el cierre del proceso, está bien que no sea así, que solo use mutableStateOf.
Para los elementos que quieres que sobrevivan al cierre del proceso, como la búsqueda, puedes respaldar su estado mutable directamente con savedStateHandle.saveable.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}Y, luego, al igual que updateScore en el ejemplo del juego anterior, cuando un método actualiza varios valores de estado a la vez, unir las escrituras en withMutableSnapshot garantiza la coherencia.
@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()
}
}
}Luego, para combinar searchQuery con un flujo de Room, solo debes leerlo directamente dentro de Transform, mientras recopilas el flujo.
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)
}¿Qué sucede con el estado en vivo, como la conectividad de red?
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}Por lo general, esto se encuentra en un singleton, ya que se lee en varias pantallas o incluso en varios componentes de la app, como los servicios.
Sin embargo, eso no significa que no puedas usar el estado de Compose para almacenarlo.
De hecho, suele ser más sencilla que una versión basada en flujos.
No necesitas un flujo de devolución de llamada ni nada para realizar la adaptación, solo actualizas el valor de dot directamente en las devoluciones de llamada.
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}Y, si un consumidor aún necesita un flujo, puedes usar snapshotFlow para volver a conectarlo.
@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)
}
}Veamos cómo se ve eso en la ViewModel.
Puedes leer isOnline directamente en un Transform, y se recompondrá cuando cambie el valor.
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)
}Y todo esto sucede directamente, no hay un alcance de la aplicación o de la corrutina que debas proporcionar para actualizar el estado, es solo una actualización directa y síncrona.
Por lo tanto, con Transform, tu ViewModel puede combinar el estado de los administradores de Singleton, el estado en un ViewModel y los flujos de los repositorios, todo en un solo bloque de Transform.
Ahora, volvamos al estado transitorio.
Anteriormente, mencioné dos tipos: el estado de la IU y el estado de la sesión.
Para el estado de la IU, e incluso el estado de la sesión que se encuentra dentro de una sola actividad, ViewModel y SavedState funcionan muy bien.
Para eso se diseñaron.
Sin embargo, las cosas comienzan a fallar cuando ese estado de sesión debe compartirse entre varios componentes de la app y de la IU.
Una vez que ese estado se mueve fuera de un ViewModel, nuestras APIs y orientación existentes dejan un pequeño vacío.
Primero, las APIs de SavedState guardan en el Bundle de una actividad, pero eso no está disponible en otros componentes de la app, como un servicio.
En segundo lugar, mutableStateOf en un singleton, que solo vive en la memoria, por lo que no sobrevive al cierre del proceso.
Y esa es la brecha que llena AppState de Jetpack, StateStore.
Es un mapa con seguridad de tipos del estado de Compose que se puede compartir entre los componentes de la app y que está respaldado por DataStore para sobrevivir al cierre del proceso.
Podrías conectar DataStore a cada Singleton por tu cuenta, pero eso significa escribir código estándar de guardado y restablecimiento para cada propiedad.
Con la integración de StateStore DataStore, conectas DataStore una vez y, luego, habilitas cada pequeña parte del estado que deseas conservar.
Entonces, compilemos eso en nuestra app de temporizador de concentración.
Primero, comenzaremos en la memoria y, luego, agregaremos la persistencia de DataStore.
Para preparar el escenario, modelaremos un FocusSession como una interfaz sellada con estados inactivo, en ejecución y en pausa.
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
}Luego, para almacenar este estado en StateStore, definimos un StateStoreKey con su tipo, FocusSession, y un valor predeterminado, inactivo.
sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)Luego, proporcionamos un singleton StateStore en Hilt que podemos insertar en diferentes componentes donde lo necesitemos, como Focus Session Manager.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}De manera similar a cómo ViewModel inserta SavedStateHandle.
Bien.
Ahora que tenemos un StateStore, ¿cómo lo usamos?
Por suerte, es una API pequeña.
Solo tiene tres métodos, por lo que los revisaremos uno por uno.
El primero es setState, que escribe un valor nuevo para una clave.
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)
}Cuando se inicia una sesión, la configuramos como en ejecución y, cuando finaliza, la configuramos como inactiva.
Eso es todo.
En segundo lugar, tenemos, y seguimos teniendo, updateState, que te permite calcular un valor nuevo en función del valor actual, como calcular el tiempo restante cuando se cambia entre la ejecución y la pausa.
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)
}
}Por último, está getState, que devuelve un State de solo lectura para una clave y tiene la opción de anular el valor predeterminado, si quieres cambiarlo en un sitio de llamada específico.
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}Además, exponer el estado de FocusSession en este administrador significa que los consumidores nunca tienen que acceder directamente a StateStore ni a sus claves.
Puedes mantener la encapsulación de escritura y lectura.
Y, luego, en lugar de consumidores, solo insertan FocusSessionManager, que, bueno, probablemente ya tengan porque necesitan controlar la sesión, y, luego, pueden leer el estado.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}Luego, esos lectores pueden volver a exponer el estado directamente o usar Transform para crear un estado nuevo y actualizado, como vimos antes con el ejemplo de Mini-Timer ViewModel.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}Luego, los elementos componibles de la IU pueden leer ese estado como cualquier otro estado de Compose y volver a componerse cuando sea necesario.
@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)
}
}Sin embargo, hasta el momento, el FocusSession solo se almacena en la memoria.
Veamos qué se necesita para sobrevivir al cierre del proceso.
Primero, debemos realizar una configuración única iniciando syncToDataStore cuando creamos el almacén.
@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) }
}
}Luego, habilitamos cada clave que queremos conservar.
@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),
PersistToDataStoreSolo debemos asegurarnos de que el tipo sea serializable y de que la clave implemente la interfaz PersistToDataStore.
Y, luego, tal vez te preguntes: "Bueno, eso parece fácil.
Entonces, ¿qué hace realmente para conservar la clave?
Por lo tanto, de forma interna, syncToDataStore usa la API de objetos de escucha de Transform, que es básicamente lo mismo que Transform, excepto que no devuelve nada, solo se usa para la observación y los efectos secundarios.
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}Luego, dentro de ese objeto de escucha, lee cada clave del almacén y verifica si una es una clave 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
}
}
}
}
}Cuando lo hace, usa LaunchedEffect para sincronizar esos valores con DataStore cada vez que cambian.
Y lo que quiero destacar aquí es que tener un contenedor estándar como StateStore es lo que nos permite proporcionar estas integraciones directas.
DataStore es solo la primera que veremos.
Por lo tanto, si volvemos a nuestras categorías de estado, ahora tenemos una idea bastante clara de cómo puedes usar el estado de Compose en toda tu aplicación.
Desde mutableStateOf para el estado activo, hasta las APIs de SavedState para el estado de presentación local de la pantalla, StateStore para el estado de sesión entre componentes y, por último, collectAsState para la interoperabilidad con Flows.
Pero, en realidad, la interoperabilidad no se limita a una sola categoría, puedes usar esos ayudantes como collectAsState cada vez que necesites conectar tipos observables con el estado de Compose.
Pero para el estado permanente, otra cosa que estamos analizando son las formas de trabajar con Compose o para que DataStore y Room expongan directamente el estado de Compose, de modo que no tengas que escribir tu propia sintaxis más agradable para hacerlo.
Y con eso, me complace anunciar que Transform, StateStore y la integración de DataStore están disponibles en versión alfa hoy, y que también publicamos toda la documentación en developer.android.com, y tenemos un repositorio de AppState Recipes en GitHub que muestra ejemplos de uso de StateStore y 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" }Y, sí, pruébalas en tu app.
Envíanos tus comentarios.
Como estamos en la fase alfa, este es el momento en el que podemos iterar y realizar cambios para asegurarnos de que funcionen en tus casos de uso.
/**
* 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" }