Conheça corrotinas avançadas do Kotlin com Flow e LiveData

Neste codelab, você aprenderá a usar o builder do LiveData para combinar corrotinas do Kotlin com LiveData em um app Android. Também usaremos o Coroutine Async Flow, que é um tipo da biblioteca de corrotinas para representar uma sequência assíncrona (ou stream) de valores, para fazer a mesma implementação.

Você começará com um app já existente, criado com os Componentes da arquitetura do Android, que usa LiveData para gerar uma lista de objetos de um banco de dados da Room e exibi-los em um layout de grade RecyclerView.

Veja a seguir alguns snippets de código para ter uma ideia do que você fará, bem como o código atual para consultar o banco de dados do Room:

val plants: LiveData<List<Plant>> = plantDao.getPlants()

O LiveData será atualizado usando o builder LiveData e corrotinas com lógica de classificação extra:

val plants: LiveData<List<Plant>> = liveData<List<Plant>> {
   val plantsLiveData = plantDao.getPlants()
   val customSortOrder = plantsListSortOrderCache.getOrAwait()
   emitSource(plantsLiveData.map { plantList -> plantList.applySort(customSortOrder) })
}

Você também implementará a mesma lógica com Flow:

private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()

val plantsFlow: Flow<List<Plant>>
   get() = plantDao.getPlantsFlow()
       .combine(customSortFlow) { plants, sortOrder ->
           plants.applySort(sortOrder)
       }
       .flowOn(defaultDispatcher)
       .conflate()

Pré-requisitos

  • Experiência com os componentes de arquitetura ViewModel, LiveData, Repository e Room
  • Experiência com sintaxe do Kotlin, incluindo funções de extensão e lambdas.
  • Experiência com corrotinas do Kotlin
  • Conhecimentos básicos sobre o uso de linhas de execução no Android, incluindo a linha de execução principal, linhas de execução em segundo plano e callbacks

O que você aprenderá

  • Converter um LiveData já existente para usar o builder LiveData compatível com corrotinas do Kotlin
  • Adicionar lógica em um builder LiveData
  • Usar Flow para operações assíncronas
  • Combinar Flows e transformar várias origens assíncronas
  • Controlar a simultaneidade com Flows
  • Saber como escolher entre LiveData e Flow.

O que será necessário

  • Android Studio 4.1 ou versão mais recente. É possível que o codelab funcione com outras versões, mas alguns recursos podem estar ausentes ou ser diferentes.

Se você encontrar algum problema (bugs no código, erros gramaticais, instruções pouco claras etc.) neste codelab, use o link "Informar um erro", localizado no canto inferior esquerdo do codelab para nos avisar.

Fazer o download do código

Clique no link abaixo para fazer o download de todo o código para este codelab:

Fazer o download do ZIP

… ou clone o repositório do GitHub pela linha de comando usando o seguinte comando:

$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git

O código para este codelab está no diretório advanced-coroutines-codelab.

Perguntas frequentes

Primeiro, vamos ver a aparência do app de exemplo inicial. Siga estas instruções para abrir o app de exemplo no Android Studio.

  1. Se você fez o download do arquivo ZIP kotlin-coroutines, descompacte-o.
  2. Abra o diretório advanced-coroutines-codelab no Android Studio.
  3. Verifique se start está selecionado na lista suspensa de configuração.
  4. Clique no botão Run execute.png e escolha um dispositivo emulado ou conecte seu dispositivo Android. O dispositivo deve ser capaz de executar o Android Lollipop. O SDK mínimo compatível é o 21.

Quando o app é executado pela primeira vez, uma lista de cards é exibida, cada um com o nome e a imagem de uma planta específica:

2faf7cd0b97434f5.png

Cada Plant tem um growZoneNumber, um atributo que representa a região em que a planta provavelmente sobreviverá. Os usuários podem tocar no ícone de filtro ee1895257963ae84.png para alternar entre a exibição de todas as plantas e das plantas de uma zona de crescimento específica, que é fixada no código na zona 9. Pressione o botão do filtro algumas vezes para ver isso em ação.

8e150fb2a41417ab.png

Visão geral da arquitetura

Esse app usa componentes de arquitetura para separar o código da IU em MainActivity e PlantListFragment da lógica do app em PlantListViewModel. PlantRepository fornece uma ponte entre ViewModel e PlantDao, que acessa o banco de dados da Room para retornar uma lista de objetos Plant. A IU pega essa lista de plantas e as exibe no layout de grade RecyclerView.

Antes de começar a modificar o código, vamos ver como os dados fluem do banco de dados para a IU. Veja como a lista de plantas é carregada no ViewModel:

PlantListViewModel.kt

val plants: LiveData<List<Plant>> = growZone.switchMap { growZone ->
    if (growZone == NoGrowZone) {
        plantRepository.plants
    } else {
        plantRepository.getPlantsWithGrowZone(growZone)
    }
}

Uma GrowZone é uma classe in-line que contém apenas um Int representando a zona dela. NoGrowZone representa a ausência de uma zona e é usada apenas para filtragem.

Plant.kt

inline class GrowZone(val number: Int)
val NoGrowZone = GrowZone(-1)

A growZone é alternada quando o botão de filtro é tocado. Usamos um switchMap para determinar a lista de plantas a serem retornadas.

