Menggunakan Hilt di aplikasi Android

Dalam codelab ini, Anda akan mempelajari pentingnya injeksi dependensi (DI) untuk membuat aplikasi yang solid dan dapat diperluas yang diskalakan ke project besar. Kita akan menggunakan Hilt sebagai alat DI untuk mengelola dependensi.

Injeksi dependensi adalah teknik yang banyak digunakan dalam pemrograman dan sesuai dengan pengembangan Android. Dengan mengikuti prinsip-prinsip DI, Anda mengatur dasar arsitektur aplikasi yang baik.

Implementasi injeksi dependensi memberikan beberapa keuntungan berikut:

  • Penggunaan kembali kode
  • Kemudahan dalam pemfaktoran ulang
  • Kemudahan dalam pengujian

Hilt adalah library injeksi dependensi dogmatis untuk Android yang mengurangi boilerplate ketika melakukan DI manual dalam project Anda. Untuk melakukan injeksi dependensi manual, Anda perlu membuat setiap class dan dependensinya secara manual, serta menggunakan container untuk memakai ulang dan mengelola dependensi.

Hilt menyediakan cara standar untuk melakukan injeksi DI pada aplikasi Anda dengan menyediakan container ke setiap komponen Android dalam project dan mengelola siklus proses container secara otomatis untuk Anda. Hal ini dilakukan dengan memanfaatkan library DI populer: Dagger.

Jika Anda mengalami masalah (bug kode, kesalahan gramatikal, susunan kata yang tidak jelas, dll.) saat mengerjakan codelab ini, laporkan masalah tersebut melalui link Laporkan kesalahan di pojok kiri bawah codelab.

Prasyarat

  • Anda memiliki pengalaman dengan sintaksis Kotlin.
  • Anda memahami mengapa injeksi dependensi penting dalam aplikasi Anda.

Yang akan Anda pelajari

  • Cara menggunakan Hilt di aplikasi Android.
  • Konsep Hilt yang relevan untuk membuat aplikasi berkelanjutan.
  • Cara menambahkan beberapa binding ke jenis yang sama dengan penentu.
  • Cara menggunakan @EntryPoint untuk mengakses container dari class yang tidak didukung Hilt.
  • Cara menggunakan unit dan uji instrumentasi untuk menguji aplikasi yang menggunakan Hilt.

Yang Anda butuhkan

  • Android Studio 4.0 atau yang lebih baru.

Mendapatkan kode

Dapatkan kode codelab dari GitHub:

$ git clone https://github.com/googlecodelabs/android-hilt

Atau, Anda dapat mendownload repositori sebagai file Zip:

Download Zip

Membuka Android Studio

Codelab ini memerlukan Android Studio 4.0 atau versi yang lebih baru. Jika perlu mendownload Android Studio, Anda dapat melakukannya di sini.

Menjalankan aplikasi contoh

Dalam codelab ini, Anda akan menambahkan Hilt ke aplikasi yang mencatat interaksi pengguna dan menggunakan Room untuk menyimpan data ke database lokal.

Ikuti petunjuk ini untuk membuka aplikasi contoh di Android Studio:

  • Jika Anda mendownload arsip zip, ekstrak file tersebut secara lokal.
  • Buka project di Android Studio.
  • Klik tombol Run execute.png, lalu pilih emulator atau hubungkan perangkat Android Anda.

Seperti yang dapat Anda lihat, log dibuat dan disimpan setiap kali Anda berinteraksi dengan salah satu tombol bernomor. Di layar Lihat Semua Log, Anda akan melihat daftar semua interaksi sebelumnya. Untuk menghapus log, ketuk tombol Hapus Log.

Penyiapan project

Project ini di-build di beberapa cabang GitHub:

  • master adalah cabang yang Anda buka atau download. Ini adalah titik awal codelab.
  • solution berisi solusi untuk codelab ini.

Sebaiknya Anda memulai dengan kode di cabang master dan mengikuti codelab langkah demi langkah sesuai kemampuan Anda.

Selama codelab, Anda akan melihat cuplikan kode yang harus ditambahkan ke project. Di beberapa tempat, Anda juga harus menghapus kode yang disebutkan secara eksplisit dalam komentar pada cuplikan kode.

Untuk mendapatkan cabang solution menggunakan git, gunakan perintah ini:

$ git clone -b solution https://github.com/googlecodelabs/android-hilt

Atau download kode solusi dari sini:

Download kode akhir

Pertanyaan umum (FAQ)

Mengapa Hilt?

Jika melihat kode awal, Anda dapat melihat instance class ServiceLocator yang disimpan di class LogApplication. ServiceLocator membuat dan menyimpan dependensi yang diperoleh sesuai permintaan oleh class yang membutuhkannya. Anda dapat menganggapnya sebagai container dependensi yang disertakan ke siklus proses aplikasi karena akan dimusnahkan saat aplikasi melakukannya.

Seperti yang dijelaskan dalam Panduan DI Android, Pencari Lokasi Layanan dimulai dengan kode boilerplate yang relatif sedikit, tetapi juga dengan skala yang cukup kecil. Untuk mengembangkan aplikasi Android dalam skala besar, Anda harus menggunakan Hilt.

Hilt menghapus boilerplate tidak diperlukan yang Anda butuhkan untuk menggunakan pola DI atau Pencari Lokasi Layanan manual di aplikasi Android dengan membuat kode yang semestinya Anda buat secara manual (mis. kode di class ServiceLocator).

Pada langkah berikutnya, Anda akan menggunakan Hilt untuk mengganti class ServiceLocator. Setelah itu, kami akan menambahkan fitur baru ke project untuk menjelajahi fungsi Hilt lainnya.

