Dùng Coroutine của Kotlin trong ứng dụng Android

Trong lớp học lập trình này, bạn sẽ tìm hiểu cách sử dụng Coroutine của Kotlin trong một ứng dụng Android – một cách mới để quản lý các luồng trong nền, có thể đơn giản hoá mã bằng cách giảm nhu cầu về lệnh gọi lại. Coroutine là một tính năng của Kotlin, chuyển đổi các lệnh gọi lại không đồng bộ cho các tác vụ chạy trong thời gian dài (chẳng hạn như quyền truy cập vào cơ sở dữ liệu hoặc mạng) thành mã tuần tự.

Dưới đây là một đoạn mã để giúp bạn hình dung được những việc bạn sẽ làm.

// Async callbacks
networkRequest { result ->
   // Successful network request
   databaseSave(result) { rows ->
     // Result saved
   }
}

Mã dựa trên lệnh gọi lại sẽ được chuyển đổi thành mã tuần tự bằng cách sử dụng coroutine.

// The same code with coroutines
val result = networkRequest()
// Successful network request
databaseSave(result)
// Result saved

Bạn sẽ bắt đầu với một ứng dụng hiện có, được tạo bằng Architecture Components, sử dụng kiểu lệnh gọi lại cho các tác vụ chạy trong thời gian dài.

Khi kết thúc lớp học lập trình này, bạn sẽ có đủ kinh nghiệm để sử dụng coroutine trong ứng dụng của mình nhằm tải dữ liệu từ mạng và bạn sẽ có thể tích hợp coroutine vào một ứng dụng. Bạn cũng sẽ nắm được các phương pháp hay nhất cho coroutine và cách viết một bài kiểm thử đối với mã sử dụng coroutine.

Điều kiện tiên quyết

  • Quen thuộc với các Thành phần cấu trúc ViewModel, LiveData, RepositoryRoom.
  • Kinh nghiệm về cú pháp Kotlin, bao gồm cả hàm mở rộng và lambda.
  • Có kiến thức cơ bản về cách sử dụng các luồng trên Android, bao gồm cả luồng chính, luồng nền và lệnh gọi lại.

Bạn sẽ thực hiện

  • Gọi mã được viết bằng coroutine và nhận kết quả.
  • Sử dụng các hàm tạm ngưng để tạo mã không đồng bộ theo tuần tự.
  • Sử dụng launchrunBlocking để kiểm soát cách thực thi mã.
  • Tìm hiểu các kỹ thuật chuyển đổi API hiện có thành coroutine bằng cách sử dụng suspendCoroutine.
  • Sử dụng coroutine với các Thành phần kiến trúc.
  • Tìm hiểu các phương pháp hay nhất để kiểm thử coroutine.

Bạn cần có

  • Android Studio 3.5 (lớp học lập trình có thể hoạt động với các phiên bản khác, nhưng một số nội dung có thể bị thiếu hoặc trông khác).

Nếu bạn gặp vấn đề (lỗi trong đoạn mã, lỗi ngữ pháp, từ ngữ không rõ ràng, v.v.) khi thực hành theo lớp học lập trình này, vui lòng báo cáo vấn đề thông qua đường liên kết Báo cáo lỗi ở góc dưới bên trái lớp học lập trình.

Tải mã xuống

Nhấp vào đường liên kết sau đây để tải toàn bộ mã nguồn cho lớp học lập trình này:

Tải tệp Zip xuống

... hoặc sao chép kho lưu trữ GitHub từ dòng lệnh bằng cách sử dụng lệnh sau:

$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git

Câu hỏi thường gặp

Trước tiên, hãy xem ứng dụng minh hoạ bắt đầu trông như thế nào. Hãy làm theo các hướng dẫn sau để mở ứng dụng mẫu trong Android Studio.

  1. Nếu bạn đã tải tệp zip kotlin-coroutines xuống, hãy giải nén tệp đó.
  2. Mở dự án coroutines-codelab trong Android Studio.
  3. Chọn mô-đun ứng dụng start.
  4. Nhấp vào nút execute.pngRun (Chạy), rồi chọn một trình mô phỏng hoặc kết nối thiết bị Android của bạn. Thiết bị này phải có khả năng chạy Android Lollipop (SDK tối thiểu được hỗ trợ là 21). Màn hình Coroutine của Kotlin sẽ xuất hiện:

Ứng dụng khởi động này sử dụng các luồng để tăng số lượng sau một khoảng thời gian ngắn kể từ khi bạn nhấn vào màn hình. Thao tác này cũng sẽ tìm nạp một tiêu đề mới từ mạng và hiển thị tiêu đề đó trên màn hình. Hãy thử ngay bây giờ, bạn sẽ thấy số lượng và thông báo thay đổi sau một khoảng thời gian ngắn. Trong lớp học lập trình này, bạn sẽ chuyển đổi ứng dụng này để sử dụng các coroutine.

Ứng dụng này sử dụng các Thành phần cấu trúc để tách mã giao diện người dùng trong MainActivity khỏi logic ứng dụng trong MainViewModel. Hãy dành chút thời gian để làm quen với cấu trúc của dự án.

  1. MainActivity hiển thị giao diện người dùng, đăng ký trình nghe lượt nhấp và có thể hiển thị một Snackbar. Thành phần này truyền các sự kiện đến MainViewModel và cập nhật màn hình dựa trên LiveData trong MainViewModel.
  2. MainViewModel xử lý các sự kiện trong onMainViewClicked và sẽ giao tiếp với MainActivity bằng cách sử dụng LiveData.
  3. Executors xác định BACKGROUND, có thể chạy các thao tác trên một luồng trong nền.
  4. TitleRepository tìm nạp kết quả từ mạng và lưu kết quả đó vào cơ sở dữ liệu.

Thêm các coroutine vào dự án

Để sử dụng coroutine trong Kotlin, bạn phải thêm thư viện coroutines-core vào tệp build.gradle (Module: app) của dự án. Các dự án trong lớp học lập trình đã thực hiện việc này cho bạn, nên bạn không cần làm vậy để hoàn tất lớp học lập trình.

Các coroutine trên Android có sẵn dưới dạng một thư viện cốt lõi và các tiện ích dành riêng cho Android:

  • kotlinx-coroutines-core – Giao diện chính để sử dụng coroutine trong Kotlin
  • kotlinx-coroutines-android – Hỗ trợ luồng chính của Android trong coroutine

Ứng dụng khởi động đã bao gồm các phần phụ thuộc trong build.gradle.Khi tạo một dự án ứng dụng mới, bạn cần mở build.gradle (Module: app) và thêm các phần phụ thuộc coroutine vào dự án.

dependencies {
  ...
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:x.x.x"
  implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:x.x.x"
}

Trên Android, bạn phải tránh việc chặn luồng chính. Luồng chính là một luồng duy nhất xử lý tất cả bản cập nhật cho giao diện người dùng. Đây cũng là luồng gọi tất cả trình xử lý lượt nhấp và các lệnh gọi lại giao diện người dùng khác. Do đó, luồng này phải chạy mượt mà để đảm bảo mang lại trải nghiệm người dùng tuyệt vời.

Để người dùng nhìn thấy ứng dụng mà không gặp phải tình trạng tạm dừng, luồng chính phải cập nhật màn hình chậm nhất sau mỗi 16 mili giây, tức là khoảng 60 khung hình/giây. Nhiều thao tác phổ biến mất nhiều thời gian hơn, chẳng hạn như phân tích cú pháp các tập dữ liệu JSON lớn, ghi dữ liệu vào cơ sở dữ liệu hoặc tìm nạp dữ liệu từ mạng. Do đó, việc gọi mã như thế này từ luồng chính có thể khiến ứng dụng tạm dừng, bị giật hoặc thậm chí bị treo. Nếu bạn chặn luồng chính trong thời gian quá dài, ứng dụng có thể gặp sự cố và cho thấy hộp thoại Ứng dụng không phản hồi.

