浅谈Kotlin协程及首页弹窗中的应用

在上一篇协程文章中,我们了解到协程在网络请求中的一些特点和优势,Android官方也对针对协程
进行了大量的支持,为我们的开发提供了许多便利。

但有过java背景的大佬们可能不免还是会产生一些疑惑,认为协程是不是只是kotlin在JVM上对线程池和线程调度的一种封装呢?
甚至后来逐渐完善的Flow系列API,看起来就像是在走Rxjava的老路。

对于这样的问题,我认为这个说法对,但不全面。官方实现的「协程库(kotlinx.coroutines)」,是使用kotlin协程实现的「异步任务/线程池」管理库,
但不意味着,使用kotlin协程只能用来进行线程管理。有一种个人比较认可的说法是

Kotlin协程是一种回调语法糖

通过kotlin协程,可以将响应式的callback形式,转变为同步阻塞的await形式, 从而实现消除回调的效果。
甚至上述callback调用时机不必须在异步线程中,因为这只是一种对代码进行流程控制的能力。

在Kotlin协程之前,对于复杂的异步场景,安卓中我们一般会通过响应式的方式配合Monad来解决此类问题,或是对异步过程直接进行抽象,从而针对性的降低编码难度。
但如果协程世界可以直接转换掉异步调用,就可以大幅简化对异步场景的建模过程,直接了当地编写业务逻辑,拳拳到肉!
甚至可以在运行时控制代码的执行流程,为我们解决异步问题提供了新的思路。

在开始介绍业务实践之前,有必要带大家简要了解一下Kotlin协程是如何实现回调语法糖的,这有助于我们理解如何使用它。下面我将从一个例子入手,由回调过渡为协程。

假设我们有两个异步方法(具体实现省略):

1
2
fun foo()
fun bar()

为了能够让调用者获取异步过程的执行结果,为其增加回调,以便于可以在异步方法内部将结果通知出去:

1
2
fun foo(callback:()->Unit)
fun bar(callback:()->Unit)

现在我们的目标是在foo执行结束后,再执行bar,并在最终输出 「done」。

可以使用嵌套的回调:

1
2
3
4
5
6
7
8
9
10
class Action { 
//调用invoke方法即可实现此目标
fun invoke() {
foo {
bar {
println("done")
}
}
}
}

这很容易理解,而且很多时候我们也一直是这么做的,但其实也有一些其他方式来完成这件事,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Action {
private var label = 0
//调用invoke方法即可实现此目标
fun invoke() {
when (label) {
0 -> {
label++
foo(this)
}
1 -> {
label++
bar(this)
}
else -> {
println("done")
}
}
}
}

fun foo(action: Action) {
//异步操作结束后,调用 action.invoke
action.invoke()
}

fun bar(action: Action) {
//异步操作结束后,调用 action.invoke
action.invoke()
}

主体逻辑依然在invoke中实现,这次foo方法和bar方法将回调替换成了直接调用Action中的invoke,
然后invoke内部根据当前的执行进度调用不同分支逻辑并更新执行进度,从而进行时序控制,就像状态机那样。

Action执行流程图

这个操作虽然也能实现上述目标,但写一个状态机可要比写嵌套的回调麻烦的多,且异步方法要与Action对象直接耦合,这在工程上是不切实际的。
更何况相比回调来说,这样的代码难以阅读,不太像是正常人会手写的代码。
那有没有一种可能,我是说如果,毕竟这份代码形式上还算是工整,咱就是说,这样的代码是不是可以通过编译器生成呢?

Continuation与协程

首先答案是肯定的,因为这就是kotlin实现协程的手段。因此,我们要搞懂的问题是:

  • 什么样的代码会被编译成所谓的状态机,我应当如何启动它,当它执行结束后,又如何获取其执行结果?
  • 在异步方法中,我该如何获取到这个状态机的实例,又如何让它继续执行后续的流程?

Continuation

一般来讲,Continuation 表示的是「剩余的计算」的概念,换句话说就是「接下来要执行的代码」,
对于我们的例子来说,当foobar两个方法执行完毕后,剩下的那句输出done的代码,就是foobar的剩余计算

同理foo的剩余计算是bar+done,整个action执行之前的剩余计算,就是foo+bar+done

在kotlin中,接口Continuation即表示上述概念,定义为

