Arquitetura Limpa em Android — De um jeito “unicórnio”

Márcio Oliveira
15 min readJan 29, 2020

--

Como esse é o meu primeiro artigo como desenvolvedor, escolhi um assunto que gostaria muito de falar, já que é considerado um mito entre muitos desenvolvedores.

Antes disso, gostaria de rapidamente me apresentar. Meu nome é Márcio e sou um profissional de TI que já usou diferentes chapéus ao longo dos 20 anos da minha carreira (de técnico a negócio) e fui recentemente contratado pela Talkdesk para trabalhar como desenvolvedor Android e retornar às minhas origens.

Desde o primeiro dia fui capaz de interagir com um dos mais incríveis e talentosos times que já tive a oportunidade de trabalhar, onde pude não só evoluir como desenvolvedor Android mas também aprender as boas práticas de engenharia que podem ser aplicadas em qualquer stack de desenvolvimento.

Portanto, o objetivo deste artigo é mostrar um pouco do que aprendi da nossa arquitetura Android, que foi baseada na Arquitetura Limpa (Clean Architecture) do Robert Martin (“Uncle” Bob), e como podemos construir apps escaláveis e com cobertura de testes.

No final, mostrarei um app simples descrevendo os detalhes da arquitetura e estratégia de testes.

Arquitetura Limpa

Não entrarei em detalhes sobre este assunto, portanto recomendo fortemente os excelentes artigos e palestras do site oficial bem como o livro oficial, mas é importante mencionar os conceitos chave para que eu possa traçar um paralelo à nossa versão na Talkdesk.

O objetivo da Arquitetura Limpa é a separação de responsabilidades. Essas são as camadas propostas e o famoso diagrama que mostra o fluxo esperado de controle:

  1. Entidades — Descrevem as regras de negócio da empresa ou as regras de negócio do app, encapsulando a maioria das regras gerais e de alto nível. Elas são as que têm menos chances de mudarem quando ocorrem mudanças nas outras camadas externas.
  2. Casos de Uso — Regras de negócio específicas do app. Elas encapsulam e implementam todos os casos de uso do sistema e orquestram o fluxo de dados que vem e vai para as entidades. Alterações nessa camada não deveriam afetar as entidades, e alterações mais externas, como em banco de dados, UI, etc., não deveriam afetar esta camada.
  3. Adaptadores de Interface — Conjunto de adaptadores que convertem dados do formato mais conveniente fornecido pelas entidades externas como banco de dados e Web para o formato mais conveniente para os casos de uso e entidades. Da mesma forma, o dado é convertido nesta camada do formato mais conveniente para as entidades e casos de uso para o formato mais conveniente para qualquer que seja o framework de persistência escolhido (ex: banco de dados).
  4. Frameworks e Drivers — Camada mais externa que é geralmente composta de frameworks e ferramentas como banco de dados, UI, serviços Web, etc., e deve conter somente o “código chiclete” ou “boilerplate” para se comunicar com a camada interna imediata.

De cara podemos nos assustar com tantas abstrações, mas a longo prazo há uma série de vantagens:

  • Separação do código em camadas diferentes com responsabilidades próprias facilitando modificações futuras.
  • Baixo acoplamento no código.
  • Criação de código de testes é indolor.

“Arquitetura Limpa” em Android

O desenvolvimento em Android tem um histórico de problemas que tornam a criação de bons apps um grande desafio:

  • O ciclo de vida das Activities é complexo e muitos apps ainda dependem dele.
  • Implementações ruins de multithreading podem facilmente causar vazamentos de memória (“memory leaks”).
  • O framework nativo fornece “classes deusas” (god classes) (ex: Activity, View, etc.) que tornam os testes unitários nas mesmas quase impossíveis de serem feitos.
  • Os treinamentos oficiais da Google continuam encorajando os novos desenvolvedores a criarem ainda mais “classes deusas” e colocar a maior parte da lógica de negócio dentro de Activities (isso não parece um círculo vicioso?).

Por um bom tempo, a Google se absteve de sugerir uma arquitetura recomendada para o desenvolvimento em Android. Não estou brincando, este é um artigo deles lançado apenas em Novembro de 2019 com orientações razoáveis de injeção de dependência (bem vindo aos anos 90!!!).

