Các phương pháp hay nhất cho coroutine trong Android

Trang này trình bày một số phương pháp hay nhất đem lại tác động tích cực thông qua việc tăng khả năng mở rộng và khả năng kiểm thử của ứng dụng khi sử dụng coroutine.

Chèn trình điều phối (dispatcher)

Đừng mã cứng Dispatchers khi tạo coroutine mới hay gọi withContext.

// DO inject Dispatchers
class NewsRepository(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}

// DO NOT hardcode Dispatchers
class NewsRepository {
    // DO NOT use Dispatchers.Default directly, inject it instead
    suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

Mẫu chèn phần phụ thuộc này giúp bạn kiểm thử dễ dàng hơn vì bạn có thể thay thế các trình điều phối đó trong loại kiểm thử đơn vị và kiểm thử đo lường bằng trình điều phối kiểm thử để quá trình kiểm thử có kết quả thống nhất hơn.

Hàm tạm ngưng phải an toàn khi gọi từ luồng chính

Hàm tạm ngưng phải là hàm an toàn cho luồng chính (main-safe), nghĩa là an toàn khi gọi từ luồng chính. Nếu một lớp đang thực hiện các thao tác chặn lâu dài trong một coroutine, thì lớp đó sẽ chịu trách nhiệm di chuyển hoạt động thực thi ra khỏi luồng chính bằng cách sử dụng withContext. Nguyên tắc này áp dụng cho tất cả các lớp trong ứng dụng của bạn, bất kể lớp đó nằm ở phần nào của cấu trúc.

class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {

    // As this operation is manually retrieving the news from the server
    // using a blocking HttpURLConnection, it needs to move the execution
    // to an IO dispatcher to make it main-safe
    suspend fun fetchLatestNews(): List<Article> {
        withContext(ioDispatcher) { /* ... implementation ... */ }
    }
}

// This use case fetches the latest news and the associated author.
class GetLatestNewsWithAuthorsUseCase(
    private val newsRepository: NewsRepository,
    private val authorsRepository: AuthorsRepository
) {
    // This method doesn't need to worry about moving the execution of the
    // coroutine to a different thread as newsRepository is main-safe.
    // The work done in the coroutine is lightweight as it only creates
    // a list and add elements to it
    suspend operator fun invoke(): List<ArticleWithAuthor> {
        val news = newsRepository.fetchLatestNews()

        val response: List<ArticleWithAuthor> = mutableEmptyList()
        for (article in news) {
            val author = authorsRepository.getAuthor(article.author)
            response.add(ArticleWithAuthor(article, author))
        }
        return Result.Success(response)
    }
}

Mẫu này giúp ứng dụng của bạn dễ mở rộng hơn vì các lớp gọi hàm tạm ngưng không phải quan tâm xem phải dùng Dispatcher gì cho loại tác vụ nào. Trách nhiệm này thuộc về lớp thực hiện tác vụ đó.

ViewModel phải tạo coroutine

Lớp ViewModel nên ưu tiên tạo coroutine thay vì hiện hàm tạm ngưng để thực hiện logic nghiệp vụ. Hàm tạm ngưng trong ViewModel có thể hữu ích nếu chỉ cần phát một giá trị duy nhất, thay vì hiện trạng thái bằng cách sử dụng luồng dữ liệu.

// DO create coroutines in the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun loadNews() {
        viewModelScope.launch {
            val latestNewsWithAuthors = getLatestNewsWithAuthors()
            _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
        }
    }
}

// Prefer observable state rather than suspend functions from the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {
    // DO NOT do this. News would probably need to be refreshed as well.
    // Instead of exposing a single value with a suspend function, news should
    // be exposed using a stream of data as in the code snippet above.
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

View không được trực tiếp kích hoạt bất kỳ coroutine nào để thực hiện logic nghiệp vụ. Thay vào đó, hãy chuyển trách nhiệm đó cho ViewModel. Điều này giúp bạn dễ dàng kiểm thử logic nghiệp vụ của mình hơn vì có thể thực hiện kiểm thử đơn vị cho đối tượng ViewModel, thay vì sử dụng loại kiểm thử đo lường bắt buộc để kiểm thử khung hiển thị.

Ngoài ra, các coroutine của bạn sẽ tự động tiếp tục hoạt động sau khi có thay đổi cấu hình nếu bạn bắt đầu tác vụ trong viewModelScope. Nếu tạo coroutine bằng cách sử dụng lifecycleScope, bạn sẽ phải xử lý việc này theo cách thủ công. Nếu coroutine cần kéo dài hơn phạm vi của ViewModel, hãy xem bài viết Tạo coroutine trong phần tầng nghiệp vụ và tầng dữ liệu.

Không cung cấp các loại dữ liệu có thể sửa đổi

