Como parte de nosso trabalho para modernizar as orientações sobre arquitetura de apps, queremos experimentar diferentes padrões de IU para ver o que funciona melhor, encontrar semelhanças e diferenças entre as alternativas e consolidar nossas descobertas como práticas recomendadas.

Para facilitar ao máximo a aplicação de nossas descobertas, precisávamos de uma amostra com um caso de negócios familiar e que não fosse muito complicada. E quem é que não conhece os apps TODO ? Escolhemos o Architecture Blueprints! O Blueprints sempre serviu como um playground experimental para escolhas de arquitetura. E ele é ótimo para isso!

App Architecture Blueprints em ação

Os padrões com os quais queremos fazer experiências são claramente afetados pelas diferentes APIs disponíveis atualmente. E a novidade são as APIs de estado do Jetpack Compose! Como o Compose funciona perfeitamente com qualquer padrão de fluxo de dados unidirecional , vamos utilizá-lo para renderizar a IU e fazer uma comparação justa.

Esta postagem do blog conta como a equipe migrou o Architecture Blueprints para o Jetpack Compose. Como o LiveData também é considerado uma alternativa em nossos experimentos, deixamos a amostra no estado em que se encontrava no momento da migração. Nessa refatoração, as classes ViewModel e a camada de dados permaneceram inalteradas.

⚠️ A arquitetura usada nesta base de código baseada no LiveData não segue totalmente as práticas recomendadas de arquitetura mais recentes. Em particular, o LiveData não deve ser usado nas camadas de dados ou de domínios. Devem-se usar fluxos e corrotinas.

Agora que o contexto já ficou claro, vamos detalhar como abordamos a refatoração do Blueprints para o Jetpack Compose. Confira o código completo na ramificação dev-compose.

✍️ Planejamento de uma migração gradual

Antes de realizar qualquer codificação real, a equipe criou um plano de migração para garantir que todos concordassem com as mudanças propostas. O objetivo final era que o Blueprints fosse um aplicativo de atividade única, com telas como funções que podem ser compostas e usando a biblioteca Compose Navigation recomendada para a movimentação entre as telas.

Felizmente, o Blueprints já era um app de atividade única que usava o Jetpack Navigation para a movimentação entre diferentes telas implementadas com fragmentos. Para migrar para o Compose, seguimos as orientações de interoperabilidade do Navigation, que recomendam que os apps híbridos usem o componente Navigation baseado em fragmentos para conter telas baseadas em visualização, telas do Compose e telas que usam tanto as visualizações quanto o Compose. Infelizmente, não é possível combinar os destinos dos fragmentos e do Compose no mesmo gráfico do Navigation.

O objetivo de uma migração gradual é facilitar as revisões de código e manter um produto em condições de entrega ao longo de toda a migração. O plano de migração envolvia três passos:

  1. Migrar o conteúdo de cada tela para o Compose. Cada tela seria migrada individualmente para o Compose, incluindo seus testes de IU. Depois, os fragmentos se tornariam o contêiner/host de cada tela migrada.
  2. Migrar o app para o Navigation Compose, o que remove todos os fragmentos do projeto, e migrar a lógica da IU Activity para funções-raiz que podem ser compostas. Os testes completos também são migrados neste ponto.
  3. Remover as dependências do sistema de visualização.

E foi isso o que fizemos! 🧑‍💻 Depois ⏩ de duas semanas, migramos as telas Statistics (PR), Add/Edit task (PR), Task detail (PR) e Tasks (PR) e mesclamos a PR final que migrou a lógica Activity e Navigation para o Compose, incluindo a remoção das dependências do sistema de visualização não utilizadas.

Como ocorreu a migração gradual do Blueprints para o Compose

💡 Destaques da migração

Durante a migração, encontramos algumas peculiaridades específicas do Compose que valem destaque:

🧪 Testes de IU

Assim que você começa a adicionar o Compose ao app, os testes que declaram IUs do Compose precisam usar APIs de teste do Compose.

Para os testes de IU no nível de tela, em vez de usar a API launchFragmentInContainer<FragmentType>, usamos a API createAndroidComposeRule<ComponentActivity>, que permite capturar recursos de strings nos testes. Esses testes são executados no Espresso e no Robolectric. Como o Compose já tem suporte para tudo isso, nenhuma mudança adicional foi necessária. Por exemplo, é possível comparar o código em AddEditTaskFragmentTest que foi migrado para AddEditTaskScreenTest. Observe que, se você usar o ComponentActivity, precisará contar com o artefato androidx.compose.ui:ui-test-manifest.

Nos testes completos ou de integração, também não tivemos nenhum problema. Graças à interoperabilidade do Espresso e do Compose, usamos as declarações do Espresso para verificar as visualizações e as APIs do Compose para verificar a IU do Compose. Aqui, podemos ver a aparência de AppNavigationTest em um ponto durante a migração para o Compose.

🤙 Eventos do ViewModel

