协程在 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
复制代码
相关文章
相关标签/搜索