Hãy xem video bên dưới để biết thông tin giới thiệu về cách coroutine giải quyết vấn đề này cho chúng ta trên Android bằng cách giới thiệu tính năng an toàn cho luồng chính.

Mẫu gọi lại

Có một mẫu để thực hiện các thao tác dài hạn mà không chặn luồng chính, đó là lệnh gọi lại. Bằng cách sử dụng các lệnh gọi lại, bạn có thể bắt đầu các thao tác dài hạn trên một luồng ở chế độ nền. Khi thao tác hoàn tất, lệnh gọi lại sẽ được gọi để thông báo cho bạn về kết quả trên luồng chính.

Hãy xem ví dụ về mẫu gọi lại.

// Slow request with callbacks
@UiThread
fun makeNetworkRequest() {
    // The slow network request runs on another thread
    slowFetch { result ->
        // When the result is ready, this callback will get the result
        show(result)
    }
    // makeNetworkRequest() exits after calling slowFetch without waiting for the result
}

Vì mã này được chú thích bằng @UiThread, nên mã phải chạy đủ nhanh để thực thi trên luồng chính. Điều đó có nghĩa là nó cần trả về rất nhanh để lần cập nhật màn hình tiếp theo không bị chậm trễ. Tuy nhiên, vì slowFetch sẽ mất vài giây hoặc thậm chí vài phút để hoàn tất, nên luồng chính không thể chờ kết quả. Lệnh gọi lại show(result) cho phép slowFetch chạy trên một luồng nền và trả về kết quả khi sẵn sàng.

Sử dụng coroutine để xoá lệnh gọi lại

Lệnh gọi lại là một mẫu tuyệt vời, nhưng cũng có một vài hạn chế. Mã sử dụng nhiều lệnh gọi lại có thể trở nên khó đọc và khó hiểu hơn. Ngoài ra, lệnh gọi lại không cho phép sử dụng một số tính năng ngôn ngữ, chẳng hạn như ngoại lệ.

Coroutine Kotlin cho phép bạn chuyển đổi mã dựa trên lệnh gọi lại thành mã tuần tự. Mã được viết tuần tự thường dễ đọc hơn và thậm chí có thể sử dụng các tính năng ngôn ngữ như ngoại lệ.

Rốt cục thì chúng hoạt động giống hệt nhau: chờ cho đến khi có kết quả từ một thao tác dài hạn rồi tiếp tục thực thi. Tuy nhiên, trong mã, chúng trông rất khác nhau.

Từ khoá suspend là cách Kotlin đánh dấu một hàm hoặc loại hàm có sẵn cho các coroutine. Khi một coroutine gọi một hàm được đánh dấu suspend, thay vì chặn cho đến khi hàm đó trả về như một lệnh gọi hàm thông thường, hệ thống sẽ tạm ngưng việc thực thi cho đến khi kết quả sẵn sàng rồi tiếp tục từ nơi dừng lại kèm theo kết quả. Trong khi tạm ngưng để chờ kết quả, coroutine sẽ bỏ chặn luồng mà coroutine đó đang chạy để các hàm hoặc coroutine khác có thể chạy.

Ví dụ: trong mã dưới đây, makeNetworkRequest()slowFetch() đều là hàm suspend.

// Slow request with coroutines
@UiThread
suspend fun makeNetworkRequest() {
    // slowFetch is another suspend function so instead of 
    // blocking the main thread  makeNetworkRequest will `suspend` until the result is 
    // ready
    val result = slowFetch()
    // continue to execute after the result is ready
    show(result)
}

// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }

Giống như phiên bản lệnh gọi lại, makeNetworkRequest phải trả về ngay từ luồng chính vì được đánh dấu là @UiThread. Điều này có nghĩa là thường thì nó không thể gọi các phương thức chặn như slowFetch. Đây là nơi từ khoá suspend phát huy tác dụng.

So với mã dựa trên lệnh gọi lại, mã coroutine đạt được kết quả tương tự là bỏ chặn luồng hiện tại với ít mã hơn. Do có kiểu tuần tự, nên bạn có thể dễ dàng liên kết một số tác vụ chạy trong thời gian dài mà không cần tạo nhiều lệnh gọi lại. Ví dụ: mã tìm nạp kết quả từ 2 điểm cuối mạng và lưu kết quả đó vào cơ sở dữ liệu có thể được viết dưới dạng một hàm trong các coroutine mà không có lệnh gọi lại. Chẳng hạn như:

// Request data from network and save it to database with coroutines

// Because of the @WorkerThread, this function cannot be called on the
// main thread without causing an error.
@WorkerThread
suspend fun makeNetworkRequest() {
    // slowFetch and anotherFetch are suspend functions
    val slow = slowFetch()
    val another = anotherFetch()
    // save is a regular function and will block this thread
    database.save(slow, another)
}

// slowFetch is main-safe using coroutines
suspend fun slowFetch(): SlowResult { ... }
// anotherFetch is main-safe using coroutines
suspend fun anotherFetch(): AnotherResult { ... }

Bạn sẽ giới thiệu coroutine cho ứng dụng mẫu trong phần tiếp theo.

Trong bài tập này, bạn sẽ viết một coroutine để hiển thị thông báo sau một khoảng thời gian trễ. Để bắt đầu, hãy đảm bảo bạn đã mở mô-đun start trong Android Studio.

Tìm hiểu về CoroutineScope

Trong Kotlin, tất cả coroutine đều chạy bên trong một CoroutineScope. Phạm vi kiểm soát toàn bộ thời gian của coroutine thông qua tác vụ. Khi huỷ công việc trong một phạm vi, thao tác đó sẽ huỷ tất cả các coroutine bắt đầu trong phạm vi đó. Trên Android, bạn có thể dùng một phạm vi để huỷ tất cả các coroutine đang chạy, chẳng hạn như khi người dùng di chuyển khỏi một Activity hoặc Fragment. Phạm vi cũng cho phép bạn chỉ định một trình điều phối mặc định. Trình điều phối kiểm soát luồng nào chạy một coroutine.

Đối với các coroutine do giao diện người dùng khởi động, bạn thường nên khởi động chúng trên Dispatchers.Main (luồng chính trên Android). Một coroutine bắt đầu trên Dispatchers.Main sẽ không chặn luồng chính trong khi bị tạm ngưng. Vì coroutine ViewModel hầu như luôn cập nhật giao diện người dùng trên luồng chính, nên việc bắt đầu các coroutine trên luồng chính sẽ giúp bạn tiết kiệm các lần chuyển đổi luồng bổ sung. Một coroutine bắt đầu trên luồng chính có thể chuyển đổi trình điều phối bất cứ lúc nào sau khi bắt đầu. Ví dụ: nó có thể sử dụng một trình điều phối khác để phân tích cú pháp một kết quả JSON lớn bên ngoài luồng chính.

Sử dụng viewModelScope

Thư viện AndroidX lifecycle-viewmodel-ktx sẽ thêm một CoroutineScope vào ViewModel được định cấu hình để bắt đầu các coroutine liên quan đến giao diện người dùng. Để sử dụng thư viện này, bạn phải đưa thư viện này vào tệp build.gradle (Module: start) của dự án. Bước đó đã được thực hiện trong các dự án của lớp học lập trình.

dependencies {
  ...
  implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:x.x.x"
}

Thư viện này thêm viewModelScope làm hàm tiện ích của lớp ViewModel. Phạm vi này được liên kết với Dispatchers.Main và sẽ tự động bị huỷ khi ViewModel bị xoá.

