Kotlin 的協程用力瞥一眼

本期做者:java

視頻:扔物線(朱凱)android

文章:LewisLuo(羅宇)git

你們好,我是扔物線朱凱。github

終於到了協程的一期了。編程

Kotlin 的協程是它很是特別的一塊地方:宣揚它的人都在說協程多麼好多麼棒,但多數人無論是看了協程的官方文檔仍是一些網絡文章以後又都以爲徹底看不懂。並且這個「不懂」和 RxJava 是屬於一類的:因爲協程在概念上對於 Java 開發者來講就是個新東西,因此對於大多數人來講,別說怎麼用了,我連它是個什麼東西都沒看明白。api

因此今天,我就先從「協程是什麼」提及。首先仍是看視頻。不過由於我一直不知道怎麼在掘金髮視頻,因此你能夠點擊 這裏 去嗶哩嗶哩看視頻,能夠點擊 這裏 去 YouTube 看。網絡

這期內容主要是講一個概念:什麼是協程。由於這個概念有點難(其實看完視頻你會發現它超級簡單),因此專門花一期來說解。後面的內容會更勁爆,若是你喜歡個人視頻,別忘了去 原視頻 點個贊投個幣,以及關注訂閱一下,不錯過個人任何新視頻!多線程

如下內容來自文章做者 LewisLuo閉包

碼上開學 Kotlin 系列的文章,協程已是第五期了,這裏簡單講一下咱們(扔物線和即刻 Android 團隊)出品的 Kotlin 上手指南系列文章的一些考量:併發

  • 官方文檔有指定的格式,由於它是官方的,必須面面俱到,寫做順序不是由淺入深,無論你懂不懂,它都得講。
  • 網上的文章大都是從做者自身的角度出發,真正從讀者的需求出發的少之又少,沒法抓住讀者的痛點,可以讀完已屬不易。
  • 疲勞度是這一系列的一個重要的衡量指標,文章中若是連續出現大段代碼,疲勞度會急劇上升,不容易集中精神,甚至中途放棄。

咱們期許基於上述的考量和原則,把技術文章寫得更加輕鬆易讀,激發讀者學習的興趣,真正實現「上手」。

協程在 Kotlin 中是很是特別的一部分,和 Java 相比,它是一個新穎的概念。宣揚它的人都在說協程是多麼好用,但就目前而言無論是官方文檔仍是網絡上的一些文章都讓人難以讀懂。

形成這種「不懂」的緣由和大多數人在初學 RxJava 時所遇到的問題實際上是一致的:對於 Java 開發者來講這是一個新東西。下面咱們從「協程是什麼」開始提及。

協程是什麼

協程並非 Kotlin 提出來的新概念,其餘的一些編程語言,例如:Go、Python 等均可以在語言層面上實現協程,甚至是 Java,也能夠經過使用擴展庫來間接地支持協程。

當在網上搜索協程時,咱們會看到:

  • Kotlin 官方文檔說「本質上,協程是輕量級的線程」。
  • 不少博客提到「不須要從用戶態切換到內核態」、「是協做式的」等等。

做爲 Kotlin 協程的初學者,這些概念並非那麼容易讓人理解。這些每每是做者根據本身的經驗總結出來的,只看結果,而無論過程就不容易理解協程

「協程 Coroutines」源自 Simula 和 Modula-2 語言,這個術語早在 1958 年就被 Melvin Edward Conway 發明並用於構建彙編程序,說明協程是一種編程思想,並不侷限於特定的語言。

Go 語言也有協程,叫 Goroutines,從英文拼寫就知道它和 Coroutines 仍是有些差異的(設計思想上是有關係的),不然 Kotlin 的協程徹底能夠叫 Koroutines 了。

所以,對一個新術語,咱們須要知道什麼是「標準」術語,什麼是變種。

當咱們討論協程和線程的關係時,很容易陷入中文的誤區,二者都有一個「程」字,就以爲有關係,其實就英文而言,Coroutines 和 Threads 就是兩個概念。

