Back home

Camadas de repositório e problemas de consistência de estado

O que é realmente difícil de gerenciar é que o cache local, o estado da memória, o retorno remoto do pacote e o estado derivado da UI estão todos escrevendo secretamente a "verdade"

Quando muitos projetos Android estão confusos, a primeira reação é continuar adicionando camadas.

ViewModel -> UseCase -> Repository -> LocalDataSource -> RemoteDataSource Quando esta string é disposta, o código parece mais organizado. O problema é que limpeza e consistência não são a mesma coisa. Muitas equipes estão tornando o Repositório cada vez mais como um “portal unificado”, mas no final descobrem que o status da página é mais difícil de inferir: a lista e os detalhes são inconsistentes, o status da coleção oscila para frente e para trás, a IU não muda após a solicitação ser bem-sucedida e um conjunto de dados antigos aparece após a reconstrução do processo.

Meu julgamento é: **O valor das camadas do repositório está em fontes de estado claras e limites de gravação. Contanto que o cache de memória, o banco de dados local, o retorno remoto de pacotes e o estado derivado da UI possam alterar seus valores, quanto mais organizada for a camada, mais difícil será manter a consistência do estado. **

O verdadeiro problema é que existe mais de uma verdade

Uma situação comum é que o Repositório possa “unificar o gerenciamento de dados”, o que é apenas parcialmente correto.

É claro que o Repositório pode empacotar a rede, o cache local e a persistência do disco, mas se você não continuar perguntando “quem é a fonte da verdade”, o Repositório apenas agrupa vários estados no mesmo nome de classe.

O caminho mais comum fora de controle é este:

  • A página lê primeiro o banco de dados local e exibe imediatamente o valor antigo;
  • Iniciar uma solicitação remota ao mesmo tempo e atualizar o cache de memória após retornar o pacote;
  • Para buscar uma interação tranquila, primeiro altere diretamente o estado da IU e faça uma atualização otimista;
  • Outra página lê outro valor do campo singleton do Repositório;
  • Finalmente, o download assíncrono do banco de dados é concluído e a página antiga é retrocedida.

Neste momento, o que você vê superficialmente é que a “arquitetura está hierarquicamente completa”. Na verdade, já existem quatro conjuntos de estados no sistema competindo pelo direito de interpretar.

Eles respondem a perguntas diferentes, respectivamente:

  • O banco de dados deseja responder “Pode ser restaurado na próxima vez que for iniciado?”;
  • A memória cache quer responder “Este acesso é rápido?”;
  • A extremidade remota retorna o pacote e quer responder “O que o servidor acabou de dizer?”;
  • O estado da UI deseja responder “como a interface deve ser renderizada neste momento”.

Todas essas coisas são importantes, mas ser importante não significa que todas possam ser a fonte da verdade.

Se não houver uma definição clara de “quem é responsável pelos valores de verdade persistentes, quem é responsável apenas pela apresentação derivada e quem só sabe ler mas não escrever”, o Repositório degenerará lentamente numa estação de transferência de estado. Ele captura toda a complexidade, mas não elimina nada dela.

O repositório é mais facilmente abusado como um coordenador que “pode mudar qualquer coisa”

O problema com muitos códigos não é que o Repositório seja muito fino, mas sim muito poderoso.

Um Repositório típico geralmente faz estas coisas ao mesmo tempo:

  • Fazer solicitações de rede;
  • Sala de leitura e escrita;
  • Manter mapa de memória; -Campos necessários para montagem da UI;
  • Reverter atualização otimista em caso de falha;
  • Envie eventos de forma conveniente para notificar outros módulos para atualização.

Parece muito concentrado, mas na verdade é a “camada de acesso a dados”, a “camada de coordenação de estado”, a “camada de estratégia de cache” e a “camada de regras de domínio” enroladas em uma bola.

Uma vez que o Repositório é responsável pela “agregação de leitura” e pela “coordenação de gravação de múltiplas fontes”, ele naturalmente entrará em um estado estranho: qualquer um pode alterar os dados por meio dele, mas ninguém pode dizer rapidamente quais observadores uma mudança afetará e qual caminho de write-back será acionado.

Por exemplo, uma operação de coleta, muitas implementações são assim:

suspend fun toggleFavorite(id: String) {
  memory[id] = !(memory[id] ?: false)
  dao.updateFavorite(id, memory[id]!!)
  api.toggleFavorite(id)
}

