Android, MVVM e repositórios no mundo real

(Mike DeMaso) (2 de novembro de 2020)

Como você consegue uma interface de usuário responsiva enquanto mantém seu código limpo, preciso, legível e sustentável? Esta é uma pergunta que tem saltado na cabeça dos desenvolvedores Android desde 2008.

Depois de muitos anos permitindo que os desenvolvedores Android descobrissem por conta própria, o Google recentemente deu algumas orientações sobre o assunto com seus Guia para arquitetura de aplicativo que promove uma variante do MVVM . Embora seja um ótimo começo, ele deixa muitas perguntas pendentes para as equipes responderem por conta própria. O Google também oferece bibliotecas como LiveData , Room e DataBinding para nos ajudar a reduzir boilerplates e separar as preocupações de nosso código. Até mesmo terceiros ajudaram com RxJava e Retrofit , o que nos proporciona uma maneira de lidar com trabalho assíncrono e buscar coisas na rede. Com todas essas coisas flutuando em torno da solução de pequenas partes da questão maior, como podemos reuni-las para fornecer uma experiência de interface do usuário fluida que seja simples de implementar e manter? o que aprendemos com nossas tentativas anteriores de criar uma arquitetura MVVM e mostrar um exemplo simples de como reunir todas essas peças do quebra-cabeça.

Uma descrição rápida do MVVM

Para fornecer a você uma compreensão do que um repositório resolverá, primeiro vamos mergulhar no padrão básico do MVVM.

O básico Padrão MVVM
Diagrama MVVM básico

A visualização

Esta é a representação da interface gráfica do usuário para seu código. Eles podem ser representados por XML (arquivos de layout) ou código (Jetpack Compose). Normalmente, há alguma forma de vinculação de dados que vincula o View ao ViewModel. Dependendo de onde você traça a linha entre uma visualização e um fichário, o objeto Activity e Fragment pode ser considerado um ou ambos.

ViewModel

Ele é responsável por transformar os dados de o modelo em um formato que faz sentido para a exibição exibir. Não deixe que a conexão de ponta dupla entre o View e o ViewModel engane você. A principal diferença entre um ViewModel e o Presenter no MVP é que um ViewModel não contém uma referência à exibição. É um relacionamento unilateral, o que significa que outras coisas precisam gerenciar o objeto ViewModel.

O Modelo

Refere-se aos dados (ou modelo de domínio) que estão sendo usados ​​como a fonte das informações que o ViewModel está fornecendo para a Visualização. Esta camada é onde as coisas ficam um pouco embaçadas, conforme alguns artigos se referem como os próprios dados, enquanto outros se referem a ele como uma camada de acesso a dados. É aqui que o padrão de repositório do Google entra em ação para esclarecer as coisas.

Entre no repositório

O Google sempre mencionando esse padrão há algum tempo . Seus exemplos são um ótimo guia para entender o princípio básico do uso de MVVM com um repositório, mas descobrimos que estão faltando alguns pequenos ( e importante) orientação para ajudar as pessoas a traduzir esses snippets em um projeto maior e mais complexo.

Diagrama de MVVM do Google

O padrão de repositório é projetado para“ fornecer uma API limpa para que o resto do aplicativo pode recuperar esses dados facilmente. ” Infelizmente, apenas adicionar um repositório à sua arquitetura não força o seu código a ser limpo. Você ainda pode criar uma confusão emaranhada sem separar adequadamente e fornecer um contrato claro entre as camadas.

No DraftKings, nos concentramos em algumas diretrizes adicionais para ajudar nossos desenvolvedores a produzir código limpo de forma consistente.

Separar camadas com interfaces

Aqui estamos adicionando interfaces para fazer com que os engenheiros pensem sobre o que estão expondo publicamente
Melhorando o diagrama do Google adicionando interfaces

Estabelecemos que o uso de interfaces entre essas camadas ajudará engenheiros de todos os níveis a seguir bons princípios de codificação. Isso ajuda a garantir que nossos testes de unidade realmente testem apenas uma camada de cada vez, reduzindo a sobrecarga de escrita e mantendo um grande conjunto de testes. Além disso, ajuda a definir claramente as APIs externas e ofusca os detalhes de implementação das diferentes camadas. Isso nos leva a avaliar o que estamos dizendo ao mundo exterior sobre a funcionalidade deste objeto e nos dá uma oportunidade integrada de garantir que nosso código esteja limpo.

Ele também nos permite a capacidade de refatorar diferentes camadas em nossa base de código de maneira mais eficaz. Contanto que a interface não mude, podemos deixar as áreas de nossa base de código que as usam intactas. Por exemplo, se quiséssemos migrar nossa biblioteca de rede de Volley para Retrofit, poderíamos simplesmente alterar os métodos em nossas classes Dagger que produzir e fornecer a interface acima da fonte de dados remota e não ter que fazer alterações em todos os repositórios que usam esse endpoint. Isso reduz enormemente o escopo de tais alterações e diminui a chance de introduzirmos bugs no produto final.