Chuyển từ luồng sang coroutine

Trong MainViewModel.kt, hãy tìm việc cần làm tiếp theo cùng với mã này:

MainViewModel.kt

/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
   // TODO: Convert updateTaps to use coroutines
   tapCount++
   BACKGROUND.submit {
       Thread.sleep(1_000)
       _taps.postValue("$tapCount taps")
   }
}

Mã này sử dụng BACKGROUND ExecutorService (được xác định trong util/Executor.kt) để chạy trong một luồng nền. Vì sleep chặn luồng hiện tại, nên nếu được gọi trên luồng chính, hàm này sẽ làm treo giao diện người dùng. Một giây sau khi người dùng nhấp vào khung hiển thị chính, ứng dụng sẽ yêu cầu một thanh thông báo.

Bạn có thể thấy điều đó bằng cách xoá BACKGROUND khỏi mã và chạy lại. Vòng quay tải sẽ không xuất hiện và mọi thứ sẽ "nhảy" đến trạng thái cuối cùng sau một giây.

MainViewModel.kt

/**
* Wait one second then update the tap count.
*/
private fun updateTaps() {
   // TODO: Convert updateTaps to use coroutines
   tapCount++
   Thread.sleep(1_000)
   _taps.postValue("$tapCount taps")
}

Thay thế updateTaps bằng đoạn mã dựa trên đồng thời này để thực hiện cùng một việc. Bạn sẽ phải nhập launchdelay.

MainViewModel.kt

/**
* Wait one second then display a snackbar.
*/
fun updateTaps() {
   // launch a coroutine in viewModelScope
   viewModelScope.launch {
       tapCount++
       // suspend this coroutine for one second
       delay(1_000)
       // resume in the main dispatcher
       // _snackbar.value can be called directly from main thread
       _taps.postValue("$tapCount taps")
   }
}

Đoạn mã này cũng làm điều tương tự, tức là đợi một giây trước khi hiện một thanh thông báo. Tuy nhiên, có một số điểm khác biệt quan trọng:

  1. viewModelScope.launch sẽ khởi động một coroutine trong viewModelScope. Điều này có nghĩa là khi công việc mà chúng ta truyền đến viewModelScope bị huỷ, tất cả coroutine trong công việc/phạm vi này sẽ bị huỷ. Nếu người dùng rời khỏi Hoạt động trước khi delay trả về, thì coroutine này sẽ tự động bị huỷ khi onCleared được gọi khi ViewModel bị huỷ.
  2. viewModelScope có trình điều phối mặc định là Dispatchers.Main, nên coroutine này sẽ được khởi chạy trong luồng chính. Sau này, chúng ta sẽ tìm hiểu cách sử dụng các luồng khác nhau.
  3. Hàm delay là hàm suspend. Điều này được thể hiện trong Android Studio bằng biểu tượng ở rãnh bên trái. Mặc dù coroutine này chạy trên luồng chính, nhưng delay sẽ không chặn luồng trong một giây. Thay vào đó, trình điều phối sẽ lên lịch để coroutine tiếp tục sau một giây tại câu lệnh tiếp theo.

Hãy tiếp tục và chạy chương trình này. Khi nhấp vào chế độ xem chính, bạn sẽ thấy một thanh thông báo sau một giây.

Trong phần tiếp theo, chúng ta sẽ xem xét cách kiểm thử hàm này.

Trong bài tập này, bạn sẽ viết một bài kiểm thử cho mã mà bạn vừa viết. Bài tập này hướng dẫn bạn cách kiểm thử các coroutine đang chạy trên Dispatchers.Main bằng cách sử dụng thư viện kotlinx-coroutines-test. Trong phần sau của lớp học lập trình này, bạn sẽ triển khai một kiểm thử tương tác trực tiếp với các coroutine.

Xem lại mã hiện có

Mở MainViewModelTest.kt trong thư mục androidTest.

MainViewModelTest.kt

class MainViewModelTest {
   @get:Rule
   val coroutineScope =  MainCoroutineScopeRule()
   @get:Rule
   val instantTaskExecutorRule = InstantTaskExecutorRule()

   lateinit var subject: MainViewModel

   @Before
   fun setup() {
       subject = MainViewModel(
           TitleRepository(
                   MainNetworkFake("OK"),
                   TitleDaoFake("initial")
           ))
   }
}

Quy tắc là một cách để chạy mã trước và sau khi thực thi một kiểm thử trong JUnit. Chúng ta sử dụng 2 quy tắc để kiểm thử MainViewModel trong một quy trình kiểm thử ngoài thiết bị:

  1. InstantTaskExecutorRule là một quy tắc JUnit định cấu hình LiveData để thực thi từng tác vụ một cách đồng bộ
  2. MainCoroutineScopeRule là một quy tắc tuỳ chỉnh trong cơ sở mã này, quy tắc này định cấu hình Dispatchers.Main để sử dụng TestCoroutineDispatcher từ kotlinx-coroutines-test. Điều này cho phép các kiểm thử chuyển đồng hồ ảo để kiểm thử và cho phép mã sử dụng Dispatchers.Main trong các kiểm thử đơn vị.

Trong phương thức setup, một thực thể mới của MainViewModel được tạo bằng cách sử dụng các đối tượng giả để kiểm thử – đây là các triển khai giả của mạng và cơ sở dữ liệu được cung cấp trong mã khởi đầu để giúp viết các kiểm thử mà không cần sử dụng mạng hoặc cơ sở dữ liệu thực.

Đối với kiểm thử này, các đối tượng giả chỉ cần thiết để đáp ứng các phần phụ thuộc của MainViewModel. Sau đó trong lớp học lập trình này, bạn sẽ cập nhật các dữ liệu giả để hỗ trợ coroutine.

Viết một bài kiểm thử kiểm soát các coroutine

Thêm một kiểm thử mới để đảm bảo rằng các thao tác nhấn được cập nhật một giây sau khi nhấp vào khung hiển thị chính:

MainViewModelTest.kt

@Test
fun whenMainClicked_updatesTaps() {
   subject.onMainViewClicked()
   Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("0 taps")
   coroutineScope.advanceTimeBy(1000)
   Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("1 taps")
}

Bằng cách gọi onMainViewClicked, coroutine mà chúng ta vừa tạo sẽ được khởi chạy. Bài kiểm thử này kiểm tra để đảm bảo văn bản về số lần nhấn vẫn là "0 lần nhấn" ngay sau khi onMainViewClicked được gọi, sau đó 1 giây sau, văn bản này sẽ được cập nhật thành "1 lần nhấn".

Bài kiểm thử này sử dụng thời gian ảo để kiểm soát quá trình thực thi coroutine do onMainViewClicked khởi chạy. MainCoroutineScopeRule cho phép bạn tạm dừng, tiếp tục hoặc kiểm soát việc thực thi các coroutine được khởi chạy trên Dispatchers.Main. Ở đây, chúng ta đang gọi advanceTimeBy(1_000). Thao tác này sẽ khiến trình điều phối chính thực thi ngay lập tức các coroutine được lên lịch tiếp tục sau 1 giây.

Thử nghiệm này hoàn toàn có tính xác định, tức là thử nghiệm sẽ luôn thực thi theo cùng một cách. Ngoài ra, vì có toàn quyền kiểm soát việc thực thi các coroutine được khởi chạy trên Dispatchers.Main, nên không cần phải đợi một giây để đặt giá trị.

