浅谈Kotlin协程及首页弹窗中的应用
在上一篇协程文章中,我们了解到协程在网络请求中的一些特点和优势,Android官方也对针对协程
进行了大量的支持,为我们的开发提供了许多便利。
但有过java背景的大佬们可能不免还是会产生一些疑惑,认为协程是不是只是kotlin在JVM上对线程池和线程调度的一种封装呢?
甚至后来逐渐完善的Flow系列API,看起来就像是在走Rxjava的老路。
对于这样的问题,我认为这个说法对,但不全面。官方实现的「协程库(kotlinx.coroutines)」,是使用kotlin协程实现的「异步任务/线程池」管理库,
但不意味着,使用kotlin协程只能用来进行线程管理。有一种个人比较认可的说法是
Kotlin协程是一种回调语法糖
通过kotlin协程,可以将响应式的callback形式,转变为同步阻塞的await形式, 从而实现消除回调的效果。
甚至上述callback调用时机不必须在异步线程中,因为这只是一种对代码进行流程控制的能力。
在Kotlin协程之前,对于复杂的异步场景,安卓中我们一般会通过响应式的方式配合Monad来解决此类问题,或是对异步过程直接进行抽象,从而针对性的降低编码难度。
但如果协程世界可以直接转换掉异步调用,就可以大幅简化对异步场景的建模过程,直接了当地编写业务逻辑,拳拳到肉!
甚至可以在运行时控制代码的执行流程,为我们解决异步问题提供了新的思路。
在开始介绍业务实践之前,有必要带大家简要了解一下Kotlin协程是如何实现回调语法糖的,这有助于我们理解如何使用它。下面我将从一个例子入手,由回调过渡为协程。
假设我们有两个异步方法(具体实现省略):
1 | fun foo() |
为了能够让调用者获取异步过程的执行结果,为其增加回调,以便于可以在异步方法内部将结果通知出去:
1 | fun foo(callback:()->Unit) |
现在我们的目标是在foo执行结束后,再执行bar,并在最终输出 「done」。
可以使用嵌套的回调:
1 | class Action { |
这很容易理解,而且很多时候我们也一直是这么做的,但其实也有一些其他方式来完成这件事,比如:
1 | class Action { |
主体逻辑依然在invoke
中实现,这次foo
方法和bar
方法将回调替换成了直接调用Action
中的invoke
,
然后invoke内部根据当前的执行进度调用不同分支逻辑并更新执行进度,从而进行时序控制,就像状态机那样。
这个操作虽然也能实现上述目标,但写一个状态机可要比写嵌套的回调麻烦的多,且异步方法要与Action对象直接耦合,这在工程上是不切实际的。
更何况相比回调来说,这样的代码难以阅读,不太像是正常人会手写的代码。
那有没有一种可能,我是说如果,毕竟这份代码形式上还算是工整,咱就是说,这样的代码是不是可以通过编译器生成呢?
Continuation与协程
首先答案是肯定的,因为这就是kotlin实现协程的手段。因此,我们要搞懂的问题是:
- 什么样的代码会被编译成所谓的状态机,我应当如何启动它,当它执行结束后,又如何获取其执行结果?
- 在异步方法中,我该如何获取到这个状态机的实例,又如何让它继续执行后续的流程?
Continuation
一般来讲,Continuation 表示的是「剩余的计算」的概念,换句话说就是「接下来要执行的代码」,
对于我们的例子来说,当foo
和bar
两个方法执行完毕后,剩下的那句输出done
的代码,就是foo
和bar
的剩余计算
同理foo
的剩余计算是bar
+done
,整个action执行之前的剩余计算,就是foo
+bar
+done
了
在kotlin中,接口Continuation
即表示上述概念,定义为
1 | interface Continuation<in T> { |
其中resumeWith
方法是剩余计算的入口,其参数result
是剩余计算的初始参数,调用resumeWith
即可执行剩余计算。
我们的例子中没有涉及到参数传递,如果bar
依赖foo
的执行结果,则对于foo
来说,执行他的剩余计算时,就需要将其结果包装为result
传入resumeWith
,以供bar
或其他代码调用。
再回到我们的例子中来,我们应该可以意识到,整个Action
类就是一个Continuation
的实现。Action
的invoke
方法,可以充当剩余计算的入口点,
无论是初始状态下调用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 | //整个action会被编译为一个`Continuation`的实现类 |
相信大家应该可以把 suspend lambda版的action,与手写的Action类对应起来,
首先整段lambda会编译为一个Continuation
的实现类(设为C0
)C0
将invokeSuspend
方法实现为一个状态机,对标Action
的invoke
方法
每一句suspend方法调用(如foo、bar),都对应了状态机的一个分支,状态转移的操作也按照代码的先后顺序一并生成,
每次调用Continuation
的resumeWith
,便会像调用Action
的invoke
那样推进整个代码的执行进度。
如此来看,如果可以在 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 | suspend fun baz() { |
在baz
所编译出的Continuation
(C2)中,suspendCoroutine
也会作为挂起点,被编译为状态机的一个分支,与自定义的suspend function行为相同。
C2由三个主要的部分组成,即输出「start」、suspendCoroutine、输出「end」。
其中suspendCoroutine那部分指的是位置1
处lambda的工作,包括开启新线程、sleep1秒以及调用c2
的resume
(resumeWith)方法。
大家可以看到,这个c2
是一个Continuation
类型的对象,它所代表的,就是由baz
所生成的Continuation
(C2)的实例。
也就是说,通过suspendCoroutine
,可以拿到当前层级(当前方法)的Continuation
实例。
同时suspendCoroutine
作为挂起点被编译为独立分支,这样我们既可以阻断后续代码的执行,又具备了推进到下一个分支的能力。
有了C2
的实例以后,什么时候调用resume,就什么时候执行下一个分支。
从我们编写的代码的表面来看,在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 | suspend val action = suspend { |
首先lambdaaction
对应了一个Continuation
C0,在执行C0的过程中,遇到了foo
分支,foo
分支调用了foo
方法,并把C0
自己传了进去。
foo方法中创建了另外一个Continuation
C1,并执行到了异步操作的分支部分。此时在异步操作结束之前,C1便执行完了这个分支,
C0目前的分支也在触发了C1之后便执行完了,万籁俱寂,JVM看了都想杀进程。
终于,foo方法内的异步任务完毕,C1
的resume
方法被调用,C1
又重新启动,它跳过了那些已经执行过的部分,很顺利的同步执行完最后一行,
于是C1
发起了C0
的resume
,
使得C0
又重新执行,C0
被推进到下一个分支, 代码从bar
处得以恢复。后面的故事,大家应该都清楚了。
需要声明的是,如果suspend方法中没有挂起操作的话,是不会生成Continuation
的,尾调用的情况也会有单独的优化;
suspend关键字除了标记Continuation的传递,还涉及到挂起标识的返回和语法上的约束;
真实的经过编译的Continuation与例子中的Action还是有很大的差别,但那些没有提到的细节,都是为了更加完善的实现上述理想模型,
不影响我们对协程运作方式的认知,关于CPS有机会为大家深入聊聊kotlin编译器中的那些魔法。
我们一直没有提到的CoroutineContext,是通过函数式列表实现的一个依赖管理工具,借助于Continuation的传递机制以实现数据共享与注入,被协程库用于线程调度与任务管理
最后,让我们来启动协程世界。
1 | val action = suspend { |
小试牛刀
至此,我猜聪明的你,已经完全明白协程是怎样运作的了,甚至迫不及待的想要一展身手!正巧这里有一个小练习,加深一下我们的理解。
假设我们有两个Optional
首先我们知道Optional.flatMap可以很方便的实现这个需求
1 | fun plus(o1: Optional<Int>, o2: Optional<Int>): Optional<Int> = |
但美中不足的是,这里出现了嵌套的回调。为了能够消除回调嵌套,希望能够通过协程把回调拉平,效果如下
1 | fun plus(o1: Optional<Int>, o2: Optional<Int>): Optional<Int> = |
解释一下,需要大家实现的是dsl
、bind
两个方法,执行结果需要满足题目要求,bind
要实现为挂起函数。
下面为解析部分,大佬们可以稍作思考,然后回来对答案。
首先我们推导一下方法定义,dsl方法接收lambda参数,返回Optional
bind方法接收Optional
1 | fun dsl(action: suspend () -> Int): Optional<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 | suspend fun bind(o: Optional<Int>) = |
在dsl方法中启动协程,参数lambda可直接作为协程体
1 | fun dsl(action: suspend () -> Int): Optional<Int> { |
我们知道,启动协程,需要调用startCoroutine,传入一个最终执行的completion,整体调用链为
startCoroutine->执行action->执行completion
但有可能在执行action的过程中被挂起,没能恢复的话,就是
startCoroutine->执行action
但无论是哪一种情况,由于我们是单线程,上面的流程会随着startCoroutine的返回而执行完毕。
对应到代码,在位置0之后,位置2之前,便是整个协程工作的代码区域,利用这一点,可以在return时(问号处)决定具体dsl的返回值究竟为何
1 | fun dsl(action: suspend () -> Int): Optional<Int> { |
这便是基于本篇的知识点得出的一个解法了。
回到到协程本身,他为我们呈现的,就是一种可主动操控的、用于打断和恢复代码执行的流程控制工具。
他们的背后,是一个个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
,对外声明为泛型。
对标Condition
的await
,提供挂起函数await
用于阻塞当前协程,对标singal
提供notify
用于唤醒阻塞的协程。
整体类定义如下
1 | class PendingData<T : Any?> { |
每一个弹窗数据对应一个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 | class PendingData<T : Any> { |
await实现如下:
1 | suspend fun await(): T? { |
这里需要注意的细节就是,当展示侧调用await时,数据侧有可能已经通过notify将数据存放在data
了, 所以此时直接返回data
,否则挂起协,并将当前Continuation存储为成员变量。
notify的实现思路就是拿着数据去恢复协程
1 | fun notify(data: T) { |
重复的notify是被忽略的,严格来讲可以抛异常,但这无伤大雅。需要考虑的细节还是当展示侧调用await之前,数据侧有可能开始notify了,此时continuation
为空,
这种情况下直接return即可,await中可以处理这种情况。
最后要说明的是,我们只考虑了在单一协程中使用PendingData
的情况,如果同时挂起多个协程的话,只有最后一个协程可以顺利恢复。计划会在后面的版本完善。
这样一个简易的挂起工具就做好了,完整代码可以在项目中查看。为了让大家理解他的工作方式,用一个小demo验证下:
1 | class MyActivity : AppCompatActivity() { |
假设页面中一共有四个按钮,在①处通过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 | lifecycleScope.launch { |
可以看到我们在在业务层面就可以简单灵活的处理此类问题。
当然,CoroutineScope不是必须的,我们可以直接像上文小练习中那样裸用协程。但为了安全和方便,这里依赖携程库,使用Android提供的LifecycleScope,
他们都是可以顺利兼容的。如此,通过PendingData
,在展示侧我们就可以获得对数据流的时序控制能力。在解决了这个大问题之后,后面的工作就只剩封装异调用了。
使用协程封装异步调用
一般的,对于简单弹窗,只关心弹窗的关闭时机的话,可以封装一个普适的工具方法
1 | suspend fun Dialog.showAndAwaitOnDismiss() = suspendCoroutine<Unit> { cor -> |
通过挂起的拓展方法showAndAwaitOnDismiss
代替原本的show
方法,使得弹窗弹出后,可以立即挂起当前协程,直到弹窗消失后恢复。
对于复杂弹窗行为,如我们需要根据用户点击的不同按钮决定后续的弹窗流程,可以将不同的点击行为封装为不同的类,作为挂起方法的返回值,根据不同返回值来编写不同逻辑。
以隐私弹窗的三次重试为例
在洋钱罐理财中,用于展示隐私说明的弹窗由ConfirmDialog
类实现。其提供了两个方法用于监听同意隐私协议/不同意隐私协议
1 | public class ConfirmDialog extends AlertDialog { |
为了方便在协程中使用他们,封装一个密封类,将两个行为统一为同一种类型的对象
1 | sealed class ConfirmDialogResult { |
之后就可以使用挂起函数封装弹窗的展示与消失
1 | private suspend fun ConfirmDialog.showAndAwait() = suspendCoroutine<ConfirmDialogResult> { cor -> |
如此,通过调用此方法展示弹窗并阻塞协程,通过返回值确定弹窗关闭的具体原因。
我们的需求是:在首页中,判断首次安装App的用户是否阅读并同意过隐私内容,如果用户没同意过,会触发隐私弹窗展示,如果此时用户点击不同意,弹窗关闭后,虽然可以正常浏览首页,
但当用户试图进入二级页面(或其他类似操作)时,会再次弹出隐私弹窗让用户重新点选,如果同意则可以正常使用,否则等待下次触发。重复上述行为最多三次,最终不同意直接退出App。
这里我们发现,展示侧需要外部环境触发弹窗弹出,可以把外界的用户行为用一个PendingData表示,展示侧阻塞在PendingData上即可,对外暴露方法用于通知恢复协程。
1 | lateinit var waitingUserTriggerPrivacyDialog: PendingData<Unit> |
之后依赖waitingUserTriggerPrivacyDialog
做弹窗的三次重试。这里贴下简化的代码,线上代码可以在yqg_android_cn
项目的HomeDialogHelper
类中查看
1 | lifecycleScope.launch { |
如此,通过一个while循环,即可像编写同步代码一样去处理弹窗流程了。当然,我们可以针对类似的过程进行封装,提供更加上层的工具用于类似场景。
但裸写看下来,代码也算清晰。核心的优势在于控制流的每一个细节都清晰的展现在眼前,没有花里胡哨的设计和弯弯绕的逻辑,就那么自然而然的,所见即所得。
简单的代码意味着更好维护和更少的错误,这在协程之前,是不敢想象的。
关于弹窗优先级/展示顺序的问题,在协程中被转化为了单纯的代码的先后顺序问题,也即先写的弹窗自然先弹出,使得弹窗的时序控制没有成本。
综上,使用协程来处理弹窗流程的整体思路就是
- 使用PendingData来包装弹窗展示需要的数据
- 在PendingData上阻塞协程等待数据获取成功
- 将弹窗的展示与消失封装为挂起函数,调用挂起函数展示弹窗并阻塞协程,直至弹窗消失恢复协程
- 重复上述流程处理后续弹窗
完整的首页弹窗代码可以在项目源码中查看,限于篇幅,这里不全部讲解了。
个人认为,首页弹窗代码,还是偏向于纯业务的代码,迭代相对更加频繁、剧烈。而且开发环境相对来说没有那么纯净,受限于系统平台和第三方库的接口,身不由己。
更多的考量点在于是否好读、好改、好维护。诚然,一套完美的封装可以让我们后续的迭代更加简单方便,
但我们无法预料需求迭代对现有业务模型的影响,减少一些封装换取更多的灵活性与拓展性是非常值得的。通过协程,脚本化作为面向过程的处理此类问题的新思路,抛砖引玉。
结语
本文介绍了kotlin协程背后Continuation的工作原理,以及我们的代码是如何一步一步转换为Continuation帮助我们对代码进行流程控制的。
之后通过一个例子,在单线程的环境下,帮助大家深入体会协程的执行过程,认清回调语法糖的本质。
最后结合业务,提出了一种协程挂起工具的封装方式,给出了协程下,连续弹窗问题的解决方案。
希望大家看完能够有所收获,谢谢!