Hilt dalam project Anda

Hilt telah dikonfigurasi di cabang master (kode yang Anda download). Anda tidak perlu menyertakan kode berikut dalam project karena sudah dilakukan untuk Anda. Meskipun demikian, mari kita lihat apa yang diperlukan untuk menggunakan Hilt di aplikasi Android.

Selain dependensi library, Hilt menggunakan plugin Gradle yang dikonfigurasi dalam project. Buka file build.gradle root dan lihat dependensi Hilt berikut di classpath:

buildscript {
    ...
    ext.hilt_version = '2.28-alpha'
    dependencies {
        ...
        classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
    }
}

Kemudian, untuk menggunakan plugin gradle dalam modul app, kami menetapkannya dalam file app/build.gradle dengan menambahkan plugin ke bagian atas file, di bawah plugin kotlin-kapt:

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    ...
}

Terakhir, dependensi Hilt disertakan dalam project kami di file app/build.gradle yang sama:

...
dependencies {
    ...
    implementation "com.google.dagger:hilt-android:$hilt_version"
    kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
}

Semua library, termasuk Hilt, didownload saat Anda mem-build dan menyinkronkan project. Mari kita mulai menggunakan Hilt!

Demikian pula dengan cara penggunaan dan inisialisasi instance ServiceLocator di class LogApplication, untuk menambahkan container yang disertakan ke siklus proses aplikasi, kita perlu menganotasi class Application dengan @HiltAndroidApp. Buka LogApplication.kt dan tambahkan anotasi ke class:

@HiltAndroidApp
class LogApplication : Application() {
    ...
}

@HiltAndroidApp memicu pembuatan kode Hilt termasuk class dasar untuk aplikasi Anda yang dapat menggunakan injeksi dependensi. Container aplikasi adalah container induk aplikasi, yang berarti container lain dapat mengakses dependensi yang disediakannya.

Kini, aplikasi siap menggunakan Hilt!

Daripada mengambil dependensi sesuai permintaan dari ServiceLocator di class, kita akan menggunakan Hilt untuk menyediakan dependensi tersebut. Mari mulai mengganti panggilan ke ServiceLocator dari class.

Buka file ui/LogsFragment.kt. LogsFragment mengisi kolomnya di onAttach. Alih-alih mengisi instance LoggerLocalDataSource dan DateFormatter secara manual menggunakan ServiceLocator, kita dapat menggunakan Hilt untuk membuat dan mengelola instance jenis tersebut.

Agar LogsFragment menggunakan Hilt, kita harus menganotasinya dengan @AndroidEntryPoint:

@AndroidEntryPoint
class LogsFragment : Fragment() {
    ...
}

Menganotasi class Android dengan @AndroidEntryPoint akan membuat container dependensi yang mengikuti siklus proses class Android.

Dengan @AndroidEntryPoint, Hilt akan membuat container dependensi yang disertakan ke siklus proses LogsFragment dan akan dapat memasukkan instance ke LogsFragment. Bagaimana cara mendapatkan kolom yang diinjeksi oleh Hilt?

Kita dapat membuat Hilt menginjeksi instance dari jenis yang berbeda dengan anotasi @Inject pada kolom yang ingin diinjeksi (mis. logger dan dateFormatter):

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var dateFormatter: DateFormatter

    ...
}

Inilah yang disebut dengan injeksi kolom.

Karena Hilt akan bertanggung jawab mengisi kolom tersebut, kita tidak memerlukan metode populateFields lagi. Mari kita hapus metode tersebut dari class:

@AndroidEntryPoint
class LogsFragment : Fragment() {

    // Remove following code from LogsFragment

    override fun onAttach(context: Context) {
        super.onAttach(context)

        populateFields(context)
    }

    private fun populateFields(context: Context) {
        logger = (context.applicationContext as LogApplication).serviceLocator.loggerLocalDataSource
        dateFormatter =
            (context.applicationContext as LogApplication).serviceLocator.provideDateFormatter()
    }

    ...
}

Di balik layar, Hilt akan mengisi kolom tersebut dalam metode siklus proses onAttach() dengan instance yang dibuat dalam container dependensi LogsFragment yang dihasilkan secara otomatis.

Untuk melakukan injeksi kolom, Hilt perlu mengetahui cara menyediakan instance dependensi tersebut. Dalam hal ini, Hilt perlu mengetahui cara menyediakan instance LoggerLocalDataSource dan DateFormatter. Namun, Hilt belum mengetahui cara menyediakan instance tersebut.

Memberi tahu Hilt cara menyediakan dependensi dengan @Inject

Buka file ServiceLocator.kt untuk melihat cara penerapan ServiceLocator. Anda dapat melihat bagaimana panggilan provideDateFormatter() selalu menampilkan instance DateFormatter yang berbeda.

Ini sama persis dengan tindakan yang ingin kita peroleh dengan Hilt. Untungnya, DateFormatter tidak bergantung pada class lain sehingga untuk saat ini kita tidak perlu khawatir dengan dependensi transitif.

Untuk memberi tahu Hilt cara memberikan instance suatu jenis, tambahkan anotasi @Inject ke konstruksi class yang Anda inginkan agar diinjeksi.

Buka file util/DateFormatter.kt dan beri anotasi pada konstruktor DateFormatter dengan @Inject. Perlu diingat bahwa untuk menganotasi konstruktor di Kotlin, Anda juga memerlukan kata kunci constructor:

class DateFormatter @Inject constructor() { ... }