Chạy kiểm thử hiện có

  1. Nhấp chuột phải vào tên lớp MainViewModelTest trong trình chỉnh sửa để mở trình đơn theo bối cảnh.
  2. Trong trình đơn theo bối cảnh, hãy chọn execute.pngRun "MainViewModelTest" (Chạy "MainViewModelTest")
  3. Đối với các lần chạy sau này, bạn có thể chọn cấu hình kiểm thử này trong các cấu hình bên cạnh nút execute.png trên thanh công cụ. Theo mặc định, cấu hình này sẽ được gọi là MainViewModelTest.

Bạn sẽ thấy chương trình kiểm thử thành công! Và quá trình này sẽ mất ít hơn một giây để chạy.

Trong bài tập tiếp theo, bạn sẽ tìm hiểu cách chuyển đổi từ các API gọi lại hiện có để sử dụng coroutine.

Trong bước này, bạn sẽ bắt đầu chuyển đổi một kho lưu trữ để sử dụng coroutine. Để làm việc này, chúng ta sẽ thêm các coroutine vào ViewModel, Repository, RoomRetrofit.

Bạn nên hiểu rõ trách nhiệm của từng phần trong cấu trúc trước khi chuyển chúng sang sử dụng coroutine.

  1. MainDatabase triển khai một cơ sở dữ liệu bằng Room để lưu và tải một Title.
  2. MainNetwork triển khai một API mạng để tìm nạp tiêu đề mới. Ứng dụng này sử dụng Retrofit để tìm nạp tiêu đề. Retrofit được định cấu hình để trả về lỗi hoặc dữ liệu mô phỏng một cách ngẫu nhiên, nhưng nếu không thì sẽ hoạt động như thể đang gửi yêu cầu mạng thực.
  3. TitleRepository triển khai một API duy nhất để tìm nạp hoặc làm mới tiêu đề bằng cách kết hợp dữ liệu từ mạng và cơ sở dữ liệu.
  4. MainViewModel biểu thị trạng thái của màn hình và xử lý các sự kiện. Thao tác này sẽ yêu cầu kho lưu trữ làm mới tiêu đề khi người dùng nhấn vào màn hình.

Vì yêu cầu mạng được điều khiển bởi các sự kiện trên giao diện người dùng và chúng ta muốn bắt đầu một coroutine dựa trên các sự kiện đó, nên vị trí tự nhiên để bắt đầu sử dụng coroutine là trong ViewModel.

Phiên bản lệnh gọi lại

Mở MainViewModel.kt để xem khai báo về refreshTitle.

MainViewModel.kt

/**
* Update title text via this LiveData
*/
val title = repository.title


// ... other code ...


/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   // TODO: Convert refreshTitle to use coroutines
   _spinner.value = true
   repository.refreshTitleWithCallbacks(object: TitleRefreshCallback {
       override fun onCompleted() {
           _spinner.postValue(false)
       }

       override fun onError(cause: Throwable) {
           _snackBar.postValue(cause.message)
           _spinner.postValue(false)
       }
   })
}

Hàm này được gọi mỗi khi người dùng nhấp vào màn hình và hàm này sẽ khiến kho lưu trữ làm mới tiêu đề và ghi tiêu đề mới vào cơ sở dữ liệu.

Hoạt động triển khai này sử dụng một lệnh gọi lại để thực hiện một số việc:

  • Trước khi bắt đầu một truy vấn, ứng dụng sẽ hiển thị một vòng quay tải có _spinner.value = true
  • Khi nhận được kết quả, phương thức này sẽ xoá biểu tượng tải bằng _spinner.value = false
  • Nếu gặp lỗi, nó sẽ yêu cầu thanh thông báo nhanh hiển thị và xoá trình quay

Xin lưu ý rằng lệnh gọi lại onCompleted không được truyền title. Vì chúng ta ghi tất cả tiêu đề vào cơ sở dữ liệu Room, nên giao diện người dùng sẽ cập nhật tiêu đề hiện tại bằng cách theo dõi một LiveData do Room cập nhật.

Trong bản cập nhật cho coroutine, chúng ta sẽ giữ nguyên hành vi này. Đây là một mẫu hay để sử dụng nguồn dữ liệu có khả năng ghi nhận như cơ sở dữ liệu Room để tự động cập nhật giao diện người dùng.

Phiên bản coroutine

Hãy viết lại refreshTitle bằng coroutine!

Vì chúng ta sẽ cần đến nó ngay lập tức, nên hãy tạo một hàm tạm ngưng trống trong kho lưu trữ (TitleRespository.kt). Xác định một hàm mới dùng toán tử suspend để cho Kotlin biết rằng hàm này hoạt động với coroutine.

TitleRepository.kt

suspend fun refreshTitle() {
    // TODO: Refresh from network and write to database
    delay(500)
}

Khi hoàn tất lớp học lập trình này, bạn sẽ cập nhật để sử dụng Retrofit và Room nhằm tìm nạp một tiêu đề mới và ghi tiêu đề đó vào cơ sở dữ liệu bằng cách sử dụng coroutine. Hiện tại, nó sẽ chỉ mất 500 mili giây để giả vờ thực hiện công việc rồi tiếp tục.

Trong MainViewModel, hãy thay thế phiên bản callback của refreshTitle bằng một phiên bản khởi chạy một coroutine mới:

MainViewModel.kt

/**
* Refresh the title, showing a loading spinner while it refreshes and errors via snackbar.
*/
fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           repository.refreshTitle()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

Hãy xem xét hàm này:

viewModelScope.launch {

Giống như coroutine để cập nhật số lần nhấn, hãy bắt đầu bằng cách chạy một coroutine mới trong viewModelScope. Thao tác này sẽ sử dụng Dispatchers.Main, điều này là chấp nhận được. Mặc dù refreshTitle sẽ thực hiện một yêu cầu mạng và truy vấn cơ sở dữ liệu, nhưng nó có thể sử dụng coroutine để hiển thị một giao diện an toàn cho luồng chính. Điều này có nghĩa là bạn có thể gọi hàm này một cách an toàn từ luồng chính.

Vì chúng ta đang sử dụng viewModelScope, nên khi người dùng rời khỏi màn hình này, công việc do coroutine này bắt đầu sẽ tự động bị huỷ. Điều đó có nghĩa là nó sẽ không đưa ra các yêu cầu mạng hoặc truy vấn cơ sở dữ liệu bổ sung.

Một vài dòng mã tiếp theo thực sự gọi refreshTitle trong repository.

try {
    _spinner.value = true
    repository.refreshTitle()
}

Trước khi thực hiện bất kỳ thao tác nào, coroutine này sẽ bắt đầu vòng quay tải – sau đó gọi refreshTitle giống như một hàm thông thường. Tuy nhiên, vì refreshTitle là một hàm tạm ngưng, nên hàm này sẽ thực thi khác với một hàm thông thường.

Chúng ta không cần truyền một lệnh gọi lại. Coroutine sẽ tạm ngưng cho đến khi được refreshTitle tiếp tục. Mặc dù trông giống như một lệnh gọi hàm chặn thông thường, nhưng hàm này sẽ tự động đợi cho đến khi truy vấn mạng và cơ sở dữ liệu hoàn tất rồi mới tiếp tục mà không chặn luồng chính.

} catch (error: TitleRefreshError) {
    _snackBar.value = error.message
} finally {
    _spinner.value = false
}

Các trường hợp ngoại lệ trong hàm tạm ngưng hoạt động giống như lỗi trong các hàm thông thường. Nếu bạn gửi một lỗi trong hàm tạm ngưng, lỗi đó sẽ được gửi đến phương thức gọi. Vì vậy, mặc dù chúng thực thi theo cách khác nhau, nhưng bạn có thể dùng các khối try/catch thông thường để xử lý chúng. Điều này rất hữu ích vì bạn có thể dựa vào tính năng hỗ trợ ngôn ngữ tích hợp để xử lý lỗi thay vì tạo tính năng xử lý lỗi tuỳ chỉnh cho mọi lệnh gọi lại.

