+21

Kotlin Coroutines trong Android

Mở đầu

Bài viết này dành cho bất cứ ai tò mò về Coroutines trong Kotlin nhưng không biết chính xác nó là gì.

Trong hướng dẫn này, chúng ta sẽ tìm hiểu Coroutines trong Kotlin bằng các chủ đề sau:

  • Chính xác thì Coroutines là gì?
  • Tại sao cần giải pháp mà Kotlin Coroutines cung cấp?
  • Hướng dẫn từng bước về cách triển khai Kotlin Coroutines trong Android.
  • Scope của Corlinines là gì?
  • Xử lý Exception trong Kotlin Coroutines.

Nguồn tham khảo: https://blog.mindorks.com/mastering-kotlin-coroutines-in-android-step-by-step-guide

Coroutines là gì?

Coroutines = Co + Routines Ở đây, Co có nghĩa là hợp tác và Routines có nghĩa là các chức năng. Có nghĩa là khi các chức năng hợp tác với nhau, chúng ta gọi nó là Coroutines.

Hãy xem ví dụ dưới đây để hiểu thêm về điều này. Giả sử chúng ta có 2 function là functionAfunctionB.

fun functionA(case: Int) {
    when (case) {
        1 -> {
            taskA1()
            functionB(1)
        }
        2 -> {
            taskA2()
            functionB(2)
        }
        3 -> {
            taskA3()
            functionB(3)
        }
        4 -> {
            taskA4()
            functionB(4)
        }
    }
}

functionB

fun functionB(case: Int) {
    when (case) {
        1 -> {
            taskB1()
            functionA(2)
        }
        2 -> {
            taskB2()
            functionA(3)
        }
        3 -> {
            taskB3()
            functionA(4)
        }
        4 -> {
            taskB4()
        }
    }
}

Sau đó, chúng ta có thể gọi functionA như sau:

functionA(1)

Tại đây, functionA sẽ thực hiện taskA1 và sau đó trao quyền điều khiển cho functionB để thực thi taskB1.

Sau đó, functionB sẽ thực hiện taskB1 và trả lại điều khiển cho functionA để thực thi taskA2, v.v.

Điều này nghĩa là functionAfunctionB đang hợp tác với nhau.

Với Kotlin Coroutines, việc hợp tác trên có thể được thực hiện rất dễ dàng mà không cần sử dụng when hoặc switch case mà tôi đã sử dụng trong ví dụ trên.

Bây giờ, chúng ta đã hiểu thế nào là coroutines khi nói đến sự hợp tác giữa các chức năng. Có những khả năng vô tận mở ra vì tính chất hợp tác của các chức năng.

Một vài khả năng như sau:

  • Nó có thể thực thi một vài dòng functionA và sau đó thực thi một vài dòng functionB và sau đó lại một vài dòng functionA, v.v. Điều này sẽ hữu ích khi một thread đang đứng yên không làm gì cả, trong trường hợp đó, nó có thể thực thi một vài dòng của function khác. Bằng cách này, nó có thể tận dụng tối đa lợi thế của thread. Cuối cùng, sự hợp tác này giúp ích trong đa nhiệm.
  • Nó sẽ cho phép viết asynchronous code theo cách synchronous. Chúng ta sẽ nói về điều này sau trong bài viết này.

Nhìn chung, Coroutines làm cho đa nhiệm rất dễ dàng.

Vì vậy, chúng ta có thể nói rằng cả Coroutines và các thread đều đa nhiệm. Nhưng sự khác biệt là các thread được quản lý bởi HĐH và coroutines bởi người dùng vì nó có thể thực thi một vài dòng chức năng bằng cách tận dụng sự hợp tác.

Đó là một framework tối ưu hóa được viết trên thread thực tế bằng cách tận dụng tính chất hợp tác của các function để làm cho nó nhẹ nhàng và mạnh mẽ hơn. Vì vậy, chúng ta có thể nói rằng Coroutines là những thread nhẹ. Một thread nhẹ có nghĩa là nó không map trên thread native. Do đó, nó không yêu cầu chuyển context trên bộ xử lý, vì vậy chúng nhanh hơn.

