Injeksi dependensi manual

Arsitektur aplikasi yang direkomendasikan Android mendorong pembagian kode ke dalam class untuk mendapatkan manfaat dari pemisahan fokus, prinsip di mana setiap class hierarki memiliki satu tanggung jawab yang sudah ditentukan. Hal ini mengarah ke lebih banyak class yang lebih kecil yang perlu dihubungkan untuk memenuhi dependensi masing-masing.

Aplikasi Android biasanya terdiri dari banyak class, dan beberapa
    di antaranya bergantung pada satu sama lain.
Gambar 1. Model grafik aplikasi milik aplikasi Android grafik

Dependensi di antara class dapat direpresentasikan sebagai grafik, dengan setiap class terhubung ke class tempatnya bergantung. Grafik aplikasi merupakan hasil representasi dari semua class dan dependensinya. Pada gambar 1, Anda dapat melihat abstraksi grafik aplikasi. Jika class A (ViewModel) bergantung pada class B (Repository), ada garis yang mengarah dari A ke B yang mewakili dependensi tersebut.

Injeksi dependensi tersebut membantu membuat hubungan ini dan memungkinkan Anda menukar implementasi untuk pengujian. Misalnya, saat menguji ViewModel yang bergantung pada repositori, Anda dapat meneruskan implementasi Repository yang berbeda menggunakan implementasi tiruan untuk menguji berbagai kasus.

Dasar-dasar injeksi dependensi manual

Bagian ini membahas cara menerapkan injeksi dependensi manual dalam skenario aplikasi Android yang sebenarnya. Bagian ini juga membahas pendekatan berulang tentang bagaimana Anda dapat mulai menggunakan injeksi dependensi di aplikasi Anda. Pendekatan ini meningkat hingga mencapai titik yang sangat mirip dengan yang akan dihasilkan oleh Dagger secara otomatis. Untuk mengetahui informasi selengkapnya tentang Dagger, baca Dasar-dasar Dagger.

Pertimbangkan alur sebagai sekumpulan layar di aplikasi Anda yang berkaitan dengan fitur. Login, pendaftaran, dan pembayaran merupakan contoh alur.

Saat membahas alur login untuk aplikasi Android standar, LoginActivity bergantung pada LoginViewModel, yang kemudian bergantung pada UserRepository. Kemudian UserRepository bergantung pada UserLocalDataSource dan UserRemoteDataSource, yang kemudian bergantung pada layanan Retrofit.

LoginActivity adalah titik masuk ke alur login dan pengguna yang berinteraksi dengan aktivitas. Dengan demikian, LoginActivity perlu membuat LoginViewModel dengan semua dependensinya.

Class Repository dan DataSource dari alur akan terlihat seperti ini:

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }

Di Compose, ComponentActivity adalah titik masuk; pengkabelan dependensi terjadi sekali di onCreate, dan UI dijelaskan oleh composable yang dipanggil dari 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)
    )
    // ...
}

Ada masalah dengan pendekatan ini:

  1. Dependensi harus dideklarasikan secara berurutan. Anda harus membuat instance UserRepository sebelum LoginViewModel agar dapat membuatnya.
  2. Sulit untuk menggunakan objek kembali. Jika ingin menggunakan kembali UserRepository di beberapa fitur, Anda harus membuatnya mengikuti pola singleton. Pola tunggal membuat pengujian lebih sulit karena semua pengujian memiliki instance singleton yang sama.

Mengelola dependensi dengan container

Untuk mengatasi masalah penggunaan kembali objek, Anda dapat membuat class container dependensi sendiri yang digunakan untuk mendapatkan dependensi. Semua instance yang disediakan oleh container ini dapat bersifat publik. Dalam contoh ini, karena Anda hanya memerlukan instance UserRepository, Anda dapat membuat dependensinya bersifat pribadi dengan opsi untuk menjadikannya publik jika dependensi perlu disediakan:

// 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)
}

Karena dependensi ini digunakan di seluruh aplikasi, keduanya perlu ditempatkan di tempat umum yang dapat digunakan semua aktivitas: Application class. Buat class Application kustom yang berisi instance 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()
}

Dengan Compose, AppContainer yang sama masih dibuat di subclass Application. Anda dapat mengaksesnya di aktivitas, sebelum memanggil setContent, atau dari dalam composable, menggunakan 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)
    )
    // ...
}

Sebaiknya teruskan dependensi sebagai parameter composable daripada menjangkau LocalContext dari jauh di hierarki. Hal ini membuat composable dapat diuji dan inputnya eksplisit. Selesaikan container sekali di root layar dan teruskan hal yang diperlukan ke bawah.