從 Android 開發者的角度去理解它們的關係:

  • 咱們全部的代碼都是跑在線程中的,而線程是跑在進程中的。
  • 協程沒有直接和操做系統關聯,但它不是空中樓閣,它也是跑在線程中的,能夠是單線程,也能夠是多線程。
  • 單線程中的協程總的執行時間並不會比不用協程少。
  • Android 系統上,若是在主線程進行網絡請求,會拋出 NetworkOnMainThreadException,對於在主線程上的協程也不例外,這種場景使用協程仍是要切線程的。

協程設計的初衷是爲了解決併發問題,讓 「協做式多任務」 實現起來更加方便。這裏就先不展開「協做式多任務」的概念,等咱們學會了怎麼用再講。

視頻裏講到,協程就是 Kotlin 提供的一套線程封裝的 API,但並非說協程就是爲線程而生的。

不過,咱們學習 Kotlin 中的協程,一開始確實能夠從線程控制的角度來切入。由於在 Kotlin 中,協程的一個典型的使用場景就是線程控制。就像 Java 中的 Executor 和 Android 中的 AsyncTask,Kotlin 中的協程也有對 Thread API 的封裝,讓咱們能夠在寫代碼時,不用關注多線程就可以很方便地寫出併發操做。

在 Java 中要實現併發操做一般須要開啓一個 Thread

☕️
new Thread(new Runnable() {
    @Override
    public void run() {
        ...
    }
}).start();
複製代碼

這裏僅僅只是開啓了一個新線程,至於它什麼時候結束、執行結果怎麼樣,咱們在主線程中是沒法直接知道的。

Kotlin 中一樣能夠經過線程的方式去寫:

🏝️
Thread({
    ...
}).start()
複製代碼

能夠看到,和 Java 同樣也擺脫不了直接使用 Thead 的那些困難和不方便:

  • 線程何時執行結束
  • 線程間的相互通訊
  • 多個線程的管理

咱們能夠用 Java 的 Executor 線程池來進行線程管理:

🏝️
val executor = Executors.newCachedThreadPool()
executor.execute({
    ...
})
複製代碼

用 Android 的 AsyncTask 來解決線程間通訊:

🏝️
object : AsyncTask<T0, T1, T2> { 
    override fun doInBackground(vararg args: T0): String { ... }
    override fun onProgressUpdate(vararg args: T1) { ... }
    override fun onPostExecute(t3: T3) { ... }
}
複製代碼

AsyncTask 是 Android 對線程池 Executor 的封裝,但它的缺點也很明顯:

  • 須要處理不少回調,若是業務多則容易陷入「回調地獄」。
  • 硬是把業務拆分紅了前臺、中間更新、後臺三個函數。

看到這裏你很天然想到使用 RxJava 解決回調地獄,它確實能夠很方便地解決上面的問題。

RxJava,準確來說是 ReactiveX 在 Java 上的實現,是一種響應式程序框架,咱們經過它提供的「Observable」的編程範式進行鏈式調用,能夠很好地消除回調。

使用協程,一樣能夠像 Rx 那樣有效地消除回調地獄,不過不管是設計理念,仍是代碼風格,二者是有很大區別的,協程在寫法上和普通的順序代碼相似。

這裏並不會比較 RxJava 和協程哪一個好,或者討論誰取代誰的問題,我這裏只給出一個建議,你最好都去了解下,由於協程和 Rx 的設計思想原本就不一樣。

下面的例子是使用協程進行網絡請求獲取用戶信息並顯示到 UI 控件上:

🏝️
launch({
    val user = api.getUser() // 👈 網絡請求(IO 線程)
    nameTv.text = user.name  // 👈 更新 UI(主線程)
})
複製代碼

這裏只是展現了一個代碼片斷,launch 並非一個頂層函數,它必須在一個對象中使用,咱們以後再講,這裏只關心它內部業務邏輯的寫法。

launch 函數加上實如今 {} 中具體的邏輯,就構成了一個協程。

一般咱們作網絡請求,要不就傳一個 callback,要不就是在 IO 線程裏進行阻塞式的同步調用,而在這段代碼中,上下兩個語句分別工做在兩個線程裏,但寫法上看起來和普通的單線程代碼同樣。

這裏的 api.getUser 是一個掛起函數,因此可以保證 nameTv.text 的正確賦值,這就涉及到了協程中最著名的「非阻塞式掛起」。這個名詞看起來不是那麼容易理解,咱們後續的文章會專門對這個概念進行講解。如今先把這個概念放下,只須要記住協程就是這樣寫的就好了。

