Guia para a arquitetura do app

Este guia engloba as práticas e a arquitetura recomendadas para a criação de apps robustos com qualidade de produção.

As informações nesta página partem do princípio que você sabe o básico sobre o framework do Android. Se você não tem experiência com o desenvolvimento de apps Android, confira nossos Guias do desenvolvedor para dar os primeiros passos e saber mais sobre os conceitos mencionados neste guia.

Se você se interessa por arquitetura de apps e quer ver o material deste guia da perspectiva da programação em Kotlin, confira o curso da Udacity, Desenvolver apps Android com Kotlin (em inglês).

Experiências dos usuários de apps para dispositivos móveis

Na maioria dos casos, os apps para computador têm um único ponto de entrada em uma área de trabalho ou um acesso rápido de programas e, em seguida, são executados como um único processo monolítico. Por outro lado, os apps Android têm uma estrutura muito mais complexa. Em geral, um app Android contém vários componentes de app, incluindo atividades, fragmentos, serviços, provedores de conteúdo e broadcast receivers.

A maioria desses componentes de app é declarada no manifesto do app. O SO Android usa esse arquivo para decidir como integrar seu app à experiência geral do usuário do dispositivo. Considerando que um app Android corretamente programado contém vários componentes e que os usuários geralmente interagem com diversos apps em um curto período, os apps precisam se adaptar a diferentes tipos de fluxo de trabalho e tarefa controlados pelo usuário.

Por exemplo, pense no que acontece quando você compartilha uma foto no seu app de rede social favorito.

  1. O app aciona um intent de câmera. Em seguida, o SO Android abre um app de câmera para lidar com a solicitação. Nesse momento, o usuário sai do app de rede social, mas a experiência dele é contínua.
  2. O app de câmera pode acionar outros intents, como a abertura do seletor de arquivos, que pode iniciar mais um app.
  3. Por fim, o usuário retorna ao app de rede social e compartilha a foto.

A qualquer momento durante o processo, o usuário pode ser interrompido por uma chamada telefônica ou notificação. Depois de lidar com essa interrupção, o usuário espera retomar o processo de compartilhamento de fotos. Esse comportamento de alternância de apps é comum em dispositivos móveis, mas seu app precisa lidar com esses fluxos corretamente.

Lembre-se de que os recursos de dispositivos móveis também são limitados. Por isso, o sistema operacional pode interromper os processos de alguns apps a qualquer momento para dar espaço a outros novos.

Considerando as condições desse ambiente, é possível que os componentes do seu app sejam iniciados individualmente e fora de ordem, e eles podem ser destruídos a qualquer momento pelo usuário ou pelo sistema operacional. Como esses eventos não estão sob seu controle, não armazene nenhum dado ou estado de app nos componentes do seu app e não permita que os componentes dele dependam uns dos outros.

Princípios arquitetônicos comuns

Se não é recomendável usar componentes do app para armazenar dados e estados, qual é a melhor forma de criar seu app?

Separação de conceitos

O princípio mais importante a ser seguido é a separação de conceitos (link em inglês). É um erro comum escrever todo o código em Activity ou Fragment. Essas classes baseadas em IU devem conter apenas a lógica que processa as interações entre a IU e o sistema operacional. Ao manter essas classes o mais enxutas possível, você pode evitar muitos problemas relacionados ao ciclo de vida.

Lembre-se de que você não possui implementações de Activity e Fragment. Na verdade, elas são apenas classes que representam o contrato entre o SO Android e seu app. O SO pode destruí-las a qualquer momento com base nas interações do usuário ou devido a condições do sistema, como pouca memória. Para oferecer uma experiência do usuário satisfatória e uma experiência de manutenção de app mais gerenciável, o melhor a se fazer é minimizar sua dependência delas.

Basear a IU em um modelo

Outro princípio importante é que você precisa basear sua IU em um modelo, de preferência um que seja persistente. Modelos são componentes responsáveis por manipular os dados de um app. Eles são independentes dos objetos View e componentes do app, de modo que não são afetados pelo ciclo de vida do app e pelas preocupações associadas.

A persistência é ideal pelos seguintes motivos:

  • Seus usuários não perderão dados se o SO Android destruir seu app para liberar recursos.
  • Seu app continuará funcionando se uma conexão de rede estiver lenta ou indisponível.

Baseando seu app em classes de modelo com responsabilidade bem definida de gerenciamento dos dados, ele se torna mais testável e consistente.

