Engenheiro de relações com desenvolvedores
O ambiente de execução de snapshot, o rastreamento automático de dependências e as atualizações atômicas do Compose não precisam parar na camada de interface. O Jetpack AppState permite mover o estado do Compose para cima, simplificando o que chega às telas ao elevar e derivar o estado com o sistema de snapshot antes que ele chegue à interface.
Nesta sessão, você vai conhecer as duas bibliotecas fundamentais do AppState. Vamos falar sobre o StateStore para elevação de estado e o Transform para usar a composição para derivar o estado com o fluxo de controle simples do Kotlin, eliminando cadeias de combinação de fluxo complexas e mantendo a interface sincronizada sem esforço.
combine, e oferece suporte a retornos antecipados e derivações de estado aninhadas.Transcrição
Olá! Este é o curso "Leve o estado do Compose além da interface com o AppState do Jetpack".
Meu nome é Ben, e sou engenheiro de relações com desenvolvedores que trabalha nas bibliotecas de arquitetura do Jetpack.
Eu sou Elif, uma das engenheiras que trabalha nas bibliotecas de arquitetura de apps.
Hoje, vamos falar sobre um futuro em que o Compose vai além da interface e entra nas camadas de dados e de domínio dos aplicativos.
Como vocês já devem saber, o Compose é o kit de ferramentas recomendado para o desenvolvimento de IU no Android.
De smartphones e tablets a widgets, relógios, TVs e dispositivos XR, o Compose é a base de tudo.
Mas, à medida que o crescimento em apps nessas plataformas de interface e componentes em segundo plano, o gerenciamento do estado por trás deles fica mais difícil, especialmente em relação a dois problemas comuns.
A primeira é derivar um estado complexo.
Combinar vários fluxos de dados, entradas de IU locais e estado de ambiente, como conectividade de rede, geralmente se transforma em um emaranhado de operadores, como combine, flatMapLatest e distinctUntilChanged.
A segunda é compartilhar o estado entre componentes: manter uma atividade, um serviço, um widget de app e outros elementos sincronizados e garantir que esse estado sobreviva ao encerramento do processo quando necessário.
Quando analisamos esses desafios, percebemos como o Compose é criado.
A maioria das pessoas pensa no Compose como um kit de ferramentas de interface, mas, na verdade, ele é composto por duas camadas distintas: a interface do Compose na parte de cima e o ambiente de execução do Compose na parte de baixo.
Enquanto a IU do Compose processa o layout, o desenho e a entrada, o ambiente de execução do Compose é um mecanismo de uso geral para gerenciamento de estado e recomposição.
Ele não tem dependência da interface.
Essa foi a conclusão que nos levou a criar o Jetpack AppState.
É uma família de bibliotecas que tem como foco aproveitar ao máximo o estado do Compose além da interface.
Mas antes de falarmos sobre o AppState, vamos garantir que estamos na mesma página quando falamos sobre o estado do Compose.
A resposta é 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
}E a interface que você provavelmente conhece melhor, 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
}Basicamente, são apenas contêineres simples.
O que faz com que eles realmente funcionem é o sistema de snapshot do Compose.
Portanto, quando dizemos "estado do Compose", geralmente estamos nos referindo ao estado do snapshot.
Portanto, ao chamar mutableStateOf, você cria um contêiner que roteia leituras e gravações pelo sistema de snapshots.
Sempre que o código é executado dentro de um observador de snapshot, como uma composição, você recebe duas propriedades que um stream como um Flow não oferece.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
}A primeira é o rastreamento de leitura automático e dinâmico.
@Composable
fun Counter() {
val count = remember { mutableStateOf(0) }
Column {
Text("Count: ${count.value}")
AddButton(onClick = { count.value++ })
}
}Portanto, automático significa que não há inscrição ou coleta.
A assinatura é apenas para ler .value.
Portanto, se analisarmos o Counter aqui, a leitura de count.value diretamente dentro do elemento combinável informa essa leitura à composição e registra o Counter na lista de leitores.
Agora, observe 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")
}
}Ele só grava para contar no clique.
Portanto, não é um leitor.
Quando um usuário clica em AddButton e modifica a contagem, o sistema de snapshot rastreia essa gravação, o que permite que a composição saiba que precisa invalidar Counter, mas não AddButton.
E dinâmico significa que o rastreamento de leitura acontece no ambiente de execução com base nos caminhos de código que realmente são executados.
@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++ })
}
}Aqui, estamos protegendo o texto do contador com uma flag isVisible.
Quando isVisible é verdadeiro, isVisible e count são lidos.
Portanto, Counter é registrado como leitor de ambos.
E quando isVisible é falso, se a ramificação "if" não é executada, "count" não é lido, então "Counter" é registrado apenas como um leitor de isVisible, não 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++ })
}
}Portanto, quando AddButton grava em count, não há mais nada para invalidar.
A segunda propriedade é não ter que escolher entre consistência e granularidade.
Consistência significa que, quando você atualiza vários valores em um snapshot, essas mudanças permanecem isoladas enquanto o código é executado, mesmo que outra linha de execução esteja gravando neles ao mesmo tempo.
Essas atualizações ficam visíveis de uma só vez quando o snapshot é concluído.
Granularidade significa que propriedades separadas podem ser declaradas e rastreadas individualmente, principalmente quando não estão relacionadas.
Portanto, atualizar uma propriedade só invalida os leitores que realmente dependem dela.
Vamos ver isso em um código real.
Por exemplo, vamos analisar um jogo que tem três partes de estado: um nome de nível, tempo de sobrevivência e pontuação.
val levelName: String = "Level 1 - Stay Alive!"
val timeSurvived: Int = 0
val score: Double = 0.0Para começar, vamos analisar uma implementação do Flow que escolhe a consistência em detrimento da granularidade, agrupando tudo em uma classe GameUiState.
data class GameUiState(
val levelName: String = "Level 1 - Stay Alive!",
val timeSurvived: Int = 0,
val score: Double = 0.0
)Isso significa que, sempre que updateScore é executado, você copia todo o GameUiState para que timeSurvived e a pontuação possam ser atualizados juntos em sincronia.
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
)
}
}Na interface, o GameScreen coleta o estado da interface e faz a leitura dele para transmitir as propriedades relevantes para Header e 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 lê todos os estados, cada chamada para updateScore invalida GameScreen, mesmo que os dados do cabeçalho nunca tenham mudado.
Agora, você pode dividir cada propriedade em um Flow próprio para ter granularidade, mas perderia a consistência, porque outra linha de execução poderia atualizar um valor enquanto o outro bloco de atualização estivesse em execução.
Agora, vamos considerar uma implementação alternativa baseada em estado.
@Stable
class GameUiState {
var levelName by mutableStateOf("Level 1 - Stay Alive!")
var timeSurvived by mutableStateOf(0)
var score by mutableStateOf(0.0)
}Neste, usamos um GameUiState novamente.
Mas, neste caso, é um pouco diferente, porque cada propriedade delega para mutableStateOf.
Aqui, o updateScore une as gravações em withMutableSnapshot, o que garante a consistência entre as gravações.
@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
}
}Em seguida, na interface, GameScreen transmite o estado diretamente para o cabeçalho e 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)
}
}Ele não lê nenhuma das propriedades. Portanto, quando updateScore é executado, GameScreen não é um leitor.
Somente o componente real, neste caso, ScoreBadge, é invalidado.
Por fim, observe que nada disso está vinculado à interface.
A IU é apenas um lugar em que essas propriedades realmente se destacam.
E com isso, agradecemos por assistir à nossa palestra.
Ok, brincadeiras à parte, vou passar a palavra para Elif, que vai apresentar a primeira biblioteca, Transform.
Ok.
Obrigado, Ben.
Como o Ben acabou de observar, o estado do snapshot do Compose oferece rastreamento de leitura, consistência e granularidade automáticos e dinâmicos. Isso me leva à pergunta: e se pudéssemos ter nossos fluxos de dados também orientados por um snapshot de estado do Compose, um estado de snapshot?
E se o estado é nosso modelo de dados, como calculamos e transformamos um estado para todos os consumidores?
É aí que entra a nova biblioteca AppState Transform do Jetpack.
Antes de nos aprofundarmos na biblioteca, precisamos falar sobre como ela surgiu.
Portanto, a ideia de aproveitar o mecanismo do Compose, que é o ambiente de execução e o compilador, para produzir valores de estado em uma composição sem interface não é nova.
Muitos de vocês já devem usar ou conhecer a biblioteca Molecule.
E, por headless, queremos dizer que não há interface.
O Molecule foi pioneiro em demonstrar o poder de usar o Compose Runtime, mostrando na prática que uma função combinável não precisa emitir nós de interface e pode ser usada para escrever uma função combinável que retorna um modelo de dados puro.
No caso do Molecule, era um fluxo do Kotlin ou um StateFlow.
Isso significa que, em vez de encadear operadores reativos, como combine, você pode escrever em Kotlin comum.
Mas, em vez de lidar com fluxos, e se o modelo de dados fosse State, já que a IU já está no Compose?
Com o Transform, seu modelo de dados é o State.
Vamos dar uma olhada na API.
public fun <R> transform(
initialValue: R,
scope: CoroutineScope,
onUpdate: @Composable (R) -> R,
): State<R>Essa é a API inteira.
É muito simples, uma função.
Ele retorna um State observável simples, não um Flow nem um StateFlow.
E quem o consome não sabe ou não se importa que uma Transform o tenha produzido.
O que é realmente interessante aqui é este bloco.
É combinável.
Isso significa que tudo o que você já sabe funciona dentro dele.
Lembre-se de que LaunchedEffect, collectAsState, retornos antecipados, ifs simples, tudo o que você pode ler de dentro de um elemento que pode ser composto, pode ser derivado de uma transformação.
Vale lembrar que todas as APIs que estamos vendo hoje nesta apresentação estão na versão Alfa e estão sujeitas a mudanças. Mesmo assim, vamos dar uma olhada em um exemplo.
Vamos usar um app de timer de foco muito simples e pequeno.
Você escolhe uma tarefa, define um timer de 25 minutos e o relógio começa a contar.
Sempre que você cria uma tela única, mesmo que seja simples como esta, provavelmente vai precisar combinar e gerenciar vários fluxos de dados independentes para montar o estado em segundo plano.
Para a tela de foco, digamos que temos duas: a sessão de foco em si, que vai ser a fase em que estamos, ou seja, pausada ou não, quantos segundos restam, e tudo isso vai vir de um mecanismo de timer como um StateFlow que vai marcar, digamos, a cada segundo.
O outro fluxo de dados será a tarefa atual em que o usuário está trabalhando, que virá de um banco de dados do Room como um Flow.
Portanto, dois fluxos de dados e um estado de tela no final.
O ViewModel é onde eles serão reunidos, e a saída, que chamaremos de resumo, será o trecho de código que vamos escrever em seguida.
Tradicionalmente, isso significa que, no ViewModel, você precisa combinar esses fluxos usando operadores encadeados.
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,
)
}Então, combine os dois fluxos em streams, a ramificação dentro da lambda, então se houver outra ali, e um estado no final.
Com o Transform, podemos descartar todo esse código boilerplate.
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(...)Essa chamada parece familiar porque temos um valor inicial em um escopo sendo transmitido, mas não há nada upstream para encadear. A derivação é o próprio bloco.
E, ao contrário de stateIn, em que você provavelmente precisaria configurar uma suspensão temporária de WhileSubscribed para pausar e retomar os fluxos upstream, uma transformação é executada continuamente durante o ciclo de vida desse escopo.
Nossas fontes são fluxos, e eles são bem-vindos.
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(...)
}
}Vamos conectar cada um com collectAsState, e a sessão é um StateFlow. Portanto, não precisamos de um valor inicial, mas o fluxo do Room é frio, então fornecemos um.
E essa é toda a história da assinatura.
A leitura é o que nos inscreve.
É o rastreamento automático de leitura que o Ben mencionou no exemplo que vimos, mas em um ViewModel.
Podemos até introduzir retornos antecipados como este aqui quando estivermos em um estado inativo.
Digamos que não temos novas tarefas, então não vamos nem processar o resto.
E, mais uma vez, o rastreamento dinâmico de leitura está valendo a pena.
Enquanto estamos inativos, o fluxo do Room não é coletado.
Por fim, emitimos o resumo.
Portanto, se o usuário pausar a tarefa ou o timer for acionado, tudo será atualizado automaticamente.
As coisas ficam um pouco mais complicadas quando saímos do exemplo de um único ViewModel.
Digamos que essa mesma sessão precise aparecer em mais duas plataformas: uma barra de timer em miniatura que fica na parte de baixo de todas as telas do app e um widget da tela inicial.
Ambos vão precisar das mesmas informações derivadas sobre o resumo da sessão, qual fase, se está pausado ou não, quanto tempo resta no timer.
Isso significa que, se cada superfície montasse a própria cópia, seria a mesma derivação três vezes em três lugares diferentes que precisam estar de acordo entre si.
Portanto, o que precisamos fazer é derivar o resumo uma vez no escopo do aplicativo e, em seguida, tudo o que estiver downstream vai consumi-lo.
Então, como podemos implementar algo assim?
Nossa implementação provavelmente vai precisar de três partes.
class FocusSessionManager(taskRepository: TaskRepository, scope: CoroutineScope) {
...
}
class MiniTimerViewModel(focusManager: FocusSessionManager) : ViewModel() {
...
}
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
...
}
}
}Portanto, um gerenciador de sessões no escopo do aplicativo, que é onde o resumo será derivado, um ViewModel do minicrono e um widget do Resumo, que vão consumir o resumo derivado nesse gerenciador de sessões de foco.
Vamos criar cada um da maneira tradicional e depois com o Transform.
Então, tradicionalmente, começaríamos derivando o resumo uma vez no escopo do aplicativo como um StateFlow.
class FocusSessionManager(
taskRepository: TaskRepository,
scope: CoroutineScope
) {
val sessionFlow: StateFlow<FocusSession> = ...
val summary: StateFlow<FocusSummary> =
combine(
sessionFlow,
taskRepository.tasksFlow
) { session, tasks ->
if (session.phase == FocusPhase.Idle) {
FocusSummary.Idle
} else {
val task = tasks.find {
it.id == session.activeTaskId
}
FocusSummary.Active(...)
}
}.stateIn(
scope = appScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FocusSummary.Idle
)
}São as mesmas chamadas de combinação e stateIn que vimos anteriormente no exemplo de ViewModel, mas movidas para um nível acima.
Com o Transform, também podemos reutilizar a mesma declaração do 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(...)
}
}A única coisa que mudou é que, agora, ele está no escopo do aplicativo.
Em seguida, temos o ViewModel do minitimer.
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 } }
}Digamos que queremos nos desafiar um pouco aqui também.
Digamos que, no minitimer, além dos dois fluxos de dados que já tínhamos, o StateFlow e o fluxo do Room, também queremos monitorar se a barra do timer está aberta ou não.
Para isso, usamos um MutableStateFlow.
Para isso, vamos usar outro combine para rastrear o fluxo de resumo e o StateFlow de isExpanded.
E temos um segundo estado.
Com o Transform, não precisamos usar nenhum desses operadores encadeados novamente para sincronizar os fluxos de dados.
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 } }
}O que podemos fazer, e isso é muito legal, são transformações aninhadas.
Aqui, o ViewModel lê o resumo, que é a saída da transformação upstream, como qualquer outro estado, e coleta isExpanded com collectAsState.
Assim, podemos misturar um estado upstream e um fluxo downstream no StateFlow no mesmo bloco.
E lembre-se da granularidade.
Portanto, se você alternar isExpanded, ele só vai executar novamente esse bloco.
O bloqueio no nível do app e o widget nem precisam perceber.
Por fim, temos o 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 o Resumo é o Compose, podemos coletar o StateFlow com collectAsState dentro de um provideContent como qualquer outro elemento combinável.
Com o Transform, isso é ainda mais simples.
class FocusWidget : GlanceAppWidget() {
override suspend fun provideGlance(
context: Context,
id: GlanceId
) {
val focusManager = ...
provideContent {
val summary by focusManager.summary
GlanceTheme { FocusWidgetContent(summary) }
}
}
}O gerenciador de foco está fornecendo um estado do Compose, então não precisamos converter nada aqui.
Por fim, a barra do minitimer e o widget da tela inicial nunca podem discordar porque estão lendo o mesmo estado.
Além disso, qualquer mudança feita nesse estado será refletida em todos os consumidores dele.
O que isso significa?
Então, se você pausar o widget aqui, ele vai passar pelo gerenciador para o fluxo da sessão, voltar pelo resumo, e as três telas serão atualizadas em sincronia.
Agora que já vimos um pouco sobre como usar o Transform, vamos fazer uma investigação nos bastidores.
A API Transform que vimos anteriormente é, na verdade, uma função auxiliar usada para criar uma transformação gerenciada 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
}Na verdade, ele usa uma função de extensão TransformScope e launchTransform, que inicia uma transformação combinável nesse TransformScope e retorna um estado descartável.
Ao ler de cima para baixo, você vai perceber que ele faz tudo por você.
Ele cria o TransformScope e inicia a transformação nele, que será o estado que você vai receber de volta.
Em seguida, ele também inicia a execução no TransformScope no dispatcher que você forneceu, para que o ciclo de vida do escopo e a linha de execução pertençam a você.
E, por fim, o finally descarta a transformação quando o escopo termina.
Essa é a história de todo o ciclo de vida da transformação.
Vamos dar uma olhada rápida na interface do 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 ela tem uma função createTransform, que vai criar uma transformação anexada a esse TransformScope.
Ele também tem uma função de execução de suspensão, que inicia o TransformScope e o processo de atualização das transformações de estado atuais.
O que isso significa?
Portanto, run configura o Recomposer e a composição.
E cada Transform no escopo reside dentro dessa composição e tem o próprio escopo de recomposição.
É por isso que queremos compartilhar o escopo e por que temos TransformScope.
Assim, sempre que um estado muda, apenas as transformações que o leem são executadas novamente.
Como já mencionamos, run é uma função de suspensão.
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
}
}Portanto, ele é executado em qualquer corrotina de onde é chamado até que ela seja cancelada.
Agora, você pode estar se perguntando: um Recomposer precisa de um relógio de frame para saber quando produzir um frame.
Na interface do Compose, essa é a tela, mas, como mencionamos antes, estamos em um ambiente headless, então não há interface.
Portanto, a menos que você forneça um relógio, vamos criar o nosso.
Ao separar a recomposição do relógio do frame de exibição, o Transform executa todas as transformações e derivações de estado fora da linha de execução de interface.
Para permitir isso, criamos um HeadlessTransformClock dedicado.
Transformar o relógio de frame.
Como um widget, um serviço ou outro downstream de Transform pode estar esperando um valor de um Transform, nosso relógio é adiantado.
Isso significa que, no momento em que qualquer estado que um Transform está consumindo muda, um novo frame é produzido imediatamente.
É uma pequena máquina de estado sem bloqueio, então solicitar um frame é uma operação simples de comparação e definição.
E quando nada muda no estado, o relógio para.
Portanto, uma transformação que consome um estado que não foi alterado não custa nada.
Antes de passar a palavra de volta para o Ben, vale lembrar que o Transform agora é compatível com rememberSaveable para garantir que seus dados sejam salvos em todas as mudanças de configuração.
Isso é feito pela nova API SavedStateContainer, que agora é o elemento básico em que o SaveableStateRegistry, o SavedStateHandle e o SavedStateRegistry do Compose são criados.
Você pode transformar qualquer um deles em um SavedStateContainer ou criar contêineres filhos e transmiti-los para o Transform para garantir que os dados sejam salvos.
Por fim, vale lembrar que todas as APIs que estamos discutindo hoje estão na versão Alfa, então estão sujeitas a mudanças.
Agora, vou passar a palavra de volta para o Ben.
Certo.
Certo, obrigada, Elif.
Agora que vimos como o estado do Compose é poderoso e como o Transform permite que ele seja derivado fora da interface, talvez você tenha notado que, no exemplo até agora, as entradas eram fluxos que conectamos usando collectAsState.
Mas será que isso é sempre necessário?
Por que não usar o estado do Compose diretamente nos detentores de estado?
Para responder a essa pergunta, é útil pensar no estado em três categorias amplas.
O primeiro é o estado ativo, que é armazenado apenas na memória.
Por exemplo, o status da conexão de rede ou Bluetooth.
Não é recomendável usar um valor persistente.
O segundo é o estado temporário, que precisa sobreviver ao encerramento do processo, mas não precisa ser armazenado indefinidamente.
Pense em coisas como o estado da interface, por exemplo, se um card está expandido ou não, e o estado da sessão, como a backstack de navegação ou um rascunho de formulário.
E o terceiro é o estado permanente, que é armazenado indefinidamente.
Pense em preferências do usuário ou uma biblioteca de mídia em um banco de dados.
Vamos começar com o uso do estado do Compose para estados ativos e temporários.
Até agora, nossa orientação de arquitetura mantinha o estado do Compose na camada de interface ou, às vezes, nos ViewModels, e dissemos para você usar o Flow em todos os outros lugares.
Mas por quê?
Isso acontece porque, assim que você precisou combinar uma propriedade de estado com um fluxo, foi necessário convertê-la de volta usando snapshotFlow.
Mas com o Transform, essa conversão não é necessária.
Vamos ver o que isso significa, começando em um ViewModel.
@HiltViewModel
class TaskListViewModel @Inject constructor(
private val tasksRepository: TasksRepository,
private val networkMonitor: NetworkMonitor,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
}Para entradas de tela locais, como selectedTaskIds, é possível declarar a propriedade mutableStateOf simples em vez de MutableStateFlow.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
}Como isso não é algo que você necessariamente quer manter após o encerramento do processo, não há problema em usar apenas mutableStateOf.
Para dados que você quer que sobrevivam ao encerramento do processo, como a consulta de pesquisa, é possível fazer backup do estado mutável diretamente com savedStateHandle.saveable.
@HiltViewModel
class TaskListViewModel @Inject constructor(...) : ViewModel() {
var selectedTaskIds by mutableStateOf(emptySet<String>())
private set
var searchQuery by savedStateHandle.saveable { mutableStateOf("") }
private set
}E, assim como updateScore no exemplo de jogo anterior, quando um método atualiza vários valores de estado de uma só vez, envolver as gravações em withMutableSnapshot garante a consistência.
@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()
}
}
}Em seguida, para combinar searchQuery com um fluxo do Room, basta ler diretamente dentro da transformação, enquanto coleta o fluxo.
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)
}E quanto ao estado em tempo real, como a conectividade de rede?
@Singleton
class NetworkMonitor @Inject constructor(
@ApplicationContext context: Context,
) {
private val connectivityManager =
context.getSystemService(ConnectivityManager::class.java)
...
}Isso geralmente fica em um Singleton, porque é lido em várias telas ou até mesmo por vários componentes do app, como serviços.
Mas isso não significa que você não pode usar o estado do Compose para armazená-lo.
Na verdade, geralmente é mais simples do que uma versão baseada em fluxo.
Não é necessário um fluxo de callback ou algo para fazer a adaptação. Basta atualizar o valor do ponto diretamente nos callbacks.
@Singleton
class NetworkMonitor @Inject constructor(...) {
...
private val _isOnline = mutableStateOf(checkCurrentConnectivity())
val isOnline: State<Boolean> = _isOnline
}E, se um consumidor ainda precisar de um fluxo, use snapshotFlow para fazer a ponte de volta.
@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)
}
}Vamos ver como isso fica no ViewModel.
Você pode ler isOnline diretamente em uma transformação, e ela será recomposta quando o valor mudar.
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)
}E tudo isso acontece diretamente, sem escopo de aplicativo ou de corrotina que você precise fornecer para atualizar o estado. É apenas uma atualização direta e síncrona.
Assim, com o Transform, seu ViewModel pode combinar o estado de gerenciadores Singleton, o estado em um ViewModel e os fluxos de repositórios, tudo em um único bloco de transformação.
Agora, vamos voltar ao estado temporário.
Anteriormente, mencionei dois tipos: estado da interface e estado da sessão.
Para o estado da interface e até mesmo o estado da sessão que reside em uma única atividade, ViewModel e SavedState funcionam muito bem.
Eles foram criados exatamente para isso.
No entanto, as coisas começam a dar errado quando esse estado de sessão precisa ser compartilhado entre várias interfaces e componentes de app diferentes.
Quando esse estado sai de um ViewModel, nossas APIs e orientações atuais deixam uma lacuna.
Primeiro, as APIs SavedState salvam em um Bundle de uma atividade, mas isso não está disponível em outros componentes do app, como um serviço.
Em segundo lugar, mutableStateOf em um Singleton, que só existe na memória, então não sobrevive ao encerramento do processo.
E essa é a lacuna que o Jetpack AppState, StateStore, preenche.
É um mapa de segurança de tipos do estado do Compose que pode ser compartilhado entre componentes do app e apoiado por DataStore para sobreviver ao encerramento do processo.
Você pode conectar DataStore a cada Singleton, mas isso significa escrever um código boilerplate de salvamento e restauração para cada propriedade.
Com a integração do StateStore com o DataStore, você conecta o DataStore uma vez e, em seguida, ativa cada parte do estado que quer manter.
Vamos criar isso no nosso app de timer de foco.
Vamos começar na memória e, em seguida, adicionar a persistência do DataStore.
Para preparar o cenário, estamos modelando um FocusSession como uma interface selada com os estados inativo, em execução e pausado.
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
}Em seguida, para armazenar esse estado em StateStore, definimos um StateStoreKey com o tipo FocusSession e um valor padrão, idle.
sealed interface FocusSession { ... }
object FocusSessionKey :
StateStoreKey<FocusSession>(defaultValue = FocusSession.Idle)Em seguida, fornecemos um Singleton StateStore no Hilt que pode ser injetado em diferentes componentes, como o Focus Session Manager.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideStateStore(): StateStore = StateStore()
}
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
...
}Isso é semelhante à forma como o ViewModel injeta SavedStateHandle.
Ok.
Agora temos um StateStore, mas como usá-lo?
Ainda bem que é uma API pequena.
Ela tem apenas três métodos, então vamos analisar um por um.
O primeiro é setState, que grava um novo valor para uma chave.
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)
}Quando uma sessão começa, ela é definida como "em execução" e, quando termina, como "ociosa".
É isso.
Em segundo lugar, ainda temos updateState, que permite calcular um novo valor com base no valor atual, como calcular o tempo restante ao alternar entre execução e 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 fim, há o getState, que retorna um estado somente leitura para uma chave e tem a opção de substituir o valor padrão, caso você queira mudar isso em um site de chamada específico.
@Singleton
class FocusSessionManager @Inject constructor(
private val stateStore: StateStore,
) {
val session: State<FocusSession> = stateStore.getState(FocusSessionKey)
...
}Além disso, expor o estado FocusSession nesse gerenciador significa que os consumidores nunca precisam acessar StateStore ou as chaves diretamente.
É possível manter o encapsulamento de gravação e leitura.
Em vez de consumidores, eles injetam o Focus Session Manager, que provavelmente já têm porque precisam controlar a sessão, e podem ler o estado.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() { ... }
@EntryPoint
@InstallIn(SingletonComponent::class)
interface WidgetEntryPoint {
fun focusSessionManager(): FocusSessionManager
}E esses leitores podem reexpor o State diretamente ou usar o Transform para criar um novo State atualizado, como vimos no exemplo do Mini-Timer ViewModel.
@HiltViewModel
class MiniTimerViewModel @Inject constructor(
focusSessionManager: FocusSessionManager,
) : ViewModel() {
val session: State<FocusSession> = focusSessionManager.session
}Em seguida, os elementos combináveis da interface podem ler esse estado como qualquer outro estado do Compose e fazer a recomposição quando necessário.
@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)
}
}Mas, até agora, o FocusSession só existe na memória.
Vamos ver o que é necessário para sobreviver ao encerramento do processo.
Primeiro, precisamos fazer uma configuração única, iniciando o syncToDataStore ao criar o repositório.
@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) }
}
}Depois, ativamos cada chave que queremos persistir.
@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),
PersistToDataStoreBasta garantir que o tipo seja serializável e que a chave implemente a interface PersistToDataStore.
Você pode estar pensando: "Parece fácil".
Então, o que ele realmente faz para persistir a chave?
Por isso, nos bastidores, syncToDataStore usa a API de listener do Transform, que é basicamente a mesma coisa que o Transform, mas não retorna nada, sendo usada apenas para observação e efeitos colaterais.
suspend fun StateStore.syncToDataStore(dataStore: DataStore<StateStorePreferences>) {
listener { … }
}Em seguida, dentro desse listener, ele lê cada chave no armazenamento e verifica se uma delas é uma chave 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
}
}
}
}
}Quando isso acontece, ele usa LaunchedEffect para sincronizar esses valores de volta com DataStore sempre que eles mudam.
E o que quero destacar aqui é que ter um contêiner padrão como o StateStore é o que nos permite fornecer essas integrações de substituição.
DataStore é apenas o primeiro que estamos analisando.
Voltando às categorias de estado, agora temos uma ideia bem clara de como usar o estado do Compose em todo o aplicativo.
De mutableStateOf para o estado ativo, às APIs SavedState para o estado de apresentação local da tela e StateStore para o estado de sessão entre componentes. Por fim, temos collectAsState para interoperabilidade com fluxos.
Mas, na verdade, a interoperabilidade não se limita a uma única categoria. Você pode usar esses auxiliares, como collectAsState, sempre que precisar conectar tipos observáveis ao estado do Compose.
Mas para o estado permanente, outra coisa que estamos analisando são maneiras de trabalhar com o Compose ou para que o DataStore e o Room exponham diretamente o estado do Compose, para que você não precise escrever sua própria sintaxe mais agradável para fazer isso.
E, com isso, tenho o prazer de anunciar que o Transform, o StateStore e a integração com o DataStore estão disponíveis em Alfa hoje. Também publicamos toda a documentação em developer.android.com e temos um repositório de receitas do AppState no GitHub que mostra exemplos de uso do StateStore e do 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" }E, sim, teste-os no seu app.
Envie seu feedback.
Como estamos na fase Alfa, é o momento de iterar e fazer mudanças para garantir que elas funcionem nos seus 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" }