Aqui temos um exemplo de como deixar um ViewModel manter a classe concreta de um repositório pode levar a comportamentos indesejados. O exemplo é um pouco inventado e pode ser retificado simplesmente marcando fooPublishSubject em FooRepository como private, mas essa solução é mais frágil. FooRepository pode precisa ser usado em um escopo diferente, exigindo acesso a esse parâmetro, e abrir o acesso para instâncias agora confunde o que pt é apropriado usar essa variável de membro diretamente.

Entrega dessas dependências

Conforme a complexidade do seu projeto aumenta, mais complicados se tornam seus relacionamentos de dependência. Isso significa que as pessoas geralmente recorrem a algum tipo de biblioteca de injeção de dependência (como Dagger ou Koin ) .

A biblioteca DI não apenas fornece uma maneira limpa e fácil de recuperar suas dependências necessárias, mas também permite que você pense em quantos desses objetos você precisará no aplicativo.

Esse processo de pensamento nos levou a estabelecer as melhores práticas de quais objetos pertencem ao gráfico Dagger. Qualquer coisa que desejamos apenas uma única instância deve residir no gráfico raiz / global / com escopo de aplicativo. Qualquer coisa que possa haver muitas instâncias deve ser criada sob demanda e mantida apropriadamente.

Isso significa que nossos novos objetos de repositório pertencem ao gráfico Dagger, pois queremos que vários ViewModels sejam capazes de acessar os Modelos por meio de uma única instância das fontes de Room ou Retrofit subjacentes. Os ViewModels, por outro lado, precisam ser criados novos para cada View que precisa deles. Pense em uma pilha de Activities como uma pilha de cartas de baralho e o ViewModel direciona o naipe e o valor. Não queremos que o ato de adicionar um 3 de clubes ao topo para alterar todos os cartões abaixo para um 3 de clubes também, então cada View precisa de sua própria instância de um ViewModel para preservar e isolar seus dados.

Nós agora definimos o que nossa solução de DI agora conterá.
Mostrar quais objetos devem ser mantidos por the Dagger Graph

Decidimos manter nossos ViewModels fora do nosso gráfico Dagger. Historicamente, éramos menos explícitos sobre essa escolha, mas achamos que esta é a direção certa dada o padrão ViewModelProvider que vem em androidx.lifecycle e como isso nos ajuda a solidificar a relação entre a atividade / fragmento / XML e ViewModel como “um para um”. Enquanto isso, o relacionamento entre ViewModel e repositório pode ser “muitos para muitos”. Na prática, isso significa que para cada Activity / Fragment / XML temos um único ViewModel que lida com essa visão ogic, mas pode chegar a muitos repositórios para obter os dados necessários. Como os dados são geralmente reutilizados e exibidos no aplicativo, muitos ViewModels diferentes podem usar a mesma instância do repositório do Dagger Graph de maneira fácil e eficiente.

Contendo a API

Em qualquer empresa em escala, são necessários muitos engenheiros, equipes e até divisões para fazer um projeto do quadro branco chegar às mãos do cliente. Aqui no DraftKings, isso não é diferente. Para inserir dados no aplicativo Android, precisamos trabalhar com algumas equipes diferentes no back-end para obter os dados do banco de dados para a API do cliente Android. Dado que este código geralmente pertence a outra equipe, isso significa que o back-end geralmente “se moverá” em um ritmo diferente do cliente.

Isso é especialmente verdadeiro no início de um projeto que não temos uma API em um estado que podemos usar para nosso desenvolvimento. Gostaríamos de tomar decisões de design e implementação sobre os objetos de dados que estão sendo repassados ​​internamente para o cliente, sem nos preocupar muito com as decisões conflitantes que os engenheiros que trabalham no back-end tomam.

Além disso, temos um poucos serviços que retornam os mesmos dados de negócios ao cliente, mas como pertencem a equipes diferentes e estão tentando resolver problemas diferentes, eles se distanciaram na estrutura e nos tipos de dados retornados por meio das APIs.Internos para o cliente, eles realmente representam a mesma coisa, portanto, ser capaz de traduzir e combinar essas respostas em algo universal para o cliente faz muito sentido.

Aqui, você pode ver que há dois endpoints na API que retornam variações de um “usuário”; os terminais de login e perfil. Para mesclá-los em um tipo de dados, simplesmente criamos um construtor para cada variação que queremos manipular e agora o aplicativo pode limitar o conhecimento dos dois tipos diferentes de usuários que a API entrega em um único lugar. Isso torna o compartilhamento de dados (por meio da camada de modelo) entre recursos muito mais fácil, ao mesmo tempo que permite que alterações na estrutura de dados e tipos da API sejam limitados a um ponto de extremidade.

Estamos fazendo uma distinção entre objetos de resposta de rede e objetos de modelo de negócios em nossa arquitetura. Isso também ajuda a definir a função da fonte de dados remota para obter respostas de rede e transformá-las em modelos de negócios para o resto de nosso aplicativo.

Definir os tipos de dados que essas camadas produzem, isso também ajuda a definir o papel da Fonte de Dados Remota
Esclarecendo o tipo de dados sendo enviados entre as camadas, permitindo mais reutilização

