協程在 UI 程式設計中的使用指南

weixin_34370347發表於2019-01-09

原文連結:github.com/Kotlin/kotl…

原文開源協議:github.com/Kotlin/kotl…

譯文釋出於我的部落格:blog.rosuh.me/2019/01/cor…

本指南假設您已經對協程這個概念有了基礎的理解,如果您不瞭解,可以看看 Guide to kotlin.coroutines,它會給出一些協程在 UI 程式設計中應用的示例。

所有 UI 應用程式庫都有一個普遍的問題:他們的 UI 均受限於一個主執行緒中,所有的 UI 更新操作都必須發生在這個特定的執行緒中。對於此類應用使用協程,這意味您必須有一個合適的協程排程器,將協程的執行操作限制在那個特定的 UI 執行緒中。

對於此,kotlin.coroutine有三個模組,他們為不同的 UI 應用程式庫提供協程上下文。

kotlin-coroutines-core庫裡的Dispatcher.Main提供了可用的 UI 分發器實現,而ServiceLoader API 會自動發現並載入正確的實現(Android,JavaFx 或 Swing)。舉個例子,如果您正在編寫 JavaFx 應用程式,您可以使用Dispatcher.MainDispatcher.JavaFx擴充套件,他們是同一個物件。

本指南同時涵蓋了所有的 UI 庫,因為每個模組只包含一個長度為幾頁的物件定義。您可以使用其中任何一個作為示例,為您喜歡的 UI 庫編寫相應的上下文物件,即便它未被本文寫出來。

設定

本指南中可執行的例子將使用 JavaFx 實現。這麼做的好處是,所有的示例可以直接在任何操作需要上執行而不需要安裝任何模擬器或類似的東西,並且他們是完全獨立的。

JavaFx

這個基礎的 JavaFx 示例程式由一個名為hello並使用Hello World!進行初始化的文字標籤以及一個名為fab的桃紅色的位於右下角的原型按鈕組成。

JavaFx 的 start函式將會呼叫setup函式,並將hellofab這兩個節點的引用作為引數傳遞給 setup 函式。setup 函式是本指南中存放各種程式碼的地方:

fun setup(hello:Text, fab: Circle) {
    // 佔個位
}
複製程式碼

點選此處檢視完整程式碼

您可以從 GitHub clone kotlinx.coroutines 專案到您本地,然後用 IDEA 開啟。本指南的所有例子都在 ui/kotlinx-coroutines-javafx 模組的 test資料夾中。這樣您便可以執行並觀察每一個例子的執行情況以及修改專案來進行實驗。

Android

跟著 Getting Started With Android and Kotlin 這份指南,在 Android Studio 中建立 Kotlin 專案。我們也推薦您使用 Kotlin Android Extensions 中的擴充套件特性。

在 Android Studio 2.3 中,您會得到下面的類似的應用程式介面:

context_main.xml檔案中,為您的TextView分配hello的資源 ID,然後使用Hello World!來初始化它。

那個桃紅色的浮動按鈕資源 ID 是fab

MainActivity.kt中,移除掉fab.setOnclickListener{...},接著在onCreate()方法的最後一行新增一行setup(hello, fab)來呼叫它。

然後在MainActivity.kt檔案的尾部,給出setup()函式的實現:

fun setup(text: TextView, fab: FloatingActionButton){
    // 佔位
}
複製程式碼

在您app/build.gradledependecies{...}塊中新增依賴:

implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.0"
複製程式碼

Android 的示例存放在 ui/kotlinx-coroutines-android/example-app ,您可以clone下來執行。

基礎 UI 協程

這個小節將展示協程在 UI 應用程式中的基礎使用。

啟動 UI 協程

kotlinx-coroutines-javafx 模組包含了Dispatchers.JavaFx 分發器,該分發器分配協程操作給 JavaFx 應用執行緒。

我們將之匯入並用Main作為其別名,以便所有示例都可以輕鬆地移植到 Android 上:

import kotlinx.coroutines.javafx.JavaFx as Main
複製程式碼

主 UI 執行緒的協程可以在 UI 執行緒上執行任何更新 UI 的操作,並且可以不阻塞主執行緒地掛起(suspend)操作。舉個例子,我們可以編寫命令式程式碼(imperative style)來執行動畫。下面的程式碼將使用 launch 協程構造器,從 10 到 1 進行倒數,每隔 2 秒倒數一次並更新文字。