Veja como fica o repositório e o objeto de acesso a dados (DAO, na sigla em inglês) para buscar os dados de plantas no banco de dados:

PlantDao.kt

@Query("SELECT * FROM plants ORDER BY name")
fun getPlants(): LiveData<List<Plant>>

@Query("SELECT * FROM plants WHERE growZoneNumber = :growZoneNumber ORDER BY name")
fun getPlantsWithGrowZoneNumber(growZoneNumber: Int): LiveData<List<Plant>>

PlantRepository.kt

val plants = plantDao.getPlants()

fun getPlantsWithGrowZone(growZone: GrowZone) =
    plantDao.getPlantsWithGrowZoneNumber(growZone.number)

Embora a maioria das modificações de código esteja em PlantListViewModel e PlantRepository, é recomendável familiarizar-se com a estrutura do projeto, com foco em como os dados de plantas são exibidos nas várias camadas do banco de dados para o Fragment. Na próxima etapa, modificaremos o código para adicionar classificação personalizada usando o builder LiveData.

A lista de plantas está sendo exibida em ordem alfabética, mas queremos alterar essa ordem listando algumas plantas primeiro e, depois, o restante em ordem alfabética. Isso é semelhante a apps de compras que exibem resultados patrocinados na parte superior de uma lista de itens disponíveis para compra. Nossa equipe de produtos quer mudar a ordem de classificação dinamicamente sem enviar uma nova versão do app. Portanto, buscaremos a lista de plantas para classificar primeiro no back-end.

Veja como ficará o app com a classificação personalizada:

ca3c67a941933bd9.png

A lista com ordem de classificação personalizada consiste em quatro plantas: laranja, girassol, uva e abacate. Observe como elas aparecem primeiro na lista, seguidas pelas outras plantas em ordem alfabética.

Se o botão de filtro for pressionado e apenas as plantas da GrowZone 9 forem exibidas, o girassol desaparecerá da lista porque a GrowZone dele não é 9. As outras três plantas da lista de classificação personalizada estão na GrowZone 9 e, portanto, permanecerão na parte superior da lista. A única outra planta na GrowZone 9 é o tomate, que aparece por último nesta lista.

50efd3b656d4b97.png

Vamos começar a programar o código para implementar a classificação personalizada.

Começaremos com uma função de suspensão para buscar a ordem de classificação personalizada na rede e armazená-la em cache.

Adicione o seguinte a PlantRepository:

PlantRepository.kt

private var plantsListSortOrderCache =
    CacheOnSuccess(onErrorFallback = { listOf<String>() }) {
        plantService.customPlantSortOrder()
    }

plantsListSortOrderCache é usado como o cache de memória para a ordem de classificação personalizada. Se houver um erro de rede, uma lista vazia será usada para que nosso app ainda possa exibir dados, mesmo que a ordem de classificação não seja recuperada.

Esse código usa a classe de utilitário CacheOnSuccess fornecida no módulo sunflower para processar o armazenamento em cache. Ao abstrair os detalhes da implementação do armazenamento em cache dessa forma, o código do aplicativo fica mais simples. Como CacheOnSuccess já foi bem testado, não é necessário programar tantos testes em nosso repositório para garantir o comportamento correto. É recomendável introduzir abstrações de nível superior semelhantes no código ao usar kotlinx-coroutines.

Agora vamos incorporar um pouco de lógica para aplicar a classificação a uma lista de plantas.

Adicione o seguinte a PlantRepository:

PlantRepository.kt

private fun List<Plant>.applySort(customSortOrder: List<String>): List<Plant> {
    return sortedBy { plant ->
        val positionForItem = customSortOrder.indexOf(plant.plantId).let { order ->
            if (order > -1) order else Int.MAX_VALUE
        }
        ComparablePair(positionForItem, plant.name)
    }
}

Essa função de extensão reorganizará a lista, colocando Plants que estão na customSortOrder na frente da lista.

Agora que a lógica de classificação já está resolvida, substitua o código para plants e getPlantsWithGrowZone pelo builder LiveData abaixo:

PlantRepository.kt

val plants: LiveData<List<Plant>> = liveData<List<Plant>> {
   val plantsLiveData = plantDao.getPlants()
   val customSortOrder = plantsListSortOrderCache.getOrAwait()
   emitSource(plantsLiveData.map {
       plantList -> plantList.applySort(customSortOrder)
   })
}

fun getPlantsWithGrowZone(growZone: GrowZone) = liveData {
    val plantsGrowZoneLiveData = plantDao.getPlantsWithGrowZoneNumber(growZone.number)
    val customSortOrder = plantsListSortOrderCache.getOrAwait()
    emitSource(plantsGrowZoneLiveData.map { plantList ->
        plantList.applySort(customSortOrder)
    })
}

Se você executar o app, a lista de plantas com classificação personalizada será exibida:

ca3c67a941933bd9.png

O builder LiveData permite calcular valores de forma assíncrona, já que liveData tem o suporte de corrotinas. Aqui, temos uma função de suspensão para buscar uma lista de LiveData de plantas no banco de dados. Além disso, chamamos uma função de suspensão para produzir a ordem de classificação personalizada. Em seguida, combinamos esses dois valores para classificar a lista de plantas e retornar o valor, tudo dentro do builder.

A execução da corrotina começa quando ela é observada e é cancelada quando a corrotina termina ou se a chamada da rede ou do banco de dados falha.