1
2
3
4
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>)
}

其中resumeWith方法是剩余计算的入口,其参数result是剩余计算的初始参数,调用resumeWith即可执行剩余计算。
我们的例子中没有涉及到参数传递,如果bar依赖foo的执行结果,则对于foo来说,执行他的剩余计算时,就需要将其结果包装为result传入resumeWith,以供bar或其他代码调用。

再回到我们的例子中来,我们应该可以意识到,整个Action类就是一个Continuation的实现。Actioninvoke方法,可以充当剩余计算的入口点,
无论是初始状态下调用invoke以启动整个流程(会触发foo+bar+done),
还是在foo方法中调用invoke(会触发bar+done),
还是在bar方法中调用invoke(会触发done),
在不同情况下,Action对象都可以充当当前状态下的Continuation的角色,
所以,虽然我们的任务中包含多个剩余计算,但是实际上,可以由同一个Action对象统一实现,
即,Action封装了原本需要回调实现的所有阶段的剩余计算,拥有它,即拥有启动后续计算的钥匙。

在kotlin协程中,像Action这样的类就是编译的产物,它实现了Continuation接口,继承自BaseContinuationImpl
主体逻辑被编译到invokeSuspend方法中, resumeWith会辗转调用到invokeSuspend,以提供继续剩余计算的能力。
大家可以通过源码和反编译的协程代码来了解更多的细节。

如果我们可以在异步方法中拿到一个Continuation的实例,调用他的resumeWith,就可以像拿到Action的实例,调用他的invoke方法那样了。
那么,回到问题,什么样的代码会被编译成Continuation

suspend function 与 suspendCoroutine

我们知道,kotlin协程提供了suspend关键字,可以标记在方法上

1
suspend fun foo()

或用于声明一个lambda

1
val foo = suspend {}

**一般的,一个suspend function/lambda,会被编译为一个Continuation**。而方法体中调用的其他suspend function/lambda,会被看做挂起点,
编译器会根据具体的挂起点的分布,来编译具体的状态机代码。还是Action这个例子,用挂起函数改造一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//整个action会被编译为一个`Continuation`的实现类
val action = suspend {
//这里是一个挂起点
foo()
//同上
bar()
println("done")
}

//这个方法要声明为suspend方法
suspend fun foo() {/*暂不实现*/}
//这个方法要声明为suspend方法
suspend fun bar() {/*暂不实现*/}

相信大家应该可以把 suspend lambda版的action,与手写的Action类对应起来,
首先整段lambda会编译为一个Continuation的实现类(设为C0)
C0invokeSuspend方法实现为一个状态机,对标Actioninvoke方法
每一句suspend方法调用(如foo、bar),都对应了状态机的一个分支,状态转移的操作也按照代码的先后顺序一并生成,
每次调用ContinuationresumeWith,便会像调用Actioninvoke那样推进整个代码的执行进度。

如此来看,如果可以在 suspend fun foo() 中拿到C0的话,我们就可以灵活控制推进action这个lambda代码块内的执行的节奏了。
很遗憾,虽然C0是存在的,但我们并不能直接拿到,或者说,设计者并不想让我们接触他。那深层次的看,为什么我们想要得到Continuation对象?
是因为我们要借助这个对象, 来表达当前这个方法执行完毕的现实意义上的时间节点,而不是代码上的方法返回的时间节点。
那如果foo方法本身就能表达清楚何时是所谓的现实意义上的结束节点,调用resumeWith也就不那么重要了,这个行为可以交给类库自行推进。

问题的关键还是在于foo中包含异步操作,foo最后一行代码的结束不能表示foo的异步过程结束。但我们之前说过,
一般的,每个suspend function/lambda,都会被编译为Continuation,这意味着,foo也有一个代表着自己的Continuation(设为C1)。
如果foo方法内部操作不了C0,那有没有办法操作C1呢?答案就是suspendCoroutine这个api

1
public suspend inline fun <T> suspendCoroutine(crossinline block: (Continuation<T>) -> Unit): T

看一个例子

1
2
3
4
5
6
7
8
9
10
suspend fun baz() {
println("start")
suspendCoroutine<Unit> { /*位置1*/ c2:Continuation<Unit> ->
thread {
Thread.sleep(1000)
c2.resume(Unit)
}
}
println("end")
}