Nesta seção, apresentamos um caso de uso completo para demonstrar como estruturar um app usando os Componentes de arquitetura.

Imagine que estamos criando uma IU que mostra um perfil de usuário. Usamos um back-end privado e uma API REST para buscar os dados de um determinado perfil.

Visão geral

Comece analisando o diagrama a seguir, que mostra como todos os módulos precisam interagir uns com os outros depois da criação do app.

Observe que cada componente depende apenas daquele que está um nível abaixo de si. Por exemplo, atividades e fragmentos dependem apenas de um modelo de visualização. O repositório é a única classe que depende de várias outras classes. Nesse exemplo, o repositório depende de um modelo de dados persistente e de uma fonte de dados de back-end remota.

Esse design cria uma experiência do usuário consistente e agradável. Independentemente de o usuário voltar ao app alguns minutos ou vários dias depois de tê-lo fechado pela última vez, ele vê instantaneamente as informações do usuário que o app mantém localmente. Se esses dados estiverem desatualizados, o módulo de repositório do app começará a atualizá-los em segundo plano.

Criar a interface do usuário

A IU consiste em um fragmento, UserProfileFragment, e no arquivo de layout correspondente, user_profile_layout.xml.

Para direcionar a IU, nosso modelo de dados precisa conter os seguintes elementos:

  • ID do usuário: o identificador do usuário. É melhor transmitir essa informação para o fragmento usando os argumentos dele. Se o SO Android destruir nosso processo, essas informações serão preservadas para que o ID esteja disponível na próxima vez que o app for reiniciado.
  • Objeto User: uma classe de dados que contém dados sobre o usuário.

Usamos um UserProfileViewModel baseado no componente de arquitetura ViewModel para manter essas informações.

Um objeto ViewModel fornece os dados para um componente de IU específico, como um fragmento ou atividade, e contém uma lógica empresarial de manipulação de dados para se comunicar com o modelo. Por exemplo, o ViewModel pode chamar outros componentes para carregar os dados e pode encaminhar solicitações de usuários para modificá-los. O ViewModel não sabe sobre componentes de IU, por isso não é afetado por mudanças de configuração, como a recriação de uma atividade ao girar o dispositivo.

Definimos os seguintes arquivos:

  • user_profile.xml: a definição do layout da IU para a tela.
  • UserProfileFragment: o controlador de IU que exibe os dados.
  • UserProfileViewModel: a classe que prepara os dados para visualização no UserProfileFragment e reage às interações do usuário.

Os snippets de código a seguir mostram o conteúdo inicial desses arquivos. Para simplificar o conteúdo, o arquivo de layout foi omitido.

UserProfileViewModel

class UserProfileViewModel : ViewModel() {
   val userId : String = TODO()
   val user : User = TODO()
}

UserProfileFragment

class UserProfileFragment : Fragment() {
   // To use the viewModels() extension function, include
   // "androidx.fragment:fragment-ktx:latest-version" in your app
   // module's build.gradle file.
   private val viewModel: UserProfileViewModel by viewModels()

   override fun onCreateView(
       inflater: LayoutInflater, container: ViewGroup?,
       savedInstanceState: Bundle?
   ): View {
       return inflater.inflate(R.layout.main_fragment, container, false)
   }
}

Agora que temos esses módulos de código, como os conectamos? Afinal, quando o campo user é definido na classe UserProfileViewModel, precisamos de uma maneira de informar a IU.

Para ter o user, nosso ViewModel precisa acessar os argumentos do fragmento. É possível passá-los a partir do fragmento, mas a melhor opção é usar o módulo SavedState para fazer nosso ViewModel ler o argumento diretamente:

// UserProfileViewModel
class UserProfileViewModel(
   savedStateHandle: SavedStateHandle
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")
   val user : User = TODO()
}

// UserProfileFragment
private val viewModel: UserProfileViewModel by viewModels(
   factoryProducer = { SavedStateVMFactory(this) }
   ...
)

Agora precisamos informar o fragmento quando tivermos o objeto do usuário. É aqui que entra em cena o componente de arquitetura LiveData.

LiveData é um armazenador de dados observáveis. Outros componentes no seu app podem monitorar se há alterações nos objetos usando esse armazenador sem criar caminhos de dependência rígidos e explícitos entre eles. O componente LiveData também respeita o estado do ciclo de vida dos componentes do seu app (como atividades, fragmentos e serviços) e inclui lógica de limpeza para evitar o vazamento de objetos e o consumo excessivo de memória.

