Tạo 1 ứng dụng CoroutineScope sử dụng Hilt

Trần Đình Quý

Android Jetpack Compose

Inject một CoroutineScope phạm vi mức Application sử dụng Hilt.

Theo các best practice của coroutine, bạn có thể cần phải inject một CoroutineScope phạm vi ứng dụng vào một số class để khởi chạy các coroutine mới theo vòng đời ứng dụng hoặc để làm cho một số công việc tồn tại lâu hơn scope của người gọi.

Trong bài viết này, bạn sẽ học cách tạo một CoroutineScope có phạm vi ứng dụng bằng cách sử dụng Hilt, và cách inject nó như một phụ thuộc. Để cải thiện cách chúng ta làm việc với Coroutines, chúng ta sẽ xem cách tiêm các CoroutineDispatcher khác nhau và thay thế cài đặt của chúng trong các bài test.

Dependency injection bằng tay

Để tạo một CoroutineScope phạm vi ứng dụng theo các nguyên tắc DI mà không cần sử dụng thư viện nào, bạn thường sẽ thêm một biến mới vào lớp ứng dụng của bạn với một phiên bản của CoroutineScope. Cùng một instance sẽ được truyền thủ công khi tạo các đối tượng khác.

class MyRepository(private val externalScope: CoroutineScope) { /* ... */ }

class MyApplication : Application() {

    // Application-scoped types that any class in the app could access
    // using the applicationContext.
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    val myRepository = MyRepository(applicationScope)

}

Vì không có cách đáng tin cậy nào để biết khi Application bị hủy bỏ trong Android, bạn không cần gọi applicationScope.cancel() bằng tay vì phạm vi và tất cả công việc đang diễn ra sẽ bị hủy bỏ khi application process kết thúc.

Một lựa chọn tốt hơn cho việc làm điều này bằng cách thủ công là tạo một class ApplicationContainer chứa các loại có phạm vi ứng dụng. Điều này giúp phân chia vấn đề vì những lớp Container này chịu trách nhiệm về:

  • xử lý logic về cách xây dựng các loại cụ thể,
  • giữ các instance có loại là container-scoped, và
  • trả về các instance của các loại đã được phạm vi hoá và không được phạm vi hoá.
class ApplicationDiContainer {
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    val myRepository = MyRepository(applicationScope)
}

class MyApplication : Application() {
    val applicationDiContainer = ApplicationDiContainer()
}

Chú ý: Một container luôn trả về cùng một instance của một loại được giới hạn, và luôn trả về một instance khác cho các loại không được phạm vi hoá. Việc giới hạn các loại vào container tốn kém vì đối tượng được giới hạn vẫn tồn tại trong bộ nhớ cho đến khi thành phần bị hủy, vì vậy chỉ giới hạn những gì thực sự cần thiết.

Trong ví dụ ApplicationDiContainer ở trên, tất cả các loại đều được phạm vi hóa. Nếu MyRepository không cần phải được phạm vi hóa cho ứng dụng, chúng ta sẽ có:

class ApplicationDiContainer {
    // Scoped type. Same instance is always returned
    val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    // Unscoped type. Always returns a different instance
    fun getMyRepository(): MyRepository {
        return MyRepository(applicationScope)
    }
}

Sử dụng Hilt trong ứng dụng

Hilt tạo ra những gì bạn có thể thấy trong ApplicationDiContainer (và hơn thế nữa!) lúc biên dịch bằng cách sử dụng các annotation. Hơn nữa, Hilt cung cấp các container cho hầu hết các class Android framework không chỉ mỗi class Application.

Để thiết lập Hilt trong ứng dụng của bạn và tạo container cho lớp Application, hãy đánh dấu class Application của bạn bằng @HiltAndroidApp.

@HiltAndroidApp
class MyApplication : Application()

Với điều này, ApplicationDiContainer đã sẵn sàng để sử dụng. Chúng ta chỉ cần cho Hilt biết cách cung cấp các instance của các loại khác nhau.

Lưu ý: Trong Hilt, các class Container được tham chiếu như là Components. Container liên kết với class Application được gọi là SingletonComponent. Kiểm tra danh sách tất cả các Hilt component có sẵn.

Inject thông qua constructor

Construction injection là cách dễ nhất để cho Hilt biết cách cung cấp các instance của một loại nếu chúng ta có quyền truy cập vào constructor của một class, chúng ta chỉ cần đánh dấu constructor bằng @Inject:

@Singleton // Scopes this type to the SingletonComponent
class MyRepository @Inject constructor(
   private val externalScope: CoroutineScope
) { 
    /* ... */ 
}

Điều này cho biết với Hilt rằng để cung cấp một instance của class MyRepository, cần phải truyền một instance của CoroutineScope như một phụ thuộc. Hilt tạo code tại thời điểm biên dịch để đảm bảo các phụ thuộc được đáp ứng và truyền vào khi tạo một instance của một loại hoặc thông báo lỗi nếu nó không có đủ thông tin. @Singleton được sử dụng để phạm vi hoá lớp này đến SingletonContainer.