baz所编译出的Continuation(C2)中,suspendCoroutine也会作为挂起点,被编译为状态机的一个分支,与自定义的suspend function行为相同。
C2由三个主要的部分组成,即输出「start」、suspendCoroutine、输出「end」。

其中suspendCoroutine那部分指的是位置1处lambda的工作,包括开启新线程、sleep1秒以及调用c2resume(resumeWith)方法。
大家可以看到,这个c2是一个Continuation类型的对象,它所代表的,就是由baz所生成的Continuation(C2)的实例。
也就是说,通过suspendCoroutine,可以拿到当前层级(当前方法)的Continuation实例。
同时suspendCoroutine作为挂起点被编译为独立分支,这样我们既可以阻断后续代码的执行,又具备了推进到下一个分支的能力。
有了C2的实例以后,什么时候调用resume,就什么时候执行下一个分支。

baz流程图

从我们编写的代码的表面来看,在baz的执行过程中,首先输出「start」,之后便调用suspendCoroutine做了一些操作(起线程),然后,代码看起来就像是停在此处一样,后面的「end」不执行了。
直到1秒钟结束,子线程中调用了c2.resume,代码才从停止处恢复回来,输出了「end」。

这便是协程的挂起与恢复了。

于是我们意识到,对于一个suspend function/lambda来说,最后一行代码执行结束,代表着当前Continuation的结束,
如果当前Continuation的执行是由外层的Continuation触发的话, 此时就是推进外层Continuation执行的合理时机
(如foo是由action内部触发,当foo方法执行结束后,推进action去执行bar)。
但是,无论如何,触发剩余计算都需要通过Continuation对象来进行。
最直接的,就是让内层Continuation持有外层Continuation的引用,这样就可以直接在内层发起一个外层协程的恢复了。

kotlin为每一个suspend function/lambda的方法签名中都偷偷添加了一个Continuation的形参,以实现Continuation的传递。
被传入的Continuation是作为父Continuation,被wrap在当前Continuation中。
在我们的例子中,foo方法签名被添加形参Continuation,调用处action中会将代表action的那个Continuation(C0)传入。
foo内部会通过c0构建c1,c1会持有c0的引用。当然c1也可能作为父Continuation传递到另外的Continuation中。
通过这种方式,可以表达任意调用链,足以支撑构建起一个协程世界。恢复父Continuation的逻辑实现在BaseContinuationImpl中。

现在我们知道了,能让协程挂起的和恢复的关键,是suspendCoroutine这个api。让我们补齐省略的代码,重新回顾一下协程方式的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
suspend val action = suspend {
foo()
bar()
println("done")
}
suspend fun foo() = suspendCoroutine<Unit> {
//异步操作结束后,调用resume
it.resume(Unit)
}
suspend fun bar() = suspendCoroutine<Unit> {
//异步操作结束后,调用resume
it.resume(Unit)
}

首先lambdaaction对应了一个ContinuationC0,在执行C0的过程中,遇到了foo分支,foo分支调用了foo方法,并把C0自己传了进去。
foo方法中创建了另外一个ContinuationC1,并执行到了异步操作的分支部分。此时在异步操作结束之前,C1便执行完了这个分支,
C0目前的分支也在触发了C1之后便执行完了,万籁俱寂,JVM看了都想杀进程。
终于,foo方法内的异步任务完毕,C1resume方法被调用,C1又重新启动,它跳过了那些已经执行过的部分,很顺利的同步执行完最后一行,
于是C1发起了C0resume
使得C0又重新执行,C0被推进到下一个分支, 代码从bar处得以恢复。后面的故事,大家应该都清楚了。

需要声明的是,如果suspend方法中没有挂起操作的话,是不会生成Continuation的,尾调用的情况也会有单独的优化;
suspend关键字除了标记Continuation的传递,还涉及到挂起标识的返回和语法上的约束;
真实的经过编译的Continuation与例子中的Action还是有很大的差别,但那些没有提到的细节,都是为了更加完善的实现上述理想模型,
不影响我们对协程运作方式的认知,关于CPS有机会为大家深入聊聊kotlin编译器中的那些魔法。
我们一直没有提到的CoroutineContext,是通过函数式列表实现的一个依赖管理工具,借助于Continuation的传递机制以实现数据共享与注入,被协程库用于线程调度与任务管理