Para incorporar o componente LiveData no nosso app, alteramos o tipo de campo no UserProfileViewModel para LiveData<User>. Agora, o UserProfileFragment é informado quando os dados são atualizados. Além disso, como o campo LiveData reconhece o ciclo de vida, ele limpa automaticamente as referências quando elas não são mais necessárias.

UserProfileViewModel

class UserProfileViewModel(
   savedStateHandle: SavedStateHandle
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")
   val user : LiveData<User> = TODO()
}

Agora modificamos UserProfileFragment para observar os dados e atualizar a IU:

UserProfileFragment

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
   super.onViewCreated(view, savedInstanceState)
   viewModel.user.observe(viewLifecycleOwner) {
       // update UI
   }
}

Toda vez que os dados de perfil do usuário são atualizados, o callback onChanged() é invocado, e a IU é atualizada.

Se você tem familiaridade com outras bibliotecas que usam callbacks observáveis, talvez tenha percebido que não foi necessário modificar o método onStop() do fragmento para interromper a observação dos dados. Essa etapa não é necessária com o LiveData porque ele reconhece o ciclo de vida, o que significa que ele não invoca o callback onChanged(), a menos que o fragmento esteja em um estado ativo; isto é, ele recebeu onStart(), mas ainda não recebeu onStop(). O LiveData também remove automaticamente o observador quando o método onDestroy() do fragmento é chamado.

Também não adicionamos nenhuma lógica para lidar com alterações de configuração, por exemplo, quando o usuário gira a tela do dispositivo. O UserProfileViewModel é restaurado automaticamente quando a configuração muda. Dessa forma, assim que o novo fragmento é criado, ele recebe a mesma instância de ViewModel, e o callback é invocado imediatamente por meio dos dados atuais. Considerando que se espera que os objetos ViewModel durem mais do que os objetos View correspondentes que eles atualizam, não inclua referências diretas a objetos View na sua implementação de ViewModel. Para ver mais informações sobre a vida útil de um ViewModel correspondente ao ciclo de vida dos componentes de IU, consulte O ciclo de vida de um ViewModel.

Buscar dados

Agora que usamos o LiveData para conectar UserProfileViewModel a UserProfileFragment, como podemos buscar os dados de perfil do usuário?

Neste exemplo, presumimos que nosso back-end ofereça uma API REST. Usaremos a biblioteca Retrofit (em inglês) para acessar nosso back-end, mas você pode usar uma biblioteca diferente que cumpra a mesma finalidade.

Esta é nossa definição de Webservice que se comunica com nosso back-end:

Webservice

interface Webservice {
   /**
    * @GET declares an HTTP GET request
    * @Path("user") annotation on the userId parameter marks it as a
    * replacement for the {user} placeholder in the @GET path
    */
   @GET("/users/{user}")
   suspend fun getUser(@Path("user") userId: String): User
}

Uma primeira ideia para implementar o ViewModel pode envolver chamar diretamente o Webservice para buscar os dados e atribuí-los de volta ao nosso objeto LiveData. Esse design funciona, mas, ao usá-lo, fica cada vez mais difícil manter nosso app à medida que ele cresce. Ele atribui muita responsabilidade à classe UserProfileViewModel, o que viola o princípio de separação de conceitos. Além disso, o escopo de um ViewModel está vinculado a um ciclo de vida de Activity ou Fragment, o que significa que os dados de Webservice são perdidos quando o ciclo de vida do objeto da IU associado termina. Esse comportamento cria uma experiência de usuário indesejável.

Em vez disso, nosso ViewModel delega o processo de busca de dados a um novo módulo, um repositório.

Módulos de repositório manipulam operações de dados. Eles disponibilizam uma API limpa para que o restante do app possa recuperar esses dados com facilidade. Eles sabem onde coletar os dados e quais chamadas de API precisam ser feitas quando os dados são atualizados. Você pode considerar os repositórios como mediadores entre fontes de dados diferentes, por exemplo, modelos persistentes, serviços da Web e caches.

Nossa classe UserRepository, mostrada no snippet de código a seguir, usa uma instância de WebService para buscar os dados de um usuário:

UserRepository