Tại thời điểm này, Hilt không biết cách đáp ứng sự phụ thuộc CoroutineScope vì chúng ta chưa nói cho Hilt biết làm thế nào. Các phần tiếp theo sẽ giải thích cách chúng ta có thể cho Hilt biết cần truyền gì làm phụ thuộc.

Lưu ý: Hilt cung cấp một annotation khác nhau cho các loại scope cho các thành phần Hilt khác nhau. Hãy kiểm tra danh sách tất cả các scope thành phần có sẵn.

Bindinds

Binding là một thuật ngữ phổ biến trong Hilt để chỉ thông tin mà Hilt biết về cách cung cấp các instance của một loại như một phụ thuộc. Chúng ta có thể nói rằng chúng ta đã thêm một binding vào Hilt với chú thích @Inject của đoạn code ở trên.

Các binding qua cấu trúc thành phần của Hilt. Các binding có sẵn trong SingletonComponent cũng có sẵn trong ActivityComponent.

Các binding cho các loại không được phạm vi hoá (một ví dụ có thể là đoạn code MyRepository ở trên nếu nó không được ghi chú với @Singleton), có sẵn trong tất cả các thành phần của Hilt. Các binding được phạm vi hoá cho một thành phần, như MyRepository được ghi chú với @Singleton, có sẵn cho thành phần có phạm vi và các thành phần ở dưới nó trong cấu trúc.

Cung cấp loại với module

Như đã đề cập ở trên, chúng ta cần cho Hilt biết cách để đáp ứng mối phụ thuộc vào CoroutineScope. Tuy nhiên, CoroutineScope là một loại interface đến từ một thư viện bên ngoài, vì vậy chúng ta không thể sử dụng việc tiêm (injection) thông qua constructor như trước đây với class MyRepository. Phương pháp thay thế là cho Hilt biết code nào để chạy khi cung cấp một instance của một loại sử dụng Modules:

@InstallIn(SingletonComponent::class)
@Module
object CoroutinesScopesModule {

    @Singleton // Provide always the same instance 
    @Provides
    fun providesCoroutineScope(): CoroutineScope {
        // Run this code when providing an instance of CoroutineScope
        return CoroutineScope(SupervisorJob() + Dispatchers.Default)
    }
}

Phương thức @Provides được chú thích bằng @Singleton để Hilt luôn trả về cùng một instance của CoroutineScope đó. Điều này bởi vì bất kỳ công việc nào cần tuân theo vòng đời của Application đều nên được tạo ra bằng cùng một instance của CoroutineScope tuân theo vòng đời của Application.

Các module Hilt được chú thích bằng @InstallIn để chỉ ra rằng việc binding được cài đặt trong thành phần Hilt nào (và các thành phần bên dưới trong cấu trúc phân cấp). Trong trường hợp của chúng ta, vì CoroutineScope của ứng dụng cần thiết cho MyRepository được phạm vi hoá trong SingletonComponent, việc binding này cũng cần được cài đặt trong SingletonComponent.

Trong cách nói của Hilt, chúng ta có thể nói rằng chúng ta đã thêm một CoroutineScope binding, vì bây giờ, Hilt biết cách cung cấp các instance của CoroutineScope.

Tuy nhiên, đoạn code trên có thể được cải thiện. Hardcoding dispatchers là một thói quen không tốt trong coroutines, chúng ta nên inject chúng để làm cho chúng có thể cấu hình và làm cho việc kiểm thử dễ dàng hơn. Theo đoạn code trước đó, chúng ta có thể tạo một module Hilt mới để cho phép nó biết Dispatcher nào để inject cho mỗi trường hợp: main, default và IO.

Cung cấp các implementation cho CoroutineDispatcher

Chúng ta phải cung cấp các implementation khác nhau cho cùng một loại là CoroutineDispatcher. Nói cách khác, chúng ta cần các ràng buộc khác nhau cho cùng một loại.

Chúng ta sử dụng qualifier (bộ điều kiện) để cho Hilt biết rằng mỗi lần nào sử dụng binding hoặc implementation nào. Qualifier chỉ là các annotation mà bạn và Hilt sử dụng để xác định các binding cụ thể. Hãy tạo một qualifier cho mỗi implementation của CoroutineDispatcher:

// CoroutinesQualifiers.kt file

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class DefaultDispatcher

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class IoDispatcher

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class MainDispatcher

@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class MainImmediateDispatcher

Sau đó, những qualifier này chú thích các phương thức @Provides khác nhau để xác định một binding cụ thể trong các mô-đun Hilt. Bộ qualifier @DefaultDispatcher chú thích phương thức trả về bộ điều phối mặc định, và cứ thế.

@InstallIn(SingletonComponent::class)
@Module
object CoroutinesDispatchersModule {

    @DefaultDispatcher
    @Provides
    fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default