Na próxima etapa, veremos uma variação de getPlantsWithGrowZone usando uma transformação.

Agora vamos modificar PlantRepository para implementar uma transformação de suspensão quando cada valor é processado, aprendendo a criar transformações assíncronas complexas em LiveData. Como pré-requisito, vamos criar uma versão do algoritmo de classificação que possa ser usada com segurança na linha de execução principal. Podemos usar withContext para alternar para outro agente apenas para o lambda e depois retomar o agente com que começamos.

Adicione o seguinte a PlantRepository:

PlantRepository.kt

@AnyThread
suspend fun List<Plant>.applyMainSafeSort(customSortOrder: List<String>) =
    withContext(defaultDispatcher) {
        this@applyMainSafeSort.applySort(customSortOrder)
    }

Podemos usar essa nova classificação protegida com o builder da LiveData. Atualize o bloco para usar um switchMap, que permitirá apontar para uma nova LiveData sempre que um novo valor for recebido.

PlantRepository.kt

fun getPlantsWithGrowZone(growZone: GrowZone) =
   plantDao.getPlantsWithGrowZoneNumber(growZone.number)
       .switchMap { plantList ->
           liveData {
               val customSortOrder = plantsListSortOrderCache.getOrAwait()
               emit(plantList.applyMainSafeSort(customSortOrder))
           }
       }

Em comparação com a versão anterior, quando a ordem de classificação personalizada é recebida da rede, ela pode ser usada com a nova applyMainSafeSort protegida. Esse resultado é emitido para switchMap como o novo valor retornado por getPlantsWithGrowZone.

Assim como a LiveData plants acima, a corrotina começa a ser executada quando é observada e é encerrada após sua conclusão ou se a chamada da rede ou do banco de dados falha. A diferença aqui é que é seguro fazer a chamada de rede no mapa, já que ela é armazenada em cache.

Agora vamos ver como o código é implementado com o Flow e comparar as implementações.

Criaremos a mesma lógica usando Flow em kotlinx-coroutines. Antes de fazermos isso, vejamos o que é um fluxo e como você pode incorporá-lo a seu app.

Um fluxo é uma versão assíncrona de uma Sequência, um tipo de coleção com valores produzidos lentamente. Assim como uma sequência, um fluxo produz cada valor sob demanda sempre que o valor é necessário, e os fluxos podem conter um número infinito de valores.

Por que o Kotlin introduziu um novo tipo de Flow e como ele é diferente de uma sequência normal? A resposta está na magia da assincronicidade. Flow inclui compatibilidade total com corrotinas. Isso significa que você pode criar, transformar e consumir um Flow usando corrotinas. Também é possível controlar a simultaneidade, o que significa coordenar a execução de várias corrotinas de maneira declarativa com Flow.

Isso abre muitas possibilidades interessantes.

Um Flow pode ser usado em um estilo de programação totalmente reativo. Se você já usou algo como RxJava, o Flow oferece uma funcionalidade semelhante. A lógica do app pode ser expressa de forma sucinta, transformando um fluxo com operadores funcionais como map, flatMapLatest, combine e assim por diante.

O Flow também é compatível com funções de suspensão na maioria dos operadores. Isso permite que você realize tarefas assíncronas sequenciais dentro de um operador como map. O uso de operações de suspensão dentro de um fluxo geralmente resulta em códigos menores e mais fáceis de ler do que o código equivalente em um estilo totalmente reativo.

Neste codelab, veremos as duas abordagens.

Como funciona o fluxo

Para se acostumar com a forma como o Flow produz valores sob demanda (ou lentamente), veja o fluxo a seguir, que emite os valores (1, 2, 3) e os exibe antes, durante e depois de cada item ser produzido.

fun makeFlow() = flow {
   println("sending first value")
   emit(1)
   println("first value collected, sending another value")
   emit(2)
   println("second value collected, sending a third value")
   emit(3)
   println("done")
}

scope.launch {
   makeFlow().collect { value ->
       println("got $value")
   }
   println("flow is completed")
}

Se você executar esse código, o resultado será:

sending first value
got 1
first value collected, sending another value
got 2
second value collected, sending a third value
got 3
done
flow is completed

Você pode ver como a execução oscila entre o lambda collect e o builder flow. Toda vez que o builder de fluxo chama emit, ele suspends (é suspenso) até que o elemento seja totalmente processado. Em seguida, quando outro valor é solicitado do fluxo, ele resumes (continua) de onde parou até que a chamada seja emitida novamente. Quando o builder flow é concluído, o Flow é cancelado, a collect é retomada e a corrotina de chamada exibe "o fluxo está concluído".

A chamada para collect é muito importante. O Flow usa operadores de suspensão, como collect, em vez de expor uma interface de Iterator para que sempre saiba quando está sendo consumido ativamente. Mais importante ainda, ele sabe quando o autor da chamada não pode mais solicitar valores, para que possa limpar recursos.

Quando um fluxo é executado

O Flow no exemplo acima começa a ser executado quando o operador collect é executado. A criação de um novo Flow chamando o builder flow ou outras APIs não gera a execução de nenhum trabalho. O operador de suspensão collect é chamado de operador de terminal no Flow. Há outros operadores de terminal de suspensão, como toList, first e single, fornecidos com kotlinx-coroutines, e você pode criar os seus.