最后,让我们来启动协程世界。

1
2
3
4
5
6
7
8
9
10
11
12
13
val action = suspend { 
foo()
bar()
println("done")
}

//通过lambda的拓展方法startCoroutine启动这个协程,他接收一个Continuation类型的参数
//这个Continuation是整个action执行结束后,最终抵达的Continuation,
//这不过这个Continuation需要我们手动实现,这里使用工具方法来创建实现类
action.startCoroutine(Continuation(EmptyCoroutineContext) {
//当action的「done」输出完毕后执行此处
println("action的返回结果为${it.getOrThrow()}")
})

小试牛刀

至此,我猜聪明的你,已经完全明白协程是怎样运作的了,甚至迫不及待的想要一展身手!正巧这里有一个小练习,加深一下我们的理解。

假设我们有两个Optional对象,现在我们要实现两者相加的功能,要求只有两个Optional都非空的时候才可加,返回包含加和的Optional,其他情况下返回Optional.empty()
首先我们知道Optional.flatMap可以很方便的实现这个需求

1
2
3
4
5
6
fun plus(o1: Optional<Int>, o2: Optional<Int>): Optional<Int> =
o1.flatMap { i1 ->
o2.flatMap { i2 ->
Optional.of(i1 + i2)
}
}

但美中不足的是,这里出现了嵌套的回调。为了能够消除回调嵌套,希望能够通过协程把回调拉平,效果如下

1
2
3
4
5
6
fun plus(o1: Optional<Int>, o2: Optional<Int>): Optional<Int> =
dsl {
val i1: Int = bind(o1)
val i2: Int = bind(o2)
return@dsl i1 + i2
}

解释一下,需要大家实现的是dslbind两个方法,执行结果需要满足题目要求,bind要实现为挂起函数。

下面为解析部分,大佬们可以稍作思考,然后回来对答案。

首先我们推导一下方法定义,dsl方法接收lambda参数,返回Optional,其中lambda没有形参,返回值是int。又由于bind是suspend方法,所以lambda为suspend lambda;
bind方法接收Optional,返回Int,最终得出定义如下:

1
2
3
fun dsl(action: suspend () -> Int): Optional<Int>

suspend fun bind(o: Optional<Int>): Int

回顾下flatMap版的运行过程,如果外层o1非空,则返回内层flatMap的返回值,如果o1为空,则内层flatMap整体不执行。

如果内层o2非空,则产生加和Optional,否则,内层flatMap的lambda不执行。由此带来启发,对应到协程版本,可以得到思路:

如果o1为空,则bind方法挂起不再恢复执行,否则在bind方法中使用o1的值恢复协程。

如果o2为空,则bind方法挂起不再恢复执行,否则在bind方法中使用o2的值恢复协程。

如果代码能成功执行到return语句,则认为o1、o2合法,整个协程返回两者加和,可在最终的Continuation中获取到这个结果,
否则协程将永远没有机会执行到return,自然也无法执行到最终的Continuation。

由此,bind方法的作用就是挂起协程,并在合适的情况下恢复协程,否则永久挂起即可

1
2
3
4
5
6
suspend fun bind(o: Optional<Int>) =
suspendCoroutine<Int> { cont ->
o.ifPresent {
cont.resume(it)
}
}

在dsl方法中启动协程,参数lambda可直接作为协程体

1
2
3
4
5
6
7
8
9
10
fun dsl(action: suspend () -> Int): Optional<Int> {
//位置0
action.startCoroutine(Continuation(EmptyCoroutineContext) { result->
//位置1
//合法情况下,action执行结束会带着最终的加和来到这里
})
//位置2
return ???
}

我们知道,启动协程,需要调用startCoroutine,传入一个最终执行的completion,整体调用链为
startCoroutine->执行action->执行completion
但有可能在执行action的过程中被挂起,没能恢复的话,就是
startCoroutine->执行action
但无论是哪一种情况,由于我们是单线程,上面的流程会随着startCoroutine的返回而执行完毕。

对应到代码,在位置0之后,位置2之前,便是整个协程工作的代码区域,利用这一点,可以在return时(问号处)决定具体dsl的返回值究竟为何

