Android版 kotlin協程入門(二):kotlin協程的關鍵知識點初步講解

南方吳彥祖_藍斯發表於2021-10-11

由於文章涉及到的只是點比較多、內容可能過長,可以根據自己的能力水平和熟悉程度分階段跳著看。如有講述的不正確的地方勞煩各位私信給筆者,萬分感謝。

kotlin協程的關鍵知識點

上一本章節 《Android kotlin協程入門實戰(一):kotlin協程的基礎用法解讀》末尾我們提到,將在本章節中對以下知識點做初步講解,包含上文提到的 launchasync函式中的3個引數作用。清單如下:

  1. 協程排程器 CoroutineDispatcher
  2. 協程下上文 CoroutineContext作用
  3. 協程啟動模式 CoroutineStart
  4. 協程作用域 CoroutineScope
  5. 掛起函式以及 suspend關鍵字的作用

當然還有一些其他的知識點也是很重要的,比如: CoroutineExceptionHandlerContinuationSchedulerContinuationInterceptor等。但是確實涉及到的東西比較多,如果都展開的話,可能再寫幾個篇幅都沒有辦法講完。上面這些是筆者認為掌握了這些知識點以後,基本可以開始著手專案實戰了。我們後面在實戰的過程中,邊寫邊講解。

協程排程器

上文我們提到一個協程排程器 CoroutineDispatcher的概念,排程器又是一個什麼神奇的東西。在這裡我們對排程器不做過多深入的解釋,這可是協程的三大件之一,後面我們會有專門的篇幅做深入講解。為了方便我們把協程排程器簡稱為 排程器,那接下來我們就看看什麼是排程器。偷個懶,引用一下官方的原話:

  • 排程器它確定了相關的協程在哪個執行緒或哪些執行緒上執行。協程排程器可以將協程限制在一個特定的執行緒執行,或將它分派到一個執行緒池,亦或是讓它不受限地執行。

對於排程器的實現機制我們已經非常清楚了,官方框架中預置了4個排程器,我們可以透過 Dispatchers物件直接訪問它們:

public actual object Dispatchers {    @JvmStatic
    public actual val Default: CoroutineDispatcher = createDefaultDispatcher()    @JvmStatic
    public actual val Main: MainCoroutineDispatcher        get() = MainDispatcherLoader.dispatcher    @JvmStatic
    public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined    @JvmStatic
    public val IO: CoroutineDispatcher = DefaultScheduler.IO
}
複製程式碼
  • Default:預設排程器,CPU密集型任務排程器,適合處理後臺計算。通常處理一些單純的計算任務,或者執行時間較短任務。比如:Json的解析,資料計算等
  • IO:IO排程器,,IO密集型任務排程器,適合執行IO相關操作。比如:網路處理,資料庫操作,檔案操作等
  • Main:UI排程器, 即在主執行緒上執行,通常用於UI互動,重新整理等
  • Unconfined:非受限排程器,又或者稱為“無所謂”排程器,不要求協程執行在特定執行緒上。

比如上面我們透過 launch啟動的時候,因為我們沒有傳入引數,所有實際上它使用的是預設排程器 Dispatchers.Default

GlobalScope.launch{
    Log.d("launch", "啟動一個協程")
}//等同於GlobalScope.launch(Dispatchers.Default){
    Log.d("launch", "啟動一個協程")
}
複製程式碼

Dispatchers.IODispatchers.Main就都很好理解了。這是我們以後在Android開發過程中,打交道最多的2個排程器。比如後臺資料上傳,我們就可以使用 Dispatchers.IO排程器。重新整理介面我們就使用 Dispatchers.Main排程器。為方便使用官方在Android協程框架庫中,已經為我們定義好了幾個供我們開發使用,如: MainScopelifecycleScopeviewModelScope。它們都是使用的 Dispatchers.Main,這些後續我們都將會使用到。

根據我們上面使用的方法,我們好像只有在啟動協程的時候,才能指定具體使用那個 Dispatchers排程器。如果我要是想中途切換執行緒怎麼辦,比如:

  • 現在我們需要透過網路請求獲取到資料的時候填充到我們的佈局當中,但是網路處理在 IO執行緒上,而重新整理UI是在 主執行緒上,那我們應該怎麼辦。

莫慌,莫慌,萬事萬物總有解決的辦法。官方為我們提供了一個 withContext頂級函式,使用 withContext函式來改變協程的上下文,而仍然駐留在相同的協程中,同時 withContext還攜帶有一個泛型 T返回值。