這種「用同步的方式寫異步的代碼」看起來很方便吧,那麼咱們來看看協程具體好在哪。

協程好在哪

開始以前

在講以前,咱們須要先了解一下「閉包」這個概念,調用 Kotlin 協程中的 API,常常會用到閉包寫法。

其實閉包並非 Kotlin 中的新概念,在 Java 8 中就已經支持。

咱們先以 Thread 爲例,來看看什麼是閉包:

🏝️
// 建立一個 Thread 的完整寫法
Thread(object : Runnable {
    override fun run() {
        ...
    }
})

// 知足 SAM,先簡化爲
Thread({
    ...
})

// 使用閉包,再簡化爲
Thread {
    ...
}
複製代碼

形如 Thread {...} 這樣的結構中 {} 就是一個閉包。

在 Kotlin 中有這樣一個語法糖:當函數的最後一個參數是 lambda 表達式時,能夠將 lambda 寫在括號外。這就是它的閉包原則。

在這裏須要一個類型爲 Runnable 的參數,而 Runnable 是一個接口,且只定義了一個函數 run,這種狀況知足了 Kotlin 的 SAM,能夠轉換成傳遞一個 lambda 表達式(第二段),由於是最後一個參數,根據閉包原則咱們就能夠直接寫成 Thread {...}(第三段) 的形式。

對於上文所使用的 launch 函數,能夠經過閉包來進行簡化 :

🏝️
launch {
    ...
}
複製代碼

基本使用

前面提到,launch 函數不是頂層函數,是不能直接用的,可使用下面三種方法來建立協程:

🏝️
// 方法一,使用 runBlocking 頂層函數
runBlocking {
    getImage(imageId)
}

// 方法二,使用 GlobalScope 單例對象
// 👇 能夠直接調用 launch 開啓協程
GlobalScope.launch {
    getImage(imageId)
}

// 方法三,自行經過 CoroutineContext 建立一個 CoroutineScope 對象
// 👇 須要一個類型爲 CoroutineContext 的參數
val coroutineScope = CoroutineScope(context)
coroutineScope.launch {
    getImage(imageId)
}
複製代碼
  • 方法一一般適用於單元測試的場景,而業務開發中不會用到這種方法,由於它是線程阻塞的。

  • 方法二和使用 runBlocking 的區別在於不會阻塞線程。但在 Android 開發中一樣不推薦這種用法,由於它的生命週期會和 app 一致,且不能取消(什麼是協程的取消後面的文章會講)。

  • 方法三是比較推薦的使用方法,咱們能夠經過 context 參數去管理和控制協程的生命週期(這裏的 context 和 Android 裏的不是一個東西,是一個更通用的概念,會有一個 Android 平臺的封裝來配合使用)。

關於 CoroutineScopeCoroutineContext 的更多內容後面的文章再講。

協程最經常使用的功能是併發,而併發的典型場景就是多線程。可使用 Dispatchers.IO 參數把任務切到 IO 線程執行:

🏝️
coroutineScope.launch(Dispatchers.IO) {
    ...
}
複製代碼

也可使用 Dispatchers.Main 參數切換到主線程:

🏝️
coroutineScope.launch(Dispatchers.Main) {
    ...
}
複製代碼

因此在「協程是什麼」一節中講到的異步請求的例子完整寫出來是這樣的:

🏝️
coroutineScope.launch(Dispatchers.Main) {   // 在主線程開啓協程
    val user = api.getUser() // IO 線程執行網絡請求
    nameTv.text = user.name  // 主線程更新 UI
}
複製代碼

而經過 Java 實現以上邏輯,咱們一般須要這樣寫:

☕️
api.getUser(new Callback<User>() {
    @Override
    public void success(User user) {
        runOnUiThread(new Runnable() {
            @Override
            public void run() {
                nameTv.setText(user.name);
            }
        })
    }
    
    @Override
    public void failure(Exception e) {
        ...
    }
});
複製代碼

這種回調式的寫法,打破了代碼的順序結構和完整性,讀起來至關難受。

協程的「1 到 0」