Por padrão, o Flow será executado:

  • sempre que um operador de terminal for aplicado e cada nova invocação for independente de qualquer outra iniciada anteriormente;
  • até que a corrotina em que ele está sendo executado seja cancelada;
  • quando o último valor tiver sido totalmente processado e outro valor tiver sido solicitado.

Devido a essas regras, um Flow pode participar da simultaneidade estruturada, e é seguro iniciar corrotinas de longa duração em um Flow. Não há chances de um Flow vazar recursos, já que eles são sempre limpos usando regras de cancelamento de corrotinas cooperativas quando o autor da chamada é cancelado.

Vamos modificar o fluxo acima para ver apenas os dois primeiros elementos usando o operador take e depois coletá-lo duas vezes.

scope.launch {
   val repeatableFlow = makeFlow().take(2)  // we only care about the first two elements
   println("first collection")
   repeatableFlow.collect()
   println("collecting again")
   repeatableFlow.collect()
   println("second collection completed")
}

Ao executar esse código, você verá esta saída:

first collection
sending first value
first value collected, sending another value
collecting again
sending first value
first value collected, sending another value
second collection completed

O lambda flow começa na parte superior sempre que collect é chamado. Isso é importante caso o fluxo tenha realizado um trabalho caro, por exemplo, fazer uma solicitação de rede. Além disso, como aplicamos o operador take(2), o fluxo produzirá apenas dois valores. Isso não retomará o lambda de fluxo novamente depois da segunda chamada para emit, de modo que a linha "segundo valor coletado..." nunca será exibida.

Ok, já entendi, o Flow é lento como uma Sequence. Mas então como assim ele também é assíncrono? Vejamos um exemplo de uma sequência assíncrona observando mudanças em um banco de dados.

Neste exemplo, precisamos coordenar os dados produzidos em um pool de linhas de execução do banco de dados com os observadores que residem em outra linha de execução, por exemplo, na linha de execução principal ou de IU. E, como vamos emitir resultados repetidamente à medida que os dados mudarem, esse cenário é natural para um padrão de sequência assíncrona.

Imagine que você tenha a tarefa de programar a integração do Room para o Flow. Se você começou usando o suporte de consulta de suspensão existente no Room, programe algo assim:

// This code is a simplified version of how Room implements flow
fun <T> createFlow(query: Query, tables: List<Tables>): Flow<T> = flow {
    val changeTracker = tableChangeTracker(tables)

    while(true) {
        emit(suspendQuery(query))
        changeTracker.suspendUntilChanged()
    }
}

Esse código depende de duas funções de suspensão imaginárias para gerar um Flow:

  • suspendQuery: uma função protegida que executa uma consulta de suspensão normal do Room.
  • suspendUntilChanged: uma função que suspende a corrotina até que uma das tabelas seja modificada.

Quando coletado, o fluxo inicialmente emits (emite) o primeiro valor da consulta. Depois que o valor é processado, o fluxo é retomado e chama suspendUntilChanged, o que resulta no que o nome da função diz: suspende o fluxo até que uma das tabelas seja modificada. Neste ponto, nada acontece no sistema até que uma das tabelas seja alterada e o fluxo seja retomado.

Quando o fluxo é retomado, ele cria outra consulta protegida e emits os resultados. Esse processo continua sem parar, em uma repetição infinita.

O Flow e a simultaneidade estruturada

Nós não queremos desperdiçar trabalho. A corrotina não é muito cara por si só, mas ela se ativa repetidamente para executar uma consulta no banco de dados. Isso é muito caro para ser desperdiçado.

Mesmo com a criação de uma repetição infinita, o Flow nos ajuda a oferecer compatibilidade com a simultaneidade estruturada.

A única maneira de consumir valores ou iterar um fluxo é usando um operador de terminal. Como todos os operadores de terminal são funções de suspensão, o trabalho é limitado ao ciclo de vida do escopo que os chama. Quando o escopo é cancelado, o fluxo é cancelado automaticamente com as regras normais de cancelamento cooperativo de rotinas. Assim, mesmo que tenhamos programado uma repetição infinita no nosso builder do fluxo, podemos consumi-lo com segurança sem desperdícios devido à simultaneidade estruturada.

Nesta etapa, você aprenderá a usar o Flow com o Room e conectá-lo à IU.

Essa etapa é comum para muitos usos de Flow. Quando usado dessa maneira, o Flow do Room opera como uma consulta de banco de dados observável semelhante a uma LiveData.

Atualizar o Dao

Para começar, abra PlantDao.kt e adicione duas novas consultas que retornam Flow<List<Plant>>:

PlantDao.kt

@Query("SELECT * from plants ORDER BY name")
fun getPlantsFlow(): Flow<List<Plant>>

@Query("SELECT * from plants WHERE growZoneNumber = :growZoneNumber ORDER BY name")
fun getPlantsWithGrowZoneNumberFlow(growZoneNumber: Int): Flow<List<Plant>>

Observe que, com exceção dos tipos de retorno, essas funções são idênticas às versões da LiveData. Mas nós as desenvolveremos lado a lado para compará-las.