1
2
3
4
5
6
7
fun dsl(action: suspend () -> Int): Optional<Int> {
var o = Optional.empty<Int>()
action.startCoroutine(Continuation(EmptyCoroutineContext) { result->
o = Optional.of(result.getOrThrow())
})
return o
}

这便是基于本篇的知识点得出的一个解法了。

回到到协程本身,他为我们呈现的,就是一种可主动操控的、用于打断和恢复代码执行的流程控制工具。
他们的背后,是一个个Continuation中状态的流转和一层层Continuation之间流程的接力。
kotlin将启动、调度和管理Continuation的工作封装起来,使其实现线程切换、异常处理、结构化并发等功能,便有了我们熟知的用于线程/任务管理的协程库。

但必须要说的是,这样的流程控制工具是开放给每一位kotlin的开发者使用的,这意味着,在多线程之外,还会有无限的使用场景。

下面,为大家介绍下kotlin协程在首页弹窗中的应用。

首页弹窗应用

连续的弹窗场景在每个app都非常常见,尤其在首页这种承载各类业务入口的页面。他们的表现形式一般为多个弹窗按照顺序先后弹出,在上一个弹窗关闭/消失的时候
会触发下一个弹窗的展示。这看起来平平无奇,但背后一直有很多问题困扰着我们。

单从展示来看,这是一个很标准的异步场景。由于我们感知弹窗关闭的方式是基于系统的异步事件监听,所以简单的,可以看做是app与framework之间的多轮双向通信过程。
但实现上,由于用于展示弹窗的数据可能分散在不同的数据源/接口中,当我们异步拉取这些数据的时候,又会涉及到数据获取与展示的时序问题。
即使抛开实际业务中的细枝末节,随着弹窗数量的不断增多,在不采取任何措施的情况下,问题规模也会因为异步场景而呈现出非线性的快速增长。

在kotlin协程之前,对于首页弹窗场景,借款和理财两款App给出了各自的处理方式。

由于洋钱罐借款中的弹窗数量众多,且弹出流程相对统一、规范,我们对其进行了针对性的业务建模。对所有可能出现的弹窗规定了优先级。模型将待展示弹窗组织为线性结构,按照优先级先后弹出。
对外暴露出接口,用于创建弹窗及收集弹窗所需的数据。当所有数据拉取完毕后,启动弹窗展示流程。

这套方案在一定程度上抑制住了问题规模的增长,降低了开发新弹窗的成本。
只是在后续的开发过程中发现,弹窗的展示策略不够灵活,只能实现先后弹出的功能,即一个弹窗关闭后立即弹出下一个。
如果遇到需要重试的弹窗的场景(如用户不同意隐私弹窗,需要再次唤起隐私弹窗让用户重选一次),在不对方案进行修改的情况下是无法实现的。
其次,还遇到了兼容性的问题。比如有些弹窗看起来像弹窗,用起来也是弹窗,但实际上他不是个弹窗(一键登录弹窗其实是一个半透明的普通页面)。这就很难接入我们封装的框架。

虽然我们最后通过一些手段规避了上述问题,没有对这套方案作出升级。
但即使我们愿意花费精力来重新设计更通用、更复杂的模型,后续开发和维护的成本也会随着模型复杂度的提高而上升。

在洋钱罐理财中,由于弹窗种类相对较少,所以直接在各个弹窗消失的回调中触发后续弹窗的弹出。这样做灵活性上不会有问题,但会造成可读性的下降。
要么代码的执行流程被打散分散在不同的方法中,要么代码形成回调地狱坨在一起。
好在数据源相对单一,可以一次性的拿到需要的全部数据。如果用于展示弹窗的数据返回的先后顺序不能跟弹窗的顺序匹配,为了控制数据的时序又会出现flag满天飞的局面了。

归根结底,这是我们的编程语言在遇到异步场景的时候表达能力不足造成的。也就是说,如果弹窗展示就像打log一样简单,其实我们没必要折腾什么。

如今协程作为流程控制工具,可以在极大的程度上弥补这个缺点。为了在简单与灵活之间找到一个平衡,需要使用协程解决如下两个问题

  • 通过协程简化弹窗数据的拉取逻辑,保证数据的成功获取发生在弹窗弹出之前,方便实现时序控制。
  • 通过协程简化异步流程代码的编写,将弹窗展示流程处理为同步过程