"nó không map trên thread native" nghĩa là sao?

Coroutines có sẵn trong nhiều ngôn ngữ. Về cơ bản, có hai loại Coroutines:

  • Xếp chồng (Stackful)
  • Không xếp chồng (Stackless)

Kotlin thực hiện các coroutines stackless - điều đó có nghĩa là coroutines không có stack của riêng nó, vì vậy nó không map trên native thread. Bây giờ, bạn có thể hiểu đoạn dưới đây, trang web chính thức của Kotlin nói

One can think of a coroutine as a light-weight thread. Like threads, coroutines can run in parallel, wait for each other and communicate. The biggest difference is that coroutines are very cheap, almost free: we can create thousands of them, and pay very little in terms of performance. True threads, on the other hand, are expensive to start and keep around. A thousand threads can be a serious challenge for a modern machine.

Coroutines không thay thế các thread, nó giống như một framework để quản lý chúng.

Định nghĩa chính xác của Coroutines: Một framework để quản lý đồng thời theo cách đơn giản và hiệu quả hơn với thread nhẹ được viết trên đầu threading framework thực tế để tận dụng tối đa nó bằng cách sử dụng tính chất hợp tác của các hàm. Bây giờ, chúng ta đã hiểu chính xác Coroutines là gì. Vậy chúng ta cần biết lý do tại sao cần có các giải pháp mà Coroutines Kotlin cung cấp.

Tại sao cần Kotlin Coroutines?

Hãy lấy một trường hợp sử dụng tiêu chuẩn của Ứng dụng Android như sau:

  • Lấy User từ server.
  • Hiển thị User lên UI
fun fetchUser(): User {
    // make network call
    // return user
}

fun showUser(user: User) {
    // show user
}

fun fetchAndShowUser() {
    val user = fetchUser()
    showUser(user)
}

Khi chúng ta gọi function fetchAndShowUser, nó sẽ throw về NetworkOnMainThreadException vì network call không được phép trên main thread. Có nhiều cách để giải quyết điều đó. Một vài trong số đó là như sau:

  1. Using Callback: Ở đây, chúng ta chạy fetchUser trong background thread và chuyển kết quả với callback.
fun fetchAndShowUser() {
    fetchUser { user ->
        showUser(user)
    }
}
  1. Using RxJava: Bằng cách này chúng ta có thể thoát khỏi cuộc gọi lại lồng nhau.
fetchUser()
        .subscribeOn(Schedulers.io())
        .observerOn(AndroidSchedulers.mainThread())
        .subscribe { user ->
            showUser(user)
        }
  1. Using Coroutines:
suspend fun fetchAndShowUser() {
     val user = fetchUser() // fetch on IO thread
     showUser(user) // back on UI thread
}

Đoạn code trên trông có vẻ đồng bộ, nhưng thật ra lại không đồng bộ. Chúng ta sẽ tìm hiểu xem

Triển khai Kotlin Coroutines trong Android

Thêm Kotlin Coroutines dependencies vào Android project như bên dưới:

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

Bây giờ, function fetchUser sẽ trông như bên dưới:

suspend fun fetchUser(): User {
    return GlobalScope.async(Dispatchers.IO) {
        // make network call
        // return user
    }.await()
}

Và function fetchAndShowUser sẽ như sau:

suspend fun fetchAndShowUser() {
    val user = fetchUser() // fetch on IO thread
    showUser(user) // back on UI thread
}

Và function showUser bên dưới vẫn như trước đây:

fun showUser(user: User) {
    // show user
}

Trong đó:

  • Dispatchers: Dispatchers giúp các coroutines quyết định thread mà công việc được thực hiện. Có ba loại Dispatchers chính là IO, DefaultMain. IO dispatcher được sử dụng để thực hiện các công việc network và disk-related. Default được sử dụng để làm công việc chuyên sâu CPU. Main là UI thread của Android. Để sử dụng chúng, chúng ta cần bọc công việc dưới async function. Chức năng Async trông như dưới đây.
suspend fun async() // implementation removed for brevity
  • suspend: Suspend function là một chức năng có thể bắt đầu, tạm dừng và tiếp tục.