Ao especificar um tipo de retorno do Flow, o Room executa a consulta com as seguintes características:

  • Proteção: como as consultas com um tipo de retorno do Flow são realizadas sempre nos executores do Room, elas são sempre protegidas. Não é necessário fazer nada no seu código para que elas funcionem fora da linha de execução principal.
  • Observa mudanças: o Room observa automaticamente as mudanças e emite novos valores para o fluxo.
  • Sequência assíncrona: o Flow emite todo o resultado da consulta em cada mudança e não introduz nenhum buffer. Se você retornar Flow<List<T>>, o fluxo emitirá uma List<T> que contém todas as linhas do resultado da consulta. Ela será executada como uma sequência, emitindo um resultado de consulta por vez e fazendo a suspensão até que a próxima seja solicitada.
  • Cancelável: quando o escopo que está coletando esses fluxos é cancelado, o Room cancela a observação dessa consulta.

Em conjunto, isso faz do Flow um ótimo tipo de retorno para observar o banco de dados na camada de IU.

Atualizar o repositório

Para continuar conectando os novos valores de retorno à IU, abra PlantRepository.kt e adicione o seguinte código:

PlantRepository.kt

val plantsFlow: Flow<List<Plant>>
   get() = plantDao.getPlantsFlow()

fun getPlantsWithGrowZoneFlow(growZoneNumber: GrowZone): Flow<List<Plant>> {
   return plantDao.getPlantsWithGrowZoneNumberFlow(growZoneNumber.number)
}

Por enquanto, estamos apenas transmitindo os valores de Flow para o autor da chamada. Isso é o mesmo que fizemos quando iniciamos este codelab passando o LiveData para o ViewModel.

Atualizar o ViewModel

Em PlantListViewModel.kt, vamos começar expondo o plantsFlow. Voltaremos e adicionaremos a opção de alternar a zona de crescimento à versão do fluxo nas próxima etapas.

PlantListViewModel.kt

// add a new property to plantListViewModel

val plantsUsingFlow: LiveData<List<Plant>> = plantRepository.plantsFlow.asLiveData()

Novamente, manteremos a versão da LiveData (val plants) para fins de comparação conforme avançamos.

Como queremos manter o LiveData na camada de IU para este codelab, usaremos a função de extensão do asLiveData para converter o Flow em LiveData. Assim como o builder LiveData, isso adiciona um tempo limite configurável à LiveData gerada. Isso é bom porque evita que a consulta seja reiniciada sempre que a configuração é alterada (como na rotação do dispositivo).

Como o fluxo oferece segurança principal e a capacidade de cancelamento, você pode transmitir o Flow para a camada de IU sem convertê-lo em uma LiveData. No entanto, neste codelab, continuaremos usando a LiveData na camada de IU.

Ainda no ViewModel, adicione uma atualização de cache ao bloco init. Por enquanto, essa etapa é opcional, mas se você limpar o cache e não adicionar essa chamada, nenhum dado será exibido no app.

PlantListViewModel.kt

init {
    clearGrowZoneNumber()  // keep this

    // fetch the full plant list
    launchDataLoad { plantRepository.tryUpdateRecentPlantsCache() }
}

Atualizar o fragmento

Abra PlantListFragment.kt e mude a função subscribeUi para apontar para o novo LiveData plantsUsingFlow.

PlantListFragment.kt.

private fun subscribeUi(adapter: PlantAdapter) {
   viewModel.plantsUsingFlow.observe(viewLifecycleOwner) { plants ->
       adapter.submitList(plants)
   }
}

Executar o app com o Flow

Se você executar o app novamente, verá que os dados agora estão sendo carregados com o Flow. Como ainda não implementamos o switchMap, a opção de filtro não faz nada.

Na próxima etapa, veremos como transformar os dados em um Flow.

Nesta etapa, você aplicará a ordem de classificação ao plantsFlow. Faremos isso usando a API declarativa de flow.

Ao usar transformações como map, combine ou mapLatest, podemos expressar como gostaríamos de transformar cada elemento conforme ele avança pelo fluxo de forma declarativa. Isso permite até mesmo expressar a simultaneidade de modo declarativo, o que pode realmente simplificar o código. Nesta seção, você verá como usar operadores para dizer ao Flow para iniciar duas corrotinas e combinar os resultados de forma declarativa.

Para começar, abra PlantRepository.kt e defina um novo fluxo privado chamado customSortFlow:

PlantRepository.kt

private val customSortFlow = flow { emit(plantsListSortOrderCache.getOrAwait()) }

Isso define um Flow que, quando coletado, chamará getOrAwait e emit (emitirá) a ordem de classificação.

Como esse fluxo só emite um único valor, é possível criá-lo diretamente da função getOrAwait usando asFlow.

// Create a flow that calls a single function
private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()

Esse código cria um novo Flow que chama getOrAwait e emite o resultado como seu primeiro e único valor. Isso é feito com a referência ao método getOrAwait usando :: e chamando asFlow no objeto Function resultante.

Esses dois fluxos fazem o mesma: chamam getOrAwait e emitem o resultado antes da conclusão.

Combinar vários fluxos de maneira declarativa

Agora que temos dois fluxos, customSortFlow e plantsFlow, vamos combiná-los de forma declarativa.

Adicione um operador combine a plantsFlow:

PlantRepository.kt

private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()