class UserRepository {
   private val webservice: Webservice = TODO()
   // ...
   suspend fun getUser(userId: String) =
       // This isn't an optimal implementation because it doesn't take into
       // account caching. We'll look at how to improve upon this in the next
       // sections.
       webservice.getUser(userId)
}

Mesmo que o módulo de repositório pareça desnecessário, ele serve a uma finalidade importante: abstrai as fontes de dados do restante do app. Agora, nosso UserProfileViewModel não sabe como os dados são buscados, para que possamos fornecer ao modelo de visualização dados de várias implementações diferentes de busca de dados.

Gerenciar dependências entre componentes

A classe UserRepository acima precisa de uma instância de Webservice para buscar os dados do usuário. Ela poderia simplesmente criar a instância, mas, para isso, também precisaria conhecer as dependências da classe Webservice. Além disso, UserRepository provavelmente não é a única classe que precisa de Webservice. Essa situação exige a duplicação do código, porque cada classe que requer uma referência a Webservice precisa saber como construí-lo, bem como as dependências dele. Se cada classe cria um novo WebService, nosso app pode ter uma carga de recursos muito pesada.

Você pode usar os seguintes padrões de design para resolver esse problema:

Esses padrões permitem dimensionar seu código, porque fornecem padrões claros para gerenciar dependências sem duplicar o código ou adicionar complexidade. Além disso, esses padrões permitem alternar rapidamente entre implementações de busca de dados de teste e de produção.

Recomendamos seguir os padrões de injeção de dependência e usar a biblioteca Hilt em apps Android. A Hilt constrói os objetos automaticamente percorrendo a árvore de dependências, além de oferecer garantias de tempo de compilação nas dependências e criar contêineres de dependência para classes do framework do Android.

Nosso app de exemplo usa a Hilt para gerenciar as dependências do objeto Webservice.

Conectar o ViewModel e o repositório

Agora, modificamos nosso UserProfileViewModel para usar o objeto UserRepository:

UserProfileViewModel

@HiltViewModel
class UserProfileViewModel @Inject constructor(
   savedStateHandle: SavedStateHandle,
   userRepository: UserRepository
) : ViewModel() {
   val userId: String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")

   private val _user = MutableLiveData<User>()
   val user: LiveData<User> = _user

   init {
       viewModelScope.launch {
           _user.value = userRepository.getUser(userId)
       }
   }
}

Dados de cache

A implementação de UserRepository abstrai a chamada para o objeto Webservice, mas por depender de apenas uma fonte de dados, ela não é muito flexível.

O principal problema com a implementação de UserRepository é que, depois de buscar dados no nosso back-end, ele não armazena esses dados em lugar nenhum. Então, se o usuário deixar o UserProfileFragment e depois retornar a ele, nosso app precisará buscar novamente os dados, mesmo que eles não tenham sido alterados.

Esse design não é o ideal pelas seguintes razões:

  • Ele desperdiça largura de banda.
  • Ele força o usuário a aguardar a conclusão da nova consulta.

Para resolver essas deficiências, adicionamos uma nova fonte de dados ao nosso UserRepository, que armazena os objetos User na memória:

UserRepository

// @Inject tells Hilt how to create instances of this type
// and the dependencies it has.
class UserRepository @Inject constructor(
   private val webservice: Webservice,
   // Simple in-memory cache. Details omitted for brevity.
   private val userCache: UserCache
) {
   suspend fun getUser(userId: String): User {
       val cached: User = userCache.get(userId)
       if (cached != null) {
           return cached
       }
       // This implementation is still suboptimal but better than before.
       // A complete implementation also handles error cases.
       val freshUser = webservice.getUser(userId)
       userCache.put(userId, freshUser)
       return freshUser
   }
}

Persistir nos dados

Usando nossa implementação atual, se o usuário girar a tela do dispositivo ou sair e retornar ao app imediatamente, a IU existente ficará visível no mesmo momento, porque o repositório recupera os dados do cache na memória.

No entanto, o que acontece se o usuário sair do app e voltar horas depois, quando o SO Android já tiver interrompido o processo? Usando nossa implementação atual nessa situação, precisamos buscar os dados novamente na rede. Esse processo de nova busca não é apenas uma má experiência do usuário, mas também um desperdício, porque consome dados móveis valiosos.