Dengan ini, Hilt tahu cara menyediakan instance DateFormatter. Hal yang sama harus dilakukan dengan LoggerLocalDataSource. Buka file data/LoggerLocalDataSource.kt dan beri anotasi pada konstruktornya dengan @Inject:

class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

Jika membuka class ServiceLocator lagi, Anda dapat melihat bahwa kita memiliki kolom LoggerLocalDataSource publik. Itu berarti bahwa ServiceLocator akan selalu menampilkan instance LoggerLocalDataSource yang sama setiap kali dipanggil. Ini yang disebut dengan "mencakup instance ke container". Bagaimana cara melakukannya di Hilt?

Kita dapat menggunakan anotasi untuk memberi cakupan instance ke container. Karena Hilt dapat menghasilkan berbagai container yang memiliki siklus proses yang berbeda, terdapat anotasi berbeda yang mencakup container tersebut.

Anotasi yang mencakup instance ke container aplikasi adalah @Singleton. Anotasi ini akan membuat container aplikasi selalu menyediakan instance yang sama, terlepas dari apakah jenis metode tersebut digunakan sebagai dependensi jenis lain atau apakah perlu diinjeksi ke kolom.

Logika yang sama dapat diterapkan ke semua container yang disertakan ke class Android. Anda dapat menemukan daftar semua anotasi pencakupan dalam dokumentasi. Misalnya, jika Anda ingin agar container aktivitas selalu memberikan instance jenis yang sama, Anda dapat menganotasi jenis tersebut dengan @ActivityScoped.

Seperti yang disebutkan di atas, karena kita ingin agar container aplikasi selalu memberikan instance LoggerLocalDataSource yang sama, kita memberi anotasi pada class-nya dengan @Singleton:

@Singleton
class LoggerLocalDataSource @Inject constructor(private val logDao: LogDao) {
    ...
}

Sekarang, Hilt mengetahui cara menyediakan instance LoggerLocalDataSource. Namun, kali ini, jenis tersebut memiliki dependensi transitif. Untuk memberikan instance LoggerLocalDataSource, Hilt juga perlu mengetahui cara menyediakan instance LogDao.

Namun, karena LogDao adalah antarmuka, kita tidak dapat memberi anotasi pada konstruktornya dengan @Inject karena antarmuka tidak memilikinya. Bagaimana cara memberi tahu Hilt cara menyediakan instance jenis ini?

Modul digunakan untuk menambahkan binding ke Hilt, atau dengan kata lain, untuk memberi tahu Hilt cara menyediakan instance dari jenis yang berbeda. Dalam modul Hilt, Anda menyertakan binding untuk jenis yang tidak dapat diinjeksi konstruktor seperti antarmuka atau class yang tidak terdapat dalam project Anda. Contohnya adalah OkHttpClient - Anda perlu menggunakan builder tersebut untuk membuat instance.

Modul Hilt adalah class yang dianotasikan dengan @Module dan @InstallIn. @Module memberi tahu Hilt bahwa ini adalah modul dan @InstallIn memberi tahu Hilt di container mana binding tersebut tersedia dengan menentukan Komponen Hilt. Anda dapat menganggap Komponen Hilt sebagai container, dan daftar lengkap Komponen dapat ditemukan di sini.

Untuk setiap class Android yang dapat diinjeksi oleh Hilt, tersedia Komponen Hilt terkait. Misalnya, container Application dikaitkan dengan ApplicationComponent, dan container Fragment dikaitkan dengan FragmentComponent.

Membuat Modul

Mari kita buat modul Hilt tempat kita bisa menambahkan binding. Buat paket baru bernama di di bawah paket hilt dan buat file baru dengan nama DatabaseModule.kt di dalam paket.

Karena LoggerLocalDataSource tercakup dengan container aplikasi, binding LogDao harus tersedia di container aplikasi. Kita menetapkan persyaratan tersebut menggunakan anotasi @InstallIn dengan meneruskan class Komponen Hilt yang terkait dengannya (yaitu ApplicationComponent:class):

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
object DatabaseModule {

}

Dalam implementasi class ServiceLocator, instance LogDao diperoleh dengan memanggil logsDatabase.logDao(). Oleh karena itu, untuk menyediakan instance LogDao, kita memiliki dependensi transitif pada class AppDatabase.

Menyediakan instance dengan @Provides

Kita dapat memberi anotasi pada fungsi dengan @Provides dalam modul Hilt untuk memberi tahu Hilt cara menyediakan jenis yang tidak dapat diinjeksi melalui konstruktor.

Isi fungsi dari fungsi yang dianotasi @Provides akan dijalankan setiap kali Hilt harus menyediakan instance dari jenis tersebut. Jenis nilai yang ditampilkan untuk fungsi yang dianotasi @Provides memberi tahu Hilt jenis binding atau cara menyediakan instance dari jenis tersebut. Parameter fungsi adalah dependensi jenis.

Dalam kasus ini, kita akan menyertakan fungsi ini di class DatabaseModule:

@Module
object DatabaseModule {

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

Kode di atas memberi tahu Hilt bahwa database.logDao() perlu dijalankan saat menyediakan instance LogDao. Karena kita memiliki AppDatabase sebagai dependensi transitif, kita juga perlu memberi tahu Hilt cara menyediakan instance jenis tersebut.

Karena AppDatabase adalah class lain yang tidak dimiliki project ini karena dibuat oleh Room, kita juga dapat menyediakannya menggunakan fungsi @Provides yang mirip dengan cara kita membuat instance database di class ServiceLocator:

@Module
object DatabaseModule {

    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext appContext: Context): AppDatabase {
        return Room.databaseBuilder(
            appContext,
            AppDatabase::class.java,
            "logging.db"
        ).build()
    }