Foi assim que a Arquitetura Limpa caiu como uma luva aqui na Talkdesk. Nós fomos capazes de criar algumas regras e padrões em Android para minimizar esses problemas com o framework adaptando os conceitos mencionados mais cedo numa arquitetura de Apresentação, Domínio e Dados:

As regras e padrões:

  • O Multithreading é abstraído pelas classes de domínio. As classes de apresentação não têm nenhum conhecimento sobre threads.
  • As classes do domínio estão associadas com o ciclo de vida da aplicação (Application) e não das Activities.
  • As classes de domínio devem se comunicar por um mecanismo de ação/reação. Por exemplo, para cada ação da apresentação para o domínio deve haver uma reação de volta do domínio para a apresentação.
  • As classes de domínio funcionam como máquinas de estados (mais detalhes abaixo). Qualquer ação no domínio provoca uma mudança de estado na máquina que irá provocar reações subsequentes. Mudanças no ciclo de vida das Activities não devem provocar ações no domínio. Ações no domínio são provocadas apenas pela apresentação (iterações do usuário) ou pelas camadas de dados (notificações push, rotações do aparelho, eventos do S.O., etc.).

Vamos explorar as camadas com mais detalhes para termos uma visão melhor dos limites da nossa arquitetura:

Domínio

O domínio contém classes que estão relacionadas apenas com os casos de uso, não havendo nenhuma relação com UI ou com a camada de dados. Essas classes se baseiam apenas nas interfaces (contratos) usando injeção de dependência. Essa é a razão de termos um isolamento completo nos testes e a possibilidade de migrarmos as funcionalidades centrais para outras plataformas.

Em relação ao domínio temos:

  • Modelos — Classes de dados (POJO’s) que guardam o estado atual de certas partes de negócio do app e respondem às perguntas relacionadas aos seus próprios dados. Por exemplo, num app de aluguel de bicicletas, teríamos uma entidade para representar as bicicletas e guardar informações das suas marcas, bateria disponível, se estão em uso, etc. Também podemos pensar em entidades para guardar dados do usuário, dados dos aluguéis, etc. Toda essa informação seria processada pelos próprios modelos para que os consumidores não precisassem inferir nenhuma lógica.
  • Gerenciadores (Managers) — Classes que implementam um framework de máquina de estados (mais detalhes abaixo) e estão dedicadas em grande parte em gerir as mudanças de estado de seus próprios modelos e comunicar tais mudanças. Os gerenciadores também implementam os métodos dos casos de uso relacionados aos seus modelos e enviam ou solicitam dados à camada de dados para emitir os modelos atualizados. Por exemplo, e ainda usando a idéia de aluguel de bicicletas, um gerenciador de bicicletas teria casos de uso como “destravar a bicicleta”, “recarregar a bicicleta”, etc., e a maioria desses casos de uso seriam requisitados pela camada de apresentação (quando os usuários fizessem ações explícitas na UI). Em certas circunstâncias o gerenciador também pode reagir às mudanças de estado de outros gerenciadores e invocar seus próprios casos de uso para enviar modelos atualizados para os consumidores interessados. Por exemplo, quando um usuário iniciar um aluguel, o gerenciador de aluguéis irá emitir um novo estado do aluguel indicando que o usuário está com um aluguel ativo e, consequentemente, essa mudança irá fazer com o que o gerenciador de bicicletas reaja e atualize o seu próprio modelo de bicicletas onde a bicicleta em questão constaria como indisponível para os consumidores interessados nesta informação.

Framework de Máquina de Estados

Conforme mencionei antes, gerenciadores implementam um framework de máquina de estados que contextualizam as mudanças nos modelos para os consumidores interessados. Por exemplo, quando um caso de uso solicita que um gerenciador faça uma requisição de dados para um serviço HTTP, essa operação pode levar algum tempo para terminar. Então é esperado que o gerenciador emita um estado de “processando” ou “loading” assim que o caso de uso é iniciado para que os possíveis apresentadores (presenters) que estão observando esse gerenciador possam exibir um indicador de progresso na UI. Quando o dado finalmente é obtido, o gerenciador emite um novo estado de “carregado” ou “loaded” com o modelo atualizado e os mesmos apresentadores irão agora atualizar a UI com a informação apropriada. E o mesmo vale para o caso de falha na obtenção dos dados onde o gerenciador emite um estado de erro e os apresentadores exibem uma mensagem de erro apropriada para os usuários.

