Kotlin協程 ——從 runBlocking 與 coroutineScope 說起

關於協程我們不多闡述,詳細內容請查看官方文檔,本文只談談 runBlockingcoroutineScope

runBlocking

我們先來看看 runBlocking 文檔是如何描述該函數的:

Runs a new coroutine and blocks the current thread interruptibly until its completion. This function should not be used from a coroutine. It is designed to bridge regular blocking code to libraries that are written in suspending style, to be used in main functions and in tests.
運行一個新的協程&塊(阻塞?) 當前線程可中斷 直至完成,該函數不應從一個協程中使用,該函數被設計用於橋接普通阻塞代碼到以掛起風格(suspending style)編寫的庫,以用於主函數與測試。

這段話怎麼理解呢?這要從 suspend 修飾符說起,協程使用中可以使用該修飾符修飾一個函數,表示該函數爲掛起函數,從而運行在協程中。 掛起函數,它不會造成線程阻塞,但是會 掛起 協程,並且只能在協程中使用。掛起函數不可以在main函數中被調用,那麼我們怎麼調試呢?對了,就是使用runBlocking 函數!

我們可以使用 runBlocking 函數,構建一個主協程,從而調試我們的協程代碼。你可能會問協程有什麼優勢麼?以至於我需要去搞懂他?引用官方舉的一個小例子吧:

import kotlinx.coroutines.*

fun main() = runBlocking {
    repeat(100_000) { // 啓動大量的協程
        launch {
            delay(1000L)
            print(".")
        }
    }
}

創建10W個協程並在一秒後同時打印一個點 ,不用我多說,你也知道如果使用線程實現的話會發生什麼吧?

如何使用:

fun main() = runBlocking<Unit> {
    // this: CoroutineScope
    launch {
        // 在 runBlocking 作用域中啓動一個新協程
        delay(1000L)
        println("World! ${Thread.currentThread().name}")
    }
    println("Hello, ${Thread.currentThread().name}")
}

總結:runBlocking 方法,可以在普通的阻塞線程中開啓一個新的協程以用於運行掛起函數,並且可以在協程中通過調用 launch 方法,開啓一個子協程,用於運行後臺阻塞任務。

如果我們在普通的線程中運行該方法:

fun main2_1() {
    runBlocking {
       launch {
            // 在後臺啓動一個新的協程並繼續
            delay(3000L)
            println("World!")
        }
    }
    println("Hello,") 
}

runBlocking 是會阻塞主線程的,直到 runBlocking 內部全部子任務執行完畢,纔會繼續執行下一步的操作!

在協程中執行耗時任務

好的,我們已經知道了如何通過 runBlocking 函數來創建一個協程了,那麼我們應該如何利用協程來處理耗時任務呢?
運行我們第一個實例代碼,通過打印結果我們可以看出倆次打印是在不同的協程上運行的,你可能會好奇爲什麼調用 launch 函數可以創建一個新的協程?我們來看一下 launch 方法的源碼:

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

從方法簽名可以看出,launch 方法是 CoroutineScope 的一個擴展函數,該方法在不阻塞當前線程的情況下啓動新的協程,並將協程的引用作爲 Job 返回。取消生成的 Job 時,協程將被取消。

默認情況下,launch中的代碼會立即執行。注意方法簽名中的第二個參數 start ,我們可以通過修改該參數,來變更不同的子協程啓動方式,詳情請查看文檔 CoroutineStart

什麼是 CoroutineScope

瞭解完了 launch 方法後,我們來看看到底什麼是 CoroutineScope 。

public interface CoroutineScope {
    /**
     * Context of this scope.
     */
    public val coroutineContext: CoroutineContext
}

該接口從字面理解是 協程的作用範圍,爲什麼要有作用範圍?

