Dependency injection trong Android

Xin chào tất cả các bạn, hôm nay chúng ta sẽ cùng nhau tìm hiểu về Dependency injection in Android, cách thêm và sử dụng Dagger trong Android.

1. Dependency injection là gì?

Trong quá trình học, hầu như các bạn sinh viên đều được học một số khái niệm OOP, học là những thứ này và khi phỏng vấn cũng là nó, và với OOP chúng ta có những nguyên lý thiết kế mà chúng ta được nghe tới rất thường xuyên đó là SOLID, SOLID là gì thì các bạn có thể tìm hiểu, đại loại khi bạn tuân thủ theo những nguyên lý thiết kế này thì ứng dụng bạn viết ra sẽ dễ đọc, dễ test, bảo trì nâng cấp sửa chữa v.v nói chung là sẽ tốt hơn. Và trong SOLID chữ D chính là viết tắt của Dependency inversion principle hiểu đơn giản là khi thiết kế thì:

  1. Các module cấp cao không nên phụ thuộc vào các modules cấp thấp. Cả 2 nên phụ thuộc vào abstraction.
  2. Interface (abstraction) không nên phụ thuộc vào chi tiết, mà ngược lại. ( Các class giao tiếp với nhau thông qua interface, không phải thông qua implementation.) Để tuân thủ nguyên lý thiết kế này ta sử dụng Dependency injection (tạm gọi là một design pattern). Các module cấp cao khi cần các phụ thuộc sẽ được inject vào.

2. Tại sao lại cần Dependency injection.

Nhược điểm lớn nhất cuẩ DI chính là kiến thức này khá là rắc rối cho người mới (với cả mình), debug sẽ khó hơn và hiệu năng chắc chắn là thấp hơn so với bình thường tuy nhiên nó sẽ đem lại cho chúng ta:

  • Khả năng tái sử dụng code.
  • Dễ dàng tái cấu trúc code.
  • Dễ dàng cho việc viết Unit Test.

3. Nguyên tắc cơ bản của Dependency injection.

Vấn đề này thì rất nhiều nơi đã có trình bày và đã có ví dụ rồi, nhưng ở đây để cho dễ hiểu (với cả mình) thì mình chỉ đưa ra một vú dụ đơn giản bằng kotlin:

class Car {

    private val engine = Engine()

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.start()
}

Đơn giản ta thấy rằng để có một chiếc ô tô và cho nó chạy thì trong bản thân ô tô chúng ta phải tạo ra một động cơ cho nó private val engine = Engine() đó vậy là đã không tuân thủ nguyên tắc thiết kế SOLID rồi, và các bạn cũng hiểu khi có thêm nhiều thành phần hay đối tượng thì nó sẽ còn gây rắc rối hơn nữa. Nhưng trong xây dựng ứng dụng thì bắt buôc phải làm điều này cũng như ô tô chắc chắn là phải có động cơ rồi. Chúng ta có 3 cách để một lớp có thể có được đối tượng mà nó cần:

  1. Lớp khởi tạo đối tượng mà nó cần: Cách này như ví dụ trên, class Car tự nó sẽ tạo ra một Engine mới.
  2. Lấy ở đâu đó, đâu đó ở đây có thể là Android Apis ví dụ như bạn hay lấy context hay getSystemService() đó là từ Android Apis
  3. Được cung cấp đối tượng đó như một tham số của class đó. Chúng ta sẽ cung cấp cho lớp khi nó được khởi tạo hoặc thông qua các hàm. Đây chính là Dependency injection với cách này ta có thể viết lại ví dụ trên như sau:
class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val engine = Engine()
    val car = Car(engine)
    car.start()
}
  • Nhưng sẽ có một vấn đề ở đây là với các thành phần ví dụ như activity hay fragment bản thân chúng được khởi tạo bởi hệ thống android, chúng ta không thể can thiệp vào việc này vậy nên việc tạo Dependency injection thông qua khởi tạo là không khả thi vậy nên chúng ta sẽ có hai cách chính để thực hiện Dependency injection trong android:
  1. Constructor Injection. Đây là cách mô tả ở trên. Bạn chuyển các phụ thuộc của một lớp cho hàm tạo của nó.
  2. Field Injection (or Setter Injection). Cách này các phụ thuộc được khởi tạo sau khi lớp được tạo. Việc này sẽ trông như thế này:
class Car {
    lateinit var engine: Engine

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.engine = Engine()
    car.start()
}