Suspend functions chỉ được phép được gọi từ một coroutine hoặc suspend function khác. Bạn có thể thấy rằng async function bao gồm keyword suspend. Vì vậy, để sử dụng nó, chúng ta cũng cần phải thực hiện function suspend.

Vì vậy, fetchAndShowUser chỉ có thể được gọi từ một suspend function khác hoặc coroutine. Chúng ta không thể thực hiện onCreate của một activity suspend, vì vậy chúng tôi cần gọi nó từ các coroutines như dưới đây:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    GlobalScope.launch(Dispatchers.Main) {
        val user = fetchUser() // fetch on IO thread
        showUser(user) // back on UI thread
    }
    
}

showUser sẽ chạy trên UI thread vì chúng ta đã sử dụng Dispatchers.Main là để khởi chạy nó.

Có hai chức năng trong Kotlin để bắt đầu các coroutines như sau:

  • launch{}
  • async{}

Launch và Async trong Kotlin Coroutines

Sự khác biệt là launch{} không trả về bất cứ thứ gì và async{} trả về một Deferred<T>, trong đó await() function trả về kết quả của coroutine như chúng ta có future trong Java, thực hiện future.get() để có được kết quả.

Một cách nói khác:

  • launch: Thực hiện sau đó lãng quên
  • async: Thực hiện một nhiệm vụ và trả về một kết quả

Hãy lấy một ví dụ để tìm hiểu Launch và Async.

Chúng tôi có một function fetchUserAndSaveInDatabase như dưới đây:

suspend fun fetchUserAndSaveInDatabase() {
    // fetch user from network
    // save user in database
    // and do not return anything
}

Bây giờ, chúng ta có thể sử dụng launch như dưới đây:

GlobalScope.launch(Dispatchers.Main) {
    fetchUserAndSaveInDatabase() // do on IO thread
}

fetchUserAndSaveInDatabase không trả về bất cứ điều gì, chúng ta có thể sử dụng launch để hoàn thành task và sau đó làm một cái gì đó trên Main Thread.

Nhưng khi chúng ta cần kết quả trở về, chúng ta cần sử dụng async.

Chúng tôi có hai chức năng trả về User như dưới đây:

suspend fun fetchFirstUser(): User {
    // make network call
    // return user
}
suspend fun fetchSecondUser(): User {
    // make network call
    // return user
}

Bây giờ, chúng ta có thể sử dụng async như bên dưới:

GlobalScope.launch(Dispatchers.Main) {
    val userOne = async(Dispatchers.IO) { fetchFirstUser() }
    val userTwo = async(Dispatchers.IO) { fetchSecondUser() }
    showUsers(userOne.await(), userTwo.await()) // back on UI thread
}

Ở đây, nó làm cho cả hai cuộc gọi mạng song song, chờ kết quả và sau đó gọi hàm showUsers.

Bây giờ, chúng ta đã hiểu sự khác biệt giữa Launch và Async.

Tiếp theo chúng ta sẽ nói về withContext.

suspend fun fetchUser(): User {
    return GlobalScope.async(Dispatchers.IO) {
        // make network call
        // return user
    }.await()
}

withContext không có gì khác ngoài cách viết async mà chúng ta không phải viết await().

suspend fun fetchUser(): User {
    return withContext(Dispatchers.IO) {
        // make network call
        // return user
    }
}

Nhưng còn nhiều điều nữa mà chúng ta nên biết về withContext và await.

Bây giờ, hãy sử dụng withContext trong ví dụ async của chúng ta về fetchFirstUser và Bây giờ, hãy sử dụng withContext trong ví dụ async của chúng tôi về fetchFirstUser và fetchSecondUser song song.

GlobalScope.launch(Dispatchers.Main) {
    val userOne = withContext(Dispatchers.IO) { fetchFirstUser() }
    val userTwo = withContext(Dispatchers.IO) { fetchSecondUser() }
    showUsers(userOne, userTwo) // back on UI thread
}

Khi chúng ta sử dụng withContext, nó sẽ chạy theo chuỗi thay vì song song. Đó là một sự khác biệt lớn.

