Cải thiện hiệu suất của ứng dụng bằng cách sử dụng coroutine của Kotlin

Coroutine cho phép bạn viết mã không đồng bộ một cách đơn giản và gọn gàng, giúp ứng dụng của bạn thích ứng trong khi vẫn quản lý các tác vụ chạy trong thời gian dài như lệnh gọi mạng hoặc thao tác trên ổ đĩa.

Chủ đề này cung cấp thông tin chi tiết về coroutine trên Android. Nếu bạn chưa quen với coroutine, hãy nhớ đọc bài viết về coroutine của Kotlin trên Android trước khi đọc chủ đề này.

Quản lý các tác vụ chạy trong thời gian dài

Coroutine được xây dựng dựa trên các hàm thông thường bằng cách thêm hai thao tác để xử lý các tác vụ chạy trong thời gian dài. Ngoài invoke (hoặc call) và return, coroutine còn thêm suspendresume:

  • suspend tạm dừng việc thực thi coroutine hiện tại, lưu tất cả các biến cục bộ.
  • resume tiếp tục thực thi một coroutine bị tạm ngưng từ vị trí tạm ngưng.

Bạn chỉ có thể gọi các hàm suspend từ các hàm suspend khác hoặc bằng cách sử dụng một hàm tạo coroutine như launch để bắt đầu một coroutine mới.

Ví dụ sau đây cho thấy một cách triển khai coroutine đơn giản cho một tác vụ giả định chạy trong thời gian dài:

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

Trong ví dụ này, get() vẫn chạy trên luồng chính nhưng lại tạm ngưng coroutine trước khi bắt đầu yêu cầu mạng. Khi yêu cầu mạng hoàn tất, get sẽ cho coroutine bị tạm ngưng trước đó chạy tiếp thay vì sử dụng lệnh gọi lại để thông báo cho luồng chính.

Kotlin sử dụng một khung ngăn xếp để quản lý việc hàm nào đang chạy cùng với các biến cục bộ. Khi tạm ngưng một coroutine, khung ngăn xếp hiện tại sẽ được sao chép và lưu lại để sử dụng sau. Khi tiếp tục, khung ngăn xếp sẽ được sao chép ngược trở lại từ vị trí đã lưu và hàm sẽ bắt đầu chạy lại. Mặc dù mã có thể trông giống như một yêu cầu thông thường để chặn theo tuần tự, nhưng coroutine giúp đảm bảo rằng yêu cầu mạng tránh chặn luồng chính.

Dùng coroutine để đảm bảo an toàn cho luồng chính

Coroutine của Kotlin sử dụng trình điều phối (dispatcher) để xác định luồng nào được dùng để thực thi coroutine. Để chạy mã bên ngoài luồng chính, bạn có thể yêu cầu các coroutine trong Kotlin thực hiện tác vụ trên trình điều phối Mặc định hoặc IO. Trong Kotlin, tất cả các coroutine phải chạy trong một trình điều phối, ngay cả khi đang chạy trên luồng chính. Coroutine có thể tự tạm ngưng và trình điều phối sẽ chịu trách nhiệm chạy tiếp coroutine.

Để chỉ định nơi coroutine sẽ chạy, Kotlin cung cấp ba trình điều phối mà bạn có thể sử dụng:

  • Dispatchers.Main – Sử dụng trình điều phối này để chạy coroutine trên luồng Android chính. Bạn chỉ nên sử dụng trình điều phối này để tương tác với giao diện người dùng và thực hiện tác vụ nhanh. Ví dụ: gọi hàm suspend, chạy thao tác khung giao diện người dùng Android và cập nhật đối tượng LiveData.
  • Dispatchers.IO – Trình điều phối này được tối ưu hoá để thực hiện I/O đĩa hoặc I/O mạng bên ngoài luồng chính. Ví dụ: sử dụng thành phần Room, đọc hoặc ghi vào tệp và chạy bất kỳ thao tác mạng nào.
  • Dispatchers.Default – Trình điều phối này được tối ưu hoá để thực hiện tác vụ nặng về CPU bên ngoài luồng chính. Ví dụ về trường hợp sử dụng: sắp xếp danh sách và phân tích cú pháp JSON.