Essa lógica também se aplica para outros gerenciadores que estão observando um gerenciador específico, que podem realizar ações apropriadas conforme o estado e modelo emitido por esse gerenciador. Por exemplo, quando um gerenciador de autorização emite um estado indicando que o usuário fez logout, é esperado que os outros gerenciadores solicitem as “limpezas” adequadas na camada de dados: cancelar tokens do usuário associados a serviços registrados, limpar arquivos temporários no dispositivo, etc.

Esse é um diagrama completo do framework de máquina de estados:

Os gerenciadores implementam a classe abstrata StateMachine que deve estar associada com uma classe de modelo. Com isso eles herdam as seguintes funcionalidades de uma máquina de estados:

  • Realizar a configuração de um estado inicial.
  • Cadastrar/descadastrar observadores interessados.
  • Mudar o estado interno para Loading, Loaded, Error, etc., comunicando cada mudança para seus observadores.

OBS: Se você estiver se perguntando porque os gerenciadores não atualizam os modelos diretamente, uma vez que os dados estão guardados em objetos POJO’s, a resposta é que isso é uma boa prática da programação funcional que nós reforçamos em nossa arquitetura. Recomendo a leitura deste artigo para um melhor entendimento dessa decisão.

Também há essa postagem do Robert Martin (“Uncle Bob”) em seu Twitter:

“É perfeitamente possível escrever um programa que seja tanto orientado a objetos quanto funcional. Não só é possível, é desejável.”

Dito isso, nós temos também o objeto State que é um recipiente que contém o nome do estado, o modelo atualizado e o erro (se for o caso).

Finalmente, temos os observadores interessados nas mudanças de estado que implementam a interface StateListener associada a um modelo e recebem de forma assíncrona os estados atualizados e tomam as ações necessárias. Dentre os observadores mais comuns podemos citar os apresentadores e gerenciadores.

Dados

Essa é a camada responsável por obter dados brutos de várias fontes como endpoints de API’s, bancos de dados, sensores do aparelho, etc. Ela converte esses dados para modelos adequados que serão entregues aos gerenciadores. Por outro lado, a camada de dados também deve ser responsável por gerar o formato adequado que será enviado de volta para as mesmas fontes quando necessário.

A camada de dados oferece interfaces (contratos) que abstraem toda essa lógica de manipulação dos dados e implementações técnicas, portanto os gerenciadores irão confiar apenas nos métodos expostos ignorando os detalhes por trás destes contratos. Isso simplifica a construção dos testes unitários em classes que dependem da camada de dados porque sempre poderemos implementar test doubles que usam os mesmos contratos para gerar respostas pré-programadas. Já na camada de domínio não temos nenhum impacto quando ocorrem mudanças na implementação da camada de dados (por exemplo, quando substituímos um endpoint de API ou o banco de dados do app) desde que os contratos continuem sendo respeitados.

Apresentação

