全民 Kotlin:協(xié)程特別篇

目錄

  • 什么是協(xié)程

  • suspend 關(guān)鍵字介紹

  • 集成協(xié)程

  • runBlocking 用法

  • launch 用法

  • async 用法

  • 協(xié)程的線程調(diào)度器

  • 協(xié)程的啟動(dòng)模式

  • 協(xié)程設(shè)置執(zhí)行超時(shí)

  • 協(xié)程的生命周期控制

什么是協(xié)程

A coroutine is an instance of suspendable computation. It is conceptually similar to a thread, in the sense that it takes a block of code to run that works concurrently with the rest of the code. However, a coroutine is not bound to any particular thread. It may suspend its execution in one thread and resume in another one.

Coroutines can be thought of as light-weight threads, but there is a number of important differences that make their real-life usage very different from threads.

  • 翻譯成中文的意思是:

一個(gè)協(xié)同程序是懸浮計(jì)算的一個(gè)實(shí)例。它在概念上類似于線程,從某種意義上說,它需要一個(gè)與其余代碼同時(shí)運(yùn)行的代碼塊來運(yùn)行。但是,協(xié)程不受任何特定線程的約束。它可以在一個(gè)線程中暫停其執(zhí)行并在另一個(gè)線程中恢復(fù)。

協(xié)程可以被認(rèn)為是輕量級(jí)線程,但是有許多重要的區(qū)別使得它們?cè)诂F(xiàn)實(shí)生活中的使用與線程大不相同。

  • 在我看來,協(xié)程是 Kotlin 對(duì)線程和 Handler 的 API 的一種封裝,是一種優(yōu)雅處理異步任務(wù)的解決方案,協(xié)程可以在不同的線程來回切換,這樣就可以讓代碼通過編寫的順序來執(zhí)行,并且不會(huì)阻塞當(dāng)前線程,省去了在各種耗時(shí)操作寫回調(diào)的情況。

suspend 關(guān)鍵字介紹

  • 在正式開講協(xié)程之前,先介紹一下 Kotlin 語法的關(guān)鍵字:suspend,suspend 的中文意思是掛起,可以理解為把當(dāng)前線程暫時(shí)掛起,稍后自動(dòng)切回來到原來的線程上,可用于修飾普通的方法,表示這個(gè)方法是一個(gè)耗時(shí)操作,只能在協(xié)程的環(huán)境下才能調(diào)用,又或者在另一個(gè) suspend 方法中調(diào)用。

  • 當(dāng)代碼執(zhí)行到有 suspend 關(guān)鍵字修飾的方法上,會(huì)先掛起當(dāng)前線程的執(zhí)行,需要注意的是這里的掛起是非阻塞式的(也就是不會(huì)阻塞當(dāng)前線程情況下),然后就會(huì)去先執(zhí)行帶有 suspend 修飾的方法上,當(dāng)這個(gè)方法執(zhí)行完成后,會(huì)讓剛剛掛起的線程繼續(xù)往下執(zhí)行,這樣我們看到的代碼順序就是代碼執(zhí)行的順序。

  • 另外需要注意的是 suspend 本身不會(huì)起到一種線程掛起或者線程切換的效果,那么它真正的作用是什么呢?其實(shí)它更多的是一種提醒,表示這是一個(gè)耗時(shí)方法,不能直接執(zhí)行,需要把我放到協(xié)程中去調(diào)用,所以我們?cè)趯懩硞€(gè)耗時(shí)方法的時(shí)候需要給它加上 suspend 關(guān)鍵字,這樣可以有效避免我們?cè)谥骶€程中調(diào)用耗時(shí)操作造成應(yīng)用卡頓的情況。

suspend fun getUserName(userId: Int): String {
    delay(20000)
    return "Android 輪子哥"
}

集成協(xié)程

dependencies {
    // Kotlin 協(xié)程:https://github.com/Kotlin/kotlinx.coroutines
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2'
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2'
}
  • 協(xié)程常見的三個(gè)操作符號(hào)

    • runBlocking:中文意思是運(yùn)行阻塞,顧名思義,會(huì)阻塞當(dāng)前線程執(zhí)行

    • launch:中文意思是啟動(dòng),不會(huì)阻塞當(dāng)前線程,但是會(huì)異步執(zhí)行代碼

    • async:中文意思是異步,跟 launch 相似,唯一不同的是它可以有返回值