Dengan cara ini, Anda tidak memiliki UserRepository singleton. Sebaliknya, Anda memiliki AppContainer yang dibagikan di seluruh aktivitas yang berisi objek dari grafik dan membuat instance objek tersebut sehingga dapat dikonsumsi class lain.

Jika LoginViewModel diperlukan di lebih banyak tempat dalam aplikasi, Anda perlu memiliki tempat terpusat untuk membuat instance LoginViewModel. Anda dapat memindahkan pembuatan LoginViewModel ke container dan menyediakan objek baru dari jenis tersebut dengan pabrik. Kode untuk LoginViewModelFactory akan terlihat seperti ini:

// 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)
    }
}

Dengan Compose, update AppContainer masih mengekspos pabrik. Pabrik kemudian digunakan oleh viewModel composable sehingga ViewModel dicakup ke yang terdekat ViewModelStoreOwner (biasanya aktivitas host atau, dengan Navigation Compose, entri navigasi):

// 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)
    // ...
}

Pendekatan ini lebih baik daripada yang sebelumnya, tetapi masih ada beberapa tantangan yang perlu dipertimbangkan:

  1. Anda harus mengelola AppContainer sendiri, membuat instance untuk semua dependensi secara manual.

  2. Masih ada banyak kode boilerplate. Anda perlu membuat factory atau parameter secara manual bergantung pada apakah Anda ingin menggunakan kembali sebuah objek atau tidak.

Mengelola dependensi dalam alur aplikasi

AppContainer menjadi rumit saat Anda ingin menyertakan lebih banyak fungsionalitas dalam project. Saat aplikasi Anda menjadi lebih besar dan Anda mulai memperkenalkan alur fitur yang berbeda, akan ada lebih banyak masalah yang muncul:

  1. Jika memiliki alur yang berbeda, Anda mungkin ingin objek hanya ditampilkan dalam cakupan alur tersebut. Misalnya, saat membuat LoginUserData (yang mungkin terdiri dari nama pengguna dan sandi yang hanya digunakan dalam alur login), Anda tidak ingin mempertahankan data dari alur login lama dari pengguna lain. Anda ingin instance baru untuk setiap alur baru. Anda dapat mencapainya dengan membuat objek FlowContainer di dalam AppContainer seperti yang ditunjukkan dalam contoh kode berikutnya.

  2. Mengoptimalkan grafik aplikasi dan penampung alur juga bisa menjadi sulit. Jangan lupa menghapus instance yang tidak diperlukan, bergantung pada alur tempat Anda berada.

Tambahkan LoginContainer ke kode contoh. Anda ingin dapat membuat beberapa instance LoginContainer di aplikasi. Jadi, daripada membuatnya menjadi singleton, buat instance tersebut menjadi class dengan dependensi yang diperlukan alur login dari 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
}

Di Compose, masa aktif penampung alur terikat dengan komposisi, bukan Activity host. Anda tidak perlu mengubah AppContainer.loginContainer bersama, karena composable menerima dependensinya sebagai parameter atau membacanya dari ViewModel yang diangkat. Anda memiliki dua opsi:

  1. Grafik bertingkat Navigation Compose (lebih disukai untuk alur multi-layar). Tempatkan semua layar dalam alur login di bawah grafik navigasi bertingkat dan cakup container ke NavBackStackEntry grafik tersebut. Container dibuat saat pengguna memasuki alur dan dihapus saat entri data sebelumnya muncul, tanpa memerlukan panggilan siklus proses manual. Untuk mengetahui informasi selengkapnya, lihat Mendesain grafik navigasi Anda.
  2. remember di root layar (untuk alur satu layar atau saat Anda tidak menggunakan Navigation Compose). Buat container di dalam remember sehingga dibuat sekali per entri ke dalam komposisi dan dikumpulkan sampah saat composable keluar:
@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.
}

Kesimpulan

Injeksi dependensi merupakan teknik yang bagus untuk membuat aplikasi Android yang dapat diskalakan dan dapat diuji. Gunakan container sebagai cara untuk berbagi instance class di berbagai bagian aplikasi Anda dan sebagai pusat untuk membuat instance class menggunakan pabrik.

Saat aplikasi Anda semakin besar, Anda akan mulai melihat bahwa Anda menulis banyak kode boilerplate (seperti factory), yang dapat menyebabkannya rentan error. Anda juga harus mengelola sendiri cakupan dan siklus proses container, mengoptimalkan dan menghapus container yang tidak diperlukan lagi untuk mengosongkan memori. Melakukan ini dengan cara yang salah dapat menyebabkan bug halus dan kebocoran memori di aplikasi Anda.

Di bagian Dagger, Anda akan mempelajari cara menggunakan Dagger untuk mengotomatiskan proses ini dan membuat kode yang sama yang akan Anda tulis secara manual.

Referensi lainnya

Konten tampilan