Este código é convenientemente curto, mas mistura três níveis de semântica:

  1. A IU deseja fornecer feedback imediato, então mude a memória primeiro;
  2. Quero manter a consistência local, então escrevo a biblioteca imediatamente;
  3. O servidor é o verdadeiro árbitro, mas os resultados são retornados no final.

O problema não é que “mudar local primeiro” deva estar errado, mas que a semântica da falha não está definida.

Como convergir se a interface expirar, mas o servidor realmente for bem-sucedido? Se a gravação local for bem-sucedida, mas a gravação remota falhar, quem fará a reversão? Se duas páginas forem clicadas como favoritas ao mesmo tempo, qual delas prevalecerá no final?

Uma vez que esses problemas não são explicitamente projetados, o Repositório apenas oculta a condição de corrida em um método aparentemente limpo.

O fluxo pode propagar o estado, o que não significa que garante consistência automaticamente.

Nos últimos anos, o Android tem gostado de conectar Flow, StateFlow e SharedFlow ao repositório e, em seguida, expor uma “fonte de dados responsiva” ao upstream. Isso certamente é melhor do que ligar de volta para qualquer lugar, mas muitas vezes cria a ilusão de que, enquanto eu transmitir os dados, o problema de consistência desaparecerá.

Não vai.

O fluxo responsivo resolve como as mudanças são propagadas, não quem determina as mudanças.

O seguinte padrão é muito comum:

val userFlow = combine(
  dao.observeUser(id),
  memoryStateFlow,
  remoteRefreshStateFlow
) { local, memory, remote ->
  mergeUser(local, memory, remote)
}

O maior risco desse código não é que ele seja feio por escrito, mas que mergeUser() frequentemente introduz decisões de negócios silenciosamente:

  • O nome é baseado na extremidade remota;
  • Estar online ou não depende da memória;
  • Se foi lido será determinado localmente;
  • O estado de carregamento também fica suspenso na IU.

O que é necessário no final é um “resultado de costura que mal consegue renderizar a página neste momento”.

Esse tipo de emenda é muito conveniente no caminho de leitura, mas pode facilmente sair do controle no caminho de gravação, pois já é difícil de responder:

  • Qual nível um determinado campo deve ser alterado?
  • Após a mudança de uma camada, as outras camadas precisam ser sincronizadas?
  • Após a reconstrução do processo, quais campos ainda podem ser reconstruídos;
  • Quais campos serão substituídos por novos valores durante a recuperação offline.

Portanto, o fenômeno estranho em muitos projetos é: quanto mais bem escrito for o fluxo de dados, mais metafísicos se tornam os erros de status. A causa raiz é que não existe uma única fonte responsável de Estado no sistema.

O que realmente deve ser controlado é o limite da escrita

A restrição mais importante no design do Repositório é “onde há permissão de gravação”.

Se um objeto de negócios puder ser modificado pela atualização otimista da UI, modificado pelo cache de memória do repositório, empurrado pelo observador do banco de dados e substituído por pacotes de retorno da interface, mais cedo ou mais tarde ele encontrará problemas de inconsistência de ordem.

Em vez de continuar adicionando abstrações, recomendo primeiro esclarecer os limites da escrita:

1. Escolha primeiro a fonte da verdade

Nem todos os cenários exigem que “o banco de dados local seja a única fonte da verdade”, mas uma fonte primária deve ser selecionada.

  • Em cenários onde a prioridade offline e a recuperação de lista são possíveis, o banco de dados local geralmente deve ser usado;
  • Em cenários onde o tempo real é forte e os valores antigos não podem ser aceitos, resultados remotos podem ser usados;
  • Os estados puros de interação da interface, como expansão, seleção e entrada, devem ser deixados explicitamente no estado da UI e não retornados ao Repositório.

A chave é não confiar no banco de dados para determinar metade dos campos, a outra metade dos campos a serem determinados na memória e, em seguida, confiar na IU para preencher a lacuna quando ocorrer um erro.

2. Separe “estado derivado” e “estado persistente”

Grande parte da confusão vem da gravação do estado de exibição temporário na camada de persistência.

Por exemplo:

-isLoading -isRefreshing -isExpanded -pendingRetryCount

Esses estados podem determinar como a IU é desenhada, mas não devem ser misturados com valores verdadeiros de negócios na mesma entidade e espalhados.

Uma vez que o estado derivado seja colocado no modelo público do Repositório, ele será reutilizado erroneamente entre diferentes páginas e diferentes ciclos de vida. No final, nem fica claro que “este campo ainda mantém o valor da última página”.

3. Torne o caminho de escrita menor que o caminho de leitura

As leituras podem ser agregadas e as gravações podem ser fechadas.