Tiếp tục ví dụ trước, bạn có thể sử dụng trình điều phối để xác định lại hàm get. Bên trong phần chính của get, hãy gọi withContext(Dispatchers.IO) để tạo một khối chạy trên nhóm luồng IO. Mọi mã mà bạn đặt bên trong khối đó sẽ luôn thực thi thông qua trình điều phối IO. Do bản thân withContext là một hàm tạm ngưng nên hàm get cũng là một hàm tạm ngưng.

suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

Khi sử dụng coroutine, bạn có thể gửi các luồng bằng công cụ kiểm soát chi tiết. Vì withContext() cho phép bạn kiểm soát nhóm luồng của mọi dòng mã mà không cần lệnh gọi lại, nên bạn có thể áp dụng cho các hàm rất nhỏ như đọc trên cơ sở dữ liệu hoặc thực hiện yêu cầu mạng. Một cách hay là sử dụng withContext() để đảm bảo mọi hàm đều an toàn cho luồng chính (main-safe), nghĩa là bạn có thể gọi hàm từ luồng chính. Bằng cách này, phương thức gọi không cần suy nghĩ xem nên dùng luồng nào để thực thi hàm.

Trong ví dụ trước, fetchDocs() thực thi trên luồng chính. Tuy nhiên, hàm này có thể gọi get một cách an toàn để thực hiện một yêu cầu mạng ở chế độ nền. Vì các coroutine hỗ trợ suspendresume, nên coroutine trên luồng chính sẽ được tiếp tục bằng kết quả get ngay khi khối withContext hoàn tất.

Hiệu suất của withContext()

withContext() không làm tiêu tốn thêm tài nguyên so với cách triển khai tương đương là triển khai lệnh gọi lại. Ngoài ra, trong một số trường hợp, bạn có thể tối ưu hoá lệnh gọi withContext() ngoài cách triển khai tương đương là triển khai lệnh gọi lại. Ví dụ: nếu một hàm thực hiện 10 lệnh gọi tới một mạng, bạn có thể yêu cầu Kotlin chỉ chuyển đổi luồng một lần bằng cách sử dụng withContext() bên ngoài. Sau đó, mặc dù thư viện mạng sử dụng withContext() nhiều lần, nhưng thư viện này vẫn ở trên cùng một trình điều phối và tránh chuyển đổi luồng. Ngoài ra, Kotlin tối ưu hoá việc chuyển đổi giữa Dispatchers.DefaultDispatchers.IO để tránh việc chuyển đổi chuỗi bất cứ khi nào có thể.

Khởi chạy coroutine

Bạn có thể khởi chạy coroutin bằng một trong hai cách sau:

  • launch khởi chạy một coroutine mới và không trả về kết quả cho phương thức gọi. Đối với những tác vụ mà bạn chỉ cần khởi chạy và không phải làm gì thêm, bạn có thể khởi chạy bằng launch
  • async khởi chạy một coroutine mới và cho phép bạn trả về kết quả bằng hàm tạm ngưng có tên là await.

Thông thường, bạn nên launch một coroutine mới từ một hàm thông thường, vì hàm thông thường không thể gọi await. Chỉ sử dụng async khi ở bên trong một tệp coroutine khác hoặc khi ở bên trong một hàm tạm ngưng và đang thực hiện hoạt động phân ly song song.

Phân ly song song

Bạn phải ngừng tất cả các coroutine được khởi chạy trong một hàm suspend khi hàm đó quay lại. Vì vậy, có khả năng bạn sẽ cần đảm bảo rằng các coroutine đó hoàn tất trước khi quay lại. Thông qua cơ chế xử lý đồng thời có cấu trúc (structured concurrency) trong Kotlin, bạn có thể xác định coroutineScope để bắt đầu một hoặc nhiều coroutine. Sau đó, hãy sử dụng await() (đối với một coroutine) hoặc awaitAll() (đối với nhiều coroutine), bạn có thể đảm bảo rằng các coroutine này đã hoàn tất trước khi quay trở lại từ hàm.

