Fazendo o fluxo de dados unidirecional do Android com Kotlin coroutines

Tempo de leitura: 6 minutes

Anos atrás, compartilhei algumas ideias sobre injeção de dependência. Hoje eu gostaria de compartilhar algumas idéias sobre como estruturar o desenvolvimento em torno do ViewModel. Como podemos configurá-lo como Estados e Eventos? Com a ajuda de coroutines Kotlin, Mockk ou mesmo Arrow-kt.io?

 

TL; DR: tudo isso foi empacotado em uma pequena biblioteca: Uniflow

 

Em 2017, o Google lançou os componentes de arquitetura do Android, oferecendo suporte real para que os desenvolvedores criem seus aplicativos. Desde o primeiro lançamento, estou trabalhando fortemente com esses componentes para muitas empresas. Também tem sido uma boa oportunidade para dar workshops para várias conferências na Europa desde 2018.

Enquanto a conhecida arquitetura MVP mantém as coisas em um contrato 1–1, vinculando a View e seu controlador por um contrato, a abordagem da arquitetura MVVM oferece uma maneira de observar seu controlador e obter resultados como fluxos de dados. Mas então, em algum lugar, estamos perdendo uma espécie de “contrato formal” entre View e ViewModel. Nada força você a estruturar seu ViewModel desta ou daquela maneira. Estamos apenas “empurrando atualizações” para as visualizações.

Mesmo que esteja cobrindo o estilo “pré-Android Architecture Components” com o RxJava encanador, a ideia principal permanece a mesma: o ViewModel deve publicar principalmente dados como estados, em vez de espalhar coisas entre diferentes fluxos de eventos. Por quê? Precisamos garantir que, com o tempo, nossos dados permaneçam consistentes para a visualização.

Vários fluxos de dados para definir os dados de exibição?

 

 

Um fluxo de dados, para definir o estado de exibição

A testabilidade decorrente desse tipo de abordagem é realmente incrível. Como seu ViewModel tem um estado por vez, o teste é apenas uma questão de testar a sequência de estados! Ainda mais, agora você pode reproduzir qualquer estado e, em seguida, maquiar facilmente qualquer cenário!

ViewModel lida com todas as abstrações lógicas para sua View. A View está aqui apenas para vincular ações e renderizar estados e eventos. Isso te faz pensar em outra coisa? React unidirectional data flow!

Fluxo de dados unidirecional – https://flaviocopes.com/react-unidirectional-data-flow/

Fluxo de dados unidirecional – https://flaviocopes.com/react-unidirectional-data-flow/

… Mas desculpe, muitas aulas para mim 😱😭! Eu gostaria de algo que pudéssemos escrever em apenas um monte de linhas e manter minha classe ViewModel. Como poderíamos fazer isso?

 

ViewModel, Actions & States

Vamos formalizar um pouco todas essas coisas com algumas definições:

  • Um estado define um conjunto de dados, que serão expostos pelo ViewModel de uma só vez
  • Uma ação é uma função oferecida pelo ViewModel, que permite criar um novo estado de ViewModel
  • ViewModel expõe ações e é o único componente com permissão para alterar seu estado

A View observa um fluxo de estados (classe de dados, classe lacrada herdada de UIState).

Esses estados potenciais representam o contrato que será usado pela View. Um ViewModel expõe Actions para a visualização e oferece resultados como fluxos de estados.

 

Vamos pegar um exemplo de aplicativo

Vamos ver um caso de uso (algumas pessoas reconhecem a captura de tela abaixo de meus workshops). Vamos nos concentrar na última tela: ela exibe o tempo de um dia.

Vamos escrever os estados (nosso contrato) que queremos expor à nossa Visão:

  • Um estado init (onde ainda não temos dados)
  • Um estado de “clima” (para nossos dados principais)
  • Um estado de falha, caso algo dê errado
sealed class WeatherViewState : UIState(){
  object Init : WeatherViewState()
  data class Weather(val day : String, val temperature : String) : WeatherViewState()
  data class Failed(val error : Exception) : WeatherViewState()
}

Agora vamos criar uma classe que estende AndroidDataFlow e vamos definir a ação getWeatherOfTheDay para enviar alguns estados:

class WeatherDataFlow(val repo : WeatherRepository) : AndroidDataFlow() {

    init {
        // definir estado inicial
        setState { WeatherViewState.Init }
    }
    
    // Nossa ação aqui
    fun getWeatherOfTheDay(day : String) = setState {
        // chamada em segundo plano para obter dados meteorológicos para o dia
        val weather = repo.getWeather(day).await()
        // cria um estado para renderizar para a IU
        WeatherViewState.Weather(weather.day, weather.temperature)
    }
}

O getWeatherOfTheDay recuperará a previsão do tempo para um determinado dia (em String) e enviará uma atualização do Weatherstate para a visualização.

Cada estado é um dado Kotlin imutável (classe de dados ou objeto), que é emitido apenas pelo ViewModel.

O View apenas chama as ações ViewModel e vincula o resultado observado para a renderização da IU. A função onStates da Activity permite observar os estados de entrada. Aqui, estamos ligando diretamente “à mão”.

class WeatherActivity : AppCompatActivity {
  
  // ViewModel criado com Koin, por exemplo :)
  val weatherFlow : WeatherDataFlow by viewModel()

  override fun onCreate(savedInstanceState: Bundle?) {		
      // Observe os estados de entrada
      onStates(weatherFlow) { state ->
          when (state) {
              // react em cada atualização
              is WeatherViewState.Init -> showEmptyView()
              is WeatherViewState.Weather -> showWeather(state)
              is WeatherViewState.Failed -> showError(state.error)
          }
      }
  }
}