Ngoài ra, nếu bạn khai báo một trường hợp ngoại lệ ngoài một coroutine, thì theo mặc định, coroutine đó sẽ huỷ tác vụ mẹ. Điều đó có nghĩa là bạn có thể dễ dàng huỷ nhiều việc có liên quan cùng một lúc.

Sau đó, trong một khối cuối cùng, chúng ta có thể đảm bảo rằng vòng quay luôn tắt sau khi truy vấn chạy.

Chạy lại ứng dụng bằng cách chọn cấu hình start (bắt đầu) rồi nhấn execute.png, bạn sẽ thấy một biểu tượng tải xoay tròn khi nhấn vào bất kỳ vị trí nào. Tiêu đề sẽ không thay đổi vì chúng ta chưa kết nối mạng hoặc cơ sở dữ liệu.

Trong bài tập tiếp theo, bạn sẽ cập nhật kho lưu trữ để thực sự thực hiện công việc.

Trong bài tập này, bạn sẽ tìm hiểu cách chuyển đổi luồng mà một coroutine chạy để triển khai phiên bản hoạt động của TitleRepository.

Xem lại mã gọi lại hiện có trong refreshTitle

Mở TitleRepository.kt rồi xem lại cách triển khai hiện có dựa trên lệnh gọi lại.

TitleRepository.kt

// TitleRepository.kt

fun refreshTitleWithCallbacks(titleRefreshCallback: TitleRefreshCallback) {
   // This request will be run on a background thread by retrofit
   BACKGROUND.submit {
       try {
           // Make network request using a blocking call
           val result = network.fetchNextTitle().execute()
           if (result.isSuccessful) {
               // Save it to database
               titleDao.insertTitle(Title(result.body()!!))
               // Inform the caller the refresh is completed
               titleRefreshCallback.onCompleted()
           } else {
               // If it's not successful, inform the callback of the error
               titleRefreshCallback.onError(
                       TitleRefreshError("Unable to refresh title", null))
           }
       } catch (cause: Throwable) {
           // If anything throws an exception, inform the caller
           titleRefreshCallback.onError(
                   TitleRefreshError("Unable to refresh title", cause))
       }
   }
}

Trong TitleRepository.kt, phương thức refreshTitleWithCallbacks được triển khai bằng một lệnh gọi lại để truyền đạt trạng thái tải và lỗi cho phương thức gọi.

Hàm này thực hiện khá nhiều việc để triển khai thao tác làm mới.

  1. Chuyển sang một chuỗi tin nhắn khác bằng cách nhấn BACKGROUND ExecutorService
  2. Chạy yêu cầu mạng fetchNextTitle bằng phương thức chặn execute(). Thao tác này sẽ chạy yêu cầu mạng trong luồng hiện tại, trong trường hợp này là một trong các luồng trong BACKGROUND.
  3. Nếu kết quả thành công, hãy lưu kết quả đó vào cơ sở dữ liệu bằng insertTitle và gọi phương thức onCompleted().
  4. Nếu kết quả không thành công hoặc có một trường hợp ngoại lệ, hãy gọi phương thức onError để thông báo cho phương thức gọi về việc làm mới không thành công.

Phương thức triển khai dựa trên lệnh gọi lại này là an toàn cho luồng chính vì phương thức này sẽ không chặn luồng chính. Tuy nhiên, nó phải sử dụng một lệnh gọi lại để thông báo cho phương thức gọi khi công việc hoàn tất. Thao tác này cũng gọi các lệnh gọi lại trên luồng BACKGROUND mà nó đã chuyển sang.

Gọi các cuộc gọi chặn từ các coroutine

Nếu không giới thiệu coroutine cho mạng hoặc cơ sở dữ liệu, chúng ta có thể làm cho mã này an toàn cho luồng chính bằng cách sử dụng coroutine. Điều này sẽ giúp chúng ta loại bỏ lệnh gọi lại và cho phép chúng ta truyền kết quả trở lại luồng đã gọi lệnh gọi lại đó ban đầu.

Bạn có thể sử dụng mẫu này bất cứ khi nào cần thực hiện công việc chặn hoặc công việc nặng về CPU từ bên trong một coroutine, chẳng hạn như sắp xếp và lọc một danh sách lớn hoặc đọc từ ổ đĩa.

Để chuyển đổi giữa bất kỳ trình điều phối nào, coroutine sẽ dùng withContext. Việc gọi withContext sẽ chuyển sang điều phối viên khác chỉ cho hàm lambda, sau đó quay lại điều phối viên đã gọi hàm đó cùng với kết quả của hàm lambda đó.

Theo mặc định, các coroutine Kotlin cung cấp 3 Dispatcher: Main, IODefault. Trình điều phối IO được tối ưu hoá cho công việc IO, chẳng hạn như đọc từ mạng hoặc ổ đĩa, trong khi Trình điều phối Default được tối ưu hoá cho các tác vụ cần nhiều CPU.

TitleRepository.kt

suspend fun refreshTitle() {
   // interact with *blocking* network and IO calls from a coroutine
   withContext(Dispatchers.IO) {
       val result = try {
           // Make network request using a blocking call
           network.fetchNextTitle().execute()
       } catch (cause: Throwable) {
           // If the network throws an exception, inform the caller
           throw TitleRefreshError("Unable to refresh title", cause)
       }
      
       if (result.isSuccessful) {
           // Save it to database
           titleDao.insertTitle(Title(result.body()!!))
       } else {
           // If it's not successful, inform the callback of the error
           throw TitleRefreshError("Unable to refresh title", null)
       }
   }
}

Phương thức triển khai này sử dụng các lệnh gọi chặn cho mạng và cơ sở dữ liệu – nhưng vẫn đơn giản hơn một chút so với phiên bản lệnh gọi lại.

Mã này vẫn sử dụng các lệnh gọi chặn. Việc gọi execute()insertTitle(...) sẽ chặn luồng mà coroutine này đang chạy. Tuy nhiên, bằng cách chuyển sang Dispatchers.IO bằng withContext, chúng ta đang chặn một trong các luồng trong trình điều phối IO. Coroutine đã gọi hàm này (có thể đang chạy trên Dispatchers.Main) sẽ bị tạm ngưng cho đến khi lambda withContext hoàn tất.

So với phiên bản lệnh gọi lại, có 2 điểm khác biệt quan trọng:

  1. withContext trả về kết quả cho Dispatcher đã gọi nó, trong trường hợp này là Dispatchers.Main. Phiên bản lệnh gọi lại đã gọi các lệnh gọi lại trên một luồng trong dịch vụ thực thi BACKGROUND.
  2. Người gọi không cần phải truyền một lệnh gọi lại đến hàm này. Họ có thể dựa vào thao tác tạm dừng và tiếp tục để nhận được kết quả hoặc lỗi.

Chạy lại ứng dụng

Nếu chạy lại ứng dụng, bạn sẽ thấy rằng việc triển khai dựa trên coroutine mới đang tải kết quả từ mạng!

Trong bước tiếp theo, bạn sẽ tích hợp các coroutine vào Room và Retrofit.

Để tiếp tục tích hợp coroutine, chúng ta sẽ sử dụng tính năng hỗ trợ cho các hàm tạm ngưng trong phiên bản ổn định của Room và Retrofit, sau đó đơn giản hoá đáng kể mã mà chúng ta vừa viết bằng cách sử dụng các hàm tạm ngưng.

Coroutine trong Room

Trước tiên, hãy mở MainDatabase.kt và đặt insertTitle làm một hàm tạm ngưng:

MainDatabase.kt

// add the suspend modifier to the existing insertTitle

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)