Quy tắc:

  • Sử dụng withContext khi bạn không cần thực thi song song.
  • Chỉ sử dụng async khi bạn cần thực thi song song.
  • Cả withContextasync đều có thể được sử dụng để có được kết quả, không thể thực hiện được với launch.
  • Sử dụng withContext để trả về kết quả của một tác vụ.
  • Sử dụng async cho kết quả từ nhiều tác vụ chạy song song.

Scope của Kotlin Coroutines

Scope trong Kotlin Coroutines rất hữu ích vì chúng ta cần hủy tác vụ nền ngay khi activity bị hủy. Ở đây, chúng ta sẽ tìm hiểu cách sử dụng scope để xử lý các loại tình huống này.

Giả sử rằng activity của chúng ta là scope, tác vụ nền sẽ bị hủy ngay khi activity bị hủy.

Trong activity, chúng ta cần implement CoroutineScope.

class MainActivity : AppCompatActivity(), CoroutineScope {

    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job

    private lateinit var job: Job

}

Trong onCreateonDestroy function.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    job = Job() // create the Job
}

override fun onDestroy() {
    job.cancel() // cancel the Job
    super.onDestroy()
}

Bây giờ, chỉ cần sử dụng launch như dưới đây:

launch {
    val userOne = async(Dispatchers.IO) { fetchFirstUser() }
    val userTwo = async(Dispatchers.IO) { fetchSecondUser() }
    showUsers(userOne.await(), userTwo.await())
}

Ngay khi activity bị hủy, tác vụ sẽ bị hủy nếu nó đang chạy vì chúng tôi đã xác định scope.

Khi chúng tôi cần global scope là scope ứng dụng của chúng ta, không phải activity scope, chúng tôi có thể sử dụng GlobalScope như sau:

GlobalScope.launch(Dispatchers.Main) {
    val userOne = async(Dispatchers.IO) { fetchFirstUser() }
    val userTwo = async(Dispatchers.IO) { fetchSecondUser() }
}

Tại đây, ngay cả khi hoạt động bị hủy, các hàm fetchUser sẽ tiếp tục chạy vì chúng ta đã sử dụng GlobalScope.

Đây là cách Scopes trong Kotlin Coroutines trở nên hữu ích.

Xử lý Exception trong Kotlin Coroutines.

Khi sử dụng launch

Một cách là sử dụng try-Catch:

GlobalScope.launch(Dispatchers.Main) {
    try {
        fetchUserAndSaveInDatabase() // do on IO thread and back to UI Thread
    } catch (exception: Exception) {
        Log.d(TAG, "$exception handled !")
    }
}

Cách khác là sử dụng handler

Để làm như vậy chúng ta cần khởi tạo một exception handler như sau:

val handler = CoroutineExceptionHandler { _, exception ->
    Log.d(TAG, "$exception handled !")
}

Sau đó, attach handler này vào như sau

GlobalScope.launch(Dispatchers.Main + handler) {
    fetchUserAndSaveInDatabase() // do on IO thread and back to UI Thread
}

Nếu như có exception trong fetchUserAndSaveInDatabase, nó sẽ được xử lý trong handler mà chúng ta vừa attach.

Khi sử dụng activity scope, chúng ta có thể attach exception vào coroutineContext như sau:

class MainActivity : AppCompatActivity(), CoroutineScope {

    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job + handler

    private lateinit var job: Job

}

và sử dụng:

launch {
    fetchUserAndSaveInDatabase()
}

When Using async

Khi sử dụng async, chúng ta cần sử dụng khối try-catch để xử lý exception bên dưới.

val deferredUser = GlobalScope.async {
    fetchUser()
}
try {
    val user = deferredUser.await()
} catch (exception: Exception) {
    Log.d(TAG, "$exception handled !")
}

Bây giờ, hãy xem thêm một số trường hợp sử dụng thực tế về xử lý ngoại lệ trong Phát triển Android.

Giả sử, chúng tôi có hai cuộc gọi mạng như dưới đây:

  • getUsers()
  • getMoreUsers()

Và, chúng ta đang thực hiện các cuộc gọi mạng theo chuỗi như dưới đây:

launch {
    try {
        val users = getUsers()
        val moreUsers = getMoreUsers()
    } catch (exception: Exception) {
        Log.d(TAG, "$exception handled !")
    }
}

Nếu một trong các cuộc gọi mạng thất bại, nó sẽ trực tiếp đi đến khối catch.

Nhưng giả sử, chúng tôi muốn trả về một danh sách trống cho cuộc gọi mạng đã thất bại và tiếp tục với phản hồi từ cuộc gọi mạng khác. Chúng tôi có thể thêm khối try-catch vào cuộc gọi mạng riêng lẻ như dưới đây:

launch {
    try {
        val users = try {
            getUsers()
        } catch (e: Exception) {
            emptyList<User>()
        }
        val moreUsers = try {
            getMoreUsers()
        } catch (e: Exception) {
            emptyList<User>()
        }
    } catch (exception: Exception) {
        Log.d(TAG, "$exception handled !")
    }
}

Bằng cách này, nếu có lỗi xảy ra, nó sẽ tiếp tục với danh sách rỗng.

Bây giờ, nếu chúng ta muốn thực hiện các cuộc gọi mạng song song. Chúng ta có thể viết mã như dưới đây bằng cách sử dụng async.

launch {
    try {
        val usersDeferred = async {  getUsers() }
        val moreUsersDeferred = async { getMoreUsers() }
        val users = usersDeferred.await()
        val moreUsers = moreUsersDeferred.await()
    } catch (exception: Exception) {
        Log.d(TAG, "$exception handled !")
    }
}

Ở đây, chúng ta sẽ đối mặt với một vấn đề, nếu như network error trả về, app sẽ crash!, nó sẽ không thể tới được catch block.

Để giải quyết vấn đề này, chúng ta sẽ sử dụng coroutineScope như sau:

launch {
    try {
        coroutineScope {
            val usersDeferred = async {  getUsers() }
            val moreUsersDeferred = async { getMoreUsers() }
            val users = usersDeferred.await()
            val moreUsers = moreUsersDeferred.await()
        }
    } catch (exception: Exception) {
        Log.d(TAG, "$exception handled !")
    }
}

Bây giờ cho dù network error nào xảy ra, nó cũng sẽ tới khối catch.

Như giả sử một lần nữa, chúng ta muốn trả về một list rỗng cho network call failed và tiếp tục đợi response từ network call khác. Chúng ta sẽ cần sử dụng supervisorScope và thêm các khối try-catch vào cho từng cá nhân network call như sau:

launch {
    try {
        supervisorScope {
            val usersDeferred = async { getUsers() }
            val moreUsersDeferred = async { getMoreUsers() }
            val users = try {
                usersDeferred.await()
            } catch (e: Exception) {
                emptyList<User>()
            }
            val moreUsers = try {
                moreUsersDeferred.await()
            } catch (e: Exception) {
                emptyList<User>()
            }
        }
    } catch (exception: Exception) {
        Log.d(TAG, "$exception handled !")
    }
}

Như vậy, với cách này cho dù error có xảy ra, nó vẫn sẽ được tiếp tục với một list rỗng.

Đây là cách mà supervisorScope làm việc.

Kết luận

  • Khi Không sử dụng async, chúng ta vấn có thể tiếp tục với try-catch hoặc CoroutineExceptionHandler và đạt được bất cứ điều gì bạn muốn với tùy từng trường hợp.
  • Khi sử dụng async, ngoài try-catch, chúng ta vẫn có 2 sự lựa chọn nữa coroutineScopesupervisorScope
  • Với async sử dụng supervisorScope với mỗi try-catch cá nhân của từng task, nằm bên trong khối try-catch ngoài cùng, bạn có thể tiếp tục các task khác cho dù một trong số chúng failed.
  • Với async, sử dụng coroutineScope với khối try-catch ngoài cùng, khi bạn không muốn tiếp tục với các task khác khi một trong số chúng fail.

Đây là git ví dụ về Coroutines của tác giả. Các bạn hãy tham khảo nhé

https://github.com/MindorksOpenSource/Kotlin-Coroutines-Android-Examples


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.