public suspend fun <T> withContext(
    context: CoroutineContext,
    block: suspend CoroutineScope.() -> T
): T {
 //......
}
複製程式碼

呀,這一看 withContext這個東西好像很符合我們的需求嘛,我們可以先使用 launch(Dispatchers.Main)啟動協程,然後再透過 withContext(Dispatchers.IO)排程到 IO執行緒上去做網路請求,把得到的結果返回,這樣我們就解決了我們上面的問題了。

GlobalScope.launch(Dispatchers.Main) {
    val result = withContext(Dispatchers.IO) {        //網路請求...
        "請求結果"
    }
    btn.text = result
}
複製程式碼

是不是很簡單!!! 麻麻再也不會說我的handler滿飛了,也不用走那萬惡的回撥地獄了。我想怎麼切就怎麼切,想去走個執行緒就去哪個執行緒。邏輯都按著順序一步一步走,而且程式碼都是這麼的絲滑。還要什麼腳踏車,額.錯了,還要什麼handler,管他回撥不回撥。

協程上下文

CoroutineContext即協程上下文。它是一個包含了使用者定義的一些各種不同元素的 Element物件集合。其中主要元素是 Job、協程排程器 CoroutineDispatcher、還有包含協程異常 CoroutineExceptionHandler、攔截器 ContinuationInterceptor、協程名 CoroutineName等。這些資料都是和協程密切相關的,每一個 Element都一個唯一key。

public interface CoroutineContext {    public operator fun <E : CoroutineContext.Element> get(key: Key<E>): E?    public fun <R> fold(initial: R, operation: (R, CoroutineContext.Element) -> R): R    public operator fun plus(context: CoroutineContext): CoroutineContext =        if (context === EmptyCoroutineContext) this else context.fold(this) { ...}    public fun minusKey(key: Key<*>): CoroutineContext    //注意這裡,這個key很關鍵
    public interface Key <E : CoroutineContext.Element>     public interface Element : CoroutineContext {        public val key: Key<*>        public override operator fun <E : Element> get(key: Key<E>): E? =            if (this.key == key) this as E else null
        public override fun <R> fold(initial: R, operation: (R, Element) -> R): R =
            operation(initial, this)        public override fun minusKey(key: Key<*>): CoroutineContext =            if (this.key == key) EmptyCoroutineContext else this
         }
}
複製程式碼

我們可以看到 ElementCoroutineContext的內部介面,同時它又實現了 CoroutineContext介面,這麼設計的原因是為了保證 Element中一定只能存放的 Element它自己,而不能存放其他型別的資料 CoroutineContext內還有一個內部介面 Key,同時它又是 Element的一個屬性,這個屬性很重要,我們先在這裡插個眼,待會再講解這個屬性的作用。

那我們上面提到 JobCoroutineDispatcherCoroutineExceptionHandlerContinuationInterceptorCoroutineName等為什麼又可以存放到 CoroutineContext中呢。我們接著往下看看它們各自的實現:

Job

public interface Job : CoroutineContext.Element {    public companion object Key : CoroutineContext.Key<Job> {        //省略...
    }
}
複製程式碼

CoroutineDispatcher

public abstract class CoroutineDispatcher :
    AbstractCoroutineContextElement(ContinuationInterceptor), ContinuationInterceptor {      public companion object Key : AbstractCoroutineContextKey<ContinuationInterceptor, CoroutineDispatcher>(
        ContinuationInterceptor,
        { it as? CoroutineDispatcher })
}
複製程式碼

CoroutineExceptionHandler

public interface CoroutineExceptionHandler : CoroutineContext.Element {    public companion object Key : CoroutineContext.Key<CoroutineExceptionHandler>
}
複製程式碼

ContinuationInterceptor

public interface ContinuationInterceptor : CoroutineContext.Element {
    companion object Key : CoroutineContext.Key<ContinuationInterceptor>
}
複製程式碼

CoroutineName

public data class CoroutineName(
    val name: String
) : AbstractCoroutineContextElement(CoroutineName) {    public companion object Key : CoroutineContext.Key<CoroutineName>
}
複製程式碼

現在要開始要集中注意力了。我們可以看到他們都是實現了 Element介面,同時都有個 CoroutineContext.Key型別的伴生物件 key,這個屬性的作用是什麼呢。那我們就得回過頭來看看 CoroutineContext介面的幾個方法了。

public operator fun <E : CoroutineContext.Element> get(key: Key<E>): E?public fun <R> fold(initial: R, operation: (R, CoroutineContext.Element) -> R): Rpublic operator fun plus(context: CoroutineContext): CoroutineContext =    if (context === EmptyCoroutineContext) this else context.fold(this) { ...}public fun minusKey(key: Key<*>): CoroutineContext
複製程式碼

我們先從 plus方法說起, plus有個關鍵字 operator表示這是一個運算子過載的方法,類似List.plus的運算子,可以透過 +號來返回一個包含原始集合和第二個運算元中的元素的結果。同理 CoroutineContext中是透過 plus來返回一個由原始的 Element集合和透過 +號引入的 Element產生新的 Element集合。

get方法,顧名思義。可以透過  key 來獲取一個 Element

fold方法它和集合中的fold是一樣的,用來遍歷當前協程上下文中的 Element集合。

minusKey方法 plus作用相反,它相當於是做減法,是用來取出除 key以外的當前協程上下文其他 Element,返回的就是不包含 key的協程上下文。

現在我們就知道為什麼我們之前說 Element中的 key這個屬性很重要了吧。因為我們就是透過它從協程上下文中獲取我們想要的 Element,同時也解釋為什麼 JobCoroutineDispatcherCoroutineExceptionHandlerContinuationInterceptorCoroutineName等等,這些 Element都有需要有一個 CoroutineContext.Key型別的伴生物件 key。我們寫個測試方法: 如:

 private fun testCoroutineContext(){
     val coroutineContext1 = Job() + CoroutineName("這是第一個上下文")
     Log.d("coroutineContext1", "$coroutineContext1")
     val  coroutineContext2 = coroutineContext1 + Dispatchers.Default + CoroutineName("這是第二個上下文")
     Log.d("coroutineContext2", "$coroutineContext2")
     val coroutineContext3 = coroutineContext2 + Dispatchers.Main + CoroutineName("這是第三個上下文")
     Log.d("coroutineContext3", "$coroutineContext3")
 }
複製程式碼
D/coroutineContext1: [JobImpl{Active}@21a6a21, CoroutineName(這是第一個上下文)]
D/coroutineContext2: [JobImpl{Active}@21a6a21, CoroutineName(這是第二個上下文), Dispatchers.Default]
D/coroutineContext3: [JobImpl{Active}@21a6a21, CoroutineName(這是第三個上下文), Dispatchers.Main]
複製程式碼

我們透過對比日誌輸出資訊可以看到,透過 +號我們可以把多個 Element整合到一個集合中,同時我們也發現:

  • 三個上下文中的 Job是同一個物件。
  • 第二個上下文在第一個的基礎上增加了一個新的 CoroutineName,新增的 CoroutineName替換了第一個上下文中的 CoroutineName
  • 第三個上下文在第二個的基礎上又增加了一個新的 CoroutineNameDispatchers,同時他們也替換了第二個上下文中的 CoroutineNameDispatchers

但是因為這個 +運算子是不對稱的,所以在我們實際的運用過程中,透過 +增加 Element的時候一定要注意它們結合的順序。那麼現在關於協程上下文的內容就講到這裡,我們點到為止,後面在深入理解階段在細講這些東西執行的原理細節。

協程啟動模式

CoroutineStart協程啟動模式,是啟動協程時需要傳入的第二個引數。協程啟動有4種:

  • DEFAULT 預設啟動模式,我們可以稱之為餓漢啟動模式,因為協程建立後立即開始排程,雖然是立即排程,單不是立即執行,有可能在執行前被取消。

  • LAZY 懶漢啟動模式,啟動後並不會有任何排程行為,直到我們需要它執行的時候才會產生排程。也就是說只有我們主動的呼叫 Jobstartjoin或者 await等函式時才會開始排程。

  • ATOMIC 一樣也是在協程建立後立即開始排程,但是它和 DEFAULT模式有一點不一樣,透過 ATOMIC模式啟動的協程執行到第一個掛起點之前是不響應 cancel 取消操作的, ATOMIC一定要涉及到協程掛起後 cancel 取消操作的時候才有意義。

  • UNDISPATCHED 協程在這種模式下會直接開始在當前執行緒下執行,直到執行到第一個掛起點。這聽起來有點像  ATOMIC,不同之處在於 UNDISPATCHED是不經過任何排程器就開始執行的。當然遇到掛起點之後的執行,將取決於掛起點本身的邏輯和協程上下文中的排程器。

我們可以透過一個小例子的來看看這幾個啟動模式的實際情況:

private fun testCoroutineStart(){
    val defaultJob = GlobalScope.launch{
        Log.d("defaultJob", "CoroutineStart.DEFAULT")
    }
    defaultJob.cancel()
    val lazyJob = GlobalScope.launch(start = CoroutineStart.LAZY){
        Log.d("lazyJob", "CoroutineStart.LAZY")
    }
    val atomicJob = GlobalScope.launch(start = CoroutineStart.ATOMIC){
        Log.d("atomicJob", "CoroutineStart.ATOMIC掛起前")
        delay(100)
        Log.d("atomicJob", "CoroutineStart.ATOMIC掛起後")
    }
    atomicJob.cancel()
    val undispatchedJob = GlobalScope.launch(start = CoroutineStart.UNDISPATCHED){
        Log.d("undispatchedJob", "CoroutineStart.UNDISPATCHED掛起前")
        delay(100)
        Log.d("atomicJob", "CoroutineStart.UNDISPATCHED掛起後")
    }
    undispatchedJob.cancel()
}
複製程式碼

每個模式我們分別啟動一個一次, DEFAULT模式啟動時,我們接著呼叫了 cancel取消協程, ATOMIC模式啟動時,我們在裡面增加了一個掛起點 delay掛起函式,來區分 ATOMIC啟動時的掛起前後執行情況,同樣的 UNDISPATCHED模式啟動時,我們也呼叫了 cancel取消協程,我們看實際的日誌輸出情況:

D/defaultJob: CoroutineStart.DEFAULTD/atomicJob: CoroutineStart.ATOMIC掛起前
D/undispatchedJob: CoroutineStart.UNDISPATCHED掛起前
複製程式碼

或者

D/undispatchedJob: CoroutineStart.UNDISPATCHED掛起前
D/atomicJob: CoroutineStart.ATOMIC掛起前
複製程式碼

為什麼會出現2種情況。我們上面提到過 DEFAULT模式協程建立後立即開始排程,但不是立即執行,所有它有可能會被 cancel取消,導致沒有輸出 defaultJob這條日誌。

同樣的 ATOMIC模式啟動的時候也接著呼叫了 cancel取消協程,但是因為沒有遇到掛起點,所以掛起前的日誌輸出了,但是掛起後的日誌沒有輸出。

UNDISPATCHED模式啟動的時候也接著呼叫了 cancel取消協程,同樣的因為沒有遇到掛起點所以輸出了 UNDISPATCHED掛起前,但是因為 UNDISPATCHED是立即執行的,所以他的日誌 UNDISPATCHED掛起前輸出在 ATOMIC掛起前的前面。

接著我們在補充一下關於 UNDISPATCHED模式。我們上面有提到當以 UNDISPATCHED模式啟動時,遇到掛起點之後的執行,將取決於掛起點本身的邏輯和協程上下文中的排程器。這句話我們又要怎麼理解呢。我們還是以一個例子來認識解釋 UNDISPATCHED模式,比如:

private fun testUnDispatched(){
    GlobalScope.launch(Dispatchers.Main){
       val job = launch(Dispatchers.IO) {
           Log.d("${Thread.currentThread().name}執行緒", "-> 掛起前")
           delay(100)
           Log.d("${Thread.currentThread().name}執行緒", "-> 掛起後")
       }
       Log.d("${Thread.currentThread().name}執行緒", "-> join前")
       job.join()
       Log.d("${Thread.currentThread().name}執行緒", "-> join後")
   }
}
複製程式碼

那我們將會看到如下輸出,掛起前後都在一個 worker-1執行緒裡面執行:

D/main執行緒: -> join前
D/DefaultDispatcher-worker-1執行緒: -> 掛起前
D/DefaultDispatcher-worker-1執行緒: -> 掛起後
D/main執行緒: -> join後
複製程式碼

現在我們在稍作修改,我們在子協程 launch的時候使用 UNDISPATCHED模式啟動:

 private fun testUnDispatched(){
     GlobalScope.launch(Dispatchers.Main){
        val job = launch(Dispatchers.IO,start = CoroutineStart.UNDISPATCHED) {
            Log.d("${Thread.currentThread().name}執行緒", "-> 掛起前")
            delay(100)
            Log.d("${Thread.currentThread().name}執行緒", "-> 掛起後")
        }
        Log.d("${Thread.currentThread().name}執行緒", "-> join前")
        job.join()
        Log.d("${Thread.currentThread().name}執行緒", "-> join後")
    }
 }
複製程式碼

那我們將會看到如下輸出:

D/main執行緒: -> 掛起前
D/main執行緒: -> join前
D/DefaultDispatcher-worker-1執行緒: -> 掛起後
D/main執行緒: -> join後
複製程式碼

我們看到當以 UNDISPATCHED模式即使我們指定了協程排程器 Dispatchers.IO掛起前還是在 main執行緒裡執行,但是 掛起後是在 worker-1執行緒裡面執行,這是因為當以 UNDISPATCHED啟動時,協程在這種模式下會直接開始在當前執行緒下執行,直到第一個掛起點。遇到掛起點之後的執行,將取決於掛起點本身的邏輯和協程上下文中的排程器,即 join處恢復執行時,因為所在的協程有排程器,所以後面的執行將會在排程器對應的執行緒上執行。

我們再改一下,把子協程在 launch的時候使用 UNDISPATCHED模式啟動,去掉 Dispatchers.IO排程器,那又會出現什麼情況呢

 private fun testUnDispatched(){
     GlobalScope.launch(Dispatchers.Main){
        val job = launch(start = CoroutineStart.UNDISPATCHED) {
            Log.d("${Thread.currentThread().name}執行緒", "-> 掛起前")
            delay(100)
            Log.d("${Thread.currentThread().name}執行緒", "-> 掛起後")
        }
        Log.d("${Thread.currentThread().name}執行緒", "-> join前")
        job.join()
        Log.d("${Thread.currentThread().name}執行緒", "-> join後")
    }
 }
複製程式碼
D/main執行緒: -> 掛起前
D/main執行緒: -> join前
D/main執行緒: -> 掛起後
D/main執行緒: -> join後
複製程式碼