协程挂起工具

jdk的concurrent包为我们提供了一个非常实用的同步控制工具Condition,配合重入锁可以实现灵活的线程通信,非常好用。
如果可以使用Condition来实现弹窗数据的组织与等待,在很大程度上,可以将时序控制的成本降到最低,且足够灵活。
但很遗憾,在基于event loop的UI线程中,为了避免阻塞用户操作,我们不能使用它。
说到这里,各位大佬们应该已经想到了,可以通过协程封装一套Condition机制,在不阻塞用户操作的情况下阻塞当前协程。
这样弹窗数据的时序问题可以通过挂起工具来解决。

结合业务场景,由于这个工具用于数据等待,所以封装一个类命名为PendingData,内部持有一个用于展示弹窗的数据对象的引用var data: Any,对外声明为泛型。
对标Conditionawait,提供挂起函数await用于阻塞当前协程,对标singal提供notify用于唤醒阻塞的协程。

整体类定义如下

1
2
3
4
5
6
7
8
class PendingData<T : Any?> {
private lateinit var data: Any

@MainThread
fun notify(data: T) {}

suspend fun await(): T? {}
}

每一个弹窗数据对应一个PendingData实例,展示侧可以通过这个对象使用await在当前协程等待,数据侧可以通过这个对象使用notify恢复协程。
一般的,数据在主线程返回,所以这里暂时不做线程安全的处理,使用MainThread注解让IDE帮忙做一个简单的检查。

由于用于notify的数据被设计为可空的,所以如果成员变量data是为null时,并不能表示当前是因为没有notify导致data为null,还是notify了一个null导致data为null。
所以这里直接把data字段设计为非空的,再额外增加一个TAG来标识notify了一个null的情况

1
private object LOST

这样private lateinit var data: Any可以认为是一个复合类型,即 T/LOST 类型,当其为LOST时表示notify了null,其他情况为notify了非空数据。
如此可以通过data字段是否非空来判断是否调用过notify

为了能够在notify时顺利的恢复协程,可以在await挂起当前协程时,存储代表后续剩余计算的Continuation,在notify方法内即可通过成员变量continuation恢复协程。

PendingData新增成员continuation

1
2
3
class PendingData<T : Any> {
private var continuation: Continuation<T?>? = null
}

await实现如下:

1
2
3
4
5
6
7
8
9
10
suspend fun await(): T? {
if (this::data.isInitialized) {
@Suppress("UNCHECKED_CAST")
//LOST要转换为null,这样可以作为空的T类型对象返回
return if (data === LOST) null else data as T
}
return suspendCoroutine { cont ->
this.continuation = cont
}
}

这里需要注意的细节就是,当展示侧调用await时,数据侧有可能已经通过notify将数据存放在data了, 所以此时直接返回data,否则挂起协,并将当前Continuation存储为成员变量。

notify的实现思路就是拿着数据去恢复协程

1
2
3
4
5
6
7
8
fun notify(data: T) {
if (!this::data.isInitialized) {
this.data = if (data === null) LOST else data
val cor = continuation ?: return
cor.resumeWith(Result.success(data))
continuation = null
}
}

重复的notify是被忽略的,严格来讲可以抛异常,但这无伤大雅。需要考虑的细节还是当展示侧调用await之前,数据侧有可能开始notify了,此时continuation为空,
这种情况下直接return即可,await中可以处理这种情况。

最后要说明的是,我们只考虑了在单一协程中使用PendingData的情况,如果同时挂起多个协程的话,只有最后一个协程可以顺利恢复。计划会在后面的版本完善。

这样一个简易的挂起工具就做好了,完整代码可以在项目中查看。为了让大家理解他的工作方式,用一个小demo验证下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class MyActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//①页面中的四个按钮
val buttons = findButtonsFromPage()
//②为四个按钮创建四个PendingData对象
val conditions = Array(4) { PendingData<Unit>() }
//③按钮点击时notify对应的button
buttons.forEachIndexed { index, button ->
button.setOnClickListener {
conditions[index].notify(Unit)
}
}
//④当四个按钮全部点击之后toast一个「done」
lifecycleScope.launch {
conditions.forEach {
it.await()
}
Toast.makeText(this@MyActivity, "done", Toast.LENGTH_SHORT).show()
}
}
}

