【碼上開學】Kotlin 協程的掛起好神奇好難懂?今天我把它的皮給扒了

本期做者:html

視頻:扔物線(朱凱)git

文章:Hugo(謝晨成)github

你們好,我是扔物線朱凱,我回來啦。今天咱們接着講協程。數據庫

在上一期裏,我介紹了 Kotlin 的協程究竟是什麼——它就是個線程框架。沒什麼說不清的,就這麼簡單,它就是個線程框架,只不過這個線程框架比較方便——另外呢,上期也講了一下協程的基本用法,但到最後也留下了一個大問號:協程最核心的那個「非阻塞式」的「掛起」究竟是怎麼回事?今天,咱們的核心內容就是來講一說這個「掛起」。api

老規矩,全國最硬核的 Android 視頻播主爲你帶來最硬核的視頻:網絡

不過由於我一直不知道怎麼在掘金髮視頻,因此你能夠點擊 這裏 去嗶哩嗶哩看視頻,能夠點擊 這裏 去 YouTube 看。閉包

如下內容來自文章做者 Hugo架構

上期回顧

在協程上一期中咱們知道了下面知識點:併發

  • 協程到底是什麼
  • 協程到底好在哪裏
  • 協程具體怎麼用

大部分狀況下,咱們都是用 launch 函數來建立協程,其實還有其餘兩個函數也能夠用來建立協程:框架

  • runBlocking
  • async

runBlocking 一般適用於單元測試的場景,而業務開發中不會用到這個函數,由於它是線程阻塞的。

接下來咱們主要來對比 launchasync 這兩個函數。

  • 相同點:它們均可以用來啓動一個協程,返回的都是 Coroutine,咱們這裏不須要糾結具體是返回哪一個類。

  • 不一樣點:async 返回的 Coroutine 多實現了 Deferred 接口。

關於 Deferred 更深刻的知識就不在這裏過多闡述,它的意思就是延遲,也就是結果稍後才能拿到。

咱們調用 Deferred.await() 就能夠獲得結果了。

接下來咱們繼續看看 async 是如何使用的,先回憶一下上期中獲取頭像的場景:

🏝️
coroutineScope.launch(Dispatchers.Main) {
    // 👇 async 函數啓動新的協程
    val avatar: Deferred = async { api.getAvatar(user) }    // 獲取用戶頭像
    val logo: Deferred = async { api.getCompanyLogo(user) } // 獲取用戶所在公司的 logo
    // 👇 👇 獲取返回值
    show(avatar.await(), logo.await())                     // 更新 UI
}
複製代碼

能夠看到 avatar 和 logo 的類型能夠聲明爲 Deferred ,經過 await 獲取結果而且更新到 UI 上顯示。

await 函數簽名以下:

🏝️
public suspend fun await(): T
複製代碼

前面有個關鍵字是以前沒有見過的 —— suspend,這個關鍵字就對應了上期最後咱們留下的一個問號:協程最核心的那個「非阻塞式」的「掛起」究竟是怎麼回事?

因此接下來,咱們的核心內容就是來好好說一說這個「掛起」。

「掛起」的本質

協程中「掛起」的對象究竟是什麼?掛起線程,仍是掛起函數?都不對,咱們掛起的對象是協程。

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

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

那此時又是從哪裏掛起?從當前線程掛起。換句話說,就是這個協程從正在執行它的線程上脫離。

注意,不是這個協程停下來了!是脫離,當前線程再也不管這個協程要去作什麼了。

suspend 是有暫停的意思,但咱們在協程中應該理解爲:當線程執行到協程的 suspend 函數的時候,暫時不繼續執行協程代碼了。

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

線程:

前面咱們提到,掛起會讓協程從正在執行它的線程上脫離,具體到代碼實際上是:

協程的代碼塊中,線程執行到了 suspend 函數這裏的時候,就暫時再也不執行剩餘的協程代碼,跳出協程的代碼塊。

那線程接下來會作什麼呢?

若是它是一個後臺線程:

  • 要麼無事可作,被系統回收
  • 要麼繼續執行別的後臺任務

跟 Java 線程池裏的線程在工做結束以後是徹底同樣的:回收或者再利用。

若是這個線程它是 Android 的主線程,那它接下來就會繼續回去工做:也就是一秒鐘 60 次的界面刷新任務。

一個常見的場景是,獲取一個圖片,而後顯示出來:

🏝️
// 主線程中
GlobalScope.launch(Dispatchers.Main) {
  val image = suspendingGetImage(imageId)  // 獲取圖片
  avatarIv.setImageBitmap(image)           // 顯示出來
}

suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
  ...
}
複製代碼

這段執行在主線程的協程,它實質上會往你的主線程 post 一個 Runnable,這個 Runnable 就是你的協程代碼:

🏝️
handler.post {
  val image = suspendingGetImage(imageId)
  avatarIv.setImageBitmap(image)
}
複製代碼

當這個協程被掛起的時候,就是主線程 post 的這個 Runnable 提早結束,而後繼續執行它界面刷新的任務。

關於線程,咱們就看完了。 這個時候你可能會有一個疑問,那 launch 包裹的剩下代碼怎麼辦?

因此接下來,咱們來看看協程這一邊。

協程:

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

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

Dispatchers 調度器,它能夠將協程限制在一個特定的線程執行,或者將它分派到一個線程池,或者讓它不受限制地運行,關於 Dispatchers 這裏先不展開了。

那咱們平日裏經常使用到的調度器有哪些?

經常使用的 Dispatchers ,有如下三種:

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

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

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

這個「切回來」是什麼意思?

咱們的協程本來是運行在主線程的,當代碼遇到 suspend 函數的時候,發生線程切換,根據 Dispatchers 切換到了 IO 線程;

當這個函數執行完畢後,線程又切了回來,「切回來」也就是協程會幫我再 post 一個 Runnable,讓我剩下的代碼繼續回到主線程去執行。

咱們從線程和協程的兩個角度都分析完成後,終於能夠對協程的「掛起」suspend 作一個解釋:

協程在執行到有 suspend 標記的函數的時候,會被 suspend 也就是被掛起,而所謂的被掛起,就是切個線程;

不過區別在於,掛起函數在執行完成以後,協程會從新切回它原先的線程

再簡單來說,在 Kotlin 中所謂的掛起,就是一個稍後會被自動切回來的線程調度操做

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

經過剛纔的分析咱們知道:掛起以後是須要恢復。

而恢復這個功能是協程的,若是你不在協程裏面調用,恢復這個功能無法實現,因此也就回答了這個問題:爲何掛起函數必須在協程或者另外一個掛起函數裏被調用。

再細想下這個邏輯:一個掛起函數要麼在協程裏被調用,要麼在另外一個掛起函數裏被調用,那麼它其實直接或者間接地,老是會在一個協程裏被調用的。

因此,要求 suspend 函數只能在協程裏或者另外一個 suspend 函數裏被調用,仍是爲了要讓協程可以在 suspend 函數切換線程以後再切回來。

怎麼就「掛起」了?

咱們瞭解到了什麼是「掛起」後,再接着看看這個「掛起」是怎麼作到的。

先隨便寫一個自定義的 suspend 函數:

🏝️
suspend fun suspendingPrint() {
  println("Thread: ${Thread.currentThread().name}")
}

I/System.out: Thread: main
複製代碼

輸出的結果仍是在主線程。

爲何沒切換線程?由於它不知道往哪切,須要咱們告訴它。

對比以前例子中 suspendingGetImage 函數代碼:

🏝️
// 👇
suspend fun suspendingGetImage(id: String) = withContext(Dispatchers.IO) {
  ...
}
複製代碼

咱們能夠發現不一樣之處其實在於 withContext 函數。

其實經過 withContext 源碼能夠知道,它自己就是一個掛起函數,它接收一個 Dispatcher 參數,依賴這個 Dispatcher 參數的指示,你的協程被掛起,而後切到別的線程。

因此這個 suspend,其實並非起到把任何把協程掛起,或者說切換線程的做用。

真正掛起協程這件事,是 Kotlin 的協程框架幫咱們作的。

因此咱們想要本身寫一個掛起函數,僅僅只加上 suspend 關鍵字是不行的,還須要函數內部直接或間接地調用到 Kotlin 協程框架自帶的 suspend 函數才行。

suspend 的意義?

這個 suspend 關鍵字,既然它並非真正實現掛起,那它的做用是什麼?

它實際上是一個提醒。

函數的建立者對函數的使用者的提醒:我是一個耗時函數,我被個人建立者用掛起的方式放在後臺運行,因此請在協程裏調用我。

爲何 suspend 關鍵字並無實際去操做掛起,但 Kotlin 卻把它提供出來?

由於它原本就不是用來操做掛起的。

掛起的操做 —— 也就是切線程,依賴的是掛起函數裏面的實際代碼,而不是這個關鍵字。

因此這個關鍵字,只是一個提醒