我們發現它們都在一個執行緒裡面執行了。這是因為當透過 UNDISPATCHED啟動後遇到掛起, join處恢復執行時,如果所在的協程沒有指定排程器,那麼就會在 join處恢復執行的執行緒裡執行,即 掛起後是在父協程 (Dispatchers.Main執行緒裡面執行,而最後 join後這條日誌的輸出排程取決於這個最外層的協程的排程規則。

現在我們可以總結一下,當以 UNDISPATCHED啟動時:

  • 無論我們是否指定協程排程器, 掛起前的執行都是在當前執行緒下執行。

  • 如果所在的協程沒有指定排程器,那麼就會在 join處恢復執行的執行緒裡執行,即我們上述案例中的 掛起後的執行是在 main執行緒中執行。

  • 當我們指定了協程排程器時,遇到掛起點之後的執行將取決於掛起點本身的邏輯和協程上下文中的排程器。即 join處恢復執行時,因為所在的協程有排程器,所以後面的執行將會在排程器對應的執行緒上執行。

同樣的我們點到為止,關於啟動模式的的相關內容我們就現講到這裡。

協程作用域

協程作用域 CoroutineScope為協程定義作用範圍,每個協程生成器 launchasync等都是 CoroutineScope的擴充套件,並繼承了它的 coroutineContext自動傳播其所有 Element和取消。協程作用域本質是一個介面,不建議手工實現該介面,而應該首選委託實現。下面我們列出了部分 CoroutineScope相關定義:

public interface CoroutineScope {    public val coroutineContext: CoroutineContext
}public operator fun CoroutineScope.plus(context: CoroutineContext): CoroutineScope =
    ContextScope(coroutineContext + context)public fun MainScope(): CoroutineScope = ContextScope(SupervisorJob() + Dispatchers.Main)public object GlobalScope : CoroutineScope {
    override val coroutineContext: CoroutineContext        get() = EmptyCoroutineContext
}public fun CoroutineScope(context: CoroutineContext): CoroutineScope =
    ContextScope(if (context[Job] != null) context else context + Job())
複製程式碼

我們可以看到 CoroutineScope也過載了 plus方法,透過 +號來新增或者修改我們 CoroutineContext協程上下文中的 Element。同時官方也為我們定義好了  MainScopeGlobalScope2個頂級作用域。 GlobalScope我們已經很熟了,前面的案例都是透過它來實現的。

MainScope我們可以看到它的上下文是透過 SupervisorJob和  Dispatchers.Main組合的,說明它是一個在主執行緒執行的協程作用域,我們在後續的Android實戰開發中,會結合Activity、Fragment,dialog等使用它。這裡不再繼續往下擴充套件。

至於 SupervisorJob分析它之前,我們得先說一下協程作用域的分類。我們之前提到過父協程和子協程的概念,既然有父協程和子協程,那麼必然也有父協程作用域和子父協程作用域。不過我們不是這麼稱呼,因為他們不僅僅是父與子的概念。協程作用域分為三種:

  • 頂級作用域 --> 沒有父協程的協程所在的作用域稱之為頂級作用域。

  • 協同作用域 --> 在協程中啟動一個協程,新協程為所在協程的子協程。子協程所在的作用域預設為協同作用域。此時子協程丟擲未捕獲的異常時,會將異常傳遞給父協程處理,如果父協程被取消,則所有子協程同時也會被取消。

  • 主從作用域 官方稱之為 監督作用域。與協同作用域一致,區別在於該作用域下的協程取消操作的單向傳播性,子協程的異常不會導致其它子協程取消。但是如果父協程被取消,則所有子協程同時也會被取消。

同時補充一點:父協程需要等待所有的子協程執行完畢之後才會進入 Completed狀態,不管父協程自身的協程體是否已經執行完成。我們在最開始提到協程生命週期的時候就提到過下,現在回過頭看是不是感覺很流程變得清晰。

                                      wait children
+-----+ start  +--------+ complete   +-------------+  finish  +-----------+| New | -----> | Active | ---------> | Completing  | -------> | Completed |+-----+        +--------+            +-------------+          +-----------+                 |  cancel / fail       |
                 |     +----------------+
                 |     |
                 V     V
             +------------+                           finish  +-----------+
             | Cancelling | --------------------------------> | Cancelled |
             +------------+                                   +-----------+
複製程式碼

子協程會繼承父協程的協程上下文中的 Element,如果自身有相同key的成員,則覆蓋對應的 key,覆蓋的效果僅限自身範圍內有效。這個就可以用上我們前面學到的協程上下文 CoroutineContext的知識,小案例奉上:

private fun  testCoroutineScope(){
    GlobalScope.launch(Dispatchers.Main){
        Log.d("父協程上下文", "$coroutineContext")
        launch(CoroutineName("第一個子協程")) {
            Log.d("第一個子協程上下文", "$coroutineContext")
        }
         launch(Dispatchers.Unconfined) {
            Log.d("第二個子協程協程上下文", "$coroutineContext")
        }
    }
}
複製程式碼

日誌順序的問題我們前面已經分析過原因,如果還不懂的話,麻煩您回到基礎用法裡面仔細的再看一遍。

D/父協程上下文: [StandaloneCoroutine{Active}@81b6e46, Dispatchers.Main]
D/第二個子協程協程上下文: [StandaloneCoroutine{Active}@f6b7807, Dispatchers.Unconfined]
D/第一個子協程上下文: [CoroutineName(第一個子協程), StandaloneCoroutine{Active}@bbe6d34, Dispatchers.Main]
複製程式碼

可以看到第一個子協程的覆蓋了父協程的 Job,但是它繼承了父協程的排程器  Dispatchers.Main,同時也新增了一個 CoroutineName。第二個子協程覆蓋了父協程的 Job,也將父協程的排程器覆蓋為 Unconfined,但是他沒有繼承第一個子協程的 CoroutineName,這就是我們說的覆蓋的效果僅限自身範圍內有效。接下來我們看看上面提到的 協同作用域主從(監督)作用域異常傳遞和協程取消的問題。

我們上面提到 協同作用域如果子協程丟擲未捕獲的異常時,會將異常傳遞給父協程處理,如果父協程被取消,則所有子協程同時也會被取消。先上程式碼看看效果:

private fun  testCoroutineScope2() {
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d("exceptionHandler", "${coroutineContext[CoroutineName]} $throwable")
    }
    GlobalScope.launch(Dispatchers.Main + CoroutineName("scope1") + exceptionHandler) {
        Log.d("scope", "--------- 1")
        launch(CoroutineName("scope2") + exceptionHandler) {
            Log.d("scope", "--------- 2")            throw  NullPointerException("空指標")
            Log.d("scope", "--------- 3")
        }
        val scope3 = launch(CoroutineName("scope3") + exceptionHandler) {
            Log.d("scope", "--------- 4")
            delay(2000)
            Log.d("scope", "--------- 5")
        }
        scope3.join()
        Log.d("scope", "--------- 6")
    }
}
複製程式碼
D/scope: --------- 1
D/scope: --------- 2
D/exceptionHandler: CoroutineName(scope1) java.lang.NullPointerException: 空指標
複製程式碼

可以看到子協程 scope2丟擲了一個異常,將異常傳遞給父協程 scope1處理,但是因為任何一個子協程異常退出會導致整體都將退出。所以導致父協程 scope1未執行完成成就被取消,同時還未執行完子協程 scope3也被取消了。