Ví dụ: hãy xác định coroutineScope có vai trò tìm nạp hai tài liệu một cách không đồng bộ. Bằng cách gọi await() trên mỗi tham chiếu trì hoãn, chúng tôi đảm bảo rằng cả hai thao tác async đều hoàn tất trước khi trả về một giá trị:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

Bạn cũng có thể sử dụng awaitAll() trên các tập hợp, như trong ví dụ sau:

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

Mặc dù fetchTwoDocs() chạy các coroutine mới bằng async, nhưng hàm này vẫn sử dụng awaitAll() để đợi các coroutine đã khởi chạy hoàn tất trước khi quay lại. Tuy nhiên, hãy lưu ý rằng ngay cả khi chúng tôi không gọi awaitAll(), hàm tạo coroutineScope cũng không tiếp tục coroutine đã gọi fetchTwoDocs, cho đến khi tất cả các coroutine mới đã hoàn tất.

Ngoài ra, coroutineScope còn phát hiện mọi ngoại lệ mà các coroutine gửi và đưa trở lại cho phương thức gọi.

Để biết thêm thông tin về việc phân ly song song, hãy xem bài viết Soạn các hàm tạm ngưng.

Các khái niệm về coroutine

CoroutineScope

CoroutineScope theo dõi mọi coroutine mà hàm này tạo bằng launch hoặc async. Bạn có thể huỷ tác vụ đang diễn ra (tức là các coroutine đang chạy) bằng cách gọi scope.cancel() tại bất kỳ thời điểm nào. Trên Android, một số thư viện KTX cung cấp CoroutineScope riêng cho một số lớp trong vòng đời. Ví dụ: ViewModelviewModelScope, và LifecyclelifecycleScope. Tuy nhiên, không giống như trình điều phối, CoroutineScope không chạy coroutine.

viewModelScope cũng được sử dụng trong các ví dụ ở phần Tạo luồng chạy ở chế độ nền trên Android thông qua coroutine. Tuy nhiên, nếu cần tạo CoroutineScope của riêng mình để kiểm soát vòng đời của coroutine trong một tầng (layer) cụ thể trong ứng dụng, thì bạn có thể tạo như sau:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

Phạm vi đã huỷ không thể tạo thêm coroutine. Do đó, bạn chỉ nên gọi scope.cancel() khi lớp kiểm soát vòng đời của hàm đó đang bị huỷ bỏ. Khi sử dụng viewModelScope, lớp ViewModel sẽ tự động huỷ phạm vi cho bạn theo phương thức onCleared() của ViewModel.

Job

Job là handle cho coroutine. Mỗi coroutine mà bạn tạo bằng launch hoặc async sẽ trả về một thực thể Job xác định riêng coroutine đó và quản lý vòng đời của coroutine đó. Bạn cũng có thể truyền Job đến CoroutineScope để quản lý vòng đời một cách sâu hơn, như trong ví dụ sau:

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

CoroutineContext xác định hành vi của một coroutine bằng cách sử dụng nhóm phần tử sau đây:

Đối với coroutine mới được tạo trong một phạm vi, thực thể Job mới sẽ được gán cho coroutine mới và các phần tử CoroutineContext khác được kế thừa từ phạm vi chứa coroutine đó. Bạn có thể ghi đè các phần tử kế thừa bằng cách truyền một CoroutineContext mới đến hàm launch hoặc async. Lưu ý rằng việc truyền Job đến launch hoặc async không có hiệu lực, vì thực thể mới của Job luôn được gán cho một coroutine mới.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

Tài nguyên khác về coroutine

Để xem thêm tài nguyên về coroutine, hãy xem các đường liên kết sau: