快速上手 Kotlin 開發系列之協程的掛起

站在巨人的肩膀上做個筆記,摘錄自:https://kaixue.io/kotlin-coroutines-2

前言

上一節中我們提到了下面的示例,它使用了 async 關鍵字來創建一個協程。

launch(Dispatchers.Main) {
    val avatar = async { getAvatar() }//獲取用戶頭像
    val logo = async { getLogo() }//獲取 Logo
    mergeShowUI(avatar.await(), logo.await())//合併展示
}

我們來看下 launch 和 async 的區別:

  • 相同點:它們都可以用來啓動一個協程,返回的都是 Coroutine;
  • 不同點:async 返回的 Coroutine 多實現了 Deferred 接口。它的意思就是延遲也就是結果稍後才能拿到,調用 Deferred.await() 就可以得到結果了。

我們看下 await 函數簽名如下:

//       👇掛起關鍵字
public suspend fun await(): T

我們看到了suspend關鍵字,它就是是本篇文章協程掛起的主角。

掛起的本質

協程中「掛起」的對象到底是什麼?掛起線程,還是掛起函數?都不對,我們掛起的對象是協程

還記得協程是什麼嗎?啓動一個協程可以使用 launch 或者 async 函數,協程其實就是這兩個函數中閉包的代碼塊

launch ,async 或者其他函數創建的協程,在執行到某一個 suspend 函數的時候,這個協程會被「suspend」,也就是被掛起。

那此時又是從哪裏掛起?從當前線程掛起。換句話說,就是這個協程從正在執行它的線程上脫離。需要注意,不是這個協程停下來了!是脫離,當前線程不再管這個協程要去做什麼了。也可以理解爲:當線程執行到協程的 suspend 函數的時候,暫時不繼續執行協程代碼了。

我們先讓時間靜止,然後兵分兩路,分別看看這兩個互相脫離的線程和協程接下來將會發生什麼事情:

線程:

協程的代碼塊中,線程執行到了 suspend 函數這裏的時候,就直接執行完畢然後返回了!完畢之後線程就該幹嘛就幹嘛了,如果是後臺線程可能就去執行其他任務了,要是主線程則會繼續刷新頁面

如果你啓動一個主線程的協程:

coroutineScope.launch {
    val bitmap = suspendingGetBitmap()// 網絡請求,後臺線程
    imageView1.setImageBitmap(bitmap)// 更新 UI 主線程
}

相當於會往你的主線程 post 一個新任務,這個任務就是你的協程代碼,當這個協程被掛起的時候實質上你 post 的這個任務就提前結束了(虛線代碼直接略過)。

handler.post {
        👇 協程代碼,遇到 suspend 函數直接執行結束
    ------------------------------------
    |val bitmap = suspendingGetBitmap()|
    |imageView1.setImageBitmap(bitmap) |
    ------------------------------------
}

看完線程,再看下協程做了什麼。

協程:

協程的代碼在到達 suspend 函數的時候被掐斷,接下來協程會從這個 suspend 函數開始繼續往下執行,不過是在指定的線程

誰指定的?是 suspend 函數指定的,比如我們這個例子中,函數內部的 withContext 傳入的 Dispatchers.IO 所指定的 IO 線程。

private suspend fun suspendingGetBitmap(): Bitmap {
    return withContext(Dispatchers.IO) {
        val url = URL("https://gitee.com/luluzhang/ImageCDN/raw/master/blog/20200420120447.png")
        val openConnection = url.openConnection() as HttpURLConnection
        BitmapFactory.decodeStream(openConnection.inputStream)
    }
}

Dispatchers 調度器,它可以將協程限制在一個特定的線程執行,或者將它分派到一個線程池,或者讓它不受限制地運行。

常用的 Dispatchers 調用器有三種:

  • Dispatchers.Main:Android 中的主線程
  • Dispatchers.IO:針對磁盤和網絡 IO 進行了優化,適合 IO 密集型的任務,比如:讀寫文件,操作數據庫以及網絡請求
  • Dispatchers.Default:適合 CPU 密集型的任務,比如計算

回到我們的協程,它從 suspend 函數開始脫離啓動它的線程,繼續執行在 Dispatchers 所指定的 IO 線程。

緊接着在 suspend 函數執行完成之後,協程爲我們做的最爽的事就來了:會自動幫我們把線程再切回來