主從(監督)作用域協同作用域一致,區別在於該作用域下的協程取消操作的單向傳播性,子協程的異常不會導致其它子協程取消。分析 主從(監督)作用域的時候,我們需要用到 supervisorScope或者 SupervisorJob,如下程式碼塊:

private fun testCoroutineScope3() {
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d("exceptionHandler", "${coroutineContext[CoroutineName]} $throwable")
    }
    GlobalScope.launch(Dispatchers.Main + CoroutineName("scope1") + exceptionHandler) {
        supervisorScope {
            Log.d("scope", "--------- 1")
            launch(CoroutineName("scope2")) {
                Log.d("scope", "--------- 2")                throw  NullPointerException("空指標")
                Log.d("scope", "--------- 3")
                val scope3 = launch(CoroutineName("scope3")) {
                    Log.d("scope", "--------- 4")
                    delay(2000)
                    Log.d("scope", "--------- 5")
                }
                scope3.join()
            }
            val scope4 = launch(CoroutineName("scope4")) {
                Log.d("scope", "--------- 6")
                delay(2000)
                Log.d("scope", "--------- 7")
            }
            scope4.join()
            Log.d("scope", "--------- 8")
        }
    }
}
複製程式碼
D/scope: --------- 1
D/scope: --------- 2
D/exceptionHandler: CoroutineName(scope2) java.lang.NullPointerException: 空指標
D/scope: --------- 6
D/scope: --------- 7
D/scope: --------- 8
複製程式碼

可以看到子協程 scope2丟擲了一個異常,並將異常傳遞給父協程 scope1處理,同時也結束了自己本身。因為在於 主從(監督)作用域下的協程取消操作是單向傳播性,因此協程 scope2的異常並沒有導致父協程退出,所以 6  7  8都照常輸出,而 3  4  5因為在協程 scope2裡面所以沒有輸出。

我們剛剛使用了 supervisorScope實現了 主從(監督)作用域,那我們透過 SupervisorJob又該如何實現呢。我們把 supervisorScope稱之為 主從(監督)作用域,那麼 SupervisorJob就可以稱之為 主從(監督)作業,如下:

private fun testCoroutineScope4() {
    val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable ->
        Log.d("exceptionHandler", "${coroutineContext[CoroutineName]} $throwable")
    }
   val coroutineScope = CoroutineScope(SupervisorJob() +CoroutineName("coroutineScope"))
    GlobalScope.launch(Dispatchers.Main + CoroutineName("scope1") + exceptionHandler) {
        with(coroutineScope){
            val scope2 = launch(CoroutineName("scope2") + exceptionHandler) {
                Log.d("scope", "1--------- ${coroutineContext[CoroutineName]}")
                throw  NullPointerException("空指標")
            }
            val scope3 = launch(CoroutineName("scope3") + exceptionHandler) {
                scope2.join()
                Log.d("scope", "2--------- ${coroutineContext[CoroutineName]}")
                delay(2000)
                Log.d("scope", "3--------- ${coroutineContext[CoroutineName]}")
            }
            scope2.join()
            Log.d("scope", "4--------- ${coroutineContext[CoroutineName]}")
            coroutineScope.cancel()
            scope3.join()
            Log.d("scope", "5--------- ${coroutineContext[CoroutineName]}")
        }
        Log.d("scope", "6--------- ${coroutineContext[CoroutineName]}")
    }
}
複製程式碼
D/scope: 1--------- CoroutineName(scope2)
D/exceptionHandler: CoroutineName(scope2) java.lang.NullPointerException: 空指標
D/scope: 2--------- CoroutineName(scope3)
D/scope: 4--------- CoroutineName(coroutineScope)
D/scope: 5--------- CoroutineName(coroutineScope)
D/scope: 6--------- CoroutineName(scope1)
複製程式碼

是不是感覺和 supervisorScope的用法很像,我們透過建立了一個 SupervisorJob的主從(監督)協程作用域,呼叫了子協程的 join是為了保證它一定是會執行。同樣的子協程 scope2丟擲了一個異常,透過協程 scope2自己內部消化了,同時也結束了自己本身。

因為協程 scope2的異常並沒有導致 coroutineScope作用域下的協程取消退出,所以協程 scope3照常執行輸出 2,後又因為呼叫了我們定義的協程作用域 coroutineScopecancel方法取消了協程,所以即使我們後面呼叫了協程 scope3join,也沒有輸出 3,因為 SupervisorJob的取消是向下傳播的,所以後面的 4  5都是在 coroutineScope的作用域中輸出的。

