Migrando do LiveData para o Fluxo de Kotlin

Tempo de leitura: 9 minutes

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.

 

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:

Expor o resultado de uma operação única com um Mutable data holder (LiveData)
Expor o resultado de uma operação única com um Mutable data holder (LiveData)
<!-- 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:

Expor o resultado de uma operação única com um Mutable data holder (StateFlow)
Expor o resultado de uma operação única com um Mutable data holder (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 operador shareIn e redefine o valor em cache para o initialValue original para o operador stateIn). 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.

Coletar fluxos com launch/launchWhenX não é seguro
Coletar fluxos com launch/launchWhenX não é seguro

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.

StateFlow exposto com WhileSubscribed(5000) e coletado com repeatOnLifecycle(STARTED)
StateFlow exposto com WhileSubscribed(5000) e coletado com repeatOnLifecycle(STARTED)

Aviso: o suporte StateFlow adicionado recentemente ao Data Binding usa launchWhenCreated para coletar atualizações e começará a usar repeatOnLifecycle` 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 o lifecycle-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. 🙂