Khi bạn làm như vậy, Room sẽ làm cho truy vấn của bạn trở nên an toàn cho luồng chính và tự động thực thi truy vấn đó trên một luồng nền. Tuy nhiên, điều này cũng có nghĩa là bạn chỉ có thể gọi truy vấn này từ bên trong một coroutine.

Và đó là tất cả những gì bạn phải làm để sử dụng coroutine trong Room. Khá hay.

Coroutine trong Retrofit

Tiếp theo, hãy xem cách tích hợp coroutine với Retrofit. Mở MainNetwork.kt và thay đổi fetchNextTitle thành một hàm tạm ngưng.

MainNetwork.kt

// add suspend modifier to the existing fetchNextTitle
// change return type from Call<String> to String

interface MainNetwork {
   @GET("next_title.json")
   suspend fun fetchNextTitle(): String
}

Để sử dụng các hàm tạm ngưng với Retrofit, bạn phải làm 2 việc sau:

  1. Thêm từ khoá xác định suspend vào hàm
  2. Xoá trình bao bọc Call khỏi loại trả về. Ở đây, chúng ta đang trả về String, nhưng bạn cũng có thể trả về loại phức tạp được hỗ trợ bằng JSON. Nếu vẫn muốn cung cấp quyền truy cập vào Result đầy đủ của retrofit, bạn có thể trả về Result<String> thay vì String từ hàm tạm ngưng.

Retrofit sẽ tự động tạo các hàm tạm ngưng an toàn cho luồng chính để bạn có thể gọi trực tiếp các hàm này từ Dispatchers.Main.

Sử dụng Room và Retrofit

Giờ đây, Room và Retrofit đều hỗ trợ các hàm tạm ngưng, nên chúng ta có thể sử dụng các hàm này từ kho lưu trữ. Mở TitleRepository.kt và xem cách sử dụng các hàm tạm ngưng giúp đơn giản hoá đáng kể logic, ngay cả khi so với phiên bản chặn:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

Wow, ngắn hơn rất nhiều. Điều gì đã xảy ra? Hoá ra việc dựa vào các thao tác tạm dừng và tiếp tục giúp mã ngắn hơn nhiều. Retrofit cho phép chúng ta sử dụng các loại dữ liệu trả về như String hoặc đối tượng User tại đây, thay vì Call. Bạn có thể làm như vậy một cách an toàn vì bên trong hàm tạm ngưng, Retrofit có thể chạy yêu cầu mạng trên một luồng trong nền và tiếp tục coroutine khi lệnh gọi hoàn tất.

Thậm chí còn tốt hơn nữa là chúng tôi đã loại bỏ withContext. Vì cả Room và Retrofit đều cung cấp các hàm tạm ngưng an toàn cho luồng chính, nên bạn có thể điều phối công việc không đồng bộ này một cách an toàn từ Dispatchers.Main.

Khắc phục lỗi trình biên dịch

Việc chuyển sang coroutine sẽ liên quan đến việc thay đổi chữ ký của các hàm vì bạn không thể gọi một hàm tạm ngưng từ một hàm thông thường. Khi bạn thêm đối tượng sửa đổi suspend trong bước này, một số lỗi trình biên dịch đã được tạo để cho biết điều gì sẽ xảy ra nếu bạn thay đổi một hàm để tạm ngưng trong một dự án thực.

Xem qua dự án và khắc phục các lỗi trình biên dịch bằng cách thay đổi hàm thành hàm tạm ngưng đã tạo. Sau đây là các giải pháp nhanh cho từng vấn đề:

TestingFakes.kt

Cập nhật các dữ liệu kiểm thử giả để hỗ trợ các đối tượng sửa đổi mới của hàm tạm ngưng.

TitleDaoFake

  1. Nhấn Alt+Enter để thêm đối tượng sửa đổi tạm ngưng vào tất cả các hàm trong hệ thống phân cấp

MainNetworkFake

  1. Nhấn Alt+Enter để thêm đối tượng sửa đổi tạm ngưng vào tất cả các hàm trong hệ thống phân cấp
  2. Thay thế fetchNextTitle bằng hàm này
override suspend fun fetchNextTitle() = result

MainNetworkCompletableFake

  1. Nhấn Alt+Enter để thêm đối tượng sửa đổi tạm ngưng vào tất cả các hàm trong hệ thống phân cấp
  2. Thay thế fetchNextTitle bằng hàm này
override suspend fun fetchNextTitle() = completable.await()

TitleRepository.kt

  • Xoá hàm refreshTitleWithCallbacks vì hàm này không còn được dùng nữa.

Chạy ứng dụng

Chạy lại ứng dụng. Sau khi ứng dụng biên dịch, bạn sẽ thấy ứng dụng đang tải dữ liệu bằng cách sử dụng coroutine từ ViewModel đến Room và Retrofit!

Xin chúc mừng, bạn đã hoàn toàn chuyển đổi ứng dụng này sang dùng coroutine! Để kết thúc, chúng ta sẽ nói một chút về cách kiểm thử những gì chúng ta vừa làm.

Trong bài tập này, bạn sẽ viết một bài kiểm thử gọi trực tiếp hàm suspend.

refreshTitle được hiển thị dưới dạng một API công khai, nên API này sẽ được kiểm thử trực tiếp, cho biết cách gọi các hàm coroutine từ các quy trình kiểm thử.

Sau đây là hàm refreshTitle mà bạn đã triển khai trong bài tập trước:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = network.fetchNextTitle()
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

Viết một chương trình kiểm thử gọi một hàm tạm ngưng

Mở TitleRepositoryTest.kt trong thư mục test có 2 việc CẦN LÀM.

Hãy thử gọi refreshTitle từ whenRefreshTitleSuccess_insertsRows kiểm thử đầu tiên.

@Test
fun whenRefreshTitleSuccess_insertsRows() {
   val subject = TitleRepository(
       MainNetworkFake("OK"),
       TitleDaoFake("title")
   )

   subject.refreshTitle()
}

refreshTitle là một hàm suspend nên Kotlin không biết cách gọi hàm này, trừ phi hàm này được gọi từ một coroutine hoặc một hàm tạm ngưng khác. Do đó, bạn sẽ gặp phải lỗi trình biên dịch như "Chỉ nên gọi hàm tạm ngưng refreshTitle từ một coroutine hoặc một hàm tạm ngưng khác."

Trình chạy kiểm thử không biết gì về coroutine nên chúng ta không thể thiết lập kiểm thử này thành một hàm tạm ngưng. Chúng ta có thể launch một coroutine bằng cách sử dụng CoroutineScope như trong ViewModel, tuy nhiên, các kiểm thử cần chạy coroutine cho đến khi hoàn tất trước khi trả về. Sau khi một hàm kiểm thử trả về, quá trình kiểm thử sẽ kết thúc. Các coroutine bắt đầu bằng launch là mã không đồng bộ, có thể hoàn tất vào một thời điểm nào đó trong tương lai. Do đó, để kiểm thử mã không đồng bộ, bạn cần có cách nào đó để yêu cầu kiểm thử đợi cho đến khi coroutine của bạn hoàn tất. Vì launch là một lệnh gọi không chặn, tức là lệnh gọi này sẽ trả về ngay lập tức và có thể tiếp tục chạy một coroutine sau khi hàm trả về – lệnh gọi này không thể dùng trong các kiểm thử. Ví dụ:

@Test
fun whenRefreshTitleSuccess_insertsRows() {
   val subject = TitleRepository(
       MainNetworkFake("OK"),
       TitleDaoFake("title")
   )

   // launch starts a coroutine then immediately returns
   GlobalScope.launch {
       // since this is asynchronous code, this may be called *after* the test completes
       subject.refreshTitle()
   }
   // test function returns immediately, and
   // doesn't see the results of refreshTitle
}