Você poderia corrigir esse problema armazenando as solicitações da Web em cache, mas isso criaria um novo problema: o que acontecerá se os mesmos dados do usuário forem exibidos a partir de outro tipo de solicitação, por exemplo, da busca de uma lista de amigos? O app mostraria dados inconsistentes, o que, na melhor das hipóteses, é confuso. Por exemplo, nosso app pode mostrar duas versões diferentes dos dados do mesmo usuário se o usuário tiver feito a solicitação list-of-friends e a solicitação single-user em momentos diferentes. Nosso app precisaria descobrir como mesclar esses dados inconsistentes.

A maneira correta de lidar com isso é usar um modelo persistente. É aqui que a biblioteca de persistência Room vem para ajudar.

A Room é uma biblioteca de mapeamento de objetos que fornece persistência de dados locais com um uso mínimo de código clichê. No tempo de compilação, ela valida cada consulta em relação ao seu esquema de dados, de modo que consultas SQL interrompidas resultam em erros de tempo de compilação, em vez de falhas de tempo de execução. A Room abstrai alguns dos detalhes subjacentes da implementação do trabalho com tabelas e consultas SQL brutas. Ela também permite observar alterações nos dados do banco de dados (incluindo coleções e consultas de mesclagem), expondo essas alterações por meio de objetos LiveData. Essa biblioteca define explicitamente até mesmo as restrições de execução que abordam problemas comuns de encadeamento, como o acesso ao armazenamento na linha de execução principal.

Para usar a Room, precisamos definir nosso esquema local. Primeiro, adicionamos a anotação @Entity à nossa classe de modelo de dados User e uma anotação @PrimaryKey ao campo id da classe. Essas anotações marcam User como uma tabela em nosso banco de dados e id como a chave primária da tabela:

User

@Entity
data class User(
   @PrimaryKey private val id: String,
   private val name: String,
   private val lastName: String
)

Em seguida, criamos uma classe de banco de dados implementando RoomDatabase para nosso app:

UserDatabase

@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase()

Observe que UserDatabase é abstrato. A Room o implementa automaticamente. Para ver mais detalhes, consulte a documentação da Room.

Agora precisamos de uma maneira de inserir os dados do usuário no banco de dados. Para essa tarefa, criamos um objeto de acesso a dados (DAO, na sigla em inglês).

UserDao

@Dao
interface UserDao {
   @Insert(onConflict = REPLACE)
   fun save(user: User)

   @Query("SELECT * FROM user WHERE id = :userId")
   fun load(userId: String): Flow<User>
}

Observe que o método load retorna um objeto do tipo Flow<User>. Usar o Flow com a Room permite receber atualizações em tempo real. Isso significa que sempre que houver uma mudança na tabela user, um novo User será emitido.

Com nossa classe UserDao definida, referenciamos o DAO da nossa classe de banco de dados:

UserDatabase

@Database(entities = [User::class], version = 1)
abstract class UserDatabase : RoomDatabase() {
   abstract fun userDao(): UserDao
}

Agora, podemos modificar nosso UserRepository para incorporar a fonte de dados da Room.

class UserRepository @Inject constructor(
   private val webservice: Webservice,
   // Simple in-memory cache. Details omitted for brevity.
   private val executor: Executor,
   private val userDao: UserDao
) {
   fun getUser(userId: String): Flow<User> {
       refreshUser(userId)
       // Returns a Flow object directly from the database.
       return userDao.load(userId)
   }

   private suspend fun refreshUser(userId: String) {
       // Check if user data was fetched recently.
       val userExists = userDao.hasUser(FRESH_TIMEOUT)
       if (!userExists) {
           // Refreshes the data.
           val response = webservice.getUser(userId)

           // Check for errors here.

           // Updates the database. Since `userDao.load()` returns an object of
           // `Flow<User>`, a new `User` object is emitted every time there's a
           // change in the `User`  table.
           userDao.save(response.body()!!)
       }
   }

   companion object {
       val FRESH_TIMEOUT = TimeUnit.DAYS.toMillis(1)
   }
}

Agora que getUser retorna um objeto de Flow<User>, é necessário atualizar UserProfileViewModel para processar o novo tipo de retorno de Flow<User>:

@HiltViewModel
class UserProfileViewModel @Inject constructor(
   savedStateHandle: SavedStateHandle,
   userRepository: UserRepository
) : ViewModel() {
   val userId : String = savedStateHandle["uid"] ?:
          throw IllegalArgumentException("missing user id")

   // asLiveData() is part of lifecycle-livedata-ktx
   // https://developer.android.com/kotlin/ktx#livedata
   val user = userRepository.getUser(userId).asLiveData()
}