Đây là kotlin nên mã trông như vậy thực ra chúng ta đã xây dựng một hàm setEngine và truyền vào một Engine, vậy là xong class Car đã có Engine rồi.

4. Automated Dependency injection.

Tự động Dependency injection là gì? Là việc Dependency injection sẽ được thực hiện tự đồng nhờ thư viện bên thứ ba, việc này giải quyết được vấn đề với các ứng dụng lớn khi mà ở đó nếu tạo Dependency injection bằng tay (như ví dụ ở trên chúng ta tự tạo, cung cấp và quản lý các phụ thuộc của lớp Car và Engine) thì sẽ rất tốn thời gian và khối lượng code lớn. Các thư viện bên thứ ba nổi tiếng và hay được sử dụng có thể kể đến là Dagger. Hiện Dagger được duy trì bởi Google. Với Automated dependency injection trong tài liệu cung cấp thì chúng phù hợp với hai loại:

  1. Sử dụng reflection để kết nối các phụ thuộc tại run-time.
  2. Tạo mã tĩnh để kết nối các phụ thuộc tại thời gian biên dịch (complime-time).

5. Giải pháp khác.

Nếu bạn không thích Dependency injection thì bạn có thể sử dụng giải pháp khác là ServiceLocator (nếu mình không nhẫn lẫn thì gần đây chúng ta có Koin là đại diện hay được sử dụng - cũng là ServiceLocator). Tương tự như tên gọi ta có thể hiểu là chúng ta sẽ có một nhà cung cấp và nhà cung cấp này sẽ cung cấp các phụ thuộc mà chúng ta cần:

object ServiceLocator {
    fun getEngine(): Engine = Engine()
}

class Car {
    private val engine = ServiceLocator.getEngine()

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.start()
}

Ở đây khác ví dụ trên ở chỗ chúng ta đã xây dựng một ServiceLocator, và lớp Car đơn giản là đưa ra yêu cầu Engine cho ServiceLocator. Đây cũng là điểm khác biệt chính so với Dependency injection. Cách làm này cũng có khá nhiều hạn chế mà mình cũng đã từng gặp phải đó là:

  1. Khó test, vì tất cả được cung cấp cùng bởi một ServiceLocator nên tất cả các tests đều phải tương tác với cùng một ServiceLocator.
  2. Việc request các phụ thuộc được bản thân các lớp yêu cầu, vậy nên khi ServiceLocator phát sinh chỉnh sửa, chúng ta không thể biết được nó đã được yêu cầu ở đâu ngoài việc tìm bằng tay.
  3. Ngoài ra google cũng có chỉ ra hạn chế: Quản lý vòng đời của các đối tượng sẽ khó khăn hơn nếu bạn muốn phạm vi cho bất kỳ thứ gì khác ngoài vòng đời của toàn bộ ứng dụng tuy nhiên về phần này mình cũng chưa làm tới đây nên không có kiến thức nhiều về việc này, các bạn nên tìm hiểu thêm.

6. Nên chọn giải pháp nào.

Như đã trình bày ở trên chúng ta có ba hướng giải quyết để cung cấp các phụ thuộc đó là Dependency injection thủ công bằng tay, Dependency injection tự động sử dụng thư viện (Dagger) và ServiceLocator. Với việc tạo mã Dependency injection thủ công thì có thể thấy là không khả thi vì hiếm dự án nào đủ nhỏ để có thể viết tay như vậy được, còn với ServiceLocator thì ngay từ xuất phát bản thân nó không phải là Dependency injection và cũng bộc lộ các nhược điểm như đã trình bày ở trên nên tính đến thời điểm hiện tại chúng ta sẽ sử dụng Automated Dependency injection (Dagger). Rất khó để hiểu và thông thạo nhưng là cần thiết nhất là với các dự án lớn, và không chỉ với riêng Android mà còn với các ngôn ngữ các môi trường khác nữa, chúng ta cũng sẽ gặp lại Dependency injection thôi nên tốt nhất là cứ bắt đầu luôn 😃. Google cũng đã định nghĩa và gợi ý rất chi tiết về vấn đề này trong tài liệu hướng dẫn của mình ví dụ như phân loại kích cỡ ứng dụng và công nghệ nên sử dụng.
Tổng kết lại là bài này viết về Dependency injection nên ngay từ đầu Automated Dependency injection bản thân nó đã là giải pháp tối ưu nhất rồi.

7. Giới thiệu sơ qua về Dagger.