Đôi khi, kiểm thử này sẽ không thành công. Lệnh gọi đến launch sẽ trả về ngay lập tức và thực thi cùng lúc với phần còn lại của trường hợp kiểm thử. Bài kiểm thử không có cách nào biết liệu refreshTitle đã chạy hay chưa – và mọi câu lệnh khẳng định như kiểm tra xem cơ sở dữ liệu đã được cập nhật hay chưa đều sẽ không ổn định. Ngoài ra, nếu refreshTitle gửi một trường hợp ngoại lệ, thì trường hợp đó sẽ không được gửi trong ngăn xếp lệnh gọi kiểm thử. Thay vào đó, nó sẽ được chuyển vào trình xử lý ngoại lệ chưa được xử lý của GlobalScope.

Thư viện kotlinx-coroutines-test có hàm runBlockingTest. Hàm này chặn nội dung trong khi gọi các hàm tạm ngưng. Khi runBlockingTest gọi một hàm tạm ngưng hoặc launches một coroutine mới, hàm này sẽ thực thi ngay lập tức theo mặc định. Bạn có thể coi đây là một cách để chuyển đổi các hàm tạm ngưng và coroutine thành các lệnh gọi hàm thông thường.

Ngoài ra, runBlockingTest sẽ gửi lại các ngoại lệ chưa được xử lý cho bạn. Điều này giúp bạn dễ dàng kiểm thử khi một coroutine đang gửi một ngoại lệ.

Triển khai một chương trình kiểm thử bằng một coroutine

Gói lệnh gọi đến refreshTitle bằng runBlockingTest và xoá trình bao bọc GlobalScope.launch khỏi subject.refreshTitle().

TitleRepositoryTest.kt

@Test
fun whenRefreshTitleSuccess_insertsRows() = runBlockingTest {
   val titleDao = TitleDaoFake("title")
   val subject = TitleRepository(
           MainNetworkFake("OK"),
           titleDao
   )

   subject.refreshTitle()
   Truth.assertThat(titleDao.nextInsertedOrNull()).isEqualTo("OK")
}

Bài kiểm thử này sử dụng các dữ liệu giả được cung cấp để kiểm tra xem "OK" có được refreshTitle chèn vào cơ sở dữ liệu hay không.

Khi kiểm thử gọi runBlockingTest, kiểm thử sẽ chặn cho đến khi coroutine do runBlockingTest bắt đầu hoàn tất. Sau đó, bên trong, khi chúng ta gọi refreshTitle, nó sẽ sử dụng cơ chế tạm ngưng và tiếp tục thông thường để chờ hàng trong cơ sở dữ liệu được thêm vào dữ liệu giả của chúng ta.

Sau khi coroutine kiểm thử hoàn tất, runBlockingTest sẽ trả về.

Viết bài kiểm thử thời gian chờ

Chúng ta muốn thêm một thời gian chờ ngắn vào yêu cầu mạng. Trước tiên, hãy viết bài kiểm thử rồi triển khai thời gian chờ. Tạo một bài kiểm tra mới:

TitleRepositoryTest.kt

@Test(expected = TitleRefreshError::class)
fun whenRefreshTitleTimeout_throws() = runBlockingTest {
   val network = MainNetworkCompletableFake()
   val subject = TitleRepository(
           network,
           TitleDaoFake("title")
   )

   launch {
       subject.refreshTitle()
   }

   advanceTimeBy(5_000)
}

Bài kiểm thử này sử dụng MainNetworkCompletableFake giả được cung cấp. Đây là một mạng giả được thiết kế để tạm ngưng các phương thức gọi cho đến khi bài kiểm thử tiếp tục. Khi refreshTitle cố gắng tạo một yêu cầu mạng, yêu cầu đó sẽ bị treo mãi mãi vì chúng ta muốn kiểm thử thời gian chờ.

Sau đó, nó sẽ khởi chạy một coroutine riêng biệt để gọi refreshTitle. Đây là một phần quan trọng của việc kiểm thử thời gian chờ, thời gian chờ phải xảy ra trong một coroutine khác với coroutine mà runBlockingTest tạo. Bằng cách này, chúng ta có thể gọi dòng tiếp theo, advanceTimeBy(5_000), dòng này sẽ tăng thời gian thêm 5 giây và khiến coroutine kia hết thời gian chờ.

Đây là một bài kiểm thử hết thời gian hoàn chỉnh và sẽ vượt qua khi chúng tôi triển khai thời gian chờ.

Chạy ứng dụng ngay bây giờ và xem điều gì sẽ xảy ra:

Caused by: kotlinx.coroutines.test.UncompletedCoroutinesError: Test finished with active jobs: ["...]

Một trong những tính năng của runBlockingTest là tính năng này sẽ không cho phép bạn rò rỉ coroutine sau khi quá trình kiểm thử hoàn tất. Nếu có bất kỳ coroutine nào chưa hoàn tất (chẳng hạn như coroutine khởi chạy của chúng ta) vào cuối quá trình kiểm thử, thì quá trình kiểm thử sẽ không thành công.

Thêm thời gian chờ

Mở TitleRepository và thêm thời gian chờ 5 giây vào quá trình tìm nạp mạng. Bạn có thể thực hiện việc này bằng cách sử dụng hàm withTimeout:

TitleRepository.kt

suspend fun refreshTitle() {
   try {
       // Make network request using a blocking call
       val result = withTimeout(5_000) {
           network.fetchNextTitle()
       }
       titleDao.insertTitle(Title(result))
   } catch (cause: Throwable) {
       // If anything throws an exception, inform the caller
       throw TitleRefreshError("Unable to refresh title", cause)
   }
}

Chạy kiểm thử. Khi chạy kiểm thử, bạn sẽ thấy tất cả kiểm thử đều đạt!

Trong bài tập tiếp theo, bạn sẽ tìm hiểu cách viết các hàm bậc cao bằng cách sử dụng coroutine.

Trong bài tập này, bạn sẽ tái cấu trúc refreshTitle trong MainViewModel để sử dụng một hàm tải dữ liệu chung. Lớp học lập trình này sẽ hướng dẫn bạn cách tạo các hàm bậc cao sử dụng coroutine.

Việc triển khai refreshTitle hiện tại vẫn hoạt động, nhưng chúng ta có thể tạo một coroutine tải dữ liệu chung luôn hiển thị chỉ báo tiến trình. Điều này có thể hữu ích trong một cơ sở mã tải dữ liệu để phản hồi một số sự kiện và muốn đảm bảo vòng quay tải luôn hiển thị.

Xem xét việc triển khai hiện tại, mọi dòng ngoại trừ repository.refreshTitle() đều là mã chuẩn để hiện chỉ báo xoay và hiển thị lỗi.

// MainViewModel.kt

fun refreshTitle() {
   viewModelScope.launch {
       try {
           _spinner.value = true
           // this is the only part that changes between sources
           repository.refreshTitle() 
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

Sử dụng coroutine trong các hàm bậc cao

Thêm mã này vào MainViewModel.kt

MainViewModel.kt

private fun launchDataLoad(block: suspend () -> Unit): Job {
   return viewModelScope.launch {
       try {
           _spinner.value = true
           block()
       } catch (error: TitleRefreshError) {
           _snackBar.value = error.message
       } finally {
           _spinner.value = false
       }
   }
}

Bây giờ, hãy tái cấu trúc refreshTitle() để sử dụng hàm bậc cao này.

MainViewModel.kt

fun refreshTitle() {
   launchDataLoad {
       repository.refreshTitle()
   }
}

Bằng cách trừu tượng hoá logic xung quanh việc hiển thị một chỉ báo tải và hiển thị lỗi, chúng ta đã đơn giản hoá mã thực tế cần thiết để tải dữ liệu. Việc hiện một chỉ báo xoay hoặc hiển thị lỗi là điều dễ dàng khái quát hoá cho mọi hoạt động tải dữ liệu, trong khi nguồn và đích dữ liệu thực tế cần được chỉ định mỗi lần.

Để tạo lớp trừu tượng này, launchDataLoad sẽ lấy một đối số block là một lambda tạm ngưng. Một lambda tạm ngưng cho phép bạn gọi các hàm tạm ngưng. Đó là cách Kotlin triển khai các trình tạo coroutine launchrunBlocking mà chúng ta đã sử dụng trong lớp học lập trình này.

// suspend lambda

block: suspend () -> Unit

Để tạo một lambda tạm ngưng, hãy bắt đầu bằng từ khoá suspend. Mũi tên hàm và kiểu dữ liệu trả về Unit hoàn tất khai báo.

Bạn không thường xuyên phải khai báo lambda tạm ngưng của riêng mình, nhưng chúng có thể hữu ích để tạo các trừu tượng như thế này nhằm đóng gói logic lặp lại!

Trong bài tập này, bạn sẽ tìm hiểu cách sử dụng mã dựa trên coroutine từ WorkManager.

WorkManager là gì

Có nhiều lựa chọn cho thao tác cần thiết trong nền có thể trì hoãn trên Android. Bài tập này hướng dẫn bạn cách tích hợp WorkManager với coroutine. WorkManager là một thư viện đơn giản, linh hoạt và có khả năng tương thích dành cho công việc có thể trì hoãn ở chế độ nền. WorkManager là giải pháp được đề xuất cho những trường hợp sử dụng này trên Android.

WorkManager là một phần của Android Jetpack và là một Thành phần kiến trúc cho công việc ở chế độ nền cần kết hợp khả năng thực thi theo cơ hội và khả năng thực thi được đảm bảo. Thực thi theo cơ hội có nghĩa là WorkManager sẽ thực hiện công việc trong nền của bạn ngay khi có thể. Thực thi được đảm bảo tức là WorkManager sẽ xử lý logic để bắt đầu công việc của bạn trong nhiều tình huống, ngay cả khi bạn rời khỏi ứng dụng.

Do đó, WorkManager là lựa chọn phù hợp cho những tác vụ phải hoàn thành.

Dưới đây là một số ví dụ về tác vụ sử dụng hiệu quả WorkManager:

  • Tải nhật ký lên
  • Áp dụng bộ lọc cho hình ảnh và lưu hình ảnh
  • Định kỳ đồng bộ hoá dữ liệu cục bộ với mạng

Sử dụng coroutine với WorkManager

WorkManager cung cấp nhiều cách triển khai lớp cơ sở ListanableWorker cho nhiều trường hợp sử dụng.

Lớp Worker đơn giản nhất cho phép chúng ta thực hiện một số thao tác đồng bộ do WorkManager thực thi. Tuy nhiên, sau khi đã chuyển đổi cơ sở mã để sử dụng coroutine và hàm tạm ngưng, cách tốt nhất để sử dụng WorkManager là thông qua lớp CoroutineWorker. Lớp này cho phép xác định hàm doWork() dưới dạng hàm tạm ngưng.

Để bắt đầu, hãy mở RefreshMainDataWork. Nó đã mở rộng CoroutineWorker và bạn cần triển khai doWork.

Bên trong hàm suspend doWork, hãy gọi refreshTitle() từ kho lưu trữ và trả về kết quả thích hợp!

Sau khi bạn hoàn tất việc cần làm, mã sẽ có dạng như sau:

override suspend fun doWork(): Result {
   val database = getDatabase(applicationContext)
   val repository = TitleRepository(network, database.titleDao)

   return try {
       repository.refreshTitle()
       Result.success()
   } catch (error: TitleRefreshError) {
       Result.failure()
   }
}

Xin lưu ý rằng CoroutineWorker.doWork() là một hàm tạm ngưng. Không giống như lớp Worker đơn giản hơn, mã này KHÔNG chạy trên Executor được chỉ định trong cấu hình WorkManager của bạn, mà thay vào đó sử dụng trình điều phối trong thành phần coroutineContext (theo mặc định là Dispatchers.Default).

Kiểm thử CoroutineWorker

Không có cơ sở mã nào hoàn chỉnh mà không có kiểm thử.

WorkManager cung cấp một số cách để kiểm thử các lớp Worker. Để tìm hiểu thêm về cơ sở hạ tầng kiểm thử ban đầu, bạn có thể đọc tài liệu.

WorkManager phiên bản 2.1 ra mắt một bộ API mới để hỗ trợ cách đơn giản hơn nhằm kiểm thử các lớp ListenableWorker và do đó là CoroutineWorker. Trong mã của mình, chúng ta sẽ sử dụng một trong những API mới này: TestListenableWorkerBuilder.

Để thêm kiểm thử mới, hãy cập nhật tệp RefreshMainDataWorkTest trong thư mục androidTest.

Nội dung của tệp là:

package com.example.android.kotlincoroutines.main

import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import com.example.android.kotlincoroutines.fakes.MainNetworkFake
import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4


@RunWith(JUnit4::class)
class RefreshMainDataWorkTest {

@Test
fun testRefreshMainDataWork() {
   val fakeNetwork = MainNetworkFake("OK")

   val context = ApplicationProvider.getApplicationContext<Context>()
   val worker = TestListenableWorkerBuilder<RefreshMainDataWork>(context)
           .setWorkerFactory(RefreshMainDataWork.Factory(fakeNetwork))
           .build()

   // Start the work synchronously
   val result = worker.startWork().get()

   assertThat(result).isEqualTo(Result.success())
}

}

Trước khi đến phần kiểm thử, chúng ta sẽ cho WorkManager biết về nhà máy để có thể chèn mạng giả.

Bản thân bài kiểm thử sử dụng TestListenableWorkerBuilder để tạo worker mà sau đó chúng ta có thể chạy bằng cách gọi phương thức startWork().

WorkManager chỉ là một ví dụ về cách sử dụng coroutine để đơn giản hoá thiết kế API.

Trong lớp học lập trình này, chúng ta đã tìm hiểu những kiến thức cơ bản mà bạn cần để bắt đầu sử dụng coroutine trong ứng dụng của mình!

Chúng tôi đã đề cập đến:

  • Cách tích hợp coroutine vào các ứng dụng Android từ cả giao diện người dùng và các tác vụ WorkManager để đơn giản hoá quy trình lập trình không đồng bộ,
  • Cách sử dụng coroutine bên trong ViewModel để tìm nạp dữ liệu từ mạng và lưu dữ liệu đó vào cơ sở dữ liệu mà không chặn luồng chính.
  • Và cách huỷ tất cả coroutine khi ViewModel hoàn tất.

Đối với hoạt động kiểm thử mã dựa trên coroutine, chúng ta đã đề cập đến cả hai bằng cách kiểm thử hành vi cũng như trực tiếp gọi các hàm suspend từ các quy trình kiểm thử.

Tìm hiểu thêm

Hãy xem lớp học lập trình "Coroutine nâng cao với LiveData và Flow Kotlin" để tìm hiểu thêm về cách sử dụng coroutine nâng cao trên Android.

Các coroutine của Kotlin có nhiều tính năng mà lớp học lập trình này chưa đề cập đến. Nếu bạn muốn tìm hiểu thêm về coroutine của Kotlin, hãy đọc các hướng dẫn về coroutine do JetBrains xuất bản. Bạn cũng có thể xem bài viết "Cải thiện hiệu suất của ứng dụng bằng coroutine của Kotlin" để biết thêm các mẫu sử dụng coroutine trên Android.