val plantsFlow: Flow<List<Plant>>
   get() = plantDao.getPlantsFlow()
       // When the result of customSortFlow is available,
       // this will combine it with the latest value from
       // the flow above.  Thus, as long as both `plants`
       // and `sortOrder` are have an initial value (their
       // flow has emitted at least one value), any change
       // to either `plants` or `sortOrder`  will call
       // `plants.applySort(sortOrder)`.
       .combine(customSortFlow) { plants, sortOrder ->
          plants.applySort(sortOrder)
       }

O operador combine combina dois fluxos. Ambos os fluxos serão executados na corrotina correspondente e, sempre que qualquer um produzir um novo valor, a transformação será chamada com o valor mais recente do fluxo.

Usando combine, podemos combinar a pesquisa da rede armazenada em cache com a consulta do banco de dados. Ambas serão executadas simultaneamente em corrotinas diferentes. Isso significa que, enquanto o Room inicia a solicitação de rede, a Retrofit pode iniciar a consulta da rede. Depois, assim que um resultado estiver disponível para os dois fluxos, ela chamará o lambda combine, em que aplicamos a ordem de classificação às plantas carregadas.

Para ver como o operador combine funciona, modifique customSortFlow para ser emitido duas vezes com um atraso substancial em onStart da seguinte forma:

// Create a flow that calls a single function
private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()
   .onStart {
       emit(listOf())
       delay(1500)
   }

A transformação onStart acontecerá quando um observador fizer a detecção antes de outros operadores e poderá emitir valores de marcador. Aqui, estamos emitindo uma lista vazia, atrasando a chamada de getOrAwait em 1.500 ms e mantendo o fluxo original. Se você executar o app agora, verá que a consulta do banco de dados do Room é retornada imediatamente, em combinação com a lista vazia (ou seja, ela será classificada em ordem alfabética). Cerca de 1.500 ms depois, ela aplica a classificação personalizada.

Antes de continuar com o codelab, remova a transformação onStart do customSortFlow.

Flow e segurança principal

O Flow pode chamar funções protegidas, como estamos fazemos aqui, mantendo as garantias normais de segurança principal das corrotinas. O Room e a Retrofit proporcionarão a segurança principal, e não precisamos fazer mais nada para fazer solicitações de rede ou consultas de banco de dados com o Flow.

Esse fluxo já usa as seguintes linhas de execução:

  • plantService.customPlantSortOrder é executada em uma linha de execução de Retrofit (ela chama Call.enqueue).
  • getPlantsFlow executará consultas em um Executor do Room.
  • applySort será executada no agente de coleta (neste caso, Dispatchers.Main).

Se tudo o que estávamos fazendo era chamar funções de suspensão na Retrofit e usar os fluxos do Room, não precisamos complicar esse código com preocupações de segurança principal.

No entanto, conforme nosso conjunto de dados cresce, a chamada para applySort pode ficar lenta o bastante para bloquear a linha de execução principal. O Flow oferece uma API declarativa chamada flowOn para controlar em qual linha de execução o fluxo é executado.

Adicione flowOn a plantsFlow desta forma:

PlantRepository.kt

private val customSortFlow = plantsListSortOrderCache::getOrAwait.asFlow()

val plantsFlow: Flow<List<Plant>>
   get() = plantDao.getPlantsFlow()
       .combine(customSortFlow) { plants, sortOrder ->
          plants.applySort(sortOrder)
       }
       .flowOn(defaultDispatcher)
       .conflate()

Chamar flowOn tem dois efeitos importantes sobre a execução do código:

  1. Inicia uma nova corrotina no defaultDispatcher (neste caso, Dispatchers.Default) para executar e coletar o fluxo antes da chamada para flowOn.
  2. Insere um buffer para enviar resultados da nova corrotina para chamadas posteriores.
  3. Emite os valores desse buffer para o Flow após flowOn. Neste caso, é asLiveData no ViewModel.

Isso é muito semelhante à maneira como withContext trabalha para alternar os agentes, mas introduz um buffer no meio das transformações que muda a forma como o fluxo funciona. A corrotina iniciada por flowOn pode produzir resultados mais rápidamente do que o autor da chamada as consome e armazenará em buffer um grande número deles por padrão.

Neste caso, como planejamos enviar os resultados para a IU, nossa preocupação seria apenas com o resultado mais recente. Isso é o que o operador conflate faz. Ele modifica o buffer de flowOn para armazenar apenas o último resultado. Se outro resultado aparecer antes que o anterior seja lido, ele será substituído.

Executar o app

Se você executar o app novamente, verá que agora está carregando os dados e aplicando a ordem de classificação personalizada usando o Flow. Como ainda não implementamos o switchMap, a opção de filtro não faz nada.

Na próxima etapa, veremos outra forma de fornecer a segurança principal usando o flow.

Para concluir a versão do fluxo dessa API, abra PlantListViewModel.kt, em que alternaremos entre os fluxos com base na GrowZone, como fazemos na versão da LiveData.

Adicione o seguinte código abaixo de plants liveData:

PlantListViewModel.kt

private val growZoneFlow = MutableStateFlow<GrowZone>(NoGrowZone)

val plantsUsingFlow: LiveData<List<Plant>> = growZoneFlow.flatMapLatest { growZone ->
        if (growZone == NoGrowZone) {
            plantRepository.plantsFlow
        } else {
            plantRepository.getPlantsWithGrowZoneFlow(growZone)
        }
    }.asLiveData()

