Kotlin 協程是個什麼東西?
Android 實現多任務併發
在 Android 中,除了可以通過 Java 的方式,創建線程、使用線程池實現多任務併發之外,還可以AsyncTask
等方式來實現多個耗時任務的併發執行:
//AsyncTask
public abstract class AsyncTask<Params, Progress, Result> {
//線程池中執行,執行耗時任務
protected abstract Result doInBackground(Params... params);
//UI線程中執行,後臺任務進度有變化則執行該方法
protected void onProgressUpdate(Progress... values) {}
//UI線程執行,耗時任務執行完成後,該方法會被調用,result是任務的返回值
protected void onPostExecute(Result result) {}
}
複製代碼
無論是 Java 還是 Android 提供的組件,都可以實現多任務併發的執行,但是上面的組件都或多或少存在着問題:
- 耗時任務執行結束後,子線程要將結果傳遞迴主線程,兩者之間的通信不太方便。
AsyncTask
處理的回調方法比較多,當有多個任務時可能會出現回調嵌套。
協程實現多任務併發
繼續以AsyncTask
舉個🌰:
AsyncTask<String, Integer, String> task = new AsyncTask<String, Integer, String>() {
@Override
protected String doInBackground(String... strings) {
String userId = getUserId(); //獲取userId
return userId;
}
@Override
protected void onPostExecute(final String userId) {
AsyncTask<String, Integer, String> task1 = new AsyncTask<String, Integer, String>() {
@Override
protected String doInBackground(String... strings) {
String name = getUserName(userId); //獲取userName,需要用到userId
return name;
}
@Override
protected void onPostExecute(String name) {
textView.setText(name); //設置到TextView控件中
}
};
task1.execute(); //假設task1是一個耗時任務,去獲取userName
}
};
task.execute(); //假設task是一個耗時任務,去獲取userId
複製代碼
如果是使用協程,上面的例子可以簡化爲:
GlobalScope.launch(Dispatchers.Main) {
val userId = getUserId() //耗時任務,這裏會切換到子線程
val userName = getUserName(userId) //耗時任務,這裏會切換到子線程
textView.text = userName //設置到TextView控件中,切換到主線程
}
suspend fun getUserId(): String = withContext(Dispatchers.IO) {
//耗時操作,返回userId
}
suspend fun getUserName(userId: String): String = withContext(Dispatchers.IO) {
//耗時操作,返回userName
}
複製代碼
上面launch
函數的 {} 的邏輯,就是一個協程。
相比於AsyncTask
的寫法,使用 kotlin 協程有以下好處:
- 協程將耗時任務和 UI 更新放在了上下三行處理,消除了
AsyncTask
的回調嵌套,使用起來更加方便、簡潔。 - 協程通過掛起與恢復,將耗時任務的結果直接返回給調用方,使得主線程能直接使用子線程的結果,UI 更新更加方便。
Kotlin 協程的接入與使用
怎麼接入
在模塊的build.gradle
中加入以下依賴:
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$1.3.9"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9"
}
複製代碼
怎麼使用
Kotlin 提供了三種方式來創建協程,如下所示:
//方式一
runBlocking { //runBlocking是一個頂級函數
...
}
//方式二
GlobalScope.launch { //GlobalScope是一個單例對象,直接使用launch開啓協程
...
}
//方式三
val coroutineScope = CoroutineScope(context) //使用CoroutineContext創建CoroutineScope對象,通過launch開啓協程
coroutineScope.launch {
...
}
複製代碼
- 方式一:它是線程阻塞的,它通常被用在單元測試和 main 函數中,平時的開發中我們一般不會用到它。
- 方式二:與方式一相比,它不會阻塞線程,但是它的生命週期和應用是一致的,而且無法做到取消(後面會講到),所以也不推薦使用。
- 方式三:通過
CoroutineContext
來創建一個CoroutineScope
對象,通過CoroutineScope.launch
或CoroutineScope.async
可以開啓協程,通過CoroutineContext
也可以控制協程的生命週期。在開發過程中,一般推薦使用這種方式開啓協程。
CoroutineContext
上面說到推薦使用CoroutineScope.launch
開啓協程,而不管是GlobalScope.launch
還是CoroutineScope.launch
,launch
方法的第一個參數就是CoroutineContext
,源碼如下:
public fun CoroutineScope.launch(
context: CoroutineContext = EmptyCoroutineContext,
start: CoroutineStart = CoroutineStart.DEFAULT,
block: suspend CoroutineScope.() -> Unit
): Job {
...
}
複製代碼
這裏的context
,即CoroutineContext
,它的其中一個作用是起到線程切換的功能,即協程體將運行在CoroutineContext
表徵的指定的線程中。
Kotlin 協程官方定義了幾個值,可供我們在開發過程中使用,它們分別是:
- Dispatchers.Main
協程體將運行在主線程,用於 UI 的更新等需要在主線程執行的場景,這個大家應該都清楚。
- Dispatchers.IO
協程體將運行在 IO 線程,用於 IO 密集型操作,如網絡請求、文件操作等場景。
- Dispatchers.Default
協程體將運行在默認的線程,context 沒有指定或指定爲 Dispatchers.Default,都屬於這種情況。用於 CPU 密集型,如涉及到大量計算等場景。要特別注意的是,這裏的默認線程,其實和上面的 IO 線程共享同一個線程池。
- Dispatchers.Unconfined
不受限的調度器,在開發中不應該使用它,暫不研究。
看一下下面這個例子:
GlobalScope.launch(Dispatchers.Main) {
println("Main Dispatcher, currentThread=${Thread.currentThread().name}")
}
GlobalScope.launch {
println("Default Dispatcher1, currentThread=${Thread.currentThread().name}")
}
GlobalScope.launch(Dispatchers.IO) {
println("IO Dispatcher, currentThread=${Thread.currentThread().name}")
}
GlobalScope.launch(Dispatchers.Default) {
println("Default Dispatcher2, currentThread=${Thread.currentThread().name}")
}
複製代碼
程序運行結果如下:
可以看到,Dispatchers.Main
調度器的協程運行在主線程,而無調度器、Dispatchers.IO
、Dispatchers.Default
調度器的協程運行在同一個線程池。
launch 與 async
上面提到可以使用launch
來創建一個協程,但是除了使用launch
之外,Kotlin 還提供了async
來幫助我們創建協程。兩者的區別是:
- launch:創建一個協程,返回一個
Job
,但是並不攜帶協程執行後的結果。 - async:創建一個協程,返回一個
Deferred
(也是一個 Job),並攜帶協程執行後的結果。
async
返回的Deferred
是一個輕量級的非阻塞 future,它代表的是一個將會在稍後提供結果的 promise,所以它需要使用await
方法來得到最終結果。拿 Kotlin 官方的一個例子對async
進行說明:
val one = async { doSomethingUsefulOne() }
val two = async { doSomethingUsefulTwo() }
println("The answer is ${one.await() + two.await()}")
suspend fun doSomethingUsefulOne(): Int {
delay(1000L) // 假設我們在這裏做了些有用的事
return 13
}
suspend fun doSomethingUsefulTwo(): Int {
delay(1000L) // 假設我們在這裏也做了些有用的事
return 29
}
複製代碼
執行上述代碼,得到的結果是:
The answer is 42
複製代碼
Kotlin 協程的使用場景
線程切換
在介紹《CoroutineContext》一節時,舉的例子中的協程還是運行在單一線程中。在實際開發過程中,常見的場景就是線程的切換與恢復,這需要用到withContext
方法了。
withContext
我們繼續以《與線程的對比》這一節的例子來說明:
GlobalScope.launch(Dispatchers.Main) {
val userId = withContext(Dispatchers.IO) {
getUserId() //耗時任務,這裏會切換到子線程
}
textView.text = userId //設置到TextView控件中,切換到主線程
}
複製代碼
上面是一個典型的網絡請求場景:一開始運行在主線程,然後需要到後臺獲取userId
的值(這裏會執行getUserId
方法),獲取結束,結果返回後,會切換回主線程,最後更新 UI 控件。
在獲取userId
的時候,調用了getUserId
方法,這裏用到了withContext
方法,將線程從main
切換到了IO
線程,當耗時任務執行結束後(即上面的getUserId
方法執行完畢),withContext
的另外一個作用是恢復到切換子線程前的所在線程,對應上面的例子是main
線程,所以我們才能做更新 UI 控件的操作。
我們也可以將withContext
的邏輯單獨放到一個方法去管理,如下所示:
GlobalScope.launch(Dispatchers.Main) {
val userId = getUserIdAsync()
textView.text = userId //設置到TextView控件中,切換到主線程
}
fun getUserIdAsync() = withContext(Dispatchers.IO) {
getUserId() //耗時任務,這裏會切換到子線程
}
複製代碼
這樣看上去就像在使用同步調用的方式執行異步邏輯,但是如果按照上面的方式來寫,IDE 會報錯的,提示信息是: Suspend function'withContext' should be called only from a coroutine or another suspend funcion
。
意思是withContext
是一個suspend
方法,它需要在協程或另外一個suspend
方法中被調用。
suspend
suspend
是 Kotlin 協程的一個關鍵字,它表示 “掛起” 的意思。所以上面的報錯,只要加上suspend
關鍵字就能解決,即:
GlobalScope.launch(Dispatchers.Main) {
val userId = getUserIdAsync()
textView.text = userId //設置到TextView控件中,切換到主線程
}
suspend fun getUserIdAsync() = withContext(Dispatchers.IO) {
getUserId() //耗時任務,這裏會切換到子線程
}
複製代碼
當代碼執行到suspend
方法時,當前協程就會被掛起,這裏所說的掛起是非阻塞的,也就是說它不會阻塞當前所在的線程。這就是所謂的 “非阻塞式掛起”。
非阻塞式掛起與協程的執行步驟
非阻塞式掛起的一個前提是:涉及的必須是多線程的操作。因爲阻塞的概念是針對單線程而言的。當我們切換了線程,那肯定是非阻塞的,因爲耗時的操作跑到別的線程了,原來的線程就自由了,該幹嘛幹嘛唄~
如果在主線程中啓動多個協程,那麼協程的執行順序是怎樣的呢?是按照代碼順序執行麼?還是有別的執行順序?如下代碼所示,假設 test 方法在主線程中執行,那麼這段代碼應該輸出什麼呢?
//假設test方法運行在主線程
fun test() {
println("start test fun, thread=${Thread.currentThread().name}")
//協程A
GlobalScope.launch(Dispatchers.Main) {
println("start coroutine1, thread=${Thread.currentThread().name}")
val userId = getUserIdAsync()
println("end coroutine1, thread=${Thread.currentThread().name}")
}
//協程B
GlobalScope.launch(Dispatchers.Main) {
println("start coroutine2, thread=${Thread.currentThread().name}")
delay(100)
println("end coroutine2, thread=${Thread.currentThread().name}")
}
println("end test fun, thread=${Thread.currentThread().name}")
}
suspend fun getUserIdAsync() = withContext(Dispatchers.IO) {
println("getUserIdAsync, thread=${Thread.currentThread().name}")
delay(1000)
return@withContext "userId from async"
}
複製代碼
在 Android 中運行上述代碼,執行結果是:
通過打印的日誌可以看到,雖然協程的代碼順序在println("end test fun...")
之前,但是在執行順序上,協程的啓動仍然在println("end test fun...")
之後,結合非阻塞式掛起,下圖展示了協程的執行順序流程:
參考文檔
1、Kotlin 的協程用力瞥一眼 - 學不會協程?很可能因爲你看過的教程都是錯的
2、協程入門指南
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://juejin.cn/post/6954393446622691342