A arquitetura de apps recomendada do Android incentiva a divisão do código em classes para tirar proveito da separação de preocupações, um princípio em que cada classe da hierarquia tem uma única responsabilidade definida. Isso leva a mais classes menores que precisam ser conectadas para atender às dependências umas das outras.
As dependências entre as classes podem ser representadas como um gráfico, em que cada classe se conecta às classes de que depende. A representação de todas as classes e as respectivas dependências representam o gráfico do aplicativo. Na Figura 1, é possível ver uma abstração do gráfico do aplicativo. Quando a classe A (ViewModel) depende da classe B (Repository), há uma linha que aponta de A para B representando essa dependência.
A injeção de dependências ajuda a fazer essas conexões e permite que você troque implementações para testes. Por exemplo, ao testar um ViewModel que depende de um repositório, você pode transmitir implementações diferentes de Repository com falsificações ou simulações para testar os casos diferentes.
Conceitos básicos da injeção de dependência manual
Esta seção aborda como aplicar a injeção manual de dependências em um cenário real de um app Android. Ela trata de uma abordagem iterada de como é possível começar a usar a injeção de dependência no seu app. A abordagem melhora até chegar a um ponto muito parecido com o que o Dagger geraria automaticamente para você. Para mais informações sobre o Dagger, consulte Princípios básicos do Dagger.
Considere um fluxo como um grupo de telas no app, correspondente a um recurso. Login, registro e finalizações de compra são exemplos de fluxos.
Ao cobrir um fluxo de login para um app Android típico, o LoginActivity depende de LoginViewModel, que, por sua vez, depende de UserRepository. Em seguida,
UserRepository depende de um UserLocalDataSource e um
UserRemoteDataSource, que, por sua vez, dependem de um Retrofit serviço.
LoginActivity é o ponto de entrada para o fluxo de login, e o usuário interage com a atividade. Assim, LoginActivity precisa criar o LoginViewModel com todas as dependências.
As classes Repository e DataSource do fluxo têm esta aparência:
class UserRepository(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) { ... }
class UserLocalDataSource { ... }
class UserRemoteDataSource(
private val loginService: LoginRetrofitService
) { ... }
No Compose, ComponentActivity é o ponto de entrada. A fiação de dependência acontece uma vez em onCreate, e a interface é descrita por elementos combináveis chamados de setContent:
class ApiService {
/* Your API implementation here */
}
class UserRepository(private val apiService: ApiService) {
/* Your implementation here */
}
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Satisfy the dependencies of LoginViewModel recursively,
// then pass what the UI needs into setContent.
val apiService = ApiService()
val userRepository = UserRepository(apiService)
setContent {
LoginScreen(userRepository)
}
}
}
@Composable
fun LoginScreen(userRepository: UserRepository) {
val viewModel: LoginViewModel = viewModel(
factory = LoginViewModelFactory(userRepository)
)
// ...
}
Há problemas com essa abordagem:
- Dependências precisam ser declaradas em ordem. É obrigatório instanciar o
UserRepositoryantes doLoginViewModelpara a criação. - É difícil reutilizar objetos. Caso sua intenção fosse reutilizar o
UserRepositoryem vários recursos, seria necessário fazer com que ele seguisse o singleton pattern. O padrão singleton dificulta o teste, porque todos os testes compartilham a mesma instância singleton.
Como gerenciar dependências com um contêiner
Para resolver o problema de reutilização de objetos, você pode criar a própria classe de contêiner de dependências, que pode ser usada para conseguir dependências. Todas as instâncias fornecidas por esse contêiner podem ser públicas. No exemplo, como você só precisa de uma instância de UserRepository, é possível tornar as dependências privadas com a opção de torná-las públicas no futuro, se necessário:
// Container of objects shared across the whole app
class AppContainer {
// apiService and userRepository aren't private and will be exposed
val apiService = ApiService()
val userRepository = UserRepository(apiService)
}
Como essas dependências são usadas em todo o aplicativo, elas precisam ser
colocadas em um local comum que todas as atividades possam usar: a Application
classe. Crie uma classe Application personalizada que contenha uma instância AppContainer.
// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {
// Instance of AppContainer that will be used by all the Activities of the app
val appContainer = AppContainer()
}
Com o Compose, o mesmo AppContainer ainda é criado na subclasse Application. Você pode acessá-lo na atividade, antes de chamar setContent, ou em um elemento combinável, usando LocalContext:
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as MyApplication).appContainer
setContent {
LoginScreen(appContainer.userRepository)
}
}
}
// Alternatively, read AppContainer from inside a composable:
@Composable
fun LoginScreen() {
val context = LocalContext.current
val appContainer = (context.applicationContext as MyApplication).appContainer
val viewModel: LoginViewModel = viewModel(
factory = LoginViewModelFactory(appContainer.userRepository)
)
// ...
}
Recomendamos transmitir dependências como parâmetros combináveis em vez de acessar LocalContext de dentro da árvore. Isso mantém os elementos combináveis testáveis e as entradas explícitas. Resolva o contêiner uma vez na raiz da tela e transmita o que for necessário para baixo.
Dessa forma, você não tem um UserRepository singleton. Em vez disso, você tem um AppContainer compartilhado por todas as atividades que contêm objetos do gráfico e que criam instâncias desses objetos que outras classes podem consumir.
Se LoginViewModel for necessário em mais lugares no aplicativo, ter um local centralizado em que você cria instâncias de LoginViewModel faz sentido.
É possível mover a criação de LoginViewModel para o contêiner e fornecer novos objetos desse tipo com uma fábrica. O código de uma LoginViewModelFactory tem esta aparência:
// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
fun create(): T
}
// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory<LoginViewModel> {
override fun create(): LoginViewModel {
return LoginViewModel(userRepository)
}
}
Com o Compose, a atualização do AppContainer ainda expõe a fábrica. A factory
é consumida pelo viewModel combinável, de modo que o ViewModel seja definido como
o ViewModelStoreOwner mais próximo (normalmente a atividade do host ou, com
o Navigation Compose, uma entrada de navegação):
// AppContainer exposing the factory (unchanged from the snippet above)
class AppContainer {
// ...
val userRepository = UserRepository(localDataSource, remoteDataSource)
val loginViewModelFactory = LoginViewModelFactory(userRepository)
}
// Compose entry point + screen composable
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as MyApplication).appContainer
setContent {
LoginScreen(appContainer.loginViewModelFactory)
}
}
}
@Composable
fun LoginScreen(factory: LoginViewModelFactory) {
val viewModel: LoginViewModel = viewModel(factory = factory)
// ...
}
Essa abordagem é melhor do que a anterior, mas ainda há alguns desafios que precisam ser considerados:
Você precisa gerenciar o
AppContainerpor conta própria, criando instâncias para todas as dependências manualmente.Ainda há muito código boilerplate. Você precisa criar fábricas ou parâmetros manualmente, dependendo de querer reutilizar um objeto ou não.
Como gerenciar dependências em fluxos de aplicativos
O AppContainer se torna complicado quando você quer incluir mais funcionalidades
no projeto. Quando o app fica maior e você começa a apresentar diferentes
fluxos de recursos, surgem ainda mais problemas:
Quando há fluxos diferentes, é recomendável que os objetos estejam no escopo de cada um. Por exemplo, ao criar
LoginUserData, que pode consistir no nome de usuário e senha usados somente no fluxo de login, não convém persistir dados do fluxo de login antigo de um usuário diferente. É recomendável criar uma nova instância para cada novo fluxo. Para isso, crie objetosFlowContainerdentro doAppContainer, conforme demonstrado no exemplo de código a seguir.Otimizar o gráfico do aplicativo e os contêineres de fluxo também pode ser difícil. É preciso lembrar de excluir instâncias que não são necessárias, dependendo do fluxo em uso.
Veja como adicionar um LoginContainer ao exemplo de código. Convém criar várias instâncias de LoginContainer no app. Portanto, em vez de torná-lo um singleton, crie uma classe com as dependências necessárias para o fluxo de login do AppContainer.
class LoginContainer(val userRepository: UserRepository) {
val loginData = LoginUserData()
val loginViewModelFactory = LoginViewModelFactory(userRepository)
}
// AppContainer contains LoginContainer now
class AppContainer {
...
val userRepository = UserRepository(localDataSource, remoteDataSource)
// LoginContainer will be null when the user is NOT in the login flow
var loginContainer: LoginContainer? = null
}
No Compose, o tempo de vida do contêiner de fluxo está vinculado à composição, e não à Activity do host. Não é necessário mudar um AppContainer.loginContainer compartilhado, porque os elementos combináveis recebem as dependências como parâmetros ou as leem de um ViewModel suspenso. Você tem duas opções:
- Gráfico aninhado do Navigation Compose (preferido para fluxos de várias telas).
Coloque todas as telas no fluxo de login em um gráfico de navegação aninhado e defina o contêiner para o
NavBackStackEntrydesse gráfico. O contêiner é criado quando o usuário entra no fluxo e limpo quando a entrada da backstack é removida, sem que sejam necessárias chamadas manuais de ciclo de vida. Para mais informações, consulte Projetar o gráfico de navegação. rememberna raiz da tela (para um fluxo de tela única ou quando você não estiver usando o Navigation Compose). Construa o contêiner dentro derememberpara que ele seja criado uma vez por entrada na composição e coletado como lixo quando o elemento combinável sair:
@Composable
fun LoginFlow(appContainer: AppContainer) {
val loginContainer = remember(appContainer) {
LoginContainer(appContainer.userRepository)
}
val viewModel: LoginViewModel = viewModel(
factory = loginContainer.loginViewModelFactory
)
// Render the login flow using loginContainer.loginData and viewModel.
}
Conclusão
A injeção de dependências é uma boa técnica para criar apps Android escalonáveis e testáveis. Use contêineres como uma maneira de compartilhar instâncias de classes em diferentes partes do app e como um local centralizado para criar instâncias de classes usando fábricas.
Quando o aplicativo ficar maior, vai ser necessário programar muitos códigos boilerplate (como fábricas), que podem ser propensos a erros. Também é preciso gerenciar o escopo e ciclo de vida dos contêineres, otimizando e descartando os que não são mais necessários para liberar memória. Fazer isso incorretamente pode levar a bugs e vazamentos de memória sutis no app.
Na seção sobre o Dagger, você vai aprender a usar esse framework para automatizar esse processo e gerar o mesmo código que você teria programado manualmente.