對於回調式的寫法,若是併發場景再複雜一些,代碼的嵌套可能會更多,這樣的話維護起來就很是麻煩。但若是你使用了 Kotlin 協程,多層網絡請求只須要這麼寫:

🏝️
coroutineScope.launch(Dispatchers.Main) {       // 開始協程:主線程
    val token = api.getToken()                  // 網絡請求:IO 線程
    val user = api.getUser(token)               // 網絡請求:IO 線程
    nameTv.text = user.name                     // 更新 UI:主線程
}
複製代碼

若是遇到的場景是多個網絡請求須要等待全部請求結束以後再對 UI 進行更新。好比如下兩個請求:

🏝️
api.getAvatar(user, callback)
api.getCompanyLogo(user, callback)
複製代碼

若是使用回調式的寫法,那麼代碼可能寫起來既困難又彆扭。因而咱們可能會選擇妥協,經過前後請求代替同時請求:

🏝️
api.getAvatar(user) { avatar ->
    api.getCompanyLogo(user) { logo ->
        show(merge(avatar, logo))
    }
}
複製代碼

在實際開發中若是這樣寫,原本可以並行處理的請求被強制經過串行的方式去實現,可能會致使等待時間長了一倍,也就是性能差了一倍。

而若是使用協程,能夠直接把兩個並行請求寫成上下兩行,最後再把結果進行合併便可:

🏝️
coroutineScope.launch(Dispatchers.Main) {
    // 👇 async 函數以後再講
    val avatar = async { api.getAvatar(user) }    // 獲取用戶頭像
    val logo = async { api.getCompanyLogo(user) } // 獲取用戶所在公司的 logo
    val merged = suspendingMerge(avatar, logo)    // 合併結果
    // 👆
    show(merged) // 更新 UI
}
複製代碼

能夠看到,即使是比較複雜的並行網絡請求,也可以經過協程寫出結構清晰的代碼。須要注意的是 suspendingMerge 並非協程 API 中提供的方法,而是咱們自定義的一個可「掛起」的結果合併方法。至於掛起具體是什麼,能夠看下一篇文章。

讓複雜的併發代碼,寫起來變得簡單且清晰,是協程的優點。

這裏,兩個沒有相關性的後臺任務,由於用了協程,被安排得明明白白,互相之間配合得很好,也就是咱們以前說的「協做式任務」。

原本須要回調,如今直接沒有回調了,這種從 1 到 0 的設計思想真的妙哉。

在瞭解了協程的做用和優點以後,咱們再來看看協程是怎麼使用的。

協程怎麼用

在項目中配置對 Kotlin 協程的支持

在使用協程以前,咱們須要在 build.gradle 文件中增長對 Kotlin 協程的依賴:

  • 項目根目錄下的 build.gradle :
buildscript {
    ...
    // 👇
    ext.kotlin_coroutines = '1.3.1'
    ...
}
複製代碼
  • Module 下的 build.gradle :
dependencies {
    ...
    // 👇 依賴協程核心庫
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines"
    // 👇 依賴當前平臺所對應的平臺庫
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines"
    ...
}
複製代碼

Kotlin 協程是以官方擴展庫的形式進行支持的。並且,咱們所使用的「核心庫」和 「平臺庫」的版本應該保持一致。

  • 核心庫中包含的代碼主要是協程的公共 API 部分。有了這一層公共代碼,才使得協程在各個平臺上的接口獲得統一。
  • 平臺庫中包含的代碼主要是協程框架在具體平臺的具體實現方式。由於多線程在各個平臺的實現方式是有所差別的。

完成了以上的準備工做就能夠開始使用協程了。

開始使用協程

協程最簡單的使用方法,其實在前面章節就已經看到了。咱們能夠經過一個 launch 函數實現線程切換的功能:

🏝️
// 👇
coroutineScope.launch(Dispatchers.IO) {
    ...
}
複製代碼

這個 launch 函數,它具體的含義是:我要建立一個新的協程,並在指定的線程上運行它。這個被建立、被運行的所謂「協程」是誰?就是你傳給 launch 的那些代碼,這一段連續代碼叫作一個「協程」。

因此,何時用協程?當你須要切線程或者指定線程的時候。你要在後臺執行任務?切!

🏝️
launch(Dispatchers.IO) {
    val image = getImage(imageId)
}
複製代碼