fun setup(hello: Text, fab: Circle) {
    GlobalScope.launch(Dispatchers.Main) { // launch coroutine in the main thread
        for (i in 10 downTo 1) { // countdown from 10 to 1 
            hello.text = "Countdown $i ..." // update text
            delay(500) // wait half a second
        }
        hello.text = "Done!"
    }
}
複製程式碼

您可以在此獲取完整的程式碼

那麼,上面究竟發生了什麼呢?因為我們在 UI 執行緒啟動(launching)了協程,所以我們可以在該協程內自由地更新 UI 的同時還可以呼叫掛起函式(suspend functions),比如 delay 。當 delay 在等待時(waits),UI 並不會卡住(frozen),因為 delay 並不會阻塞 UI 執行緒 —— 這就是協程的掛起。

相應的 Android 應用程式碼是一樣的。您只需要複製setup函式內的程式碼到 Android 專案中的對應函式中即可

取消 UI 協程

當我們想要停止一個協程的時候,我們可以持有一個由 launch函式返回的 Job 物件並利用它來取消(cancel)。

讓我們通過點選桃紅色的按鈕來停止協程:

fun setup(hello: Text, fab: Circle) {
    val job = GlobalScope.launch(Dispatchers.Main) { // launch coroutine in the main thread
        for (i in 10 downTo 1) { // countdown from 10 to 1 
            hello.text = "Countdown $i ..." // update text
            delay(500) // wait half a second
        }
        hello.text = "Done!"
    }
    fab.onMouseClicked = EventHandler { job.cancel() } // cancel coroutine on click
}
複製程式碼

您可以在這裡獲取完整程式碼

現在實現的效果是:當倒數正在進行時,點選圓形按鈕將會停止倒數。請注意,Job.cancel 方法執行緒安全並且非阻塞。它只是給協程傳送取消訊號,而不會等待協程真正終止。

Job.cancel 該方法可以在任何地方呼叫,而如果在已經取消或者完成的協程上,該方法不做什麼事情。

相應的 Android 程式碼示例如下

fab.setOnClickListener{job.cancel()}
複製程式碼

在 UI Context 中使用 actors

在一節中,我們將會展示 UI 應用程式是如何在其 UI 上下文(Context)中使用 actors ,以確保啟動的協程數量不會無限增長。

協程擴充套件

我們的目標是編寫一個名為onClick的擴充套件協程構建器函式,這樣每當圓形按鈕被點選的時候,都會執行一個倒數動畫:

fun setup(hello: Text, fab: Circle) {
    fab.onClick { // start coroutine when the circle is clicked
        for (i in 10 downTo 1) { // countdown from 10 to 1 
            hello.text = "Countdown $i ..." // update text
            delay(500) // wait half a second
        }
        hello.text = "Done!"
    }
}
複製程式碼

我們的第一個onClick版本:在每一個滑鼠事件上啟動一個新的協程,並將之對應的滑鼠事件傳遞給動作使用者:

fun Node.onClick(action: suspend (MouseEvent) -> Unit) {
    onMouseClicked = EventHandler { event ->
        GlobalScope.launch(Dispatchers.Main) { 
            action(event)
        }
    }
}
複製程式碼

您可以在此獲取完整的程式碼

請注意,每當圓形按鈕被點選,它便會啟動一個新的協程,這些新協程會競爭地更新文字。這看起來並不好,我們會在後面解決這個問題。

在 Android 中,可以為 View 類編寫對應的擴充套件函式程式碼,所以上面 setup 函式中的程式碼可以不需要另作更改就直接使用。Android 中沒有 MouseEvent,所以此處略過

fun View.onClick(action: suspend () -> Unit) {
    setOnClickListener { 
        GlobalScope.launch(Dispatchers.Main) {
            action()
        }
    }
}
複製程式碼

最多隻有一個協程 Job

我們可以在開啟一個新的協程之前,取消掉一個正在執行(active)的 Job,以此來確保最多隻有一個協程在執行倒數計時工作。然而,通常來說這並不是一個最好的解決方法。cancel 函式僅僅傳送一個取消訊號去中斷一個協程。取消的操作是合作性的,在現在的版本中,當協程在做一件不可取消的或類似的事件時,它是可以忽略取消訊號的。

一個更好的解決方法是使用一個 actor 來確保協程不會同時進行。讓我們修改onClick擴充套件實現:

fun Node.onClick(action: suspend (MouseEvent) -> Unit) {
    // 啟動一個 actor 來接管這個節點中的所有事件
    val eventActor = GlobalScope.actor<MouseEvent>(Dispatchers.Main) {
        for (event in channel) action(event) //傳遞事件給 action
    }
    // install a listener to offer events to this actor
    onMouseClicked = EventHandler { event ->
        eventActor.offer(event)
    }
}
複製程式碼