現在我們關於協程作用域 CoroutineScope的作用我們已經有了一個大概的瞭解,同樣的因為這個篇幅中我們是基礎講解,所以我們點到為止,如果還想深入瞭解,那就只能看後面的深入協程篇幅。

掛起函式

透過前面的篇幅我們已經知道,使用 suspend關鍵字修飾的函式叫作 掛起函式掛起函式只能在協程體內,或著在其他 掛起函式內呼叫。那掛起又是啥玩意呢。

我估計各位看到這裡的時候,可能有些人已經被上面的知識點弄的有點暈乎,別急,先放鬆下大腦,喝杯水,然後做個眼保健操緩解一下。下面開始敲黑板了,打起精神,要開始劃重點了。

首先一個 掛起函式既然要掛起,那麼他必定得有一個 掛起點,不然我們怎麼知道函式是否掛起,從哪掛起呢。 我們定義一個空實現的 suspend方法,然後透過AS的工具欄中 Tools-> kotlin-> show kotlin ByteCode解析成位元組碼

private suspend fun test(){
}
複製程式碼
final synthetic test(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
複製程式碼
public interface Continuation<in T> {    public val context: CoroutineContext    public fun resumeWith(result: Result<T>)}
複製程式碼

我們看到 test方法需要的是一個 Continuation介面,官方給的介紹是用於掛起點之後,返回型別為 T的值用的。那我們又是怎麼拿到的這個 Continuation呢。要解開這個問題我們得先回到協程的建立和執行是的過程。

我們啟動一個協程無非是透過 launchasync等方法。我們之前有說到過他們的啟動模式 CoroutineStart,但是並沒有深入的去分析它的建立和啟動過程,我們這裡先回過頭大概的看一下:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    val newContext = newCoroutineContext(context)
    val coroutine = if (start.isLazy)
        LazyStandaloneCoroutine(newContext, block) else
        StandaloneCoroutine(newContext, active = true)
    coroutine.start(start, coroutine, block)    return coroutine
}
複製程式碼

我們看到在透過 launch啟動一個協程的時候,他透過 coroutinestart方法啟動協程,然後我們接著往下看

public fun start(start: CoroutineStart, block: suspend () -> T) {
    initParentJob()
    start(block, this)
}public fun <R> start(start: CoroutineStart, receiver: R, block: suspend R.() -> T) {
    initParentJob()
    start(block, receiver, this)
}
複製程式碼

然後 start方法裡面呼叫了 CoroutineStartinvoke,這個時候我們發現了 Continuation

public operator fun <T> invoke(block: suspend () -> T, completion: Continuation<T>): Unit =
    when (this) {
        DEFAULT -> block.startCoroutineCancellable(completion)
        ATOMIC -> block.startCoroutine(completion)
        UNDISPATCHED -> block.startCoroutineUndispatched(completion)
        LAZY -> Unit // will start lazily
    }public operator fun <R, T> invoke(block: suspend R.() -> T, receiver: R, completion: Continuation<T>): Unit =
    when (this) {
        DEFAULT -> block.startCoroutineCancellable(receiver, completion)
        ATOMIC -> block.startCoroutine(receiver, completion)
        UNDISPATCHED -> block.startCoroutineUndispatched(receiver, completion)
        LAZY -> Unit // will start lazily
    }
複製程式碼

Continuation又是透過 start方法傳進來的 coroutine。所以現在可以確定,我們的協程體本身就是一個 Continuation,這也就解釋了為什麼可以在協程體內呼叫 suspend掛起函式了。

現在我們也可以確定,在協程內部 掛起函式的呼叫處就是 掛起點,如果 掛起點出現非同步呼叫,那麼當前協程就被掛起,直到對應的 Continuation透過呼叫 resumeWith函式才會恢復協程的執行,同時返回 Result<T>型別的成功或者失敗的結果。

由於章節主題的限制,這裡我們就不再下深入了。需要注意的是 掛起函式不一定真的會掛起,如果只是提供了掛起的條件,但是協程沒有產生非同步呼叫,那麼協程還是不會被掛起。

預告:下一篇我們將會講解kotlin協程中的異常處理,其實我們在這篇章節中已經,提到了一些異常處理,沒有注意的同學可以回到 協程作用域看看。

作者:一個被攝影耽誤的程式猿
來源: 稀土掘金 
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69983917/viewspace-2795354/,如需轉載,請註明出處,否則將追究法律責任。

相關文章