Esse padrão mostra como integrar eventos (mudança da zona de crescimento) em um fluxo. Ele faz exatamente o mesmo que a versão LiveData.switchMap, alternando entre duas fontes de dados com base em um evento.

Percorrer o código

PlantListViewModel.kt

private val growZoneFlow = MutableStateFlow<GrowZone>(NoGrowZone)

Isso define um novo MutableStateFlow com um valor inicial de NoGrowZone. Esse é um tipo especial do detentor de valor do Flow que contém apenas o último valor fornecido. Como esse é um primitivo de simultaneidade seguro para linhas de execução, você pode gravar nele por várias linhas de execução ao mesmo tempo, e a que for considerada a "última" vencerá.

Você também pode se inscrever para receber atualizações do valor atual. Em geral, ele tem um comportamento semelhante ao da LiveData: apenas mantém o último valor e permite que você veja as mudanças.

PlantListViewModel.kt

val plantsUsingFlow: LiveData<List<Plant>> = growZoneFlow.flatMapLatest { growZone ->

StateFlow também é um Flow normal, portanto, você pode usar todos os operadores como faria normalmente.

Aqui, usamos o operador flatMapLatest, que é exatamente igual ao switchMap da LiveData. Sempre que o valor de growZone mudar, esse lambda será aplicado e retornará um Flow. Em seguida, o Flow retornado será usado como o Flow para todos os operadores downstream.

Basicamente, isso permite alternar diferentes fluxos com base no valor da growZone.

PlantListViewModel.kt

if (growZone == NoGrowZone) {
    plantRepository.plantsFlow
} else {
    plantRepository.getPlantsWithGrowZoneFlow(growZone)
}

Dentro do flatMapLatest, alternamos com base na growZone. Esse código é muito parecido com a versão LiveData.switchMap; a única diferença é que ele retorna Flows em vez de LiveDatas.

PlantListViewModel.kt

   }.asLiveData()

Por fim, convertemos o Flow em uma LiveData, já que o Fragment espera a exposição de uma LiveData do ViewModel.

Mudar um valor de StateFlow

Para informar o app sobre a mudança de filtro, podemos definir MutableStateFlow.value. Essa é uma maneira fácil de comunicar um evento em uma corrotina como estamos fazendo aqui.

PlantListViewModel.kt

fun setGrowZoneNumber(num: Int) {
    growZone.value = GrowZone(num)
    growZoneFlow.value = GrowZone(num)

    launchDataLoad {
        plantRepository.tryUpdateRecentPlantsForGrowZoneCache(GrowZone(num)) }
    }

fun clearGrowZoneNumber() {
    growZone.value = NoGrowZone
    growZoneFlow.value = NoGrowZone

    launchDataLoad {
        plantRepository.tryUpdateRecentPlantsCache()
    }
}

Executar o app novamente

Se você executar o app novamente, o filtro funcionará para a versão da LiveData e do Flow.

Na próxima etapa, aplicaremos a classificação personalizada a getPlantsWithGrowZoneFlow.

Um dos recursos mais interessantes do Flow é sua compatibilidade de primeira classe com funções de suspensão. O builder flow e quase todas as transformações expõem um operador suspend que pode chamar qualquer função de suspensão. Como resultado, a segurança principal para chamadas da rede e do banco de dados, bem como a orquestração de várias operações assíncronas, podem ser feitas usando chamadas para funções de suspensão normais de dentro de um fluxo.

Na verdade, isso permite que você combine transformações declarativas com código imperativo. Como você verá neste exemplo, dentro de um operador de mapa normal, é possível orquestrar várias operações assíncronas sem aplicar transformações extras. Em muitos locais, isso pode gerar um código muito mais simples que o de uma abordagem totalmente declarativa.

Usar funções de suspensão para orquestrar um trabalho assíncrono

Para encerrar nosso estudo do Flow, aplicaremos a classificação personalizada usando operadores de suspensão.

Abra PlantRepository.kt e adicione uma transformação de mapa a getPlantsWithGrowZoneNumberFlow.

PlantRepository.kt

fun getPlantsWithGrowZoneFlow(growZone: GrowZone): Flow<List<Plant>> {
   return plantDao.getPlantsWithGrowZoneNumberFlow(growZone.number)
       .map { plantList ->
           val sortOrderFromNetwork = plantsListSortOrderCache.getOrAwait()
           val nextValue = plantList.applyMainSafeSort(sortOrderFromNetwork)
           nextValue
       }
}

Usando funções de suspensão normais para processar o trabalho assíncrono, essa operação de mapa é protegida, embora combine duas operações assíncronas.

Conforme cada resultado do banco de dados for retornado, usaremos a ordem de classificação em cache. Se ela ainda não estiver pronta, ele aguardará na solicitação de rede assíncrona. Depois que tivermos a ordem de classificação, será seguro chamar applyMainSafeSort, que executará a classificação no agente padrão.

Ao adiar as preocupações de segurança para funções de suspensão normais, esse código agora ficou totalmente protegido. Isso é um pouco mais simples que a mesma transformação implementada em plantsFlow.

No entanto, lembre-se de que a execução será um pouco diferente. O valor em cache será buscado toda vez que o banco de dados emitir um novo valor. Isso não é um problema, porque ele está sendo armazenado em cache corretamente em plantsListSortOrderCache. Porém, se isso iniciasse uma nova solicitação de rede, essa implementação faria muitas solicitações de rede desnecessárias. Além disso, na versão .combine, a solicitação de rede e a consulta do banco de dados são executadas simultaneamente, enquanto nessa versão são executadas em sequência.