切回來是什麼意思呢?

示例中我們的協程原本運行在主線程中,當代碼遇到 suspend 函數時,發生線程切換,協程代碼會在 Dispatchers 指定的 IO 線程中運行,運行結束後,協程會幫我們再 post 一個任務,讓剩下的代碼回到主線程執行。

從上面兩個角度分析後,我們再對協程的掛起做一個解釋:

協程在執行到有 suspend 標記的函數的時候,會被 suspend, 也就是被掛起,而所謂的被掛起,就是切個線程;不過區別在於,掛起函數在執行完成之後,協程會重新切回它原先的線程。再簡單來講,在 Kotlin 中所謂的掛起,就是一個稍後會被自動切回來的線程調度操作

另外,這個「切回來」的動作,在 Kotlin 裏叫做 resume,恢復。

通過剛纔分析我們瞭解到:掛起之後是需要恢復的。而恢復這個功能是協程的,如果你不在協程裏面恢復這個功能就沒法實現,這就是爲什麼掛起函數必須在協程中或者另一個掛起函數中被調用的原因。

掛起是如何做到的

首先,我們可以自定義一個掛起函數:

private suspend fun suspendingFun() {
    println("Current Thread: ${Thread.currentThread().name}")
}

Logcat 輸出👇
I/System.out: Current Thread: main

輸出結果還是主線程。嗯?爲什麼沒切線程?因爲它不知道往哪兒切,需要我們告訴它。

對比之前的示例,不同之處在於 withContext。

private suspend fun suspendingGetBitmap(): Bitmap {
    return withContext。(Dispatchers.IO) {
        ...
    }
}

其實通過 withContext 源碼可以知道,它本身就是一個掛起函數,它接收一個 Dispatcher 參數,依賴這個 Dispatcher 參數的指示,你的協程被掛起,然後切到別的線程。

所以這個 suspend,其實並不是起到把任何協程掛起,或者說切換線程的作用。還需要你在掛起函數裏面去調用另外一個掛起函數,而且裏面的這個掛起函數需要是直接或間接調用協程自帶的、內部實現了協程掛起代碼的掛起函數,讓它來真正做掛起,也就是線程切換的工作。

suspend 存在的意義

通過上面瞭解到,suspend 並不能真正的實現掛起,那它有什麼作用呢?

它其實是一個提醒,是函數創建者對函數調用者的提醒。 提醒調用者我是一個耗時函數,請在協程中調用。

所以我們知道,suspend 關鍵字的定位就不是用來去操作掛起的,掛起操作靠的是掛起函數裏面的實際代碼,而不是這個關鍵字。

例如,我們寫一個掛起函數,但是不在內部調用別的掛起函數:

Android Studio 會給你一個提醒,認爲這個 suspend 關鍵字是多餘的。因爲其內部並沒有調用其他掛起函數,也無需在協程中運行。

自定義掛起函數

什麼時候自定義

其實原則上如果你的函數比較耗時就應該寫成掛起函數。哪些操作會比較耗時呢?

耗時操作一般分爲兩類:I/O 操作和 CPU 計算工作 。比如文件的讀寫、網絡交互、圖片的模糊處理,都是耗時的,通通可以把它們寫進 suspend 函數裏。

另外這個「耗時」還有一種特殊情況,就是這件事本身做起來並不慢,但它需要等待,比如 5 秒鐘之後再做這個操作。這種也是 suspend 函數的應用場景。

如何寫

非常簡單,給函數加上 suspend 關鍵字,然後用 withContext 把函數的內容包住就可以了:

private suspend fun getAvatar() = withContext(Dispatchers.IO) {
    val url = URL("https://gitee.com/luluzhang/ImageCDN/raw/master/blog/20200626111013.jpg")
    val openConnection = url.openConnection() as HttpURLConnection
    BitmapFactory.decodeStream(openConnection.inputStream)
}

當然並不是只有 withContext 這一個函數來輔助我們實現自定義的 suspend 函數,比如還有一個掛起函數叫 delay,它的作用是等待一段時間後再繼續往下執行代碼。

使用它就可以實現剛纔提到的等待類型的耗時操作:

suspend fun suspendUntilDone() {
  while (!done) {
    delay(5)
  }
}

以上就是本節內容,歡迎大家關注👇👇👇

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章