    @Provides
    fun provideLogDao(database: AppDatabase): LogDao {
        return database.logDao()
    }
}

Karena kita selalu ingin Hilt menyediakan instance database yang sama, kita menganotasi metode @Provides provideDatabase dengan @Singleton.

Setiap container Hilt dilengkapi dengan serangkaian binding default yang dapat diinjeksikan sebagai dependensi ke dalam binding kustom Anda. Hal ini berlaku untuk applicationContext: untuk mengaksesnya, Anda perlu menganotasi kolom dengan @ApplicationContext.

Menjalankan aplikasi

Sekarang, Hilt memiliki semua informasi yang diperlukan untuk menginjeksi instance di LogsFragment. Namun, sebelum menjalankan aplikasi, Hilt harus mengetahui Activity yang menghosting Fragment agar dapat berfungsi. Kita perlu menganotasi MainActivity dengan @AndroidEntryPoint.

Buka file ui/MainActivity.kt dan beri anotasi MainActivity dengan @AndroidEntryPoint:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() { ... }

Sekarang, Anda dapat menjalankan aplikasi dan memeriksa apakah semuanya berfungsi dengan baik seperti sebelumnya.

Mari kita lanjutkan pemfaktoran ulang aplikasi untuk menghapus panggilan ServiceLocator dari MainActivity.

MainActivity mendapatkan instance AppNavigator dari ServiceLocator yang memanggil fungsi provideNavigator(activity: FragmentActivity).

Karena AppNavigator adalah antarmuka, kita tidak dapat menggunakan injeksi konstruktor. Untuk memberi tahu Hilt implementasi apa yang akan digunakan untuk antarmuka, Anda dapat menggunakan anotasi @Binds pada fungsi di dalam modul Hilt.

@Binds harus menganotasi fungsi abstrak (karena abstrak, fungsi tidak berisi kode apa pun dan class harus abstrak). Jenis nilai yang ditampilkan dari fungsi abstrak adalah antarmuka yang ingin kita sediakan implementasinya (yaitu AppNavigator). Implementasi ditentukan dengan menambahkan parameter unik dengan jenis implementasi antarmuka (yaitu AppNavigatorImpl).

Dapatkah kita menambahkan informasi ke class DatabaseModule yang kita buat sebelumnya atau apakah kita memerlukan modul baru? Ada beberapa alasan mengapa kita harus membuat modul baru:

  • Untuk pengaturan yang lebih baik, nama modul harus mencerminkan jenis informasi yang diberikan. Misalnya, tidak masuk akal untuk menyertakan binding navigasi dalam modul bernama DatabaseModule.
  • Modul DatabaseModule diinstal di ApplicationComponent, sehingga binding tersedia di container aplikasi. Informasi navigasi baru (yaitu AppNavigator) memerlukan informasi khusus dari Aktivitas (karena AppNavigatorImpl memiliki Activity sebagai dependensi). Oleh karena itu, modul harus diinstal di container Activity sebagai ganti container Application karena di sanalah tempat informasi tentang Activity tersedia.
  • Modul Hilt tidak boleh berisi metode binding non-statis dan abstrak, sehingga Anda tidak dapat menempatkan anotasi @Binds dan @Provides di class yang sama.

Buat file baru bernama NavigationModule.kt di folder di. Mari kita buat class abstrak baru yang dengan nama NavigationModule yang dianotasikan dengan @Module dan @InstallIn(ActivityComponent::class) seperti yang dijelaskan di atas:

@InstallIn(ActivityComponent::class)
@Module
abstract class NavigationModule {

    @Binds
    abstract fun bindNavigator(impl: AppNavigatorImpl): AppNavigator
}

Di dalam modul, kita dapat menambahkan binding untuk AppNavigator. Ini adalah fungsi abstrak yang menampilkan antarmuka yang kita informasikan kepada Hilt (yaitu AppNavigator) dan parameternya adalah implementasi antarmuka tersebut (yaitu AppNavigatorImpl).

Sekarang, kita harus memberi tahu Hilt cara menyediakan instance AppNavigatorImpl. Karena class ini dapat kita injeksi melalui konstruktor, kita hanya menganotasi konstruktornya dengan @Inject.

Buka file navigator/AppNavigatorImpl.kt dan lakukan hal tersebut:

class AppNavigatorImpl @Inject constructor(
    private val activity: FragmentActivity
) : AppNavigator {
    ...
}

AppNavigatorImpl bergantung pada FragmentActivity. Karena instance AppNavigator disediakan dalam container Activity (instance ini juga tersedia dalam container Fragment dan container View karena NavigationModule diinstal di ActivityComponent), FragmentActivity sudah tersedia karena tersedia sebagai binding standar.

Menggunakan Hilt dalam Aktivitas

Sekarang, Hilt memiliki semua informasi untuk dapat menginjeksi instance AppNavigator. Buka file MainActivity.kt, lalu lakukan hal berikut ini:

  1. Anotasi kolom navigator dengan @Inject untuk mendapatkan Hilt,
  2. Hapus pengubah visibilitas private, dan
  3. Hapus kode inisialisasi navigator di fungsi onCreate.

Kode baru akan terlihat seperti ini:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject lateinit var navigator: AppNavigator

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        if (savedInstanceState == null) {
            navigator.navigateTo(Screens.BUTTONS)
        }
    }

    ...
}

Menjalankan aplikasi

Anda dapat menjalankan aplikasi dan melihat cara kerjanya.

Menyelesaikan pemfaktoran ulang

Satu-satunya class yang masih menggunakan ServiceLocator untuk mengambil dependensi adalah ButtonsFragment. Karena Hilt sudah mengetahui cara menyediakan semua jenis yang dibutuhkan ButtonsFragment, kita cukup melakukan injeksi kolom di class.

