Injeção de dependência no Android com o Hilt
Recentemente, em 18 de maio de 2021, foi anunciada a versão release do Hilt, uma biblioteca para injeção de dependência no Android. A partir dela, evitamos boilerplates, trechos de código que se repetem ao longo do projeto, para configurar e implementar uma solução de injeção de dependência no nosso código, o que não era uma realidade com o Dagger.
Caso você não sabia o conceito de injeção de dependência e seus benefícios, dê uma olhada neste tópico do artigo de injeção de dependência no Android com o Koin. Você também pode conferir um resumo da documentação do Android que aborda o assunto.
Dentro dessa proposta, o Hilt é uma biblioteca bastante atrativa recomendada pela equipe de desenvolvedores do Andoid.
Neste artigo eu vou demonstrar como é possível fazer a configuração do Hilt em um projeto Android com Navigation, ViewModel, Repository, Room e Retrofit! Quer saber como fica essa configuração? Então, bora começar!
Projeto de exemplo
Para este artigo, vou considerar o uso do Ceep, um app que simula o Google Keep para cadastrar notas. Se quiser acompanhar os exemplos do artigo, você pode acessar o código-fonte ou baixar o projeto em seu estado inicial (sem a integração com o Hilt).
Funcionalidades do projeto
Este tópico é focado em explicar mais sobre o projeto. Então, caso você não tenha interesse, vá para o próximo tópico, "Preparando o ambiente".
O app lista notas com imagem, título e descrição, sejam armazenadas internamente ou recebidas via web API.
As técnicas e tecnologias utilizadas neste projeto são:
- Navigation: configuração da navegação das telas
- RecyclerView: listagem das notas
- ConstraintLayout: ViewGroup para implementar o layout das notas
- Coil: carregar imagens via requisição HTTP
- Retrofit: requisição HTTP para buscar novas notas
- Room: Armazenamento interno das notas
- ViewModel: modelo para manter regra de negócio das Activities/Fragments
- Coroutines: processamento assíncrono
- View Binding: busca de views do layout de forma segura
Observações da web API
As notas apresentadas na amostra são carregadas por meio de uma web API mockada, ou seja, para que você consiga fazer a mesma simulação, você precisa utilizar um endereço de uma API em funcionamento.
Caso você implemente a sua própria ou utilize uma mockada, é necessário configurar um end-point GET para /notes
. Como corpo da resposta, você deve devolver uma lista de notas:
[
{
"id" : 1,
"title": "my first title",
"description": "my first description"
},
{
"id" : 2,
"title": "my second title",
"description": "my second description"
}
]
O app também funciona sem essa integração com uma web API. O grande detalhe é que ele vai mostrar no logcat que não conseguiu fazer uma comunicação:
br.com.alura.ceep E/NoteRepository: findAll: failure to fetch new notes from API
Para adicionar notas sem a conexão com a web API, você pode acessar a CeepApp
e descomentar o código do onCreate()
. Na amostra de código é apresentado um exemplo de como salvar notas:
class CeepApp : Application() {
override fun onCreate() {
super.onCreate()
saveNotes(
listOf(
Note(
title = "first title",
description = "first description"
),
Note(
title = "second title",
description = "second description"
)
)
)
}
private fun saveNotes(notes: List<Note>) {
CoroutineScope(IO).launch {
AppDatabase.getInstance(this@CeepApp)
.getNoteDao().save(notes)
}
}
}
Neste exemplo, foram salvas 2 notas, e você pode modificar a quantidade conforme a sua preferência. A única orientação é que, depois de executar o app uma vez, comente ou apague essa amostra de código para que você consiga testar as mudanças e não apareçam várias notas!
Agora que passei todas as informações do projeto, vamos começar com a configuração do Hilt!
Preparando o ambiente
Para adicionar o Hilt, adicionamos os seguintes scripts:
build.gradle
do projeto:
buildscript {
...
ext.hilt_version = '2.35'
dependencies {
...
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
}
}
app/build.gradle
do módulo:
...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'
android {
...
}
dependencies {
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-compiler:$hilt_version"
}
Feitas as modificações, sincronize o projeto com o Gradle.
Configurando a Application
Como primeiro passo, precisamos anotar a Application
com a @HiltAndroidApp
para que o Hilt gere o código a nível de aplicação que vai fornecer as dependências para todas as entidades, desde as classes do Android framework, como também as suas classes do projeto:
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class CeepApp: Application() {
// rest of the code
}
Apenas com essa anotação fizemos a configuração mínima para configurar o Hilt
.
Configurando classes para injetar dependências
Após configurar o Hilt, podemos anotar as classes que o Hilt pode injetar dependências a partir da @AndroidEntryPoint
. Como por exemplo, podemos fazer a nossa primeira configuração na MainActivity
:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// rest of the code
}
A partir de agora, a nossa Activity pode receber dependências a partir do Hilt! Para um primeiro teste, podemos criar a classe TestMyFirstDI
com as seguintes informações:
package br.com.alura.ceep.ui.activity
// other imports
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
private lateinit var instance: TestMyFirstDI
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.i("MainActivity", "onCreate: $instance")
// rest of the code
}
}
class TestMyFirstDI @Inject constructor()
Note que precisamos indicar qual construtor da classe deve ser injetado pelo Hilt com a anotação @Inject
e, então, criamos uma property lateinit do tipo TestMyFirstDI
e anotamos com a @Inject
também, pois é dessa forma indicamos para o Hilt qual construtor ele vai utilizar para criar a instância!
Inclusive, a partir dessa configuração, é indicado à esquerda da property que o mapeamento com o Hilt foi feito:
Dependências na Activity não podem ser privadas! Caso configure dessa forma, ao gerar o código no processo de build, o Hilt vai indicar que não é possível!
Então, se executamos o app para conferir o valor da instância, temos o seguinte resultado:
I/MainActivity: onCreate: br.com.alura.ceep.ui.activity.TestMyFirstDI@c7edb3e
E é dessa forma que fazemos a configuração básica de injeção de dependência com o Hilt!
Por mais que essa demonstração seja simples, sabemos que ao utilizar ferramentas para injeção de dependência no Android, procuramos configurar classes como: ViewModel, Repositories, DAO, Services etc.
Sendo assim, vamos começar a configuração de injeção de dependência do ViewModel da tela de lista de notas!
Injetando o ViewModel
Da mesma forma que fizemos na Activity, para injetar dependências no Fragment, precisamos utilizar a anotação @AndroidEntryPoint
:
@AndroidEntryPoint
class NotesListFragment : Fragment() {
// rest of the code
}
Então, precisamos modificar o ViewModel que queremos injetar também, a diferença é que para o ViewModel temos uma anotação específica para que seja criado a partir de um factory, a @HiltViewModel
. Como primeira amostra, vamos criar um ViewModel de teste:
@HiltViewModel
class TestViewModel @Inject constructor() : ViewModel()
A partir desta anotação, as classes anotadas com @AndroidEntryPoint
, são capazes de injetar o TestViewModel
.
Diferente de classes comuns, em ViewModels precisamos utilizar um ViewModelProvider
ou a delegated property viewModels()
para injetar dependências com o Hilt:
@AndroidEntryPoint
class NotesListFragment : Fragment() {
// properties
private val testViewModel: TestViewModel by viewModels()
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?
) {
super.onViewCreated(view, savedInstanceState)
Log.i("NotesListFragment", "onViewCreated: $testViewModel")
configureAdapter()
}
// rest of the code
}
@HiltViewModel
class TestViewModel @Inject constructor() : ViewModel()
Ao testar o código, o Hilt apresenta a instância pra gente:
I/NotesListFragment: onViewCreated: br.com.alura.ceep.ui.fragment.TestViewModel@efd0d57
O grande detalhe é que essa delegated property também funciona sem o Hilt! Ou seja, não temos tanto benefício com ViewModels sem dependências! Porém, ao configurar o NoteViewModel
:
@HiltViewModel
class NoteViewModel @Inject constructor(
private val repository: NoteRepository
) : ViewModel() {
fun findAll(): Flow<List<Note>> = repository.findAll()
}
Observe que, dessa vez, o construtor do NoteViewModel
tem uma dependência! Ao tentar buildar o projeto, temos a seguinte mensagem de problema:
[Dagger/MissingBinding] br.com.alura.ceep.repository.NoteRepository cannot be provided without an @Inject constructor or an @Provides-annotated method.
Ela indica que para que seja possível instanciar o NoteViewModel
, é necessário indicar também como é possível instanciar o NoteRepository
!
Uma das maneiras de fazer isso é anotando o construtor que desejamos instância do NoteRepository
com o @Inject
:
class NoteRepository @Inject constructor(
private val dao: NoteDao,
private val service: NoteService
) {
// rest of the code
}
Ao fazer isso, temos um novo problema ao fazer o build:
[Dagger/MissingBinding] br.com.alura.ceep.database.dao.NoteDao cannot be provided without an @Provides-annotated method.
Precisamos também indicar ao Hilt como ele cria instâncias do NoteDao
e NoteService
! O grande detalhe é que são interfaces, ou seja, não temos um construtor para indicar ao Hilt como ele pode criar instâncias de interface.
Módulos do Hilt
Nesses casos, utilizamos um recurso conhecido como módulo, que é uma configuração que permite ensinar ao Hilt a criar instâncias de referências que não podem ser criadas a partir de um construtor anotado.
Para configurar um módulo, precisamos de uma classe para representar o módulo. A representação de um módulo pode ser feita a partir do que ele vai configurar, por exemplo, o NoteDao
faz parte da configuração do Room que é realizado à configuração do banco de dados, portanto, podemos nomear como um módulo de banco de dados:
package br.com.alura.ceep.di.modules;
class DatabaseModule {
}
Para iniciar a configuração, precisamos anotar a classe com @Module
para indicar ao Hilt que é um módulo e também utilizar a @InstallIn
que indica onde o módulo será utilizado/instalado.
@Module
@InstallIn
class DatabaseModule {
{
Além de anotar, a @InstallIn
exige que indiquemos, via argumento, a classe do Android que vamos instalar o módulo. Isso é necessário para determinar em qual entidade do Android a instância será criada e oferecida, bem como o seu ciclo de vida.
A princípio parece bastante abstrato, mas é mais simples do que parece, pois, basicamente, instalamos módulos em classes como: Application, Activity, Fragment, ViewModel etc.
Em outras palavras, se você instala o módulo em uma Application, a instância vai ficar disponível para toda aplicação; caso seja para uma Activity, a instância será criada pela Activity e apenas seus membros terão acesso, além disso, será sincronizada com o seu ciclo de vida, ou seja, se a Activity for destruída a instância também será!
"Então qual o escopo escolhemos?"
Em casos como o banco de dados, geralmente mantemos a disponibilidade da mesma instância para toda a aplicação. Portanto, podemos instalar o módulo em nível de aplicação. Para isso, utilizamos a referência SingletonComponent
:
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {
{
Além da
SingletonComponent
, o Hilt oferece outras classes para instalar os módulos, você pode conferir aqui as demais possibilidades.
Provendo instâncias no módulo
Com o módulo configurado, o nosso próximo passo é configurar como ele vai prover as instâncias que queremos. Por exemplo, se precisamos que ele forneça a instância do NoteDao
, criamos um método que retorne esse tipo e o anotamos com @Provides
:
@Module
@InstallIn
class DatabaseModule {
@Provides
fun provideDao() : NoteDao {
}
}
Com o método criado, precisamos instanciar o NoteDao
, então, para isso, utilizamos o método getInstance()
da classe AppDatabase
que cria um NoteDao
. O grande detalhe é que esse método precisa de um Context
para criar a instância, e não temos acesso no módulo!
É nesse momento que o Hilt brilha! Pois podemos acessar um Context
da aplicação via parâmetro a partir da anotação @ApplicationContext
:
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {
@Provides
fun getNoteDao(@ApplicationContext context: Context) : NoteDao {
return AppDatabase.getInstance(context).getNoteDao()
}
}
Ao fazer o build do projeto, ainda temos o problema com o NoteService
, mas já conseguimos ver o mapeamento da NoteDao
dentro do NoteRepository
:
Com essa configuração, seria o suficiente para testar o app. Porém, observe que temos também o NoteService
que podemos configurar de maneira similar ao que fizemos. Então, vamos criar o Módulo para o Retrofit:
@Module
@InstallIn(SingletonComponent::class)
class RetrofitModule {
@Provides
fun provideNoteService(): NoteService {
}
}
Um padrão comum ao criar provider é utilizar a nomeação
provideDependencyName()
, ou seja, em vez degetNoteDao()
, é mais comum utilizar oprovideNoteDao()
. Fique à vontade para manter o que preferir, mas, a partir deste ponto, vou utilizar apenas o padrão mais comum.
O grande detalhe do NoteService
é que precisamos de uma instância do Retrofit
. Inicialmente, podemos fazer a seguinte implementação para prover o NoteService
:
@Module
@InstallIn(SingletonComponent::class)
class RetrofitModule {
@Provides
fun provideNoteService(): NoteService {
val logging = HttpLoggingInterceptor()
logging.setLevel(HttpLoggingInterceptor.Level.BODY)
val client: OkHttpClient = OkHttpClient.Builder()
.addInterceptor(logging)
.build()
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
return retrofit.create(NoteService::class.java)
}
}
Com essa implementação, temos todo o mapeamento necessário e podemos testar o app que ele roda sem problemas.
Otimizando as dependências
Por mais que o app funcione, ainda existem alguns detalhes nesta implementação. Por exemplo, para atualizar automaticamente a busca de novos dados com o Room, é necessário que a instância do AppDatabase
seja uma instância única em todo app, isto é, um Singleton!
Da maneira como implementamos, todas as instâncias oferecidas pelo provide são novas instâncias cada vez que uma entidade do Android recebe como injeção, portanto, por padrão, são factories, um padrão que sempre fabrica uma nova instância!
Para transformar as instâncias como Singleton, utilizamos a anotação @Singleton
no provide:
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {
@Singleton
@Provides
fun provideNoteDao(@ApplicationContext context: Context) : NoteDao {
return AppDatabase.getInstance(context).getNoteDao()
}
}
O grande detalhe desta configuração é que o NoteDao
está sendo o Singleton em vez do AppDatabase
, ou seja, somos os responsáveis em oferecer uma instância de AppDatabase
como Singleton, como é o caso do método getInstance()
.
Para delegar essa responsabilidade para o Hilt, podemos criar um outro provider focado apenas em criar a instância do AppDatabase
:
private const valDATABASE_NAME= "ceep.db"
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {
@Provides
fun provideNoteDao(db: AppDatabase): NoteDao {
return db.getNoteDao()
}
@Singleton
@Provides
fun provideAppDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
DATABASE_NAME
).build()
}
}
Observe que agora ensinamos o Hilt como ele pode criar um AppDatabase
e, a partir dessa configuração, podemos receber instâncias de AppDatabase
para configurar dependências! Como o provider do NoteDao
precisa de um AppDatabase
para ser criado, logo, podemos receber, via parâmetro, a sua dependência (db: AppDatabase
).
Com essa mudança, não temos mais a necessidade de manter a anotação @Singleton
para no provide do NoteDao
, e a implementação dele fica mais simples!
Veja também que agora o AppDatabase
simplifica bem o seu código dado que não precisa manter mais o getInstance()
:
@Database(
version = 1,
exportSchema = false,
entities = [Note::class]
)
abstract class AppDatabase : RoomDatabase() {
abstract fun getNoteDao(): NoteDao
}
Com essa mudança, pontos de código que usavam a maneira antiga precisam ser ajustados, como é o caso da Application (CeepApp
), que tem o código que salva notas diretamente no banco dados, que podemos ajustar para acessar via injeção de dependência:
@HiltAndroidApp
class CeepApp : Application() {
@Inject lateinit var dao: NoteDao
override fun onCreate() {
super.onCreate()
// saveNotes(
// listOf(
// Note(
// title = "first title",
// description = "first description"
// ),
// Note(
// title = "second title",
// description = "second description"
// )
// )
// )
}
private fun saveNotes(notes: List<Note>) {
CoroutineScope(Dispatchers.IO).launch{
dao.save(notes)
}
}
}
O
NoteViewModelFactory
também utiliza uma instância doAppDatabase
, porém, não é necessário mantê-lo, dado que o Hilt é o único responsável por criar ViewModels, então, podemos removê-lo do projeto!
Da mesma maneira que fizemos com o módulo de banco de dados, podemos também fazer com o do Retrofit:
private const val BASE_URL= "https://your_web_api_address"
@Module
@InstallIn(SingletonComponent::class)
class RetrofitModule {
@Singleton
@Provides
fun provideOkHttpClient(): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.setLevel(HttpLoggingInterceptor.Level.BODY)
return OkHttpClient.Builder()
.addInterceptor(logging)
.build()
}
@Singleton
@Provides
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(MoshiConverterFactory.create())
.client(client)
.build()
}
@Provides
fun provideNoteService(retrofit: Retrofit): NoteService {
return retrofit.create(NoteService::class.java)
}
}
Com esta implementação, não há mais a necessidade de utilizar o
RetrofitInit
, portanto, podemos remover do projeto também.
Conclusão
Neste artigo, demonstrei a configuração mais simples e comum no Hilt, porém, ainda existem outros detalhes que podem ser úteis no seu projeto Android.
Caso seja do seu interesse, você pode conferir esta página da documentação para mais detalhes do Hilt.
E se você gostou deste artigo, a Formação Arquitetura Android é para você!