Ưu tiên cung cấp các loại dữ liệu không sửa đổi được cho các lớp khác. Bằng cách này, mọi thay đổi đối với loại dữ liệu có thể sửa đổi sẽ dồn vào cùng một lớp. Nhờ đó, bạn sẽ dễ dàng khắc phục hơn khi xảy ra lỗi.

// DO expose immutable types
class LatestNewsViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    /* ... */
}

class LatestNewsViewModel : ViewModel() {

    // DO NOT expose mutable types
    val uiState = MutableStateFlow(LatestNewsUiState.Loading)

    /* ... */
}

Lớp dữ liệu và lớp nghiệp vụ sẽ hiện các hàm tạm ngưng và Flows (luồng dữ liệu)

Các lớp (class) trong lớp (layer) dữ liệu và lớp nghiệp vụ thường hiện các hàm để thực hiện lệnh gọi một lần hoặc để nhận thông báo về các thay đổi liên quan đến dữ liệu theo thời gian. Các lớp trong những tầng đó sẽ cung cấp hàm tạm ngưng cho tác vụ gọi một lầnFlow để thông báo về thay đổi đối với dữ liệu.

// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
    suspend fun makeNetworkRequest() { /* ... */ }

    fun getExamples(): Flow<Example> { /* ... */ }
}

Phương pháp hay nhất này là để giúp phương thức gọi (thường là tầng trình bày – presentation layer) có thể kiểm soát quá trình thực thi và vòng đời của tác vụ diễn ra trong các tầng đó và huỷ khi cần.

Tạo coroutine trong lớp nghiệp vụ và lớp dữ liệu

Các lớp trong tầng dữ liệu hay tầng nghiệp vụ cần tạo coroutine vì nhiều lý do và có một số cách để làm được điều này.

Nếu bạn chỉ cần thực hiện tác vụ trong các coroutine đó khi người dùng có mặt trên màn hình hiện tại, thì tác vụ đó phải tuân theo vòng đời của phương thức gọi. Trong hầu hết các trường hợp, phương thức gọi sẽ là ViewModel và lệnh gọi sẽ bị huỷ khi người dùng rời khỏi màn hình, đồng thời ViewModel bị xoá. Trong trường hợp này, bạn nên sử dụng coroutineScope hoặc supervisorScope.

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // In parallel, fetch books and authors and return when both requests
        // complete and the data is ready
        return coroutineScope {
            val books = async { booksRepository.getAllBooks() }
            val authors = async { authorsRepository.getAllAuthors() }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

Nếu bạn cần thực hiện tác vụ trong suốt thời gian ứng dụng đang mở và tác vụ này không bị ràng buộc với một màn hình cụ thể, thì tác vụ đó phải kéo dài hơn vòng đời của phương thức gọi. Trong trường hợp này, bạn nên dùng CoroutineScope bên ngoài như giải thích trong bài đăng trên blog Coroutines & Patterns for work that shouldn’t be cancelled (Coroutine và mẫu cho những tác vụ không được phép huỷ).

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch { articlesDataSource.bookmarkArticle(article) }
            .join() // Wait for the coroutine to complete
    }
}

externalScope phải được tạo và quản lý bằng một lớp hoạt động lâu hơn màn hình hiện tại, nó có thể được quản lý bằng lớp Application hoặc ViewModel có phạm vi giới hạn trong một biểu đồ điều hướng.

Chèn trình điều phối TestDispatcher vào kiểm thử

Một thực thể của TestDispatcher sẽ được chèn vào lớp của bạn trong các kiểm thử. Có hai cách triển khai trong thư viện kotlinx-coroutines-test:

  • StandardTestDispatcher: sử dụng trình lập lịch (scheduler) để xếp hàng đợi cho các coroutine bắt đầu trên nó, sau đó thực thi các coroutine này khi luồng kiểm thử không bận. Bạn có thể tạm ngưng luồng kiểm thử để cho phép chạy các coroutine khác trong hàng đợi bằng những phương thức như advanceUntilIdle.

  • UnconfinedTestDispatcher: Chạy coroutine mới ngay lập tức, theo cách có thể chặn luồng tác vụ. Cách làm này thường giúp quá trình viết kiểm thử trở nên dễ dàng hơn, nhưng bạn sẽ có ít quyền kiểm soát hơn đối với cách thực thi coroutine khi kiểm thử.

Hãy xem tài liệu về cách triển khai trình điều phối để biết thêm thông tin.

Để kiểm thử coroutine, hãy sử dụng hàm tạo coroutine runTest. runTest sử dụng TestCoroutineScheduler để bỏ qua độ trễ trong kiểm thử và cho phép bạn kiểm soát thời gian ảo. Bạn cũng có thể sử dụng trình lập lịch này để tạo thêm trình điều phối kiểm thử nếu cần.