Seperti yang telah kita pelajari sebelumnya, agar class ini diinjeksi kolomnya oleh Hilt, kita harus:

  1. Menganotasi ButtonsFragment dengan @AndroidEntryPoint,
  2. Menghapus pengubah pribadi dari kolom logger dan navigator dan menganotasinya dengan @Inject,
  3. Menghapus kode inisialisasi kolom (yaitu metode onAttach dan populateFields).

Kode untuk ButtonsFragment:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerLocalDataSource
    @Inject lateinit var navigator: AppNavigator

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

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        ...
    }
}

Perhatikan bahwa instance LoggerLocalDataSource akan sama dengan instance yang kita gunakan di LogsFragment karena jenisnya tercakup ke container aplikasi. Namun, instance AppNavigator akan berbeda dari instance di MainActivity karena kita belum mencakupkannya ke container Activity masing-masing.

Pada tahap ini, class ServiceLocator tidak lagi menyediakan dependensi sehingga kita dapat menghapusnya sepenuhnya dari project. Satu-satunya penggunaannya masih ada di class LogApplication tempat kita menyimpan instance. Mari hapus class tersebut karena tidak diperlukan lagi.

Buka class LogApplication dan hapus penggunaan ServiceLocator. Kode baru untuk class Application adalah:

@HiltAndroidApp
class LogApplication : Application()

Sekarang, jangan ragu untuk menghapus class ServiceLocator dari project sekaligus. Karena ServiceLocator masih digunakan dalam pengujian, hapus juga penggunaannya dari class AppTest.

Konten dasar yang dibahas

Apa yang baru saja Anda pelajari seharusnya sudah cukup untuk menggunakan Hilt sebagai alat Injeksi Dependensi di aplikasi Android.

Mulai sekarang, kita akan menambahkan fungsi baru ke aplikasi untuk mempelajari cara menggunakan fitur Hilt lanjutan di berbagai situasi.

Setelah menghapus class ServiceLocator dari project dan mempelajari dasar-dasar Hilt, mari tambahkan fungsionalitas baru ke aplikasi untuk mempelajari fitur Hilt lainnya.

Di bagian ini, Anda akan mempelajari:

  • Cara membuat cakupan ke container Aktivitas.
  • Apa yang dimaksud dengan penentu, masalah apa yang dipecahkan, dan cara menggunakannya.

Untuk menampilkan ini, kita memerlukan perilaku yang berbeda di aplikasi. Kita akan menukar penyimpanan log dari database ke daftar dalam memori dengan tujuan hanya merekam log selama sesi aplikasi.

Antarmuka LoggerDataSource

Mari kita mulai mengabstraksi sumber data menjadi antarmuka. Buat file baru dengan nama LoggerDataSource.kt pada folder data dengan konten berikut:

package com.example.android.hilt.data

// Common interface for Logger data sources.
interface LoggerDataSource {
    fun addLog(msg: String)
    fun getAllLogs(callback: (List<Log>) -> Unit)
    fun removeLogs()
}

LoggerLocalDataSource digunakan di kedua Fragmen: ButtonsFragment dan LogsFragment. Kita harus memfaktorkannya ulang untuk menggunakannya agar memakai instance LoggerDataSource sebagai gantinya.

Buka LogsFragment dan buat variabel logger jenis LoggerDataSource:

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

Lakukan hal yang sama di ButtonsFragment:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @Inject lateinit var logger: LoggerDataSource
    ...
}

Selanjutnya, mari kita buat LoggerLocalDataSource agar menerapkan antarmuka ini. Buka file data/LoggerLocalDataSource.kt dan:

  1. Buat agar menerapkan antarmuka LoggerDataSource, dan
  2. Tandai metodenya dengan override
@Singleton
class LoggerLocalDataSource @Inject constructor(
    private val logDao: LogDao
) : LoggerDataSource {
    ...
    override fun addLog(msg: String) { ... }
    override fun getAllLogs(callback: (List<Log>) -> Unit) { ... }
    override fun removeLogs() { ... }
}

Sekarang, mari kita buat implementasi lain dari LoggerDataSource bernama LoggerInMemoryDataSource yang menyimpan log dalam memori. Buat file baru bernama LoggerInMemoryDataSource.kt pada folder data dengan konten berikut:

package com.example.android.hilt.data

import java.util.LinkedList

class LoggerInMemoryDataSource : LoggerDataSource {

    private val logs = LinkedList<Log>()

    override fun addLog(msg: String) {
        logs.addFirst(Log(msg, System.currentTimeMillis()))
    }

    override fun getAllLogs(callback: (List<Log>) -> Unit) {
        callback(logs)
    }

    override fun removeLogs() {
        logs.clear()
    }
}

Membuat cakupan ke container Aktivitas

Agar dapat menggunakan LoggerInMemoryDataSource sebagai detail implementasi, kita perlu memberi tahu Hilt cara menyediakan instance jenis ini. Seperti sebelumnya, kita memberi anotasi konstruktor class dengan @Inject:

class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

Karena aplikasi kita hanya terdiri dari satu Aktivitas (juga disebut aplikasi Aktivitas tunggal), kita harus memiliki instance LoggerInMemoryDataSource di container Activity dan menggunakan kembali instance tersebut di Fragment.

Kita dapat memperoleh perilaku logging dalam memori dengan membuat cakupan LoggerInMemoryDataSource ke container Activity: setiap Activity yang dibuat akan memiliki container sendiri, yaitu instance yang berbeda. Pada setiap container, instance LoggerInMemoryDataSource yang sama akan diberikan saat logger diperlukan sebagai dependensi atau untuk injeksi kolom. Selain itu, instance yang sama akan disediakan dalam container di bawah Hierarki komponen.

