本期做者:html
視頻:扔物線(朱凱)git
文章:Hugo(謝晨成)github
你們好,我是扔物線朱凱,我回來啦。今天咱們接着講協程。數據庫
在上一期裏,我介紹了 Kotlin 的協程究竟是什麼——它就是個線程框架。沒什麼說不清的,就這麼簡單,它就是個線程框架,只不過這個線程框架比較方便——另外呢,上期也講了一下協程的基本用法,但到最後也留下了一個大問號:協程最核心的那個「非阻塞式」的「掛起」究竟是怎麼回事?今天,咱們的核心內容就是來講一說這個「掛起」。api
老規矩,全國最硬核的 Android 視頻播主爲你帶來最硬核的視頻:網絡
不過由於我一直不知道怎麼在掘金髮視頻,因此你能夠點擊 這裏 去嗶哩嗶哩看視頻,能夠點擊 這裏 去 YouTube 看。閉包
如下內容來自文章做者 Hugo。架構
在協程上一期中咱們知道了下面知識點:併發
大部分狀況下,咱們都是用 launch
函數來建立協程,其實還有其餘兩個函數也能夠用來建立協程:框架
runBlocking
async
runBlocking
一般適用於單元測試的場景,而業務開發中不會用到這個函數,由於它是線程阻塞的。
接下來咱們主要來對比 launch
與 async
這兩個函數。
相同點:它們均可以用來啓動一個協程,返回的都是 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
函數,協程其實就是這兩個函數中閉包的代碼塊。
launch
,async
或者其餘函數建立的協程,在執行到某一個 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
關鍵字並無實際去操做掛起,但 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
函數。這就是原則。
耗時操做通常分爲兩類:I/O 操做和 CPU 計算工做。好比文件的讀寫、網絡交互、圖片的模糊處理,都是耗時的,統統能夠把它們寫進 suspend
函數裏。
另外這個「耗時」還有一種特殊狀況,就是這件事自己作起來並不慢,但它須要等待,好比 5 秒鐘以後再作這個操做。這種也是 suspend
函數的應用場景。
給函數加上 suspend
關鍵字,而後在 withContext
把函數的內容包住就能夠了。
提到用 withContext
是由於它在掛起函數裏功能最簡單直接:把線程自動切走和切回。
固然並非只有 withContext
這一個函數來輔助咱們實現自定義的 suspend
函數,好比還有一個掛起函數叫 delay
,它的做用是等待一段時間後再繼續往下執行代碼。
使用它就能夠實現剛纔提到的等待類型的耗時操做:
🏝️
suspend fun suspendUntilDone() {
while (!done) {
delay(5)
}
}
複製代碼
這些東西,在咱們初步使用協程的時候不用立馬接觸,能夠先把協程最基本的方法和概念理清楚。
咱們今天整個文章其實就在理清一個概念:什麼是掛起?掛起,就是一個稍後會被自動切回來的線程調度操做。
好,關於協程中的「掛起」咱們就解釋到這裏。
可能你心中還會存在一些疑惑:
這些疑惑的答案,咱們都會在下一篇中所有揭曉。
使用協程下載一張圖,並行進行兩次切割
獲得結果後,將它們展現在兩個 ImageView 上。
Hugo(謝晨成),即刻 Android 工程師。2017 年加入即刻,參與了即刻 3.0 到 6.0 版本的架構設計和產品迭代。多年 Android 開發經驗,如今負責即刻客戶端中臺基礎建設。