Coroutine 是輕量級的線程,並不意味着就不消耗系統資源。 當異步操作比較耗時的時候,或者當異步操作出現錯誤的時候,需要把這個 Coroutine 取消掉來釋放系統資源。在 Android 環境中,通常每個界面(Activity、Fragment 等)啓動的 Coroutine 只在該界面有意義,如果用戶在等待 Coroutine 執行的時候退出了這個界面,則再繼續執行這個 Coroutine 可能是沒必要的。另外 Coroutine 也需要在適當的 context 中執行,否則會出現錯誤,比如在非 UI 線程去訪問 View。 所以 Coroutine 在設計的時候,要求在一個範圍(Scope)內執行,這樣當這個 Scope 取消的時候,裏面所有的子 Coroutine 也自動取消。所以要使用 Coroutine 必須要先創建一個對應的 CoroutineScope。

所以 CoroutineScope 只是定義了一個新 Coroutine 的執行 Scope。每個 coroutine builder 都是 CoroutineScope 的擴展函數,並且自動的繼承了當前 Scope 的 coroutineContext 和取消操作。

每個 coroutine builder 和 scope 函數(withContext、coroutineScope 等)都使用自己的 Scope 和 自己管理的 Job 來運行提供給這些函數的代碼塊。並且也會等待該代碼塊中所有子 Coroutine 執行,當所有子 Coroutine 執行完畢並且返回的時候, 該代碼塊才執行完畢,這種行爲被稱之爲 “structured concurrency”(結構化併發)。

coroutineScope 函數又是怎麼一回事呢?

官方文檔中說了一段不是很容易理解的話:

除了由不同的構建器提供協程作用域之外,還可以使用 coroutineScope 構建器聲明自己的作用域。它會創建一個協程作用域並且在所有已啓動子協程執行完畢之前不會結束。runBlocking 與 coroutineScope 的主要區別在於後者在等待所有子協程執行完畢時不會阻塞當前線程。

我們已經知道了 runBlocking 方法會創建一個新的協程,coroutineScope 函數看起來效果與 runBlocking 效果很像。但其實他們兩者存在本質性的差異。

前面我們說了 runBlocking 是橋接阻塞代碼與掛起代碼之前的橋樑,其函數本身是阻塞的,但是可以在其內部運行 suspend 修飾的掛起函數。在內部所有子協程運行完畢之前,他是阻塞線程的。

而 coroutineScope 函數不同:

public suspend fun <R> coroutineScope(block: suspend CoroutineScope.() -> R): R =
    suspendCoroutineUninterceptedOrReturn { uCont ->
        val coroutine = ScopeCoroutine(uCont.context, uCont)
        coroutine.startUndispatchedOrReturn(coroutine, block)
    }

該函數被 suspend 修飾,是一個掛起函數,前面我們說了掛起函數是不會阻塞線程的,它只會掛起協程,而不阻塞線程。

如何在我們的項目裏使用協程?

如果你使用 MVVM ,那麼只需要引入
androidx.lifecycle:lifecycle-viewmodel-ktx 這個包,就可以直接在ViewModel 中使用 viewModelScope 這個擴展字段,從而開啓你的協程之旅。

如果我們沒有使用 MVVM 呢,我想在Activity中使用應該如何操作呢?

參考代碼如下:

class ScopedActivity : Activity(), CoroutineScope {
    lateinit var job: Job
    // CoroutineScope 的實現
    override val coroutineContext: CoroutineContext
        get() = Dispatchers.Main + job
 
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        job = Job()
    }
 
    override fun onDestroy() {
        super.onDestroy()
        // 當 Activity 銷燬的時候取消該 Scope 管理的 job。
        // 這樣在該 Scope 內創建的子 Coroutine 都會被自動的取消。
        job.cancel()
    }
 
    /*
     * 注意 coroutine builder 的 scope, 如果 activity 被銷燬了或者該函數內創建的 Coroutine
     * 拋出異常了,則所有子 Coroutines 都會被自動取消。不需要手工去取消。
     */
    fun loadDataFromUI() = launch { // <- 自動繼承當前 activity 的 scope context,所以在 UI 線程執行
        val ioData = async(Dispatchers.IO) { // <- launch scope 的擴展函數,指定了 IO dispatcher,所以在 IO 線程運行
            // 在這裏執行阻塞的 I/O 耗時操作
        }
        // 和上面的並非 I/O 同時執行的其他操作
        val data = ioData.await() // 等待阻塞 I/O 操作的返回結果
        draw(data) // 在 UI 線程顯示執行的結果
    }
}
  1. 使 Activity 實現 CoroutineScope接口;
  2. 重寫 coroutineContext 的 get() 方法;
  3. 在onDestroy 方法中調用 job.cancel();
  4. 在適當的位置調用 launch 方法即可;