Setelah membuat cakupan ke dokumentasi Komponen, agar suatu jenis tercakup ke container Activity, kita perlu menganotasi jenis tersebut dengan @ActivityScoped:

@ActivityScoped
class LoggerInMemoryDataSource @Inject constructor(
) : LoggerDataSource { ... }

Saat ini, Hilt mengetahui cara menyediakan instance LoggerInMemoryDataSource dan LoggerLocalDataSource, tetapi bagaimana dengan LoggerDataSource? Hilt tidak mengetahui implementasi mana yang akan digunakan saat LoggerDataSource diminta.

Seperti yang kita ketahui dari bagian sebelumnya, kita dapat menggunakan anotasi @Binds dalam modul untuk memberi tahu Hilt implementasi mana yang akan digunakan. Namun, bagaimana jika kita perlu menyediakan kedua implementasi dalam project yang sama? Misalnya, menggunakan LoggerInMemoryDataSource saat aplikasi sedang berjalan dan LoggerLocalDataSource dalam Service.

Dua implementasi untuk antarmuka yang sama

Mari membuat file baru di folder di bernama LoggingModule.kt. Karena implementasi LoggerDataSource yang berbeda tercakup ke container yang berbeda, kita tidak dapat menggunakan modul yang sama: LoggerInMemoryDataSource dicakupkan ke container Activity dan LoggerLocalDataSource ke container Application.

Untungnya, kita dapat menetapkan binding untuk kedua modul dalam file yang sama yang baru saja kita buat:

