Migrando do LiveData para o Fluxo de Kotlin
O LiveData era algo de que precisávamos em 2017. O padrão do observador tornava nossas vidas mais fáceis, mas opções como RxJava eram muito complexas para iniciantes na época. A equipe de Componentes de Arquitetura criou o LiveData: uma classe de suporte de dados observável muito opinativa, projetada para Android. Ele foi mantido simples para facilitar o início e a recomendação era usar o RxJava para casos de fluxos reativos mais complexos, aproveitando a integração entre os dois.
Conteudo
DeadData?
O LiveData ainda é nossa solução para desenvolvedores Java, iniciantes e situações simples. Para o resto, uma boa opção é mudar para Kotlin Flows. Os fluxos ainda têm uma curva de aprendizado acentuada, mas fazem parte da linguagem Kotlin, suportada pelo Jetbrains; e o Compose está chegando, o que se encaixa perfeitamente no modelo reativo.
Nesta postagem, você aprenderá como expor os fluxos a uma visualização, como coletá-los e como ajustá-los para atender a necessidades específicas.
Fluxo: coisas simples são mais difíceis e coisas complexas são mais fáceis
O LiveData fez uma coisa e muito bem: expôs dados enquanto armazenava em cache o valor mais recente e entendia os ciclos de vida do Android. Mais tarde, aprendemos que ele também poderia iniciar coroutines e criar transformações complexas, mas isso era um pouco mais complicado.
Vejamos alguns padrões LiveData e seus equivalentes de fluxo:
#1: Exponha o resultado de uma operação única com um Mutable data holder
Este é o padrão clássico, em que você altera um detentor de estado com o resultado de uma coroutine:
<!-- Copyright 2020 Google LLC. SPDX-License-Identifier: Apache-2.0 --> class MyViewModel { private val _myUiState = MutableLiveData<Result<UiState>>(Result.Loading) val myUiState: LiveData<Result<UiState>> = _myUiState // Load data from a suspend fun and mutate state init { viewModelScope.launch { val result = ... _myUiState.value = result } } }
Para fazer o mesmo com Flows, usamos (Mutable) StateFlow:
class MyViewModel { private val _myUiState = MutableStateFlow<Result<UiState>>(Result.Loading) val myUiState: StateFlow<Result<UiState>> = _myUiState // Load data from a suspend fun and mutate state init { viewModelScope.launch { val result = ... _myUiState.value = result } } }
Uma vez que os detentores de estado sempre têm um valor, é uma boa ideia envolver nosso estado de IU em algum tipo de classe Result que ofereça suporte a estados como Loading
, Success
e Error
.
O equivalente de fluxo é um pouco mais complicado porque você precisa fazer algumas configurações:
Expor o Resultado de uma operação única (StateFlow)
class MyViewModel(...) : ViewModel() { val result: StateFlow<Result<UiState>> = flow { emit(repository.fetchItem()) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), // Or Lazily because it's a one-shot initialValue = Result.Loading ) }
stateIn
é um operador de fluxo que converte um fluxo em StateFlow. Vamos confiar nesses parâmetros por enquanto, pois precisamos de mais complexidade para explicá-los adequadamente mais tarde.
#3: Carregamento de dados one-shot com parâmetros
Digamos que você queira carregar alguns dados que dependem do ID do usuário e obter essas informações de um AuthManager
que expõe um fluxo:
Carregamento de dados instantâneo com parâmetros (LiveData)
Com o LiveData, você faria algo semelhante a isto:
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: LiveData<String?> = authManager.observeUser().map { user -> user.id }.asLiveData() val result: LiveData<Result<Item>> = userId.switchMap { newUserId -> liveData { emit(repository.fetchItem(newUserId)) } } }
switchMap
é uma transformação cujo corpo é executado e o resultado assinado quando o userId
é alterado.
Se não houver razão para userId
ser um LiveData, uma alternativa melhor para isso é combinar streams com Flow e, finalmente, converter o resultado exposto em LiveData.
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id } val result: LiveData<Result<Item>> = userId.mapLatest { newUserId -> repository.fetchItem(newUserId) }.asLiveData() }
Fazer isso com fluxos é muito semelhante:
Carregamento de dados one-shot com parâmetros (StateFlow)
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: Flow<UserId> = authManager.observeUser().map { user -> user.id } val result: StateFlow<Result<Item>> = userId.mapLatest { newUserId -> repository.fetchItem(newUserId) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.Loading ) }
Observe que, se precisar de mais flexibilidade, você também pode usar transformLatest
e emit
itens explicitamente:
val result = userId.transformLatest { newUserId -> emit(Result.LoadingData) emit(repository.fetchItem(newUserId)) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.LoadingUser // Note the different Loading states )
Nº 4: Observando um fluxo de dados com parâmetros
Agora vamos tornar o exemplo mais reativo. Os dados não são buscados, mas observed, portanto, propagamos as alterações na origem dos dados automaticamente para a IU.
Continuando com nosso exemplo: em vez de chamar fetchItem
na fonte de dados, usamos um operador observeItem
hipotético que retorna um Flow.
Com o LiveData, você pode converter o fluxo em LiveData e emitSource todas as atualizações:
Observando um fluxo com parâmetros (LiveData)
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: LiveData<String?> = authManager.observeUser().map { user -> user.id }.asLiveData() val result = userId.switchMap { newUserId -> repository.observeItem(newUserId).asLiveData() } }
Ou, de preferência, combine os dois fluxos usando flatMapLatest
e converta apenas a saída em LiveData:
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: Flow<String?> = authManager.observeUser().map { user -> user?.id } val result: LiveData<Result<Item>> = userId.flatMapLatest { newUserId -> repository.observeItem(newUserId) }.asLiveData() }
A implementação do Flow é semelhante, mas não tem conversões LiveData:
Observando um fluxo com parâmetros (StateFlow)
class MyViewModel(authManager..., repository...) : ViewModel() { private val userId: Flow<String?> = authManager.observeUser().map { user -> user?.id } val result: StateFlow<Result<Item>> = userId.flatMapLatest { newUserId -> repository.observeItem(newUserId) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.LoadingUser ) }
O StateFlow exposto receberá atualizações sempre que o usuário mudar ou os dados do usuário no repositório forem alterados.
#5 Combinando várias fontes: MediatorLiveData -> Flow.combine
MediatorLiveData permite observar uma ou mais fontes de atualizações (observáveis LiveData) e fazer algo quando obtêm novos dados. Normalmente, você atualiza o valor do MediatorLiveData:
val liveData1: LiveData<Int> = ... val liveData2: LiveData<Int> = ... val result = MediatorLiveData<Int>() result.addSource(liveData1) { value -> result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0)) } result.addSource(liveData2) { value -> result.setValue(liveData1.value ?: 0 + (liveData2.value ?: 0)) }
O equivalente de fluxo é muito mais simples:
val flow1: Flow<Int> = ... val flow2: Flow<Int> = ... val result = combine(flow1, flow2) { a, b -> a + b }
Você também pode usar a função combineTransform ou zip.
Configurando o StateFlow exposto (operador stateIn)
Anteriormente, usamos o stateIn para converter um fluxo regular em um StateFlow, mas isso requer alguma configuração. Se você não deseja entrar em detalhes agora e só precisa copiar e colar, esta combinação é o que eu recomendo:
val result: StateFlow<Result<UiState>> = someFlow .stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.Loading )
No entanto, se você não tem certeza sobre esse parâmetro de início aparentemente aleatório de 5 segundos, continue lendo.
stateIn
tem 3 parâmetros (de documentos):
@param escopo o escopo da co-rotina em que o compartilhamento é iniciado. @param iniciou a estratégia que controla quando o compartilhamento é iniciado e interrompido. @param initialValue o valor inicial do fluxo de estado. Este valor também é usado quando o fluxo de estado é redefinido usando a estratégia [SharingStarted.WhileSubscribed] com o parâmetro `replayExpirationMillis`.
started
pode ter 3 valores:
- Lazily: inicia quando o primeiro assinante aparece e para quando o escopo é cancelado.
- Eagerly: comece imediatamente e pare quando o escopo for cancelado
- WhileSubscribed: é complicado.
Para operações one-shot, você pode usar Lazily
ou Eagerly
. No entanto, se estiver observando outros fluxos, você deve usar o WhileSubscribed
para fazer pequenas, mas importantes otimizações, conforme explicado abaixo.
A estratégia WhileSubscribed
WhileSubscribed cancela o fluxo upstream quando não há coletores. O StateFlow criado usando stateIn
expõe os dados para a Visualização, mas também está observando os fluxos vindos de outras camadas ou do aplicativo (upstream). Manter esses fluxos ativos pode levar ao desperdício de recursos, por exemplo, se eles continuarem lendo dados de outras fontes, como uma conexão de banco de dados, sensores de hardware, etc. Quando seu aplicativo vai para o segundo plano, você deve ser um bom cidadão e interromper essas coroutines.
WhileSubscribed
leva dois parâmetros:
public fun WhileSubscribed( stopTimeoutMillis: Long = 0, replayExpirationMillis: Long = Long.MAX_VALUE )
Stop timeout
De sua documentação:
stopTimeoutMillis
configura um atraso (em milissegundos) entre o desaparecimento do último assinante e a parada do fluxo ascendente. O padrão é zero (pare imediatamente).
Isso é útil porque você não deseja cancelar os fluxos upstream se a visualização parar de escutar por uma fração de segundo. Isso acontece o tempo todo – por exemplo, quando o usuário gira o dispositivo e a visualização é destruída e recriada em rápida sucessão.
A solução no construtor de corrotina liveData
era adicionar um atraso de 5 segundos após o qual a corrotina seria interrompida se nenhum assinante estivesse presente. WhileSubscribed(5000)
faz exatamente isso:
class MyViewModel(...) : ViewModel() { val result = userId.mapLatest { newUserId -> repository.observeItem(newUserId) }.stateIn( scope = viewModelScope, started = WhileSubscribed(5000), initialValue = Result.Loading ) }
Esta abordagem verifica todas as caixas:
- Quando o usuário envia seu aplicativo para segundo plano, as atualizações provenientes de outras camadas serão interrompidas após cinco segundos, economizando bateria.
- O valor mais recente ainda será armazenado em cache para que, quando o usuário voltar a ele, a visualização tenha alguns dados imediatamente.
- As assinaturas são reiniciadas e novos valores entrarão, atualizando a tela quando disponíveis.
Replay expiration
Se você não quiser que o usuário veja dados desatualizados quando eles ficarem longe por muito tempo e preferir exibir uma tela de carregamento, verifique o parâmetro replayExpirationMillis
em WhileSubscribed
. É muito útil nesta situação e também economiza alguma memória, pois o valor em cache é restaurado para o valor inicial definido em stateIn. Voltar ao aplicativo não será tão rápido, mas você não mostrará dados antigos.
replayExpirationMillis
— configura um atraso (em milissegundos) entre a parada da co-rotina de compartilhamento e a redefinição do cache de reprodução (que torna o cache vazio para o operadorshareIn
e redefine o valor em cache para oinitialValue
original para o operadorstateIn
). O padrão éLong.MAX_VALUE
(manter o cache de repetição para sempre, nunca redefinir o buffer). Use valor zero para expirar o cache imediatamente.
Observing StateFlow do view
Como vimos até agora, é muito importante para a visualização permitir que os StateFlows no ViewModel saibam que não estão mais ouvindo. No entanto, como tudo relacionado a ciclos de vida, não é tão simples.
Para coletar um fluxo, você precisa de um coroutine. Atividades e fragmentos oferecem vários construtores de coroutine:
Activity.lifecycleScope.launch: inicia a coroutine imediatamente e a cancela quando a activity é destruída.
Fragment.lifecycleScope.launch: inicia a coroutine imediatamente e a cancela quando o fragmento é destruído.
Fragment.viewLifecycleOwner.lifecycleScope.launch: inicia a coroutine imediatamente e a cancela quando o ciclo de vida da visão do fragmento é destruído. Você deve usar o ciclo de vida da visualização se estiver modificando a IU.
LaunchWhenStarted, launchWhenResumed…
Versões especializadas de launch
chamadas launchWhenX
irão esperar até que o lifecycleOwner
esteja no estado X e suspender a coroutine quando o lifecycleOwner
cair abaixo do estado X. É importante observar que eles não cancelam a coroutine até que o proprietário do ciclo de vida seja destruído.
Receber atualizações enquanto o aplicativo está em segundo plano pode levar a travamentos, que são resolvidos suspendendo a coleção na Visualização. No entanto, os fluxos upstream são mantidos ativos enquanto o aplicativo está em segundo plano, possivelmente desperdiçando recursos.
Isso significa que tudo o que fizemos até agora para configurar o StateFlow seria totalmente inútil; no entanto, há uma nova API na cidade.
lifecycle.repeatOnLifecycle para o resgate
Este novo construtor de coroutine (disponível em lifecycle-runtime-ktx 2.4.0-alpha01) faz exatamente o que precisamos: ele inicia coroutine em um estado particular e as interrompe quando o proprietário do ciclo de vida cai abaixo dele.
Por exemplo, em um fragmento:
onCreateView(...) { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycle.repeatOnLifecycle(STARTED) { myViewModel.myUiState.collect { ... } } } }
Isso iniciará a coleta quando a visualização do fragmento for STARTED
, continuará com RESUMED
e parará quando voltar para STOPPED
. Leia tudo sobre isso em Uma maneira mais segura de coletar fluxos de IUs do Android.
Combinar a API repeatOnLifecycle
com a orientação do StateFlow acima obterá o melhor desempenho ao fazer um bom uso dos recursos do dispositivo.
Aviso: o suporte StateFlow adicionado recentemente ao Data Binding usa
launchWhenCreated
para coletar atualizações e começará a usarrepeatOnLifecycle
` em vez de ficar estável.Para Data Binding, você deve usar fluxos em todos os lugares e simplesmente adicionar
asLiveData()
para expô-los à exibição. A vinculação de dados será atualizada quando olifecycle-runtime-ktx 2.4.0
ficar estável.
Resumo
A melhor maneira de expor dados de um ViewModel e coletá-los de uma visualização é:
✔️ Expor um StateFlow
, usando a estratégia WhileSubscribed
, com um tempo limite. [exemplo]
✔️ Colete com repeatOnLifecycle
. [exemplo]
Qualquer outra combinação manterá os fluxos upstream ativos, desperdiçando recursos:
❌ Expor usando WhileSubscribed
e coletar dentro do lifecycleScope.launch/launchWhenX
❌ Expor usando Lazily/Eagerly
e coletar com repeatOnLifecycle
Claro, se você não precisa de todo o poder do Flow … basta usar o LiveData. 🙂