Um exemplo em código

Agora vamos mergulhar no exemplo do Google UserRepository e criar nossa própria versão mantendo as alterações que fizemos acima.

Primeiro, vamos dar uma olhada na versão final do Google do UserRepository.

Você pode ver que eles estão usando um punhal (ou Hilt ) , Kotlin Coroutines , Room e, provavelmente, Retrofit. Fornecendo o serviço Retrofit, o executor Coroutine e o Dao (Data Access Object) para o repositório.

O fluxo básico disso é fazer uma solicitação de rede para os dados e retornar os dados (ou algo que está observando para dados) da Room imediatamente. Quando a solicitação de rede for concluída, faça tudo o que for necessário para os dados e insira o novo objeto na tabela. A inserção notifica automaticamente os dados retornados anteriormente de que foram alterados, solicitando uma nova recuperação e, finalmente, uma atualização da visualização.

Algumas configurações

Antes de começarmos a criar o UserRepository, devemos primeiro abordar algumas coisas de que precisamos, como uma maneira útil de injetar em quais threads queremos executar.

Isso é para nos ajudar com os testes mais tarde. Configure isso no gráfico Dagger e agora você pode injetar os threads corretos facilmente em toda a base de código enquanto ainda é capaz de trocá-los por um TestScheduler em sua unidade testes (você está escrevendo testes de unidade … certo?)

Aqui estão nossas classes de usuário, UserResponse sendo aquela retornada por nossa API via Retrofit e User, nosso modelo de negócios passamos internamente. Você pode ver que podemos tornar o objeto de rede simples e até mesmo usar um gerador de código para criá-lo, enquanto nossos objetos de negócios podem estar mais alinhados com o que precisamos.

Aqui, estamos definindo nosso serviço de Retrofit. Poderíamos ter feito com que retornassem um Observable em vez de um Single, o que tornaria a lógica downstream um pouco mais simples, mas gostamos dos paralelos como as solicitações de rede e Single funcionam, tanto assíncronas quanto com êxito ou falha. Levamos essa lógica também para a camada de fonte de dados remota.

A próxima é nossa sala Dao. Como a Room já funciona com interfaces e anotações, não sentimos a necessidade de criar outra interface, classe e objeto para ofuscar como lidamos com dados persistentes.

Estamos usando um Observable para reagir às emissões de User objetos e fazendo com que nossa ação de inserção retorne um Completable para nos ajudar a lidar com quaisquer erros de sala que possam ocorrer.

Finalmente, aqui está nossa última interface para o próprio UserRepository. É muito simples e limpo. A única parte adicional além do necessário é o método onCleared() que nos ajudará a limpar todos os descartáveis ​​existentes em nossas camadas inferiores como o ViewModel também é limpo.

Implementações

Você notará que a assinatura do construtor é muito semelhante ao exemplo do Google acima.Estamos fornecendo um objeto que pode recuperar dados pela rede, um Dao e um objeto que nos diz em quais threads executar.

Até o método getUser(userId: Int) é semelhante em como funciona no exemplo acima. Crie uma solicitação de rede assíncrona e retorne imediatamente um objeto que está observando os dados do banco de dados. Quando a solicitação de rede for concluída, insira os dados na tabela, o que irá acionar uma atualização da IU.

Você notará que não estamos fazendo nada muito elaborado com o RxJava aqui. Criamos um novo PublishSubject através do qual canalizamos nossos dados. Nós o fundimos com a seleção da camada persistente, que retorna um Observable e também enviamos erros da camada de rede para lá. Repetimos e voltamos sobre como lidar com a obtenção apenas dos erros desejados de cada camada para a camada acima, e essa foi a solução mais simples e personalizável que encontramos. Sim, ele cria observáveis ​​extras, mas nos dá um controle mais preciso sobre o tratamento de erros.

O último peça do quebra-cabeça é a fonte de dados remota. Aqui você pode ver como estamos convertendo UserResponse em User, permitindo que o resto da base de código se mova em um ritmo diferente do dados vindos da API.

Resumindo

Neste ponto, você deve adicionar esses objetos de cima ao gráfico Dagger. Lembre-se de que os métodos fornecidos devem retornar o tipo de interface e não as implementações. Desta forma, você pode construir cada objeto definido acima no gráfico Dagger e isso torna muito fácil obter dados de um UserRepository em qualquer lugar na base de código.

Finalmente, como estamos usando RxJava, devemos chamar clear() em qualquer Disposable objetos em nosso ViewModel e também chame onCleared() em nosso UserRepository instância para limpar quaisquer objetos Disposable internos.

Agora temos uma implementação MVVM bastante simples com um repositório que é fornecido para nós via nosso gráfico Dagger. Cada uma das camadas que criamos serve a um propósito claro e é mantida por interfaces que nos ajudam a filtrar o que expomos para o mundo exterior. Isso também nos permite testar essas camadas independentemente umas das outras de forma isolada usando um RxJava TestScheduler que nos permite ter controle total sobre a execução do teste.

Deixe uma resposta

O seu endereço de email não será publicado. Campos obrigatórios marcados com *