package com.example.android.hilt.di

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {

    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

Metode @Binds harus memiliki anotasi pencakupan jika jenisnya adalah tercakup, jadi itu sebabnya fungsi di atas dianotasi dengan @Singleton dan @ActivityScoped. Jika @Binds atau @Provides digunakan sebagai binding untuk suatu jenis, anotasi pencakupan dalam jenis tersebut tidak digunakan lagi, sehingga Anda dapat melanjutkan dan menghapusnya dari class implementasi yang berbeda.

Jika mencoba mem-build project sekarang, Anda akan melihat error DuplicateBindings.

error: [Dagger/DuplicateBindings] com.example.android.hilt.data.LoggerDataSource is bound multiple times

Hal ini karena jenis LoggerDataSource sedang diinjeksi di Fragment, tetapi Hilt tidak mengetahui implementasi mana yang akan digunakan karena ada dua binding dari jenis yang sama Bagaimana Hilt tahu yang mana yang harus digunakan?

Menggunakan penentu

Untuk memberi tahu Hilt cara menyediakan implementasi yang berbeda (beberapa binding) dari jenis yang sama, Anda dapat menggunakan penentu.

Kami perlu menetapkan penentu per implementasi karena setiap penentu akan digunakan untuk mengidentifikasi binding. Saat menginjeksi jenis di class Android atau memiliki jenis tersebut sebagai dependensi class lain, anotasi penentu harus digunakan untuk menghindari ambiguitas.

Karena penentu hanyalah anotasi, kita dapat menentukannya dalam file LoggingModule.kt tempat kita menambahkan modul:

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

Sekarang, penentu tersebut harus memberi anotasi pada fungsi @Binds (atau @Provides jika kita membutuhkannya) yang menyediakan setiap implementasi. Lihat kode lengkap dan perhatikan penggunaan penentu dalam metode @Binds:

package com.example.android.hilt.di

@Qualifier
annotation class InMemoryLogger

@Qualifier
annotation class DatabaseLogger

@InstallIn(ApplicationComponent::class)
@Module
abstract class LoggingDatabaseModule {

    @DatabaseLogger
    @Singleton
    @Binds
    abstract fun bindDatabaseLogger(impl: LoggerLocalDataSource): LoggerDataSource
}

@InstallIn(ActivityComponent::class)
@Module
abstract class LoggingInMemoryModule {

    @InMemoryLogger
    @ActivityScoped
    @Binds
    abstract fun bindInMemoryLogger(impl: LoggerInMemoryDataSource): LoggerDataSource
}

Selain itu, penentu tersebut harus digunakan pada titik injeksi dengan implementasi yang ingin diinjeksikan. Dalam hal ini, kita akan menggunakan implementasi LoggerInMemoryDataSource di Fragment.

Buka LogsFragment dan gunakan penentu @InMemoryLogger pada kolom logger untuk memberi tahu Hilt agar menginjeksikan instance LoggerInMemoryDataSource:

@AndroidEntryPoint
class LogsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

Lakukan hal yang sama untuk ButtonsFragment:

@AndroidEntryPoint
class ButtonsFragment : Fragment() {

    @InMemoryLogger
    @Inject lateinit var logger: LoggerDataSource
    ...
}

Jika ingin mengubah implementasi database yang ingin digunakan, Anda hanya perlu memberi anotasi pada kolom yang diinjeksi dengan @DatabaseLogger, bukan @InMemoryLogger.

Menjalankan aplikasi

Kita bisa menjalankan aplikasi dan mengonfirmasi yang telah kita lakukan dengan berinteraksi dengan tombol-tombol tersebut dan mengamati log yang sesuai muncul pada layar "Lihat semua log".

Perhatikan bahwa log tidak disimpan ke database lagi. Log tersebut tidak akan muncul di antara sesi, setiap kali Anda menutup dan membuka aplikasi lagi, layar log akan kosong.

Setelah aplikasi dimigrasikan sepenuhnya ke Hilt, kita juga dapat memigrasikan uji instrumentasi yang dimiliki dalam project. Pengujian yang memeriksa fungsi aplikasi ada di file AppTest.kt di folder app/androidTest. Buka file tersebut.

Anda akan melihat bahwa aplikasi tersebut tidak dikompilasi karena kita telah menghapus class ServiceLocator dari project. Hapus referensi ke ServiceLocator yang tidak digunakan lagi dengan menghapus metode @After tearDown dari class.

Pengujian androitTest berjalan pada emulator. Pengujian happyPath mengonfirmasi bahwa ketukan pada "Tombol 1" telah dicatat ke database. Karena aplikasi menggunakan database dalam memori, setelah pengujian selesai, semua log akan hilang.

Pengujian UI dengan Hilt

Hilt akan menginjeksi dependensi dalam pengujian UI seperti yang akan terjadi pada kode produksi.

Pengujian dengan Hilt tidak memerlukan pemeliharaan karena Hilt otomatis menghasilkan serangkaian komponen baru untuk setiap pengujian.

Menambahkan dependensi pengujian

Hilt menggunakan library tambahan dengan anotasi khusus pengujian yang mempermudah pengujian kode Anda dengan nama hilt-android-testing yang harus ditambahkan ke project. Selain itu, karena Hilt perlu membuat kode untuk class di folder androidTest, pemroses anotasinya juga harus dapat dijalankan di sana. Untuk mengaktifkannya, Anda harus menyertakan dua dependensi dalam file app/build.gradle.

Untuk menambahkan dependensi ini, buka app/build.gradle dan tambahkan konfigurasi ini ke bagian bawah dependencies:

...
dependencies {

    // Hilt testing dependency
    androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"
    // Make Hilt generate code in the androidTest folder
    kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"
}

TestRunner Kustom

Pengujian yang diinstrumentasi menggunakan Hilt harus dijalankan di Application yang mendukung Hilt. Library sudah dilengkapi dengan HiltTestApplication yang dapat digunakan untuk menjalankan pengujian UI. Menentukan Application yang akan digunakan dalam pengujian sudah dilakukan dengan membuat runner pengujian baru dalam project.

Di tingkat yang sama dengan tempat file AppTest.kt berada pada folder androidTest, buat file baru bernama CustomTestRunner. CustomTestRunner diperluas dari AndroidJUnitRunner dan diimplementasikan sebagai berikut:

class CustomTestRunner : AndroidJUnitRunner() {

    override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {
        return super.newApplication(cl, HiltTestApplication::class.java.name, context)
    }
}

Berikutnya, kita harus memberi tahu project agar menggunakan runner pengujian ini untuk uji instrumentasi. Hal tersebut ditetapkan dalam atribut testInstrumentationRunner dari file app/build.gradle. Buka file, dan ganti konten testInstrumentationRunner default dengan ini:

...
android {
    ...
    defaultConfig {
        ...
        testInstrumentationRunner "com.example.android.hilt.CustomTestRunner"
    }
    ...
}
...

Sekarang kita siap menggunakan Hilt dalam pengujian UI.

Menjalankan pengujian yang menggunakan Hilt

Selanjutnya, agar class pengujian emulator dapat menggunakan Hilt, maka harus:

  1. Dianotasikan dengan @HiltAndroidTest yang bertanggung jawab membuat komponen Hilt untuk setiap pengujian
  2. Menggunakan HiltAndroidRule yang mengelola status komponen dan digunakan untuk melakukan injeksi pada pengujian Anda.

Mari kita sertakan dalam AppTest:

@RunWith(AndroidJUnit4::class)
@HiltAndroidTest
class AppTest {

    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    ...
}

Sekarang, jika Anda menjalankan pengujian menggunakan tombol putar di samping definisi class atau definisi metode pengujian, emulator akan dimulai jika Anda telah mengonfigurasinya dan pengujian akan berhasil.

Untuk mempelajari lebih lanjut pengujian dan fitur seperti injeksi kolom atau mengganti binding dalam pengujian, lihat dokumentasi.

Di bagian codelab ini, kita akan mempelajari cara menggunakan anotasi @EntryPoint yang digunakan untuk menginjeksi dependensi pada class yang tidak didukung oleh Hilt.

Seperti yang kita lihat sebelumnya, Hilt dilengkapi dengan dukungan untuk komponen Android yang paling umum. Namun, Anda mungkin perlu melakukan injeksi kolom di class yang tidak didukung langsung oleh Hilt atau tidak dapat menggunakan Hilt.

Dalam kasus tersebut, Anda dapat menggunakan @EntryPoint. Titik entri adalah tempat batas Anda bisa mendapatkan objek yang disediakan Hilt dari kode yang tidak dapat menggunakan Hilt untuk menginjeksikan dependensinya. Ini adalah titik ketika kode pertama kali dimasukkan ke dalam container yang dikelola oleh Hilt.

Kasus penggunaan

Kita ingin mengekspor log di luar proses aplikasi. Untuk itu, kita perlu menggunakan ContentProvider. Kita hanya mengizinkan konsumen untuk meminta satu log tertentu (misalnya id) atau semua log dari aplikasi menggunakan ContentProvider. Kita akan menggunakan database Room untuk mengambil data. Oleh karena itu, class LogDao harus memperlihatkan metode yang menampilkan informasi yang diperlukan menggunakan database Cursor. Buka file LogDao.kt dan tambahkan metode berikut ke antarmuka.

@Dao
interface LogDao {
    ...

    @Query("SELECT * FROM logs ORDER BY id DESC")
    fun selectAllLogsCursor(): Cursor

    @Query("SELECT * FROM logs WHERE id = :id")
    fun selectLogById(id: Long): Cursor?
}

Selanjutnya, kita harus membuat class ContentProvider baru dan mengganti metode query untuk menampilkan Cursor dengan log. Buat file baru bernama LogsContentProvider.kt pada direktori contentprovider baru dengan konten berikut:

package com.example.android.hilt.contentprovider

import android.content.ContentProvider
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.UriMatcher
import android.database.Cursor
import android.net.Uri
import com.example.android.hilt.data.LogDao
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ApplicationComponent
import java.lang.UnsupportedOperationException

/** The authority of this content provider.  */
private const val LOGS_TABLE = "logs"

/** The authority of this content provider.  */
private const val AUTHORITY = "com.example.android.hilt.provider"

/** The match code for some items in the Logs table.  */
private const val CODE_LOGS_DIR = 1

/** The match code for an item in the Logs table.  */
private const val CODE_LOGS_ITEM = 2

/**
 * A ContentProvider that exposes the logs outside the application process.
 */
class LogsContentProvider: ContentProvider() {

    private val matcher: UriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI(AUTHORITY, LOGS_TABLE, CODE_LOGS_DIR)
        addURI(AUTHORITY, "$LOGS_TABLE/*", CODE_LOGS_ITEM)
    }

    override fun onCreate(): Boolean {
        return true
    }

    /**
     * Queries all the logs or an individual log from the logs database.
     *
     * For the sake of this codelab, the logic has been simplified.
     */
    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor? {
        val code: Int = matcher.match(uri)
        return if (code == CODE_LOGS_DIR || code == CODE_LOGS_ITEM) {
            val appContext = context?.applicationContext ?: throw IllegalStateException()
            val logDao: LogDao = getLogDao(appContext)

            val cursor: Cursor? = if (code == CODE_LOGS_DIR) {
                logDao.selectAllLogsCursor()
            } else {
                logDao.selectLogById(ContentUris.parseId(uri))
            }
            cursor?.setNotificationUri(appContext.contentResolver, uri)
            cursor
        } else {
            throw IllegalArgumentException("Unknown URI: $uri")
        }
    }

    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }

    override fun getType(uri: Uri): String? {
        throw UnsupportedOperationException("Only reading operations are allowed")
    }
}