runBlocking 用法

  • runBlocking 的中文翻譯:運(yùn)行阻塞。說太多沒用,直接用代碼測試一下
println("測試開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
runBlocking {
    println("測試延遲開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
    delay(20000)
    println("測試延遲結(jié)束")
}
println("測試結(jié)束")
12:53:15.799 System.out: 測試開始 true
12:53:15.811 System.out: 測試延遲開始 true
12:53:35.814 System.out: 測試延遲結(jié)束
12:53:35.815 System.out: 測試結(jié)束
  • runBlocking 運(yùn)行在主線程,由此可見和它的名稱一樣,真的會(huì)阻塞當(dāng)前的線程,只有等 runBlocking 里面的代碼執(zhí)行完了才會(huì)執(zhí)行 runBlocking 外面的代碼

  • 到這里大家可能有一個(gè)疑問,既然阻塞線程,那我直接用代碼寫不是也一樣?干嘛還用 runBlocking 呢?解決這一疑問很簡單,我們只需要找一個(gè)沒有協(xié)程的地方,寫一句 delay(20000) 就收到編譯器給你的提示:

Suspend function 'delay' should be called only from a coroutine or another suspend function

掛起函數(shù) 'delay' 應(yīng)該只從協(xié)程或另一個(gè)掛起函數(shù)調(diào)用

  • 如果我們調(diào)用的是被 suspend 修飾的方法,那么它必須要在協(xié)程內(nèi)才能被調(diào)用,所以 runBlocking 并非一無是處

launch 用法

  • launch 的中文翻譯:啟動(dòng),上代碼測試

  • 測試的時(shí)候是主線程,但是到了 launch 中就會(huì)變成子線程,這種效果類似 new Thread(),有木有?和 runBlocking 最不同的是 launch 沒有執(zhí)行順序這個(gè)概念

println("測試開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
GlobalScope.launch {
    println("測試延遲開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
    delay(20000)
    println("測試延遲結(jié)束")
}
println("測試結(jié)束")
17:19:17.190 System.out: 測試開始 true
17:19:17.202 System.out: 測試結(jié)束
17:19:17.203 System.out: 測試延遲開始 false
17:19:37.223 System.out: 測試延遲結(jié)束

async 用法

  • async 的中文翻譯:異步,還是老套路,直接上代碼測試
println("測試開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
GlobalScope.async {
    println("測試延遲開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
    delay(20000)
    println("測試延遲結(jié)束")
}
println("測試結(jié)束")
17:29:00.694 System.out: 測試開始 true
17:29:00.697 System.out: 測試結(jié)束
17:29:00.697 System.out: 測試延遲開始 false
17:29:20.707 System.out: 測試延遲結(jié)束
  • 這結(jié)果不是跟 launch 一樣么?那么這兩個(gè)到底有什么區(qū)別呢?讓我們先看一段測試代碼
println("測試開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
val async = GlobalScope.async {
    println("測試延遲開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
    delay(20000)
    println("測試延遲結(jié)束")
    return@async "666666"
}
println("測試結(jié)束")
println("測試返回值:" + async.await())
17:50:57.117 System.out: 測試開始 true
17:50:57.120 System.out: 測試結(jié)束
17:50:57.120 System.out: 測試延遲開始 false
17:51:17.131 System.out: 測試延遲結(jié)束
17:51:17.133 System.out: 測試返回值:666666
  • 看到這里你是否懂了,async 和 launch 還是有區(qū)別的,async 可以有返回值,通過它的 await 方法進(jìn)行獲取,需要注意的是這個(gè)方法只能在協(xié)程的操作符或者被 suspend 修飾的方法中才能調(diào)用。

協(xié)程的線程調(diào)度器

  • 介紹一下線程調(diào)度器的類型,總共有四種:

    • Dispatchers.Main:主線程調(diào)度器,人如其名,會(huì)在主線程中執(zhí)行

    • Dispatchers.IO:工作線程調(diào)度器,人如其名,會(huì)在子線程中執(zhí)行

    • Dispatchers.Default:默認(rèn)調(diào)度器,沒有設(shè)置調(diào)度器時(shí)就用這個(gè),經(jīng)過測試效果基本等同于 Dispatchers.IO

    • Dispatchers.Unconfined:無指定調(diào)度器,根據(jù)當(dāng)前執(zhí)行的環(huán)境而定,會(huì)在當(dāng)前的線程上執(zhí)行,另外有一點(diǎn)需要注意,由于是直接拿當(dāng)前線程執(zhí)行,經(jīng)過實(shí)踐,協(xié)程塊中的代碼執(zhí)行過程中不會(huì)有延遲,會(huì)被立馬執(zhí)行,除非遇到需要協(xié)程被掛起了,才會(huì)去執(zhí)行協(xié)程外的代碼,這個(gè)也是跟其他類型的調(diào)度器不相同的地方

  • 啥?協(xié)程有類似 RxJava 線程調(diào)度?先用 launch 試驗(yàn)一下

println("測試開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
GlobalScope.launch(Dispatchers.Main) {
    println("測試延遲開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
    delay(20000)
    println("測試延遲結(jié)束")
}
println("測試結(jié)束")
18:00:23.244 System.out: 測試開始 true
18:00:23.246 System.out: 測試結(jié)束
18:00:23.247 System.out: 測試延遲開始 true
18:00:43.256 System.out: 測試延遲結(jié)束
  • 使用線程調(diào)度器可以控制協(xié)程在哪個(gè)線程上面執(zhí)行,這主要?dú)w功于 Dispatchers(調(diào)度器),如果我不指定 launch 語句的調(diào)度器,那么它肯定是要子線程中執(zhí)行的,但是當(dāng)我指定了 Dispatchers.Main 之后,它就會(huì)變成在主線程中執(zhí)行了。

  • 線程調(diào)度不僅可以發(fā)生在協(xié)程的 launch 和 async 中,還可以發(fā)生在協(xié)程內(nèi)部,例如:

println("測試開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
GlobalScope.launch(Dispatchers.Main) {
    withContext(Dispatchers.IO) {
        println("測試是否為主線程 " + (Thread.currentThread() == Looper.getMainLooper().thread))
    }
    println("測試延遲開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
    delay(20000)
    println("測試延遲結(jié)束")
}
println("測試結(jié)束")
22:21:21.355 System.out: 測試開始 true
22:21:21.367 System.out: 測試結(jié)束
22:21:21.390 System.out: 測試是否為主線程 false
22:21:22.058 System.out: 測試延遲開始 true
22:21:42.059 System.out: 測試延遲結(jié)束
  • 從打印的日志來看,withContext 的作用就是將當(dāng)前線程掛起,只有當(dāng) withContext 里面的代碼執(zhí)行完了,才會(huì)恢復(fù)當(dāng)前線程的執(zhí)行,你現(xiàn)在回過頭看看代碼,這里面的執(zhí)行順序是不是就按照代碼的編寫順序來了呢,這個(gè)協(xié)程的魅力所在,盡管代碼需要在不同線程上面執(zhí)行,但是線程切換的效果十分優(yōu)雅,代碼從上向下執(zhí)行。

  • 需要注意的是 withContext 只能在協(xié)程的操作符或者被 suspend 修飾的方法中才能調(diào)用,具體的用法有兩種,如下:

suspend fun getUserName(userId: Int): String  {
    return withContext(Dispatchers.IO) {
        delay(20000)
        return@withContext "Android 輪子哥"
    }
}
suspend fun getUserName(userId: Int): String = withContext(Dispatchers.IO) {
    delay(20000)
    return@withContext "Android 輪子哥"
}

協(xié)程的啟動(dòng)模式

  • 介紹一下線程調(diào)度器的模式,總共有四種:

    • CoroutineStart.DEFAULT:默認(rèn)模式,會(huì)立即執(zhí)行

    • CoroutineStart.LAZY:懶加載模式,不會(huì)執(zhí)行,只有手動(dòng)調(diào)用協(xié)程的 start 方法才會(huì)執(zhí)行

    • CoroutineStart.ATOMIC:原子模式,跟 CoroutineStart.DEFAULT 類似,但協(xié)程在開始執(zhí)行之前不能被取消,需要注意的是,這是一個(gè)實(shí)驗(yàn)性的 api,后面可能會(huì)發(fā)生變更。

    • CoroutineStart.UNDISPATCHED:未指定模式,會(huì)立即執(zhí)行協(xié)程,經(jīng)過實(shí)踐得出,會(huì)導(dǎo)致原先設(shè)置的線程調(diào)度器失效,一開始會(huì)在原來的線程上執(zhí)行,類似于 Dispatchers.Unconfined,但是一旦協(xié)程被掛起,再恢復(fù)執(zhí)行,會(huì)變成線程調(diào)度器的設(shè)置的線程上面去執(zhí)行。

  • 說了那么多,那么到底怎么用呢?拿 CoroutineStart.LAZY 舉例,具體用法如下:

val job = GlobalScope.launch(Dispatchers.Default, CoroutineStart.LAZY) {

}
job.start()
  • 這里額外這里介紹一下協(xié)程幾個(gè)函數(shù)的用法:

    • job.start:啟動(dòng)協(xié)程,除了 lazy 模式,協(xié)程都不需要手動(dòng)啟動(dòng)

    • job.cancel:取消一個(gè)協(xié)程,可以取消,但是不會(huì)立馬生效,存在一定延遲

    • job.join:等待協(xié)程執(zhí)行完畢,這是一個(gè)耗時(shí)操作,需要在協(xié)程中使用

    • job.cancelAndJoin:等待協(xié)程執(zhí)行完畢然后再取消

協(xié)程設(shè)置執(zhí)行超時(shí)

  • 協(xié)程在執(zhí)行的時(shí)候,我們其實(shí)可以給它設(shè)置一個(gè)執(zhí)行時(shí)間,如果執(zhí)行的耗時(shí)時(shí)間超過規(guī)定的時(shí)間,那么協(xié)程就會(huì)自動(dòng)停止,具體的用法如下:
GlobalScope.launch() {
    try {
        withTimeout(300) {
            // 重復(fù)執(zhí)行 5 次里面的內(nèi)容
            repeat(5) { i ->
                println("測試輸出 " + i)
                delay(100)
            }
        }
    } catch (e: TimeoutCancellationException) {
        // TimeoutCancellationException 是 CancellationException 的子類
        // 其它的不用我說了吧?文章前面有介紹 CancellationException 類作用的
        println("測試協(xié)程超時(shí)了")
    }
}
23:52:48.415 System.out: 測試輸出 0
23:52:48.518 System.out: 測試輸出 1
23:52:48.618 System.out: 測試輸出 2
23:52:48.715 System.out: 測試協(xié)程超時(shí)了
  • 除了 withTimeout 這個(gè)用法,還有另外一個(gè)用法,那就是 withTimeoutOrNull,這個(gè)和 withTimeout 最大的不同的是不會(huì)超時(shí)之后不會(huì)拋 TimeoutCancellationException 給協(xié)程,而是直接返回 null,如果沒有超時(shí)則會(huì)返回協(xié)程體里面的結(jié)果,具體用法如下:
GlobalScope.launch() {
    val result = withTimeoutOrNull(300) {
        // 重復(fù)執(zhí)行 5 次里面的內(nèi)容
        repeat(5) { i ->
            println("測試輸出 " + i)
            delay(100)
        }
        return@withTimeoutOrNull "執(zhí)行完成了"
    }
    println("測試輸出結(jié)果 " + result)
}
23:56:02.462 System.out: 測試輸出 0
23:56:02.569 System.out: 測試輸出 1
23:56:02.670 System.out: 測試輸出 2
23:56:02.761 System.out: 測試輸出結(jié)果 null
  • 如果我們將超時(shí)從 300 毫秒改到成 1000 毫秒,那么這個(gè)案例的協(xié)程是一定不會(huì)超時(shí)的,最終打印的結(jié)果如下:
23:59:37.288 System.out: 測試輸出 0
23:59:37.390 System.out: 測試輸出 1
23:59:37.491 System.out: 測試輸出 2
23:59:37.591 System.out: 測試輸出 3
23:59:37.692 System.out: 測試輸出 4
23:59:37.793 System.out: 測試輸出結(jié)果 執(zhí)行完成了

協(xié)程的生命周期控制

  • 我們?nèi)绻诖a中直接使用 GlobalScope 這個(gè)類來操作協(xié)程,會(huì)有黃色的警告:

This is a delicate API and its use requires care. Make sure you fully read and understand documentation of the declaration that is marked as a delicate API.

這是一個(gè)微妙的API,使用時(shí)需要小心。確保您充分閱讀并理解標(biāo)記為敏感API的聲明的文檔。

  • 讓我們先看一下谷歌對(duì) GlobalScope 這個(gè)類的介紹:

A global [CoroutineScope] not bound to any job.

Global scope is used to launch top-level coroutines which are operating on the whole application lifetime and are not cancelled prematurely.

Active coroutines launched in GlobalScope do not keep the process alive. They are like daemon threads.

This is a delicate API. It is easy to accidentally create resource or memory leaks when GlobalScope is used. A coroutine launched in GlobalScope is not subject to the principle of structured concurrency, so if it hangs or gets delayed due to a problem (e.g. due to a slow network), it will stay working and consuming resources.

  • 翻譯成中文的意思是:

全局的[CoroutineScope]不綁定到任何作業(yè)。

全局作用域用于啟動(dòng)頂級(jí)協(xié)程,這些協(xié)程在整個(gè)應(yīng)用程序生命周期中運(yùn)行,不會(huì)提前取消。

在“GlobalScope”中啟動(dòng)的活動(dòng)協(xié)程不會(huì)使進(jìn)程保持活動(dòng)狀態(tài)。它們就像守護(hù)線程。

這是一個(gè)非常精致的API。當(dāng)使用' GlobalScope '時(shí),很容易意外地創(chuàng)建資源或內(nèi)存泄漏。在“GlobalScope”中啟動(dòng)的協(xié)程不受結(jié)構(gòu)化并發(fā)原則的約束,所以如果它掛起或由于問題(例如由于網(wǎng)絡(luò)緩慢)而延遲,它將繼續(xù)工作并消耗資源。

  • 通過閱讀 GlobalScope 類上面的代碼注釋,可以了解到,通過 CoroutineScope 開啟的協(xié)程是全局的,也就是不會(huì)跟隨組件(例如 Activity)的生命周期,這樣就可能會(huì)導(dǎo)致一些內(nèi)存泄漏的問題。

  • 那么為了解決這一問題,Jetpack 中其實(shí)有提供關(guān)于 Kotlin 協(xié)程的一些擴(kuò)展組件,例如 LifecycleScope 和 ViewModelScope,集成的方式如下:

dependencies {
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1'
    implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1'
}
  • 如果是在 LifecycleOwner 的子類(AppCompatActivity 和 Fragment 都是它的子類)中使用,這樣寫出來的協(xié)程會(huì)在 Lifecycle 派發(fā) destroy 事件的時(shí)候 cancel 掉
class TestActivity : AppCompatActivity() {

    fun test() {
    
        lifecycleScope.launch {
            
        }
    }
}
  • 如果是在 ViewModel 的子類中使用,這樣寫出來的協(xié)程會(huì)在 ViewModel 調(diào)用 clear 方法的時(shí)候 cancel 掉
class TestViewModel : ViewModel() {
    
    fun test() {
    
        viewModelScope.launch() {

        }
    }
}
  • 如果我是在 Lifecycle 或者 ViewModel 之外的地方使用協(xié)程,又擔(dān)心內(nèi)存泄漏,那么該怎么辦呢?
val launch = GlobalScope.launch() {
    
}
launch.cancel()
  • 可以在合適的時(shí)機(jī)手動(dòng)調(diào)用 cancel 方法,這樣就可以取消
println("測試開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
val job = GlobalScope.launch() {
    try {
        println("測試延遲開始 " + (Thread.currentThread() == Looper.getMainLooper().thread))
        delay(20000)
        println("測試延遲結(jié)束")
        delay(20000)
        println("測試延遲結(jié)束")
    } catch (e: CancellationException) {
        // 在這里可以添加一個(gè) try catch 來捕獲取消的動(dòng)作
        // 另外需要注意的是,如果在協(xié)程體內(nèi)發(fā)生 CancellationException 異常
        // 會(huì)被協(xié)程內(nèi)部自動(dòng)消化掉,并不會(huì)導(dǎo)致應(yīng)用程序崩潰的
        // 所以一般情況下不需要捕獲該異常,除非需要手動(dòng)釋放資源
        println("測試協(xié)程被取消了")
    }
}
println("測試結(jié)束")

// 手動(dòng)取消協(xié)程
job.cancel()
12:35:10.005 System.out: 測試開始 true
12:35:10.022 System.out: 測試結(jié)束
12:35:10.023 System.out: 測試延遲開始 false
12:35:10.027 System.out: 測試協(xié)程取消了

另外推薦一個(gè) Kotlin 語言編寫的開源項(xiàng)目,大家感興趣可以看看:AndroidProject-Kotlin

Android 技術(shù)討論 Q 群:10047167

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容