Như đã trình bày với các bạn ở trên, nếu chỉ dừng lại ở đó thì hơi thiếu, chúng ta sẽ xem qua về Manual Dependency injection và Automated Dependency injection với Dagger, nó là gì và làm được gì mà được tin dùng như vậy.

7.1 Manual Dependency injection

Ví dụ về Manual Dependency injection được google đưa ra là về Flow đăng nhập như sau: Khi đó chúng ta sẽ có LoginActivity phụ thuộc vào LoginViewModel, điều này phụ thuộc vào UserRepository. Sau đó, UserRepository phụ thuộc vào UserLocalDataSourceUserRemoteDataSource, sau đó là phụ thuộc vào dịch vụ Retrofit. Đầu tiên ta sẽ khai báo một class AppContainer thông thường. Lưu ý class này không phải là Singleton. Đây sẽ là nơi mà chúng ta cung cấp các phụ thuộc.

class AppContainer {

    // Nếu thành phần nào không cần phải public bạn nên đặt private cho nó.
    private val retrofit = Retrofit.Builder()
                            .baseUrl("https://example.com")
                            .build()
                            .create(LoginService::class.java)

    private val remoteDataSource = UserRemoteDataSource(retrofit)
    private val localDataSource = UserLocalDataSource()

    val userRepository = UserRepository(localDataSource, remoteDataSource)
}

Vậy là chúng ta đã có một dependencies container do chúng ta tự tạo bằng tay, nơi cung cấp các phụ thuộc như sơ đồ ở trên ảnh. Tại vì các phụ thuộc này sẽ được sử dụng xuyêt suốt ứng dụng nên chúng ta sẽ để nó ở Application, nơi mà tất cả các activity đều có thể sử dụng.

class MyApplication : Application() {

    // AppContainer sẽ có thể sử dụng được tất cả các activity trong ứn dụng.
    val appContainer = AppContainer()
}

Lúc này chúng ta có thể khởi tạo viewModel như sau:

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appContainer = (application as MyApplication).appContainer
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}

Tuy nhiên vậy còn LoginViewModel thì sao, nếu chúng ta cần ViewModel này ở rất nhiều nơi thì sao, việc này chúng ta có thể tạo một nơi mà ở đó chúng ta khởi tạo instance của ViewModel.

// Định nghĩa Factory interface  hàm tạo của đối tượng dữ liệu  loại "T"
interface Factory {
    fun create(): T
}

class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

Sau đó ta chỉ việc đưa LoginViewModelFactory vào Application tương tự như đã làm với UserRepository và sau đó ta có thể có được LoginViewModel ở mọi nơi.

class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val appContainer = (application as MyApplication).appContainer
        loginViewModel = appContainer.loginViewModelFactory.create()
    }
}

Tuy nhiên nếu bạn tạo dữ liệu LoginViewModel khi start LoginActivity và giải phóng nó khi kết thúc thì bạn có thể tách riêng việc tạo LoginViewModelFactory thành một container

class LoginContainer(val userRepository: UserRepository) {
    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

var loginContainer: LoginContainer? = null
}

Tại activity lúc này bạn sẽ làm như sau:

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel
    private lateinit var appContainer: AppContainer

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appContainer = (application as MyApplication).appContainer

        appContainer.loginContainer = LoginContainer(appContainer.userRepository)

        loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
    }

    override fun onDestroy() {
        // Loại bỏ instance của loginContainer.
        appContainer.loginContainer = null
        super.onDestroy()
    }
}

Vậy là theo hướng dẫn của Google chúng ta đã tự tạo được Dependency injection bằng tay, việc này rất tốn thời gian code và phức tạp nên chúng ta sẽ tìm hiểu tiếp tới Automated Dependency injection với Dagger.

7.2 Automated Dependency injection với Dagger.