Anda akan melihat bahwa panggilan getLogDao(appContext) tidak dapat dikompilasi. Kita perlu mengimplementasikannya dengan mengambil dependensi LogDao dari container aplikasi Hilt. Namun, Hilt tidak mendukung injeksi ke ContentProvider secara langsung seperti yang dilakukan dengan Aktivitas, misalnya, dengan @AndroidEntryPoint.

Kita perlu membuat antarmuka baru yang dianotasi dengan @EntryPoint untuk mengaksesnya.

@EntryPoint sedang dijalankan

Titik entri adalah antarmuka dengan metode pengakses untuk setiap jenis binding yang kita inginkan (termasuk penentunya). Selain itu, antarmuka harus dianotasi dengan @InstallIn untuk menentukan komponen yang akan menginstal titik entri.

Praktik terbaik adalah menambahkan antarmuka titik entri baru di dalam class yang menggunakannya. Oleh karena itu, sertakan antarmuka dalam file LogsContentProvider.kt:

class LogsContentProvider: ContentProvider() {

    @InstallIn(ApplicationComponent::class)
    @EntryPoint
    interface LogsContentProviderEntryPoint {
        fun logDao(): LogDao
    }

    ...
}

Perhatikan bahwa antarmuka dianotasi dengan @EntryPoint dan diinstal di ApplicationComponent karena kita menginginkan dependensi dari instance container Application. Di dalam antarmuka, kita menampilkan metode untuk binding yang ingin diakses, dalam hal ini, LogDao.

Untuk mengakses titik entri, gunakan metode statis yang sesuai dari EntryPointAccessors. Parameter harus berupa instance komponen atau objek @AndroidEntryPoint yang berfungsi sebagai pemegang komponen. Pastikan bahwa komponen yang Anda teruskan sebagai parameter dan metode statis EntryPointAccessors cocok dengan class Android dalam anotasi @InstallIn pada antarmuka @EntryPoint:

Sekarang, kita dapat menerapkan metode getLogDao yang tidak ada pada kode di atas. Mari kita gunakan antarmuka titik entri yang kita tentukan di atas dalam class LogsContentProviderEntryPoint:

class LogsContentProvider: ContentProvider() {
    ...

    private fun getLogDao(appContext: Context): LogDao {
        val hiltEntryPoint = EntryPointAccessors.fromApplication(
            appContext,
            LogsContentProviderEntryPoint::class.java
        )
        return hiltEntryPoint.logDao()
    }
}

Perhatikan cara meneruskan applicationContext ke metode EntryPoints.get statis dan class antarmuka yang dianotasi dengan @EntryPoint.

Sekarang Anda sudah terbiasa dengan Hilt dan seharusnya dapat menambahkannya ke aplikasi Android. Dalam codelab ini, Anda mempelajari:

  • Cara menyiapkan Hilt di class Aplikasi menggunakan @HiltAndroidApp.
  • Cara menambahkan container dependensi ke komponen siklus proses Android yang berbeda menggunakan @AndroidEntryPoint.
  • Cara menggunakan modul untuk memberi tahu Hilt cara menyediakan jenis tertentu.
  • Cara menggunakan penentu untuk menyediakan beberapa binding untuk jenis tertentu.
  • Cara menguji aplikasi menggunakan Hilt.
  • Kapan @EntryPoint berguna dan cara menggunakannya.