Observe que, embora você tenha mudado a origem dos dados no UserRepository, não foi necessário mudar o UserProfileFragment. Essa atualização de escopo pequeno demonstra a flexibilidade que a arquitetura do app oferece. Isso também é ótimo para testes, porque é possível fornecer uma instância simulada do UserRepository e testar seu UserProfileViewModel de produção ao mesmo tempo.

Se os usuários demorarem alguns dias para retornar a um app que usa essa arquitetura, provavelmente eles verão informações desatualizadas até que o repositório possa buscar outras atualizadas. Dependendo do seu caso de uso, talvez você não queira mostrar essas informações desatualizadas. Em vez disso, você pode exibir dados de marcadores, que mostram valores de exemplo e indicam que seu app está buscando e carregando informações atualizadas no momento.

Fonte única da verdade

É comum que diferentes endpoints da API REST retornem os mesmos dados. Por exemplo, se nosso back-end tiver outro endpoint que retorna uma lista de amigos, o mesmo objeto de usuário poderá vir de dois endpoints de API diferentes, talvez usando inclusive níveis diferentes de granularidade. Se o UserRepository retornasse a resposta da solicitação do Webservice no estado em que se encontrava, sem verificar a consistência, nossas IUs poderiam mostrar informações confusas, porque a versão e o formato dos dados do repositório dependeriam do endpoint chamado mais recentemente.

Por esse motivo, nossa implementação do UserRepository salva as respostas do serviço da Web no banco de dados. Então, as alterações no banco de dados acionam callbacks nos objetos ativos do LiveData. Usando esse modelo, o banco de dados atua como única fonte da verdade, e outras partes do app o acessam usando nosso UserRepository. Independentemente de você usar ou não um cache de disco, recomendamos que seu repositório designe uma fonte de dados como a única fonte da verdade para o restante do seu app.

Mostrar operações em andamento

Em alguns casos de uso, por exemplo, deslizar para baixo para atualizar, é importante que a IU mostre para o usuário se há uma operação de rede em andamento. É recomendável separar a ação da IU dos dados reais, porque eles podem ser atualizados por vários motivos. Por exemplo, se buscarmos uma lista de amigos, o mesmo usuário poderá ser buscado novamente de maneira programática, acionando uma atualização de LiveData<User>. Do ponto de vista da IU, o fato de existir uma solicitação em andamento é apenas outro ponto de dados, semelhante a qualquer outro dado no próprio objeto User.

Podemos usar uma das seguintes estratégias para exibir um status de atualização de dados consistente na IU, independentemente da origem da solicitação para atualizar os dados:

  • Altere getUser() para retornar um objeto do tipo LiveData. Esse objeto incluiria o status da operação de rede.
    Para ver um exemplo, consulte a implementação de NetworkBoundResource no projeto android-architecture-components do GitHub.
  • Forneça outra função pública na classe UserRepository que possa retornar o status de atualização de User. Use essa opção se você quiser mostrar o status da rede na sua IU somente quando o processo de busca de dados tiver sido originado de uma ação explícita do usuário, como deslizar para baixo para atualizar.

Testar cada componente

Na seção separação de conceitos, mencionamos que um dos principais benefícios de seguir esse princípio é a capacidade de teste.