Tivemos problemas com o modo como os eventos do ViewModel eram manipulados no Blueprints. O Blueprints implementava uma solução de wrapper de evento para enviar comandos do ViewModel para a IU. No entanto, isso não funciona no Compose. Nossas orientações recentes recomendam a modelagem desses "eventos" como estado, e foi isso o que fizemos durante a migração.

Se observarmos o caso de uso do evento de exibição de mensagens na tela , substituímos o tipo Event<Int>do LiveData por "Int?". Isso também modela o cenário no qual não há mensagens a serem exibidas para o usuário. Nesse caso de uso em particular, o ViewModel também exige uma confirmação da IU sempre que a mensagem é exibida. Veja a diferença entre as duas implementações no seguinte código:

Embora isso possa parecer um trabalho adicional, à primeira vista, é uma garantia de que a mensagem seja exibida na tela.

No código da IU, a forma de assegurar que o evento seja manipulado apenas uma vez é fazer uma chamada para event.getContentIfNotHandled(). Essa abordagem funciona mais ou menos nos fragmentos, mas falha totalmente no Compose. Como as recomposições podem ocorrer a qualquer momento no Compose, o wrapper de evento não é uma solução válida. Se o evento for processado e a função for recomposta (algo que aconteceu bastante durante o teste dessa abordagem), o snackbar será cancelado, e o usuário poderá perder a mensagem. Esse é um problema de UX inaceitável! A solução wrapper de evento não deve ser usada em apps do Compose.

Veja o snippet de código a seguir com o antes (wrapper de evento) e o depois (evento como estado) do código. Como a exibição de mensagens na tela envolve a lógica de IU e as funções de tela que podem ser compostas estavam se tornando mais complexas, usamos uma classe detentora de estado simples para gerenciar essa complexidade (veja, por exemplo, AddEditTaskState).

👌 Na dúvida, escolha a precisão do app

Durante a refatoração, pode ser tentador migrar tudo para o Compose. Embora não haja nenhum problema nisso, você não deve sacrificar a experiência do usuário nem a precisão do app. A finalidade de fazer uma migração gradual é que o app esteja sempre em condições de entrega.

Isso aconteceu conosco durante a migração de algumas telas para o Compose. Não queríamos fazer um monte de migrações ao mesmo tempo, então migramos algumas das telas para o Compose antes da migração do wrapper de evento. Em vez de manipular o wrapper de evento no Compose e entregar uma experiência insatisfatória, continuamos manipulando essas mensagens no fragmento, enquanto o restante do código da tela estava no Compose. Veja, por exemplo, o estado de TasksFragment durante a migração.

🧐 Desafios

Nem tudo correu tão bem quanto parecia. 🫤 Embora a conversão de conteúdo de fragmentos para o Compose seja direta, a migração dos fragmentos do Navigation para o Navigation Compose exigiu um pouco mais de tempo e raciocínio.

É necessário expandir e melhorar as orientações quanto a diferentes aspectos que tornarão a migração para o Compose mais simples no futuro. Esse trabalho provocou várias conversas, e esperamos ter novas orientações sobre isso em breve! 🎊

Por ser iniciante em Navigation ✋ e a pessoa que lidou com a migração para o Navigation Compose, enfrentei os seguintes desafios:

  • Nenhum código na documentação mostrava como navegar com argumentos opcionais! Graças ao gráfico de navegação do Tivi, consegui me orientar e resolvi o problema (siga o problema registrado para a melhoria da documentação aqui).
  • A migração de um gráfico de navegação baseado em XML e de SafeArgs para o Kotlin DSL deveria ser uma tarefa mecânica e direta. Mas não foi tão fácil para mim, considerando-se que não trabalhei na implementação original. Ter um pouco de orientação sobre como fazer isso corretamente teria me ajudado bastante (siga o problema registrado para a melhoria da documentação aqui).
  • Mais do que um desafio, este é um ponto do tipo "te peguei!". A IU do Navigation já faz algumas coisas por você quando se trata da navegação. Como isso não existe no Compose, você precisa ficar de olho e fazer tudo manualmente. Por exemplo, manter limpa a pilha de retorno ao navegar entre as telas da gaveta requer NavigationOptions especiais (veja um exemplo aqui). Isso já é coberto pela documentação, mas primeiro você tem que estar ciente de que precisa disso!

🧑‍🏫 Conclusões

Em geral, a migração de fragmentos do Navigation para o Navigation Compose foi bem divertida! O mais engraçado é que gastamos mais tempo aguardando revisões de pares do que fazendo a migração do projeto em si! Criar o plano de migração e sincronizar todo mundo com certeza ajudou a definir as expectativas logo no começo e alertar os pares sobre futuras revisões demoradas.

Esperamos que você tenha gostado de ler sobre nossa abordagem da migração para o Compose, e queremos compartilhar em breve mais informações sobre os experimentos e as melhorias que faremos no Architecture Blueprints.

Caso tenha interesse em ver o Blueprints com o código do Compose, confira a ramificação dev-compose. E, caso deseje ver todas as PRs da migração gradual, esta é a lista:

👋