而後須要在前臺更新界面?再切!

🏝️
coroutineScope.launch(Dispatchers.IO) {
    val image = getImage(imageId)
    launch(Dispatch.Main) {
        avatarIv.setImageBitmap(image)
    }
}
複製代碼

好像有點不對勁?這不仍是有嵌套嘛。

若是隻是使用 launch 函數,協程並不能比線程作更多的事。不過協程中卻有一個很實用的函數:withContext 。這個函數能夠切換到指定的線程,並在閉包內的邏輯執行結束以後,自動把線程切回去繼續執行。那麼能夠將上面的代碼寫成這樣:

🏝️
coroutineScope.launch(Dispatchers.Main) {      // 👈 在 UI 線程開始
    val image = withContext(Dispatchers.IO) {  // 👈 切換到 IO 線程,並在執行完成後切回 UI 線程
        getImage(imageId)                      // 👈 將會運行在 IO 線程
    }
    avatarIv.setImageBitmap(image)             // 👈 回到 UI 線程更新 UI
} 
複製代碼

這種寫法看上去好像和剛纔那種區別不大,但若是你須要頻繁地進行線程切換,這種寫法的優點就會體現出來。能夠參考下面的對比:

🏝️
// 第一種寫法
coroutineScope.launch(Dispachers.IO) {
    ...
    launch(Dispachers.Main){
        ...
        launch(Dispachers.IO) {
            ...
            launch(Dispacher.Main) {
                ...
            }
        }
    }
}

// 經過第二種寫法來實現相同的邏輯
coroutineScope.launch(Dispachers.Main) {
    ...
    withContext(Dispachers.IO) {
        ...
    }
    ...
    withContext(Dispachers.IO) {
        ...
    }
    ...
}
複製代碼

因爲能夠"自動切回來",消除了併發代碼在協做時的嵌套。因爲消除了嵌套關係,咱們甚至能夠把 withContext 放進一個單獨的函數裏面:

🏝️
launch(Dispachers.Main) {              // 👈 在 UI 線程開始
    val image = getImage(imageId)
    avatarIv.setImageBitmap(image)     // 👈 執行結束後,自動切換回 UI 線程
}
// 👇
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
    ...
}
複製代碼

這就是以前說的「用同步的方式寫異步的代碼」了。

不過若是隻是這樣寫,編譯器是會報錯的:

🏝️
fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
    // IDE 報錯 Suspend function'withContext' should be called only from a coroutine or another suspend funcion
}
複製代碼

意思是說,withContext 是一個 suspend 函數,它須要在協程或者是另外一個 suspend 函數中調用。

suspend

suspend 是 Kotlin 協程最核心的關鍵字,幾乎全部介紹 Kotlin 協程的文章和演講都會提到它。它的中文意思是「暫停」或者「可掛起」。若是你去看一些技術博客或官方文檔的時候,大概能夠了解到:「代碼執行到 suspend 函數的時候會『掛起』,而且這個『掛起』是非阻塞式的,它不會阻塞你當前的線程。」

上面報錯的代碼,其實只須要在前面加一個 suspend 就可以編譯經過:

🏝️
//👇
suspend fun getImage(imageId: Int) = withContext(Dispatchers.IO) {
    ...
}
複製代碼

本篇文章到此結束,而 suspend 具體是什麼,「非阻塞式」又是怎麼回事,函數怎麼被掛起,這些疑問的答案,將在下一篇文章所有揭曉。

練習題

  1. 開啓一個協程,並在協程中打印出當前線程名。
  2. 經過協程下載一張網絡圖片並顯示出來。

做者介紹

視頻做者

扔物線(朱凱)
  • 碼上開學創始人、項目管理人、內容模塊規劃者和視頻內容做者。
  • 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 ,學員遍及全球,有來自阿里、頭條、華爲、騰訊等知名一線互聯網公司,也有來自中國臺灣、日本、美國等地區的資深軟件工程師。

文章做者

LewisLuo(羅宇)

LewisLuo(羅宇) ,即刻 Android 工程師。2019 年加入即刻,參與即刻日記功能的開發和迭代及中臺基礎建設。曾就任於 mobike,負責國際化業務開發。

相關文章
相關標籤/搜索