    @IoDispatcher
    @Provides
    fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

    @MainDispatcher
    @Provides
    fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main

    @MainImmediateDispatcher
    @Provides
    fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
}

Lưu ý rằng những CoroutineDispatchers này không cần được phạm vi hoá trong SingletonComponent. Mỗi khi cần các phụ thuộc này, Hilt gọi phương thức @Provides và trả về CoroutineDispatcher tương ứng. Tạo lại vẫn OK.

Cung cấp Coroutine Scope ở mức Application

Để loại bỏ CoroutineDispatcher được hard code từ CoroutineScope ở mức Application trước đó của chúng ta, chúng ta cần inject vào dispatcher mặc định do Hilt cung cấp. Để làm điều đó, chúng ta có thể truyền vào loại mà chúng ta muốn inject, CoroutineDispatcher, bằng cách sử dụng qualifier tương ứng, @DefaultDispatcher, như một phụ thuộc trong phương thức cung cấp CoroutineScope của ứng dụng.

@InstallIn(SingletonComponent::class)
@Module
object CoroutinesScopesModule {

    @Singleton
    @Provides
    fun providesCoroutineScope(
        @DefaultDispatcher defaultDispatcher: CoroutineDispatcher
    ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher)
}

Vì Hilt có nhiều ràng buộc cho loại CoroutineDispatcher, chúng ta làm rõ nó sử dụng chính xác loại nào bằng cách sử dụng chú thích @DefaultDispatcher khi CoroutineDispatcher được sử dụng làm phụ thuộc.

Qualifier cho ApplicationScope

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope

@InstallIn(SingletonComponent::class)
@Module
object CoroutinesScopesModule {

    @Singleton
    @ApplicationScope
    @Provides
    fun providesCoroutineScope(
        @DefaultDispatcher defaultDispatcher: CoroutineDispatcher
    ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher)
}

Vì MyRepository phụ thuộc vào scope này, rõ ràng scope bên ngoài nào sử dụng như triển khai sau:

@Singleton
class MyRepository @Inject constructor(
    @ApplicationScope private val externalScope: CoroutineScope
) { /* ... */ }

Thay thế Replacing Dispatchers cho instrumentation test

Chúng ta đã nói trước đó rằng chúng ta nên inject dispatchers để làm cho việc kiểm thử dễ dàng hơn và có hoàn toàn kiểm soát được những gì đang xảy ra. Đối với các bài kiểm tra instrumentation, chúng ta muốn làm cho Espresso đợi cho đến khi các coroutines hoàn thành.

Thay vì tạo một CoroutineDispatcher tùy chỉnh với một số Espresso Idling resource để khiến nó chờ các coroutine hoàn thành, chúng ta có thể tận dụng API AsyncTask. Mặc dù AsyncTask đã bị loại bỏ trong Android API 30, Espresso kết nối vào thread pool của nó để kiểm tra tính trống rỗng. Do đó, bất kỳ coroutine nào cần được thực thi trong một luồng nền có thể được thực thi trong thread pool của AsyncTask.

Sử dụng API TestInstallIn của Hilt để Hilt cung cấp một cách triển khai khác của một loại trong các bài test. Tương tự như cách chúng ta cung cấp các Dispatcher khác nhau ở trên, chúng ta có thể tạo một file mới trong gói androidTest để cung cấp các triển khai khác nhau cho những Dispatcher đó.

// androidTest/projectPath/TestCoroutinesDispatchersModule.kt file

@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [CoroutinesDispatchersModule::class]
)
@Module
object TestCoroutinesDispatchersModule {

    @DefaultDispatcher
    @Provides
    fun providesDefaultDispatcher(): CoroutineDispatcher =
        AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher()

    @IoDispatcher
    @Provides
    fun providesIoDispatcher(): CoroutineDispatcher =
        AsyncTask.THREAD_POOL_EXECUTOR.asCoroutineDispatcher()

    @MainDispatcher
    @Provides
    fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
}

Với mã code ở trên, chúng ta đang làm cho Hilt “quên” CoroutinesDispatchersModule được sử dụng trong production code trong các bài test. Module đó sẽ được thay thế bằng TestCoroutinesDispatchersModule sử dụng thread pool của Async Task cho công việc cần xảy ra ở nền, và Dispatchers.Main cho công việc cần xảy ra trên luồng chính mà Espresso cũng đang chờ.

Để biết thêm thông tin về kiểm thử, hãy xem hướng dẫn kiểm thử của Hilt.

Ví dụ của bài viết trên được áp dụng tại đây các bạn có thể tham khảo.


Trong bài viết này, bạn đã học cách tạo một CoroutineScope có phạm vi ứng dụng bằng cách sử dụng Hilt, inject nó như một phụ thuộc, inject các trường hợp khác nhau của CoroutineDispatcher, và thay thế các triển khai của chúng trong các bài kiểm tra.

Bài viết gốc.

Viết một bình luận