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.
- 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.
- O app de câmera pode acionar outros intents, como a abertura do seletor de arquivos, que pode iniciar mais um app.
- 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.
Arquitetura de app recomendada
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, oViewModel
pode chamar outros componentes para carregar os dados e pode encaminhar solicitações de usuários para modificá-los. OViewModel
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 noUserProfileFragment
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:
- Injeção de dependência (DI, na sigla em inglês): permite que as classes definam as próprias dependências sem construí-las. No tempo de execução, outra classe será responsável por disponibilizar essas dependências.
- Localizador de serviço (em inglês): esse padrão traz um registro de onde as classes podem buscar as próprias dependências em vez de construí-las.
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 tipoLiveData
. Esse objeto incluiria o status da operação de rede.
Para ver um exemplo, consulte a implementação deNetworkBoundResource
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 deUser
. 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 oUserProfileViewModel
, 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 simularWebservice
eUserDao
. 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
eUserDao
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
eRequestType
, 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 classeRetrofit2.Call
que converte respostas em instâncias deLiveData
.
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)
}
}
}