Essa é a camada onde os usuários podem interagir com o app e nós ainda podemos abstrair algumas partes para deixá-la agnóstica de tecnologia e facilitar a construção de testes. Para isso, usamos o padrão Model-View-Presenter (MVP).

  • Apresentadores — São os componentes que observam alterações no estado do modelo e implementam a lógica para apresentar dados independente da implementação técnica da plataforma. Por exemplo, apresentadores não sabem nada sobre Activities, Fragments, etc., então eles se baseiam nas interfaces das visualizações (views) (ocultar um botão, exibir uma mensagem de erro, montar uma lista de dados, etc.). Isso também facilita os testes na apresentação ao criarmos “doubles” das visualizações usando os contratos definidos e verificando se os métodos corretos estão sendo executados conforme as mudanças de estado no domínio. Apresentadores também são responsáveis por criar os ViewModels necessários para as visualizações usando como base o modelo recebido do domínio (veja mais detalhes abaixo).
  • ViewModels — classes de dados simples (POJO’s) que guardam apenas os dados específicos para aquela visualização (não confunda com os ViewModels da biblioteca Android Jetpack). Por exemplo, uma lista de contatos que apenas exibe os nomes e números telefônicos em cada item da lista devem ter apenas esses dados na classe. ViewModels também podem conter alguma lógica específica de apresentação. Um exemplo é quando um contato tem apenas o número de telefone disponível (não temos o seu nome). Talvez faça sentido usar o número telefônico para ser exibido na lista como o nome do contato, portanto o ViewModel pode conter um atributo computado apenas para fazer esta lógica e a visualização apenas irá consultar esse atributo, em vez dos atributos de nome e número diretamente, quando for exibir os nomes dos contatos na lista. Novamente, isso aumenta a cobertura de lógica de apresentação que pode ser testada facilmente com testes unitários em vez dos pesados testes de UI.
  • Visualizações — Aqui estão os componentes específicos de Android para exibir dados para os usuários: Activities, Fragments, List Adapters, etc. Como você já deve imaginar, pelo que vimos até agora, as visualizações devem ser as mais “burras” possíveis, apenas expondo os métodos para exibir ou ocultar elementos e aguardando os apresentadores invocarem tais métodos, sem manter nenhuma lógica específica. Além disso, as visualizações também podem invocar métodos do gerenciador diretamente ou usar os apresentadores para isso (quando for conveniente tratar alguma lógica de UI antecipadamente como campos em branco num formulário).

Com essa estratégia podemos exaurir os casos extremos (edge cases) de apresentação com testes unitários mais rápidos e deixar os casos de uso principais para os testes de integração e de componentes em frameworks mais pesados como o Espresso para verificar se a apresentação funciona corretamente num aparelho real ou emulador.

Navegação

Dada a natureza do projeto, onde as máquinas de estado reagem de forma assíncrona à camada de dados e outras máquinas de estado, a navegação do app fica sob a responsabilidade dos apresentadores conforme os casos de uso e estados emitidos. Por exemplo, após um login bem sucedido, o possível gerenciador de autenticação irá emitir um estado “loaded” e o apresentador correspondente irá invocar o método do Navegador que carrega a tela principal do app após o login. A mesma coisa acontece quando a autorização expira e os apresentadores devem navegar para a tela de login.

Da mesma forma, navegadores devem expor seus métodos para os apresentadores através de interfaces (contratos) e esses métodos são agnósticos de tecnologia que podem facilmente ser testados com “doubles”. A implementação concreta dos navegadores estão no nível da Activity onde são injetados nos Fragments necessários.

Arquitetura de Activity Única

Por causa de algumas dificuldades para gerenciar o ciclo de vida das Activities em Android, até mesmo a Google vem encorajando os desenvolvedores a usar esta arquitetura em que os apps tem apenas uma única Activity como ponto de entrada e usam Fragments para exibir a UI e a navegação acontece entre eles.

Em algumas ocasiões o app pode requerer mais de uma Activity em sua concepção, quando há a necessidade de mais de um ponto de entrada. Por exemplo:

  • Abrir a tela inicial do app pelo ícone instalado no aparelho (o caso mais comum).
  • Abrir uma tela específica do app através de uma notificação do sistema.
  • Abrir uma tela específica do app através de algum evento externo como “click-to-call”.

Isso não muda o fato de que o navegador continuará carregando os Fragments em qualquer uma das Activities abertas acima já que não importa de fato qual delas está ativa e os gerenciadores também não sabem da existência das mesmas.

Estratégia de Testes

Eu sugiro esses dois artigos escritos por engenheiros mobile da Talkdesk:

São artigos bem detalhados que explicam porque a arquitetura limpa pode ajudar na abstração de camadas de forma a isolar detalhes de implementação da lógica de negócio e promover testes unitários e rápidos no ambiente Android.

App de Exemplo

Para essa última parte do artigo gostaria de agradecer por ter chegado até aqui! Irei recompensá-lo com um projeto totalmente funcional que foi inspirado num dos primeiros projetos que desenvolvi quando comecei minha carreira como desenvolvedor Android.