Với Dagger, code sẽ được tự động sinh ra khi bạn biên dịch ứng dụng, như đã trình bày ở trên, đây là cách tạo Automated Dependency injection bằng cách sinh ra các mã tại thời gian biên dịch. Cách này có ưu điểm là hiệu năng cao hơn so với cách sử dụng reflection tại run-time. Dagger tạo mã tương tự như những gì bạn sẽ viết bằng tay (như ở trên). căn bản Dagger tạo ra một biểu đồ các đối tượng mà nó có thể tham chiếu đến để tìm cách cung cấp một của một class. Đối với mỗi lớp Dagger sẽ tạo ra một lớp Factory (tương tự như LoginViewModelFactory ở trên mà chúng ta đã tạo) để lấy các của type đó (như ở trên type sẽ là LoginViewModel). Các bạn lưu ý là khi chạy bạn phải đảm bảo rằng mọi phụ thuộc đều có thể được cung cấp để tránh lỗi RuntimeException và không có vòng lặp. Vòng lặp ở đây ví dụ như là A cần phụ thuộc của B, B lại cần phục thuộc vào C và C lại cần phụ thuộc vào A, nó sẽ thành một vòng tròn vô hạn và dĩ nhiên là cũng sẽ lỗi rồi. Chúng ta sẽ bắt đầu với ví dụ như ở trên nhưng lần này sẽ sử dụng Dagger. này mình sẽ chỉ làm mô tả để chúng ta cùng nhau hiểu qua về cách mà Dagger hoạt động thôi, còn implement thì mình có thể sẽ làm ở bài khác. Nếu bạn muốn implement luôn thì nhớ là phải thêm dependence vào build.gradle thì mới có thể sử dụng Dagger:

dependencies {
    implementation 'com.google.dagger:dagger:2.x' //Version của dagger.
    kapt 'com.google.dagger:dagger-compiler:2.x'
}
  1. Đầu tiên chúng ta sẽ đi đến với @Inject annotation dùng cho UserRepository để Dagger làm sao để tạo UserRepository.
class UserRepository @Inject constructor(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

Việc sử dụng annotation nhằm hai mục đích:

  • Thông báo cho Dagger làm sao để tạo một instance (thể hiện) của UserRepository
  • Các phụ thuộc của nó sẽ là UserLocalDataSourceUserRemoteDataSource Tương tự với các thành phần còn lại (chúng ta tạm chưa bàn tới cái RetroifitUserRemoteDataSource nó đang cần):
class UserLocalDataSource @Inject constructor() { ... }
class UserRemoteDataSource @Inject constructor() { ... }
  1. Dagger có thể tạo một biểu đồ về các phụ thuộc trong project của bạn mà nó có thể sử dụng để lấy các phụ thuộc khi nó cần. Để Dagger có thể làm được điều này, bạn cần tạo một interface và chú thích với annotation @Component. Dagger lúc này sẽ tạo ra một container tương tự với AppContainer hay LoginContainer mà chúng ta tạo khi làm thủ công.
    Chúng ta tạm hiểu sơ đồ nó sẽ như hình trên. Trong interface mà chúng ta đánh dấu với @Component chúng ta sẽ định nghĩa các hàm trả về instance của lớp mà bạn cần, như ví dụ sẽ là UserRepository chẳng hạn.
@Component
interface ApplicationGraph {
    fun repository(): UserRepository
}

Khu bạn build ứng dụng Dagger sẽ tạo ra một implement của interface mà bạn đã tạo, theo tên ở trên sẽ là DaggerApplicationGraph sau đó bạn có thể sử dụng như sau:

// Tạo một instance của ApplicationGraph
val applicationGraph: ApplicationGraph = DaggerApplicationGraph.create()
// Lấy một instance của UserRepository từ DaggerApplicationGraph
val userRepository: UserRepository = applicationGraph.repository()

Lưu ý mỗi khi bạn tạo một instance của UserRepository đó là là một instance mới. Tuy nhiên có lúc bạn sẽ cần một instance duy nhất của một lớp thay vì nhiều instance bạn sẽ cần đến scope annotations để đánh dấu với Dagger rằng bạn cần một instace thôi.

@Singleton
@Component
interface ApplicationGraph {
    fun repository(): UserRepository
}

@Singleton
class UserRepository @Inject constructor(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

Bạn cũng có thể custom các annotation này như sau:

@Scope
@MustBeDocumented
@Retention(value = AnnotationRetention.RUNTIME)
annotation class MyCustomScope

Sau đó sử dụng các custom annotation này như bình thường:

@MyCustomScope
@Component
interface ApplicationGraph {
    fun repository(): UserRepository
}

@MyCustomScope
class UserRepository @Inject constructor(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

8. Tổng kết.

Trên đây mình đã giới thiệu sơ qua về Dependency injection trong Android và khái niệm sơ qua về Dagger. Tuy nhiên để có thể áp dụng vào project thì còn phải tìm hiểu thêm, có thể mình sẽ giới thiệu ở bài sau. Cảm ơn các bạn đã theo dõi bài viết. Các bạn cũng có thể tham khảo nguồn của bài viết này tại trang https://developer.android.com/guide