A lista a seguir mostra como testar cada módulo de código do nosso exemplo estendido:

  • Interface do usuário e interações: use um teste de instrumentação da IU do Android. A melhor maneira de criar esse teste é usar a biblioteca Espresso. Você pode criar o fragmento e atribuir um UserProfileViewModel simulado a ele. Como o fragmento se comunica apenas com o UserProfileViewModel, a simulação dessa classe é suficiente para testar completamente a IU do app.
  • ViewModel: você pode testar a classe UserProfileViewModel usando um teste JUnit. Você só precisa simular uma classe, UserRepository.
  • UserRepository: você também pode testar o UserRepository usando um teste JUnit. É necessário simular Webservice e UserDao. Nesses testes, verifique o seguinte comportamento:
    • O repositório faz as chamadas de serviço da Web corretas.
    • O repositório salva os resultados no banco de dados.
    • O repositório não faz solicitações desnecessárias se os dados estão armazenados em cache e atualizados.
  • Como Webservice e UserDao são interfaces, é possível simulá-los ou criar falsas implementações para casos de teste mais complexos.
  • UserDao: teste as classes DAO usando testes de instrumentação. Como esses testes não exigem componentes de IU, eles são executados rapidamente. Para cada teste, crie um banco de dados na memória para garantir que não haja efeitos colaterais, como alterar os arquivos do banco de dados no disco.

    Cuidado: como a Room permite especificar a implementação do banco de dados, é possível testar seu DAO fornecendo a implementação JUnit de SupportSQLiteOpenHelper. No entanto, essa abordagem não é recomendada, porque a versão do SQLite em execução no dispositivo pode ser diferente da versão do SQLite na sua máquina de desenvolvimento.

  • Webservice: nesses testes, evite fazer chamadas de rede para seu back-end. É importante que todos os testes, especialmente os baseados na Web, sejam independentes do mundo externo. Várias bibliotecas, inclusive a MockWebServer (em inglês), podem ajudar você a criar um servidor local fictício para esses testes.

  • Testar artefatos: os Componentes de arquitetura oferecem um artefato do Maven para controlar as próprias linhas de execução em segundo plano. O artefato androidx.arch.core:core-testing contém as seguintes regras de JUnit:

    • InstantTaskExecutorRule: use essa regra para executar instantaneamente qualquer operação em segundo plano na linha de execução de chamada.
    • CountingTaskExecutorRule: use essa regra para aguardar operações em segundo plano dos Componentes de arquitetura. Também é possível associar essa regra ao Espresso como um recurso inativo.

Práticas recomendadas

A programação é um campo criativo, e a criação de apps Android não é uma exceção. Há muitas maneiras de resolver um problema, seja comunicando dados entre várias atividades ou fragmentos, recuperando dados remotos e mantendo-os localmente no modo off-line ou qualquer outro cenário comum que os apps não triviais encontrem.

Embora as recomendações a seguir não sejam obrigatórias, nossa experiência mostra que segui-las torna sua base de código mais robusta, testável e sustentável em longo prazo:

Evite designar os pontos de entrada do seu app, como atividades, serviços e broadcast receivers, como fontes de dados.

Em vez disso, eles precisam se coordenar apenas com outros componentes para recuperar o subconjunto de dados relevante para esse ponto de entrada. Cada componente do app tem vida curta, dependendo da interação do usuário com o dispositivo e da integridade geral do sistema.

Crie limites de responsabilidade bem definidos entre os vários módulos do seu app.

Por exemplo, não divulgue o código que carrega dados da rede em várias classes ou pacotes na sua base de código. Da mesma forma, não defina diversas responsabilidades não relacionadas, como armazenamento de dados em cache e vinculação de dados, na mesma classe.

Exponha o mínimo possível de cada módulo.

Não caia na tentação de criar "apenas aquele" atalho que expõe um detalhe de implementação interna de um módulo. Você pode ganhar um pouco de tempo em curto prazo, mas pagará caro por isso tecnicamente à medida que sua base de código progredir.

Pense em como tornar cada módulo isoladamente testável.

Por exemplo, ter uma API bem definida para buscar dados da rede facilita os testes do módulo que mantém esses dados em um banco de dados local. Se, em vez disso, você mesclar a lógica desses dois módulos em um só lugar ou distribuir seu código de rede por toda a base de código, será muito mais difícil, se não impossível, testá-los.

Concentre-se no núcleo exclusivo do seu app para que ele se destaque de outros apps.

Não reinvente a roda escrevendo o mesmo código clichê várias vezes. Em vez disso, concentre seu tempo e energia no que torna seu app único e deixe que os Componentes de arquitetura do Android e outras bibliotecas recomendadas lidem com o clichê repetitivo.

Aplique o máximo de persistência possível em dados relevantes e atualizados

Dessa forma, os usuários podem aproveitar a funcionalidade do seu app mesmo quando o dispositivo estiver no modo off-line. Lembre-se de que nem todos os seus usuários têm conectividade constante e de alta velocidade.

Designe uma fonte de dados como a única fonte da verdade.

Sempre que seu app tiver que acessar esse dado, ele precisará vir dessa única fonte da verdade.

Adendo: como exibir o status da rede

Na seção de arquitetura de app recomendada acima, omitimos erros de rede e estados de carregamento para simplificar os snippets de código.

Esta seção demonstra como expor o status da rede usando uma classe Resource que encapsula os dados e o estado deles.