class ArticlesRepositoryTest {

    @Test
    fun testBookmarkArticle() = runTest {
        // Pass the testScheduler provided by runTest's coroutine scope to
        // the test dispatcher
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)

        val articlesDataSource = FakeArticlesDataSource()
        val repository = ArticlesRepository(
            articlesDataSource,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

Tất cả TestDispatchers phải chia sẻ cùng một trình lập lịch. Bằng cách này, bạn có thể chạy tất cả mã coroutine trên một luồng kiểm thử duy nhất để giúp kiểm thử có kết quả thống nhất hơn. runTest sẽ đợi tất cả các coroutine nằm trong cùng trình lập lịch hoặc các coroutine là phần tử con của coroutine kiểm thử cho đến khi các coroutine này hoàn tất rồi mới quay lại.

Tránh sử dụng GlobalScope

Cách làm này cũng tương tự như phương pháp hay nhất để chèn trình điều phối. Khi sử dụng GlobalScope, bạn sẽ mã cứng CoroutineScope mà một lớp sử dụng, gây ra những điểm bất lợi như sau:

  • Tạo điều kiện cho việc mã cứng các giá trị Nếu bạn mã cứng GlobalScope, có thể bạn cũng đang mã cứng Dispatchers.

  • Khiến việc kiểm thử trở nên khó khăn vì mã của bạn sẽ thực thi trong một phạm vi không được kiểm soát, bạn sẽ không thể kiểm soát quá trình thực thi mã.

  • Bạn không thể khiến CoroutineContext thông thường thực thi cho tất cả các coroutine được tạo trong phạm vi đó.

Thay vào đó, hãy cân nhắc việc chèn một CoroutineScope cho những tác vụ cần phải kéo dài hơn phạm vi hiện tại. Hãy xem bài viết Cách tạo coroutine trong phần tầng dữ liệu và tầng nghiệp vụ để tìm hiểu thêm về chủ đề này.

// DO inject an external scope instead of using GlobalScope.
// GlobalScope can be used indirectly. Here as a default parameter makes sense.
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope = GlobalScope,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch(defaultDispatcher) {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

// DO NOT use GlobalScope directly
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
) {
    // As we want to complete bookmarking the article even if the user moves away
    // from the screen, the work is done creating a new coroutine with GlobalScope
    suspend fun bookmarkArticle(article: Article) {
        GlobalScope.launch {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

Hãy tìm hiểu thêm về GlobalScope và các lựa chọn thay thế trong Bài đăng blog về coroutine và mẫu cho những tác vụ không được phép huỷ.

Đảm bảo coroutine của bạn có thể huỷ được

Thao tác huỷ coroutine mang tính phối hợp, có nghĩa là khi Job của coroutine bị huỷ, coroutine sẽ không bị huỷ cho đến khi tạm ngưng hoặc kiểm tra để huỷ. Nếu bạn thực hiện thao tác chặn trong coroutine, hãy đảm bảo rằng coroutine có thể huỷ được.

Ví dụ: nếu bạn đang đọc nhiều tệp lấy từ đĩa, trước khi bắt đầu đọc từng tệp, hãy kiểm tra xem coroutine có bị huỷ không. Có một cách để kiểm tra tình trạng huỷ, đó là gọi hàm ensureActive.

someScope.launch {
    for(file in files) {
        ensureActive() // Check for cancellation
        readFile(file)
    }
}

Tất cả các hàm tạm ngưng từ kotlinx.coroutines, chẳng hạn như withContextdelay, đều có thể huỷ được. Nếu coroutine của bạn gọi các hàm này, thì bạn không cần phải làm gì thêm.

Để biết thêm thông tin về thao tác huỷ trong coroutine, hãy xem bài đăng blog về thao tác huỷ trong coroutine.

Cẩn thận với các ngoại lệ

Tình trạng không xử lý các ngoại lệ được gửi vào coroutine có thể khiến ứng dụng của bạn gặp sự cố. Nếu có nhiều khả năng xảy ra ngoại lệ, hãy xác định các ngoại lệ này trong phần thân của những coroutine được tạo bằng viewModelScope hoặc lifecycleScope.

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            try {
                loginRepository.login(username, token)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

Để biết thêm thông tin, hãy xem bài đăng trên blog Exceptions in coroutines (Các ngoại lệ trong coroutine) hoặc Coroutine exceptions handling (Cách xử lý ngoại lệ về coroutine) trong tài liệu về Kotlin.

Tìm hiểu thêm về coroutine

Để xem thêm tài nguyên về coroutine, hãy xem trang Tài nguyên khác về coroutine và flow trong Kotlin.