Finalmente, você pode testar seu fluxo facilmente com o Mockk:

@Test
fun `has some weather`() {
    // preparar dados de teste
    val day = "Friday"
    val weatherData = WeatherData(...)
    
    // mock coroutine call
    coEvery { mockedRepo.getWeather(day) } return weatherData

    dataFlow.getWeatherOfTheDay(day)
        
    // verificar a sequência de estados
    verifySequence {
    		view.hasState(WeatherViewState.Init)
        view.hasState(WeatherViewState.Weather(weatherData.day, weatherData.temperature))
    }
}

“Nosso sistema tem estados de dados previsíveis ao longo do tempo! E mesmo se você tiver várias ações acionadas em paralelo, garantimos que temos apenas um estado de cada vez.”

Seu aplicativo é mais fácil de depurar: cada atualização de estado é rastreada pelo Uniflow. Este registro de estado pode ser facilmente redirecionado para qualquer sistema de registro (Crashlytics…). Finalmente, é bastante fácil entender o que está acontecendo na produção e repetir qualquer situação de bug.

O Uniflow nos permite fazer uma guarda para um determinado estado: ele garante que desencadearemos uma ação apenas se estivermos em um determinado estado, e então teremos um fluxo consistente de estados.

Às vezes, você não deseja enviar apenas novas atualizações de IU. Nesse caso, usaremos eventos para desencadear “efeitos colaterais”. Seu uso é muito semelhante aos estados, verifique a documentação.

 

Pronto para Kotlin Coroutines

Como você deve ter visto, podemos escrever Kotlin coroutines diretamente em qualquer bloco de código de ação. Basta usar setState ou qualquer outro construtor de ação, ele permitirá que você execute qualquer código de co-rotina por padrão no IO Dispatcher.

fun getWeatherOfTheDay(day : String) = setState {
     // chamada em segundo plano para obter dados meteorológicos para o dia
     val weather = repo.getWeather(day).await()
     // cria um estado para renderizar para a IU
     WeatherViewState.Weather(weather.day, weather.temperature)
 }

Uma função de ação precisa apenas retornar um objeto UIState, ele será enviado para você no thread principal com LiveData.

Ótimo! Mas … qualquer pessoa que já trabalhou com Kotlin coroutines sabe que é preciso lidar com os erros com cuidado. Por quê? Porque as corrotinas dependem de exceções, e então você tem que usar o antigo bloco “try/catch” para detectar qualquer problema ao redor.

Uma maneira, oferecida pelo Uniflow, é fazer este bloco “try/catch” para você, oferecendo uma função lambda de fallback, para permitir que você controle sua ação em caso de erro. Aqui abaixo, setStateallow uma segunda expressão lambda para detectar qualquer erro:

fun getWeatherOfTheDay(day : String) = setState ({
      // chamada em segundo plano para obter dados meteorológicos para o dia
      val weather = repo.getWeather(day).await()
      // cria um estado para renderizar para a IU
      WeatherViewState.Weather(weather.day, weather.temperature)
  },
  // Erro obtido
  { error -> WeatherViewState.Failed(error = error) })

Fluxo mais seguro com programação funcional

Você pode perfeitamente escrever código imperativo e usar o tratamento de erros oferecido pelo Uniflow para escrever suas ações. Mas você pode ter a necessidade de controlar o fluxo de seu código com mais precisão, sem usar try/catch em blocos específicos.

A outra maneira de lidar com expressões que podem levar a erros (também conhecidos como “efeitos colaterais”), é introduzir algumas vantagens funcionais com o Arrow-kt.io. Usamos aqui safeCall para envolver uma expressão arriscada com o tipo Try:

safeCall { weatherDatasource.geocode(targetLocation).await() }
        .map { it.mapToLocation() }
        .flatMap { (lat, lng) -> safeCall { weatherDatasource.weather(lat, lng).await() } }
        .map { it.mapToWeatherEntities() }
        .onSuccess { weatherCache.save(it) }

A partir de qualquer expressão funcional criada, você pode renderizar estados de sucesso e falha facilmente com toState:

fun getWeatherOfTheDay(day : String) = setState {
      // chamada em segundo plano para obter dados meteorológicos para o dia
      safeCall { repo.getWeather(day).await() }
        // render para o estado de sucesso
        .toState { weather -> WeatherViewState.Weather(weather.day, weather.temperature) }
  }, 
  { error -> WeatherViewState.Failed(error = error) })

Uniflow agrupa todas essas ideias em uma pequena biblioteca Kotlin/Android. Eu o uso diariamente com minhas equipes. Estamos usando em produção há alguns meses.

Verifique o projeto Uniflow GitHub para configurar com o Gradle. É apenas uma linha para adicionar às suas dependências:

// Jcenter()// Core
implementation 'io.uniflow:uniflow-core:$version'
testImplementation 'io.uniflow:uniflow-test:$version'

// Android
implementation 'io.uniflow:uniflow-android:$version'
testImplementation 'io.uniflow:uniflow-android-test:$version'

// AndroidX
implementation 'io.uniflow:uniflow-androidx:$version'
testImplementation 'io.uniflow:uniflow-androidx-test:$version'

Vou publicar um exemplo de aplicativo mais completo para ilustrar mais sobre isso. podemos imaginar ainda mais integração com Arrow-kt.io ou integração direta com futuros componentes do Android Compose. ✨

Espero que você aprecie esse monte de ideias! 🙂 Não hesite em dar algum feedback