Devido a essas diferenças, não há uma regra clara para estruturar esse código. Em muitos casos, não há problema em usar transformações de suspensão como estamos fazendo aqui, o que torna todas as operações assíncronas sequenciais. No entanto, em outros casos, é melhor usar operadores para controlar a simultaneidade e fornecer segurança principal.

Falta pouco. Como uma última etapa (opcional), vamos mover as solicitações de rede para uma corrotina baseada em fluxo.

Ao fazer isso, removeremos a lógica para fazer as chamadas de rede dos gerenciadores chamados por onClick e as moveremos da growZone. Isso nos ajuda a criar uma única fonte de informações confiáveis e a evitar a duplicação de código. Não é possível alterar nenhum filtro sem atualizar o cache.

Abra PlantListViewModel.kt e adicione este código ao bloco init:

PlantListViewModel.kt

init {
   clearGrowZoneNumber()

   growZone.mapLatest { growZone ->
           _spinner.value = true
           if (growZone == NoGrowZone) {
               plantRepository.tryUpdateRecentPlantsCache()
           } else {
               plantRepository.tryUpdateRecentPlantsForGrowZoneCache(growZone)
           }
       }
       .onEach {  _spinner.value = false }
       .catch { throwable ->  _snackbar.value = throwable.message  }
       .launchIn(viewModelScope)
}

Esse código iniciará uma nova corrotina para observar os valores enviados ao growZoneChannel. Agora você pode comentar as chamadas de rede nos métodos abaixo, porque elas só são necessárias para a versão da LiveData.

PlantListViewModel.kt

fun setGrowZoneNumber(num: Int) {
    growZone.value = GrowZone(num)
    growZoneFlow.value = GrowZone(num)

    // launchDataLoad {
    //    plantRepository.tryUpdateRecentPlantsForGrowZoneCache(GrowZone(num))
    // }
}

fun clearGrowZoneNumber() {
    growZone.value = NoGrowZone
    growZoneFlow.value = NoGrowZone

    // launchDataLoad {
    //    plantRepository.tryUpdateRecentPlantsCache()
    // }
}

Executar o app novamente

Se você executar o app novamente, verá que a atualização da rede agora é controlada pela growZone. Melhoramos o código substancialmente, conforme mais maneiras de mudar o filtro surgem e o canal atua como uma única fonte de verdade para a qual o filtro está ativo. Dessa forma, a solicitação de rede e o filtro atual nunca ficam fora de sincronia.

Percorrer o código

Vamos analisar todas as novas funções usadas uma por vez, começando do lado de fora:

PlantListViewModel.kt

growZone
    // ...
    .launchIn(viewModelScope)

Desta vez, usamos o operador launchIn para coletar o fluxo dentro do nosso ViewModel.

O operador launchIn cria uma nova corrotina e coleta todos os valores do fluxo. Ele será lançado no CoroutineScope fornecido. Neste caso, o viewModelScope. Isso é ótimo porque significa que, quando o ViewModel for liberado, a coleta será cancelada.

Sem fornecer outros operadores, isso não fará muito. Porém, como Flow fornece lambdas em todos os seus operadores, é fácil criar ações assíncronas com base em todos os valores.

PlantListViewModel.kt

.mapLatest { growZone ->
    _spinner.value = true
    if (growZone == NoGrowZone) {
        plantRepository.tryUpdateRecentPlantsCache()
    } else {
        plantRepository.tryUpdateRecentPlantsForGrowZoneCache(growZone)
    }
}

É aqui que a mágica acontece: a mapLatest aplicará essa função de mapa para cada valor. No entanto, ao contrário da map normal, ela iniciará uma nova corrotina para cada chamada à transformação do mapa. Se um novo valor for emitido pelo growZoneChannel antes da conclusão da corrotina anterior, ele será cancelado antes do início de uma nova.

Podemos usar mapLatest para controlar a simultaneidade. Em vez de criarmos a lógica de cancelamento/reinicialização por conta própria, a transformação do fluxo cuida disso. Assim, há muito menos código e complexidade em comparação com a criação manual da mesma lógica de cancelamento.

O cancelamento de um Flow segue as regras normais de cancelamento cooperativo (link em inglês) de corrotinas.

PlantListViewModel.kt

.onEach {  _spinner.value = false }
.catch { throwable -> _snackbar.value = throwable.message }

onEach será chamada sempre que o fluxo acima emitir um valor. Ela é usada aqui para redefinir o ícone de carregamento após a conclusão do processamento.

O operador catch capturará todas as exceções geradas acima dele no fluxo. Ele pode emitir um novo valor para o fluxo como um estado de erro, gerar a exceção novamente no fluxo ou executar o trabalho como estamos fazendo aqui.

Quando há um problema, dizemos ao nosso _snackbar para exibir a mensagem de erro.

Conclusão

Esta etapa mostrou como controlar a simultaneidade usando o Flow, além de consumir Flows em um ViewModel sem depender de um observador da IU.

Como uma etapa de desafio, tente definir uma função para encapsular o carregamento de dados desse fluxo com a seguinte assinatura:

fun <T> loadDataFor(source: StateFlow<T>, block: suspend (T) -> Unit) {