O snippet de código abaixo mostra um exemplo de implementação de Resource:

Recurso

// A generic class that contains data and status about loading this data.
sealed class Resource<T>(
   val data: T? = null,
   val message: String? = null
) {
   class Success<T>(data: T) : Resource<T>(data)
   class Loading<T>(data: T? = null) : Resource<T>(data)
   class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
}

É comum carregar dados da rede ao mostrar a cópia em disco desses dados, por isso recomendamos criar uma classe auxiliar que possa ser reutilizada em vários locais. Para este exemplo, criamos uma classe chamada NetworkBoundResource.

O diagrama a seguir mostra a árvore de decisões para NetworkBoundResource:

Ela começa observando o banco de dados do recurso. Quando a entrada é carregada a partir do banco de dados pela primeira vez, NetworkBoundResource verifica se o resultado é bom o suficiente para ser enviado ou se é necessário buscá-lo na rede novamente. Observe que essas duas situações podem acontecer ao mesmo tempo, já que você pode querer mostrar os dados armazenados em cache durante a atualização da rede.

Se a chamada de rede for concluída, ela salvará a resposta no banco de dados e reinicializará o stream. Se a solicitação de rede falhar, o NetworkBoundResource enviará uma falha diretamente.

Observação: depois de salvar novos dados no disco, reinicializamos o stream do banco de dados. Normalmente não precisamos fazer isso, porque o próprio banco de dados já despacha a alteração.

Lembre-se de que contar com o banco de dados para despachar a alteração significa contar com os efeitos colaterais associados. Isso não é bom, porque um comportamento indefinido desses efeitos colaterais pode ocorrer se o banco de dados acabar não despachando as alterações porque os dados não foram alterados.

Além disso, não envie o resultado que chegou da rede, porque isso violaria o princípio de fonte única da verdade. Afinal, pode ser que o banco de dados inclua acionadores que mudam os valores de dados durante uma operação de "salvamento". Da mesma forma, não envie "SUCCESS" sem os novos dados, porque o cliente receberia a versão incorreta dos dados.

O snippet de código a seguir mostra a API pública que a classe NetworkBoundResource disponibiliza para as respectivas subclasses:

NetworkBoundResource.kt

// ResultType: Type for the Resource data.
// RequestType: Type for the API response.
abstract class NetworkBoundResource<ResultType, RequestType> {
   // Called to save the result of the API response into the database
   @WorkerThread
   protected abstract suspend fun saveCallResult(item: RequestType)

   // Called with the data in the database to decide whether to fetch
   // potentially updated data from the network.
   @MainThread
   protected abstract fun shouldFetch(data: ResultType?): Boolean

   // Called to get the cached data from the database.
   @MainThread
   protected abstract suspend fun loadFromDb(): Flow<ResultType>

   // Called to create the API call.
   @MainThread
   protected abstract fun createCall(): Flow<ApiResponse<RequestType>>

   // Called when the fetch fails. The child class may want to reset components
   // like rate limiter.
   protected open fun onFetchFailed() {}
}

Observe os seguintes detalhes importantes sobre a definição da classe:

  • Ela define dois tipos de parâmetros, ResultType e RequestType, porque o tipo de dado retornado da API pode não corresponder ao tipo de dado usado localmente.
  • Ela usa uma classe chamada ApiResponse para solicitações de rede. ApiResponse é um wrapper simples em torno da classe Retrofit2.Call que converte respostas em instâncias de LiveData.

A implementação completa da classe NetworkBoundResource aparece como parte do projeto android-architecture-components do GitHub (em inglês).

Depois de criar o NetworkBoundResource, podemos usá-lo para gravar nossas implementações de disco e de rede do User na classe UserRepository:

UserRepository

class UserRepository @Inject constructor(
   private val webservice: Webservice,
   private val userDao: UserDao
) {
   fun getUser(userId: String) =
       object : NetworkBoundResource<User, User>() {
           override suspend fun saveCallResult(item: User) {
               userDao.save(item)
           }

           override fun shouldFetch(data: User?): Boolean {
               return rateLimiter.canFetch(userId) && (data == null || !isFresh(data))
           }

           override suspend fun loadFromDb(): Flow<User> {
               return userDao.load(userId)
           }

           override fun createCall(): Flow<ApiResponse<User>> {
               return webservice.getUser(userId)
           }
       }
}