Você pode reunir o banco de dados, a memória e os sinais de atualização remota durante a leitura para fornecer à página um modelo suficiente; mas ao escrever, é melhor seguir apenas um caminho controlado e deixá-lo decidir:

  • Se deve escrever primeiro localmente;
  • Se é necessária compensação; -Se é permitido substituir versões antigas;
  • Se deve incluir um número de versão ou carimbo de data/hora;
  • Qual semântica a UI deve ver após uma falha.

Quanto mais entradas de gravação o sistema permitir, mais consistência dependerá de “não cometer erros”. Não é design, é sorte.

Um contra-exemplo comum: para “experimentar uma suavidade sedosa”, faça alterações antes de falar sobre isso

A maneira mais fácil de escrever consistência de estado ruim é a pequena decisão de “esta interação é muito simples, vamos alterá-la localmente primeiro”.

Por exemplo, curtidas, coleções, seguidores e leituras são muito fáceis de serem considerados como “mudar a IU primeiro e depois falhar”. O problema é que, uma vez que cruzam páginas, listas e camadas de cache, elas não são mais decisões pequenas.

Os casos de falha geralmente são assim:

  • Clique em Favorito na página de detalhes e o botão acenderá imediatamente;
  • A página da lista também monitora o mesmo estado da memória do Repositório, portanto acende de forma síncrona;
  • A interface atinge o tempo limite e o Repositório aciona a reversão;
  • Mas a página da lista já obteve o valor antigo por causa do observador do banco de dados, e a ordem de reversão é diferente da página de detalhes;
  • O usuário retorna ao nível anterior e vê que o status das duas páginas está inconsistente;
  • Após o processo de abate ser reiniciado, ele reverte para o terceiro resultado.

O mais irritante desse tipo de problema é que ele nem sempre se repete, então a equipe pode facilmente atribuí-lo a “problemas de tempo de fluxo”, “problemas de reorganização da composição” ou “flutuações esporádicas de rede”.

Na verdade, a causa raiz é mais simples: ** permite que múltiplas camadas tenham a qualificação para escrever o resultado final ao mesmo tempo. **

Este serviço é dividido em camadas para responsabilidade, não para limpeza formal

Não sou contra camadas de repositório. Sem o Repositório, muitos projetos Android seriam ainda mais confusos.

Mas o que o Repositório realmente deveria fornecer é:

  • Você pode explicar de onde vem o caminho da leitura? -Escrever por quais decisões o caminho passou e se a responsabilização pode ser realizada;
  • Quem prevalecerá quando ocorrer um erro e se este pode ser recuperado;
  • O que é compartilhado entre as páginas é o valor real do negócio ou o estado de exibição temporário e se ele pode ser separado.

Se essas perguntas não puderem ser respondidas, por mais bonita que seja a estratificação, será apenas uma ordem visual.

Isso faz com que o código pareça mais um diagrama de arquitetura, mas não faz necessariamente com que o estado pareça mais um sistema.

Limites aplicáveis

Este artigo se concentra principalmente em:

  • Ter cache local ou Sala;
  • Status de compartilhamento de múltiplas páginas;
  • Busque simultaneamente a velocidade da primeira tela, recuperação offline e feedback interativo instantâneo;
  • Use Repository + Flow/StateFlow para organizar a leitura e gravação de dados.

Se o aplicativo for muito leve, os dados são quase sempre uma solicitação única, a página está pronta para uso e não há necessidade de sincronização entre páginas, mesmo que o Repositório seja escrito de forma simples e grosseira, o problema de consistência de estado não será particularmente proeminente.

O verdadeiro problema é para projetos que são “médios a grandes, mas ainda não grandes o suficiente para serem completamente plataformados”: há cada vez mais funções e as fontes de dados estão se tornando cada vez mais complexas, mas a equipe ainda está usando o método inicial de “empacotar uma camada de repositório primeiro e depois falar sobre ela” para apoiá-la. Nesse estágio, é mais provável que surjam sistemas com estrutura organizada e comportamento caótico.

Resumo

O mal-entendido mais comum sobre camadas de repositório no Android é confundir “entrada de acesso unificado” com “estado natural unificado”.

As entradas unificadas só podem reduzir a confusão na superfície de chamada; somente limpando a fonte da verdade, fechando os limites da escrita e definindo antecipadamente a semântica do fracasso poderemos realmente reduzir as lutas estatais.

Caso contrário, o que é necessário é um conjunto bem organizado de coisas que sejam mais difíceis de responsabilizar.