假设页面中一共有四个按钮,在①处通过findViewById取得他们的引用,并将其放入数组buttons中。在②处准备了四个PendingData,与button一一对应。
在③处为每一个button绑定点击事件,点击后只需要notify对应的PendingData,不做其他操作。

之后我们就可以很方便的对按钮点击行为做时序控制了。比如④处,我们想在所有按钮点击之后,输出一个「done」,可以直接await所有的PendingData,
让协程先后四次挂起并恢复,当然具体次数可能会少于4,因为PendingData的await的顺序按照Button在array中的顺序进行,如果在PendingData[0]await的过程中,
先点击了button[1],再点击button[0],那么当执行到PendingData[1]await时,按照我们的实现,便不需要再挂起协程了。
但无论点击顺序如何,当最后一个PendingData的await在协程中返回时,四个按钮一定全部点击了,后面可以执行show toast的代码。

更细腻的,假设我们希望在最终输出done之前,只要button[0]、button[1]都被点击了,就先输出一个「01按钮已点击」,那我们可以将代码改写为

1
2
3
4
5
6
7
8
lifecycleScope.launch {
conditions[0].await()
conditions[1].await()
Toast.makeText(this@MyActivity, "01按钮已点击", Toast.LENGTH_SHORT).show()
conditions[2].await()
conditions[3].await()
Toast.makeText(this@MyActivity, "done", Toast.LENGTH_SHORT).show()
}

可以看到我们在在业务层面就可以简单灵活的处理此类问题。

当然,CoroutineScope不是必须的,我们可以直接像上文小练习中那样裸用协程。但为了安全和方便,这里依赖携程库,使用Android提供的LifecycleScope,
他们都是可以顺利兼容的。如此,通过PendingData,在展示侧我们就可以获得对数据流的时序控制能力。在解决了这个大问题之后,后面的工作就只剩封装异调用了。

使用协程封装异步调用

一般的,对于简单弹窗,只关心弹窗的关闭时机的话,可以封装一个普适的工具方法

1
2
3
4
5
6
suspend fun Dialog.showAndAwaitOnDismiss() = suspendCoroutine<Unit> { cor ->
setOnDismissListener {
cor.resumeWith(Result.success(Unit))
}
show()
}

通过挂起的拓展方法showAndAwaitOnDismiss代替原本的show方法,使得弹窗弹出后,可以立即挂起当前协程,直到弹窗消失后恢复。

对于复杂弹窗行为,如我们需要根据用户点击的不同按钮决定后续的弹窗流程,可以将不同的点击行为封装为不同的类,作为挂起方法的返回值,根据不同返回值来编写不同逻辑。

以隐私弹窗的三次重试为例

在洋钱罐理财中,用于展示隐私说明的弹窗由ConfirmDialog类实现。其提供了两个方法用于监听同意隐私协议/不同意隐私协议

1
2
3
4
public class ConfirmDialog extends AlertDialog {
public void setConfirmClickListener(ConfirmClickListener confirmClickListener){ /*...*/ }
public void setCancelClickListener(CancelClickListener confirmClickListener){ /*...*/ }
}

为了方便在协程中使用他们,封装一个密封类,将两个行为统一为同一种类型的对象

1
2
3
4
5
6
sealed class ConfirmDialogResult {
//用于表示同意按钮点击
object Confirm : ConfirmDialogResult()
//用于表示不同意按钮点击
object Cancel : ConfirmDialogResult()
}

之后就可以使用挂起函数封装弹窗的展示与消失

1
2
3
4
5
6
7
8
9
private suspend fun ConfirmDialog.showAndAwait() = suspendCoroutine<ConfirmDialogResult> { cor ->
setConfirmClickListener {
cor.resumeWith(Result.success(ConfirmDialogResult.Confirm))
}
setCancelClickListener {
cor.resumeWith(Result.success(ConfirmDialogResult.Cancel))
}
show()
}

如此,通过调用此方法展示弹窗并阻塞协程,通过返回值确定弹窗关闭的具体原因。

