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<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 協程有以下好處:

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 {
   ...
}

複製代碼

CoroutineContext

上面說到推薦使用CoroutineScope.launch開啓協程,而不管是GlobalScope.launch還是CoroutineScope.launchlaunch方法的第一個參數就是CoroutineContext,源碼如下:

public fun CoroutineScope.launch(
    context: CoroutineContext = EmptyCoroutineContext,
    start: CoroutineStart = CoroutineStart.DEFAULT,
    block: suspend CoroutineScope.() -> Unit
): Job {
    ...
}

複製代碼

這裏的context,即CoroutineContext,它的其中一個作用是起到線程切換的功能,即協程體將運行在CoroutineContext表徵的指定的線程中。

Kotlin 協程官方定義了幾個值,可供我們在開發過程中使用,它們分別是:

協程體將運行在主線程,用於 UI 的更新等需要在主線程執行的場景,這個大家應該都清楚。

協程體將運行在 IO 線程,用於 IO 密集型操作,如網絡請求、文件操作等場景。

協程體將運行在默認的線程,context 沒有指定或指定爲 Dispatchers.Default,都屬於這種情況。用於 CPU 密集型,如涉及到大量計算等場景。要特別注意的是,這裏的默認線程,其實和上面的 IO 線程共享同一個線程池。

不受限的調度器,在開發中不應該使用它,暫不研究。

看一下下面這個例子:

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.IODispatchers.Default調度器的協程運行在同一個線程池。

launch 與 async

上面提到可以使用launch來創建一個協程,但是除了使用launch之外,Kotlin 還提供了async來幫助我們創建協程。兩者的區別是:

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、協程入門指南

3、最全面的 kotlin 協程

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/6954393446622691342