還記得剛纔咱們嘗試自定義掛起函數的方法嗎?

🏝️
// 👇 redundant suspend modifier
suspend fun suspendingPrint() {
  println("Thread: ${Thread.currentThread().name}")
}
複製代碼

若是你建立一個 suspend 函數但它內部不包含真正的掛起邏輯,編譯器會給你一個提醒:redundant suspend modifier,告訴你這個 suspend 是多餘的。

由於你這個函數實質上並無發生掛起,那你這個 suspend 關鍵字只有一個效果:就是限制這個函數只能在協程裏被調用,若是在非協程的代碼中調用,就會編譯不經過。

因此,建立一個 suspend 函數,爲了讓它包含真正掛起的邏輯,要在它內部直接或間接調用 Kotlin 自帶的 suspend 函數,你的這個 suspend 纔是有意義的。

怎麼自定義 suspend 函數?

在瞭解了 suspend 關鍵字的前因後果以後,咱們就能夠進入下一個話題了:怎麼自定義 suspend 函數。

這個「怎麼自定義」其實分爲兩個問題:

  • 何時須要自定義 suspend 函數?
  • 具體該怎麼寫呢?

何時須要自定義 suspend 函數

若是你的某個函數比較耗時,也就是要等的操做,那就把它寫成 suspend 函數。這就是原則。

耗時操做通常分爲兩類:I/O 操做和 CPU 計算工做。好比文件的讀寫、網絡交互、圖片的模糊處理,都是耗時的,統統能夠把它們寫進 suspend 函數裏。

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

具體該怎麼寫

給函數加上 suspend 關鍵字,而後在 withContext 把函數的內容包住就能夠了。

提到用 withContext是由於它在掛起函數裏功能最簡單直接:把線程自動切走和切回。

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

使用它就能夠實現剛纔提到的等待類型的耗時操做:

🏝️
suspend fun suspendUntilDone() {
  while (!done) {
    delay(5)
  }
}
複製代碼

這些東西,在咱們初步使用協程的時候不用立馬接觸,能夠先把協程最基本的方法和概念理清楚。

總結

咱們今天整個文章其實就在理清一個概念:什麼是掛起?掛起,就是一個稍後會被自動切回來的線程調度操做。

好,關於協程中的「掛起」咱們就解釋到這裏。

可能你心中還會存在一些疑惑:

  • 協程中掛起的「非阻塞式」究竟是怎麼回事?
  • 協程和 RxJava 在切換線程方面功能是同樣的,都能讓你寫出避免嵌套回調的複雜併發代碼,那協程還有哪些優點,或者讓開發者使用協程的理由?

這些疑惑的答案,咱們都會在下一篇中所有揭曉。

練習題

使用協程下載一張圖,並行進行兩次切割

  • 一次切成大小相同的 4 份,取其中的第一份
  • 一次切成大小相同的 9 份,取其中的最後一份

獲得結果後,將它們展現在兩個 ImageView 上。

做者介紹

視頻做者

扔物線(朱凱)
  • 碼上開學創始人、項目管理人、內容模塊規劃者和視頻內容做者。
  • Android GDE( Google 認證 Android 開發專家),前 Flipboard Android 工程師。
  • GitHub 全球 Java 排名第 92 位,在 GitHub 上有 6.6k followers 和 9.9k stars。
  • 我的的 Android 開源庫 MaterialEditText 被全球多個項目引用,其中包括在全球擁有 5 億用戶的新聞閱讀軟件 Flipboard 。
  • 曾屢次在 Google Developer Group Beijing 線下分享會中擔任 Android 部分的講師。
  • 我的技術文章《給 Android 開發者的 RxJava 詳解》發佈後,在國內多個公司和團隊內部被轉發分享和做爲團隊技術會議的主要資料來源,以及逆向傳播到了美國一些如 Google 、 Uber 等公司的部分華人團隊。
  • 創辦的 Android 高級進階教學網站 HenCoder 在全球華人 Android 開發社區享有至關的影響力。
  • 以後創辦 Android 高級開發教學課程 HenCoder Plus ,學員遍及全球,有來自阿里、頭條、華爲、騰訊等知名一線互聯網公司,也有來自中國臺灣、日本、美國等地區的資深軟件工程師。

文章做者

Hugo(謝晨成)

Hugo(謝晨成),即刻 Android 工程師。2017 年加入即刻,參與了即刻 3.0 到 6.0 版本的架構設計和產品迭代。多年 Android 開發經驗,如今負責即刻客戶端中臺基礎建設。

相關文章
相關標籤/搜索