我们的需求是:在首页中,判断首次安装App的用户是否阅读并同意过隐私内容,如果用户没同意过,会触发隐私弹窗展示,如果此时用户点击不同意,弹窗关闭后,虽然可以正常浏览首页,
但当用户试图进入二级页面(或其他类似操作)时,会再次弹出隐私弹窗让用户重新点选,如果同意则可以正常使用,否则等待下次触发。重复上述行为最多三次,最终不同意直接退出App。

这里我们发现,展示侧需要外部环境触发弹窗弹出,可以把外界的用户行为用一个PendingData表示,展示侧阻塞在PendingData上即可,对外暴露方法用于通知恢复协程。

1
2
3
4
5
6
lateinit var waitingUserTriggerPrivacyDialog: PendingData<Unit>

//当检测到目标用户行为后,调用此方法触发弹窗
fun triggerPrivacyDialog() {
waitingUserTriggerPrivacyDialog.notify(Unit)
}

之后依赖waitingUserTriggerPrivacyDialog做弹窗的三次重试。这里贴下简化的代码,线上代码可以在yqg_android_cn项目的HomeDialogHelper类中查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
lifecycleScope.launch {
//最大展示次数
val popMaxLimit = 3
//当前是第几次展示弹窗
var curTimes = 0
do {
//创建弹窗,根据当前展示的次数决定取消按钮的文案
val dialog = createPrivacyPolicyDialog(
cancelText = if (curTimes < popMaxLimit - 1) "不同意" else "退出 APP"
)
//展示弹窗并阻塞当前协程
val result = dialog.showAndAwait()
//用户点击了取消按钮关闭了弹窗
if (result == ConfirmDialogResult.Cancel) {
//最后一次还不同意就退出App
if (curTimes >= popMaxLimit - 1) {
exitProcess(0)
}
//否则先关闭弹窗,等下次再弹
dialog.dismiss()
curTimes++
//由于PendingData是一次性的,所以每次都要创建一个新的
waitingUserTriggerPrivacyDialog = pending()
//在此处阻塞协程,等待用户下一次触发弹窗
waitingUserTriggerPrivacyDialog.await()
} else {
//用户点了同意,可以直接关闭弹窗,退出while,进入后面的弹窗流程
dialog.dismiss()
break
}
} while (curTimes < popMaxLimit)
}

如此,通过一个while循环,即可像编写同步代码一样去处理弹窗流程了。当然,我们可以针对类似的过程进行封装,提供更加上层的工具用于类似场景。
但裸写看下来,代码也算清晰。核心的优势在于控制流的每一个细节都清晰的展现在眼前,没有花里胡哨的设计和弯弯绕的逻辑,就那么自然而然的,所见即所得。
简单的代码意味着更好维护和更少的错误,这在协程之前,是不敢想象的。

关于弹窗优先级/展示顺序的问题,在协程中被转化为了单纯的代码的先后顺序问题,也即先写的弹窗自然先弹出,使得弹窗的时序控制没有成本。

综上,使用协程来处理弹窗流程的整体思路就是

  1. 使用PendingData来包装弹窗展示需要的数据
  2. 在PendingData上阻塞协程等待数据获取成功
  3. 将弹窗的展示与消失封装为挂起函数,调用挂起函数展示弹窗并阻塞协程,直至弹窗消失恢复协程
  4. 重复上述流程处理后续弹窗

完整的首页弹窗代码可以在项目源码中查看,限于篇幅,这里不全部讲解了。

个人认为,首页弹窗代码,还是偏向于纯业务的代码,迭代相对更加频繁、剧烈。而且开发环境相对来说没有那么纯净,受限于系统平台和第三方库的接口,身不由己。
更多的考量点在于是否好读、好改、好维护。诚然,一套完美的封装可以让我们后续的迭代更加简单方便,
但我们无法预料需求迭代对现有业务模型的影响,减少一些封装换取更多的灵活性与拓展性是非常值得的。通过协程,脚本化作为面向过程的处理此类问题的新思路,抛砖引玉。

结语

本文介绍了kotlin协程背后Continuation的工作原理,以及我们的代码是如何一步一步转换为Continuation帮助我们对代码进行流程控制的。
之后通过一个例子,在单线程的环境下,帮助大家深入体会协程的执行过程,认清回调语法糖的本质。
最后结合业务,提出了一种协程挂起工具的封装方式,给出了协程下,连续弹窗问题的解决方案。

希望大家看完能够有所收获,谢谢!