您可以在此獲取完整程式碼

整合 actor 協程和常規事件控制(event handler)的關鍵點,在於 SendChannel 中有一個不中斷(no wait)的 offer 函式。如果傳送訊息這個行為可行的話,offer 函式會立即傳送一個元素給 actor ,否則該元素將會被丟棄。offer 函式會返回一個 Boolean 作為結果,不過在此該結果被我們忽略了。

試著重複點選這個版本的程式碼中的圓形按鈕。當倒數都動畫正在執行時,該點選操作會被忽略掉。這是因為 actor 正忙於動畫而沒有從 channel 接受訊息。預設情況下,一個 actor 的訊息信箱(mailbox)是由 RendezvousChannel實現的,後者的 offer操作僅在 receive 活躍時有效。

在 Android 中,View 被傳遞給 OnClickListener,所以我們把 view 當作訊號(signal)傳遞給 actor 。對應的 View 類擴充套件如下:

fun View.onClick(action: suspend (View) -> Unit) {
    // launch one actor
    val eventActor = GlobalScope.actor<View>(Dispatchers.Main) {
        for (event in channel) action(event)
    }
    // install a listener to activate this actor
    setOnClickListener { 
        eventActor.offer(it)
    }
}
複製程式碼

事件合併

有時候處理最新的事件比忽略掉它更合適。 actor 協程構建器接受一個可選的 capacity 引數來控制用於訊息信箱(mailbox)的 channel 的實現。所有有效的選項均在 Channel() 工廠函式中有所闡述。

讓我們修改程式碼,傳遞 Channel.CONFLATED 這個 capacity 引數來使用 ConflatedChannel 。只需要更改建立 actor 的那行程式碼即可:

fun Node.onClick(action: suspend (MouseEvent) -> Unit) {
    // launch one actor to handle all events on this node
    val eventActor = GlobalScope.actor<MouseEvent>(Dispatchers.Main, capacity = Channel.CONFLATED) { // <--- Changed here
        for (event in channel) action(event) // pass event to action
    }
    // install a listener to offer events to this actor
    onMouseClicked = EventHandler { event ->
        eventActor.offer(event)
    }
}
複製程式碼

您可以在此獲取完整的 JavaFx 程式碼。在 Android 上,您需要修改之前示例中的 val eventActor = ... 這一行。

現在,如果動畫正在進行時圓形按鈕被點選了,動畫將會在結束之後重新啟動。僅會重啟一次。當動畫進行時,重複的點選操作將會被合併,而僅有最新的事件會被處理。

這對於那些需要接收高頻率事件流,並基於最新事件更新 UI 的 UI 應用程式而言,也是一種合乎需求的行為( a desired behaviour )。使用 ConflatedChannel 的協程可以避免由事件緩衝(buffering of events)帶來的延遲。

您可以試驗不同的 capacity 引數來看看上面程式碼的效果和行為。設定 capacity = Channel.UNLIMITED 將建立一個 LinkedListChannel 實現的信箱,這會緩衝所有事件。在這種情況下,動畫的執行次數和圓形按鈕點選次數保持一致。

阻塞操作

這一小節將解釋如何在 UI 協程中完成執行緒阻塞操作(thread-blocking operations)。

UI 卡頓問題

The problem of UI freezes

如果所有 API 介面函式均以掛起函式(suspending functions)來實現那是最好不過的事情了,這樣那些函式將永遠不會阻塞呼叫它們的執行緒。然而,事實往往並非如此。比如,有時候您必須做一些消耗 CPU 的計算操作,或者只是需要呼叫第三方的 API 來訪問網路,這些行為都會阻塞呼叫函式的執行緒。您無法在 UI 執行緒或是 UI 執行緒啟動的協程直接做上述操作,因為那會直接阻塞 UI 執行緒從而導致 UI 介面卡頓。

下面的例子將會展示這個問題。我們將使用 onClick 擴充套件和上一節中的 UI 限制性事件合併 actor 來處理 UI 執行緒的最後一次點選。

舉個例子,我們將進行 斐波那契數列 的簡單演算:

fun fib(x: Int): Int = 
	if (x <= 1) x else fib(x - 1) + fib(x - 2)
複製程式碼

每當圓形按鈕被點選,我們都會進行更大的斐波那契數的計算。為了讓 UI 卡頓變得明顯可見,將會有一個持續執行的快速的計數器動畫,並在 UI 分發器(dispatcher)更新文字:

fun setup(hello: Text, fab: Circle) {
    var result = "none" // the last result
    // counting animation 
    GlobalScope.launch(Dispatchers.Main) {
        var counter = 0
        while (true) {
            hello.text = "${++counter}: $result"
            delay(100) // update the text every 100ms
        }
    }
    // compute the next fibonacci number of each click
    var x = 1
    fab.onClick {
        result = "fib($x) = ${fib(x)}"
        x++
    }
}
複製程式碼

您可以在這裡獲取完整的 JavaFx 程式碼。您只需要複製 fib 函式及 setup 函式體內程式碼到您的 Android 專案即可

試著點選例子中的圓形按鈕。大概第在 30~40 次點選後,我們的計算將會變得緩慢,接著您會立刻看到 UI 卡頓,因為倒數動畫在 UI 卡頓的時候停止了。

結構化併發、生命週期和協程親子繼承

一個典型的 UI 應用程式擁有許多生命週期元素。Windows、UI 控制、activities,views,fragments 以及其他視覺化元素將會被建立和銷燬。一個長時間執行的協程,在後臺執行著諸如 IO 或計算操作,如果它持有 UI 元素的引用,那麼可能導致 UI 元素生命週期過長,繼而阻止那些已經銷燬並且不再顯示的 UI 樹被 GC 收集和回收。

一個自然的解決方法是將一個 Job 物件關聯到 UI 物件,後者擁有生命週期並在其上下文(Context)中建立協程。但是傳遞已關聯的 Job 物件給所有執行緒構造器容易出錯,而且這個操作容易被遺忘。故此,CoroutineScope 介面可以被 UI 所有者所實現,然後每一個在 CoroutineScope 上定義為擴充套件的協程構造器都將繼承 UI 的 Job,而無需顯式宣告。為了簡單起見,可以使用 MainScope() 工廠方法。它會自動提供 Dispatchers.Main 及其父級 job 。

舉個例子,在 Android 應用程式中,一個 Activitycreated 中被初始化,而當其不再被需要或者其記憶體必須被釋放時,該物件被銷燬destroyed)。一個自然的解決方法是為一個 Activity 例項物件附加一個 Job 例項物件:

abstract class ScopedAppActivity: AppCompatActivity(), CoroutineScope by MainScope() {
    override fun onDestroy() {
        super.onDestroy()
        cancel() // CoroutineScope.cancel
    } 
}
複製程式碼

現在,繼承 ScopedAppActivity 來讓一個 activity 和一個 job 關聯起來:

class MainActivity : ScopedAppActivity() {

    fun asyncShowData() = launch { // Is invoked in UI context with Activity's job as a parent
        // actual implementation
    }
    
    suspend fun showIOData() {
        val deferred = async(Dispatchers.IO) {
            // impl      
        }
        withContext(Dispatchers.Main) {
          val data = deferred.await()
          // Show data in UI
        }
    }
}
複製程式碼

每個從MainActivity中啟動(launched)的協程都將擁有它的 job 作為其父親,當 activity 被銷燬時,協程將會被立刻取消(canceled)。

可以使用多種方法,來將 activtiy 的 scope 傳遞給它的 Views 及 Presenters:

class ActivityWithPresenters: ScopedAppActivity() {
    fun init() {
        val presenter = Presenter()
        val presenter2 = ScopedPresenter(this)
    }
}

class Presenter {
    suspend fun loadData() = coroutineScope {
        // Nested scope of outer activity
    }
    
    suspend fun loadData(uiScope: CoroutineScope) = uiScope.launch {
      // Invoked in the uiScope
    }
}

class ScopedPresenter(scope: CoroutineScope): CoroutineScope by scope {
    fun loadData() = launch { // Extension on ActivityWithPresenters's scope
    }
}

suspend fun CoroutineScope.launchInIO() = launch(Dispatchers.IO) {
   // Launched in the scope of the caller, but with IO dispatcher
}
複製程式碼

jobs 間的親子關係形成了層級結構。一個代表檢視在後臺執行工作的協程,可以進一步建立子協程。當父級 job 被取消的時候,整個協程樹都將被取消。協程指南中的“子協程”用一個例子闡述了這些用法。

阻塞操作

使用協程可以非常簡單地解決 UI 執行緒上的阻塞操作。我們將把我們的“阻塞” fib 函式轉換為掛起函式,然後通過使用 withContext 函式來將把後臺運算部分的執行緒的執行上下文(execution context)轉換為 Dispatchers.DefaultDispatchers.Default 由一個後臺執行緒池( background pool)實現。請注意,fib函式現在標有 suspend 修飾符。這表示無論它怎麼被呼叫都會不會阻塞協程,而是在後臺執行緒執行計算時,掛起它的操作。

