Injeção de dependência no Android com o Hilt

Injeção de dependência no Android com o Hilt
Alex Felipe
Alex Felipe

Compartilhe

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
Amostra animada do app em que as partes vão surgindo até completar a tela: primeiro, aparece o cabeçalho do app onde se lê “Ceep”, em seguida aparecem duas notas na tela (my first title/my first description e my second title/my second description) e, por fim, surgem as imagens meramente ilustrativas de cada nota #inset

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!

Banner de divulgação da Imersão IA da Alura em colaboração com o Google. Mergulhe em Inteligência artificial com a Alura e o Google. Serão cinco aulas gratuitas para você aprender a usar IA na prática e desenvolver habilidades essenciais para o mercado de trabalho. Inscreva-se gratuitamente agora!

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:

Animação que exibe os ícones indicando o mapeamento da dependência `lateinit var instance: TextMyFirstDI` com a classe `TestMyFirstDI` configurada para injeção de dependência na MainActivity #inset

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:

Animação mostrando que o cursor clica em um ícone à esquerda da linha 13 (private val dao: NoteDao,) do código que leva para a linha 23 (fun getNoteDao(@ApplicationContext context: Context) : NoteDao{.) #inset

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 de getNoteDao(), é mais comum utilizar o provideNoteDao(). 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 do AppDatabase, 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ê!

Alex Felipe
Alex Felipe

Alex é instrutor e desenvolvedor e possui experiência em Java, Kotlin, Android. Atualmente cria conteúdo no canal https://www.youtube.com/@AlexFelipeDev.

Veja outros artigos sobre Mobile