協程在 UI 編程中的使用指南

原文連接:github.com/Kotlin/kotl…html

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

譯文發佈於個人博客:blog.rosuh.me/2019/01/cor…node

本指南假設您已經對協程這個概念有了基礎的理解,若是您不瞭解,能夠看看 Guide to kotlin.coroutines,它會給出一些協程在 UI 編程中應用的示例。android

全部 UI 應用程序庫都有一個廣泛的問題:他們的 UI 均受限於一個主線程中,全部的 UI 更新操做都必須發生在這個特定的線程中。對於此類應用使用協程,這意味您必須有一個合適的協程調度器,將協程的執行操做限制在那個特定的 UI 線程中。git

對於此,kotlin.coroutine有三個模塊,他們爲不一樣的 UI 應用程序庫提供協程上下文。github

kotlin-coroutines-core庫裏的Dispatcher.Main提供了可用的 UI 分發器實現,而ServiceLoader API 會自動發現並加載正確的實現(Android,JavaFx 或 Swing)。舉個例子,若是您正在編寫 JavaFx 應用程序,您可使用Dispatcher.MainDispatcher.JavaFx擴展,他們是同一個對象。shell

本指南同時涵蓋了全部的 UI 庫,由於每一個模塊只包含一個長度爲幾頁的對象定義。您可使用其中任何一個做爲示例,爲您喜歡的 UI 庫編寫相應的上下文對象,即使它未被本文寫出來。編程

設置

本指南中可運行的例子將使用 JavaFx 實現。這麼作的好處是,全部的示例能夠直接在任何操做須要上運行而不須要安裝任何模擬器或相似的東西,而且他們是徹底獨立的。api

JavaFx

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

ui-example-javafx

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 中,您會獲得下面的相似的應用程序界面:

ui-example-android

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
複製代碼
相關文章
相關標籤/搜索