suspend fun fib(x: Int): Int = withContext(Dispatchers.Default) {
    if (x <= 1) x else fib(x - 1) + fib(x - 2)
}
複製程式碼

您可以在這裡 獲取完整程式碼。

您可以執行上述程式碼然後確認在計算較大的斐波那契數時 UI 不會被卡住。然而,這段 fib計算程式碼速度稍慢,因為每一次都是通過 withContext 來遞迴呼叫的。這在練習中並不是什麼大問題,因為 withContext 能夠機智地檢查該協程是否已經在所需的上下文中,然後避免過度分發(dispatching)協程到不同的執行緒。儘管如此,這仍是一種開銷。它在原生程式碼(primitive code)上是可見的,並且它除了呼叫 withContext 之間提供整數以外,不做其他工作。對於一些實際性的程式碼, withContext 的開銷不會很明顯。

儘管如此,這個特定實現的可在後臺執行緒工作的 fib 函式也可以變得和沒有使用掛起函式時一樣快,只需要重新命名原來的 fib 函式為 fibBlocking 然後定義一個用 withContext 包裝在 fibBlocking 頂部的 fib 函式即可:

suspend fun fib(x: Int): Int = withContext(Dispatchers.Default) {
    fibBlocking(x)
}

fun fibBlocking(x: Int): Int = 
    if (x <= 1) x else fibBlocking(x - 1) + fibBlocking(x - 2)
複製程式碼

您可以在這裡 獲取完整程式碼。

您現在可以享受全速(full-speed)的原生斐波那契數計算而不會阻塞 UI 執行緒了。我們僅僅需要 withContext(Dispatchers.Default) 而已。

請記住,因為在我們程式碼中 fib 函式是被單個 actor 所呼叫的,故而在任何時間都最多隻有一個並行運算。所以這份程式碼在資源利用上有著天然的限制性。它最多隻能佔用一個 CPU 核心。

進階提示

這個小結覆蓋了多種進階提示。

不使用分發器在 UI 事件控制器中啟動協程

讓我們用下列 setup 函式中的程式碼來形象展示協程從 UI 中啟動的執行步驟:

fun setup(hello: Text, fab: Circle) {
    fab.onMouseClicked = EventHandler {
        println("Before launch")
        GlobalScope.launch(Dispatchers.Main) {
            println("Inside coroutine")
            delay(100)
            println("After delay")
        } 
        println("After launch")
    }
}
複製程式碼

您可以在這裡獲取完整的 JavaFx 程式碼。

當我們執行程式碼並點選桃紅色的圓形按鈕,控制檯將會列印出如下資訊:

Before launch
After launch
Inside coroutine
After delay
複製程式碼

正如您所見,launch 後的操作被立刻執行了,而釋出到 UI 執行緒的協程則在其之後才執行。所有在 kotlinx.coroutines 的分發器都是如此實現的。為什麼要這樣呢?

基本上,這是在 “JavaScript 風格”非同步方法(非同步操作總是被延遲給事件分發執行緒執行)和 “C# 風格”非同步方法(非同步操作在呼叫者執行緒遇到第一個掛起函式時被執行)之間的選擇。儘管 C# 風格看起來更有效率,但是它最終建議諸如“如果您需要時請使用 yield ...”的資訊。這樣是容易出錯的。JavaScript 風格的方法更加一致,它也不要求程式設計人員去思考什麼時候該或不該使用 yield

然而,當協程從事件控制器(event handler)啟動,並且沒有其周圍沒有其它的程式碼,這中特殊情況下,此種額外的分派確實會帶來額外的開銷,並且沒有其他的附加價值。在這樣的情況下, launchasyncactor 三種協程構造器均可以傳遞一個可選的 CoroutineStart 引數來優化效能。傳遞 CoroutineStart.UNDISPATCHED 引數將會實現:遇到首個掛在函式便立刻執行協程的效果。正如下面程式碼所示:

fun setup(hello: Text, fab: Circle) {
    fab.onMouseClicked = EventHandler {
        println("Before launch")
        GlobalScope.launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) { // <--- Notice this change
            println("Inside coroutine")
            delay(100)                            // <--- And this is where coroutine suspends      
            println("After delay")
        }
        println("After launch")
    }
}
複製程式碼

您可以在此獲取到完整的 JavaFx 程式碼。

當點選時,下面的資訊將會被列印出來,可以確認協程中的程式碼被立刻執行:

Before launch
Inside coroutine
After launch
After delay
複製程式碼

相關文章