如何在協程中切換不同的線程

我們知道,在 Android 中是不可以在主線程發起網絡請求的,我們的協程是寄宿在當前線程的,所以即使在協程中,我們任然不可以發起網絡請求,否則一樣會報 NetworkOnMainThreadException 這個異常的。

這時候我們就需要用到在協程中切換線程,上面代碼中已經演示瞭如何操作了:

		val ioData = async(Dispatchers.IO) { // <- launch scope 的擴展函數,指定了 IO dispatcher,所以在 IO 線程運行
            // 在這裏執行阻塞的 I/O 耗時操作
        }
        // 和上面的並非 I/O 同時執行的其他操作
        val data = ioData.await() // 等待阻塞 I/O 操作的返回結果

async 函數中添加 Dispatchers.IO 參數,該函數不同於 launch 函數的是,launch 函數的返回值是 Job,而 async 的返回值是用戶自己設置的 Deferred<T>,在獲取異步結果時需要通過調用 await 函數來獲取。

如果我們有多個耗時操作呢?多個 async 函數調用會一同進行嗎?答案是否定的,他們將會依次執行,也就是說 async 配合 await 時會阻塞當前協程的。

那如果我們的多個操作需要並行呢?很簡單,在其外層增加一層 launch 即可,這樣不同的耗時操作就執行在不同的協程之中,互相之間不會阻塞,從而實現並行,代碼如下所示:

   fun delayLoad(v: View) = launch {
      launch {
          Logger.d("開始網絡請求1 ${Thread.currentThread().name}")
          val response1 = async(Dispatchers.IO) {
              Logger.d("${Thread.currentThread().name}")
              ServiceCreator.create(PlaceService::class.java).login().execute() }
          tv_detail.text = response1.await().body().toString()
      }
       Logger.d("開始網絡請求2 ${Thread.currentThread().name}")
       val response2 = async(Dispatchers.IO) {
           Logger.d("${Thread.currentThread().name}")
           ServiceCreator.create(PlaceService::class.java).login().execute() }
       tv_detail2.text = response2.await().body().toString()
   }

Dispatchers.IO 又是什麼

先說結論,它是抽象類 CoroutineDispatcher 的一個實現,它是 CoroutineContext 接口的一個實現。那麼什麼是 CoroutineContext,字面意思:協程上下文。

Persistent context for the coroutine. It is an indexed set of [Element] instances.
An indexed set is a mix between a set and a map.
Every element in this set has a unique [Key]. Keys are compared by reference.
*協程的持久上下文。它是一組索引的[元素]實例。
*索引集是集和映射的混合。
*這個集合中的每個元素都有一個唯一的[鍵]。通過參考比較鍵。

故而當我們傳入 Dispatchers.IO 時,這個新的協程被創建在 Dispatchers.IO 對應的上下文中,而非當前的主線程,所以纔不會導致 NetworkOnMainThreadException

CoroutineDispatcher 定義了 Coroutine 執行的線程。CoroutineDispatcher 可以限定 Coroutine 在某一個線程執行、也可以分配到一個線程池來執行、也可以不限制其執行的線程。

在 Dispatchers 中 有以下四個常用的實現:

  1. Default
  2. Main
  3. Unconfined(一般而言我們不使用 Unconfined)
  4. IO

子協程會集成父協程的 context,所以如果不需要切換協程所在線程,我們只需在父類設置 Dispatcher

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章