O projeto original foi o primeiro do Nanodegree de Desenvolvedor Android da Udacity (um curso excelente com material oficial da Google) onde o objetivo era desenvolver um app que consumia dados de uma API pública de dados de filmes para exibir uma grade de posteres de filmes na tela principal onde a ordenação pode ser mudada e também exibir detalhes de um filme selecionado numa tela secundária.

Para esse app de exemplo, usei os mesmos requisitos do projeto original, mas fazendo um projeto novo do zero usando toda a arquitetura discutida até aqui e aplicando a disciplina de desenvolvimento orientado a testes (Test-Driven Development ou TDD) desde o início.

Você pode obter uma cópia do projeto aqui:

Se você observar o histórico de commits neste repositório, irá notar que há 20 commits totais, mas apenas no 13º há um app Android funcional que pega algum dado real da API. E essa é a beleza e o poder de trabalhar com TDD e com uma arquitetura que isola as regras de negócio dos detalhes de implementação. Você pode começar o trabalho pelos casos de uso principais, estratégia de apresentação e contratos dos dados sem ter qualquer código específico de Android ou implementação real da API porque esses são detalhes que podem ser substituídos e testados com implementações simuladas (“fakes”) até você realmente precisar das implementações reais mais tarde.

Essa é a arquitetura do app:

A estratégia de construção do app seguiu esses passos:

  1. Criar a máquina de estados (que é o núcleo do gerenciador principal do app).
  2. Criar uma versão inicial do gerenciador principal com o primeiro caso de uso (obter filmes populares quando o app inicializa), o modelo inicial e o contrato inicial do gateway de dados.
  3. Criar o mapeador de dados para fazer parsing da resposta da API e gerar o modelo de filmes.
  4. Criar o apresentador principal inicial, contrato da visualização principal e o ViewModel principal.
  5. Fazer a injeção das dependências e os primeiros testes de componente.
  6. Adicionar mais casos de uso no gerenciador principal (obter os filmes mais bem avaliados).
  7. Adicionar o Fragment principal inicial que exibe uma lista simples com apenas os títulos dos filmes conforme a ordenação selecionada no menu. OBS: Esses casos de uso ainda podem ser avaliados com o Robolectric em testes de componente sem qualquer necessidade de um app completo rodando.
  8. Adicionar mais casos de uso no gerenciador e no gateway da API que implementam a funcionalidade de “rolagem infinita” na lista de filmes (buscar mais dados de filmes da API conforme a lista rola até o fim e aumentar o tamanho da mesma em tempo da mesma permitindo rolar mais).
  9. Adicionar componentes reais de Android e chamadas reais da API para construir uma primeira versão funcional do app para testes exploratórios.
  10. Adicionar uma abstração do carregador de imagens com a implementação da biblioteca Picasso para o carregamento das imagens (pôsteres de filmes).
  11. Adicionar o navegador.
  12. Adicionar o caso de uso de selecção de filmes no gerenciador principal com as alterações necessárias no modelo de filmes.
  13. Adicionar o apresentador e o ViewModel para as telas de detalhes do filme.
  14. Adicionar a visualização de detalhes (Fragment) e a navegação entre as telas.
  15. Adicionar testes de ponta a ponta.

E é isso. Espero ter dado uma boa visão geral de como implementamos apps Android aqui na Talkdesk. Embora essa arquitetura possa parecer um pouco exagerada para este app, ainda podemos tirar grandes vantagens da mesma para adicionar novos recursos e casos de uso de forma tão simples quanto adicionar mais peças de Lego a uma construção existente.

Por exemplo, nesse mesmo curso da Udacity, o objetivo do segundo projeto era justamente adicionar mais requisitos neste app como persistência dos dados num banco de dados (para uso desconectado da internet) e adicionar mais endpoints da API para obter dados extras de filmes como trailers e comentários de usuários.

Na época eu precisei criar um novo app já que era quase impossível apenas adicionar esses novos recursos no app existente sem ter que fazer uma refatoração gigantesca por conta da implementação feita. Vou deixar o código dessas versões para reflexão:

Se estiver interessado, posso criar um novo artigo mostrando como eu poderia adicionar essas novas features no app de exemplo deste artigo em algumas poucas iterações dessa vez!

Referências:

Artigo original em inglês: https://engineering.talkdesk.com, publicado em 29 de Janeiro, 2020.

--

--