
前几篇文章一直在看 UI 主链路:页面创建、响应式更新、布局计算、指令系统、Bridge 通信和 Render 落地。这些机制解决的是界面怎么创建、怎么更新、怎么显示。
真实业务里还有另一类问题:异步。
先简单说一下协程。协程不是线程,也不是自动把代码丢到后台执行。它更像一种把异步流程写成顺序代码的语法和运行时机制:执行到需要等待的地方先挂起,把当前进度保存下来;等网络回包、定时器触发或回调返回时,再从挂起的位置继续往下跑。
对于习惯了前端的同学,可以先把它类比成 async/await。await fetchUser() 看起来像一行同步代码,但浏览器不会因此阻塞整个页面。Kotlin 协程也是类似思路,只是它在语言和运行时层面把“挂起”和“恢复”做得更系统。
回到 KuiklyUI,网络请求要等回包,定时器要等下一次触发,Module 调用可能异步返回,页面销毁时还要避免回调打到已经不存在的 Pager。Kotlin 生态已经有 kotlinx.coroutines,但 KuiklyUI 在 Core 里仍然实现了一套轻量协程能力。
这一篇就看这套协程为什么存在,以及它和标准 Kotlin 协程有什么不同。
KuiklyUI 的协程实现规模不大。核心文件集中在 core/src/commonMain/.../coroutines/ 下。如果不熟 Kotlin 协程里的那些类型名,可以先只记住四类能力:启动一段异步逻辑、等待一个异步结果、延迟一段时间、把 callback 包成可以等待的函数。
它没有标准协程里复杂的线程调度、异步流、通道和锁,也没有严格管理父子协程关系的完整机制。这说明它的目标不是复刻 kotlinx.coroutines,而是解决 KuiklyUI 业务里常见的一类问题:把异步回调写成顺序代码,同时和 Pager 生命周期、Bridge 回调、Native 定时器配合。
放到 KuiklyUI 里,可以先看一个最小例子:
lifecycleScope.launch {
val user = requestUser()
delay(1000)
updateUI(user)
}这段代码看起来是顺序执行,但 requestUser() 和 delay(1000) 都不会阻塞 Pager 线程。协程在这些点挂起,等 Module 回调或定时器回来再继续。
理解 KuiklyUI 协程,需要先看清它没有标准协程那套调度器。所有业务代码仍然在 Pager 线程执行。这里的 Pager 线程,可以先理解成 KuiklyUI 页面自己的执行线程。

delay 的实现不是 Thread.sleep,也不是切到另一个线程,而是通过 Bridge 注册一个 Native 定时器。Kotlin 侧会把当前协程的“恢复点”保存起来;Native 定时器触发后,再通知 Kotlin 从这个恢复点继续执行。源码里这个恢复点叫 continuation,读这篇文章不需要展开它的完整机制。
简化后的流程是:
launch 启动协程
↓
执行到 delay(1000)
↓
保存当前恢复点,并注册 Native 定时器
↓
协程挂起,Pager 线程继续处理其他任务
↓
1000ms 后 Native 回调
↓
恢复协程,从 delay 后继续执行这对应 JavaScript 事件循环里的 await new Promise(resolve => setTimeout(resolve, 1000))。单线程没有阻塞,但控制流被拆成了两段。
这个模型的好处是简单。业务代码不需要关心线程切换。KuiklyUI 的响应式字段和 view 树本来就要求在框架线程里访问,协程保持单线程,反而减少了线程安全问题。
代价也明确:协程里不能跑 CPU 密集任务。大计算会卡住 Pager 线程,影响 UI 更新。需要后台线程的任务,仍然应该通过 Module 交给 Native 侧处理。
协程挂起之后,最常见的问题不是怎么继续,而是什么时候不应该继续。比如页面发起了网络请求,用户马上返回上一页;或者页面启动了一个轮询任务,业务状态变化后不再需要它。这个时候,如果回调晚一点才回来,还继续更新 UI,就很容易打到已经销毁的 Pager。
KuiklyUI 用 Job 表示一段正在运行的协程。业务可以把它理解成协程句柄,需要停掉时调用 cancel()。取消要解决的不是一个抽象 API 问题,而是两个实际问题:已经注册出去的 Native 定时器或 callback 要失效,正在等待的协程也要结束。
所以取消时会先清掉外部引用,再让等待中的协程以已取消的结果退出。这样即使 Native 后面真的触发回调,Kotlin 侧也不会再找到对应处理函数;业务写在 finally 里的收尾逻辑,也有机会执行。
这个顺序很重要。先清外部资源,再结束协程,最后通知外部观察者,可以避免同一个定时器重复恢复,也能避免观察者看到半清理状态。
launch 更适合做一段不关心返回值的异步逻辑,比如延迟后刷新状态。async 更适合有结果的任务,比如进入页面后先请求一份配置,后面多个区域都要用这份配置。
val deferred = lifecycleScope.async { requestConfig() }
lifecycleScope.launch {
val config = deferred.await()
renderA(config)
}
lifecycleScope.launch {
val config = deferred.await()
renderB(config)
}这段代码的重点不是 Deferred<T> 这个类型名,而是请求只启动一次,两个地方都等同一个结果。结果已经回来时,await() 直接拿;结果还没回来时,当前协程先挂起,等结果回来后继续。
这个能力适合配置加载、用户信息复用、多个 UI 区块依赖同一份异步数据这类场景。需要注意的是,KuiklyUI 不会完整维护标准库里的父子协程规则。业务如果启动长期任务,需要保留 Job 引用,或者把任务放在 lifecycleScope 里和 Pager 生命周期绑定。
KuiklyUI 里大量原生能力都是 callback 形态。网络请求完成后回调,View 方法调用完成后回调,Native 订阅也可能多次回调。如果业务一直写 callback,逻辑很快会散:请求成功后更新状态,失败后处理错误,页面销毁后还要判断回调是否有效。
协程的价值,是把这类 callback 包成可以等待的函数。这里的 suspend 可以先理解成这个函数能在协程里等待结果。
suspend fun requestUser(): JSONObject {
return lifecycleScope.suspendCancellableCoroutine { cont, onCancel ->
var canceled = false
onCancel { canceled = true }
network.request("/user") { data ->
if (canceled) return@request
cont.resume(data)
}
}
}这段代码里,cont 可以理解成让协程继续往下跑的开关,onCancel 是取消时的清理入口。Module 回调回来后,调用 cont.resume(data),上层业务就能像写同步代码一样继续处理 data;如果协程已经取消,回调回来也不会再更新 UI。
封装之后,页面里就不需要继续嵌套 callback:
lifecycleScope.launch {
loading = true
val user = requestUser()
userName = user.optString("name")
loading = false
}这段业务代码读起来就是加载用户、更新状态、结束 loading。callback 仍然来自 Native,但业务层可以用顺序代码组织它;取消发生时,也能挡住已经没有意义的回调。
实际开发时,我们通常不需要额外创建 LifecycleScope。每个 KuiklyUI Pager 都自带一个懒创建的 lifecycleScope,页面里发起网络请求、延迟任务、Module 调用时,直接用它启动协程就够了。
lifecycleScope.launch {
val user = requestUser()
delay(300)
showUser(user)
}它的作用不是提供一套完整的生命周期协程管理,而是把这些异步任务登记到当前 Pager 名下。页面还在,回调回来就继续执行;页面销毁了,相关回调会被清掉,避免异步结果打到旧页面。
如果是轮询这类长期任务,最好自己保留 Job,在不需要时主动取消:
private var refreshJob: Job? = null
fun startRefresh() {
refreshJob = lifecycleScope.launch {
while (true) {
refreshData()
delay(5000)
}
}
}
fun stopRefresh() {
refreshJob?.cancel()
refreshJob = null
}这里有一个容易踩的坑。显式调用 job.cancel() 时,协程会主动结束等待点,finally 有机会执行。但如果只是 Pager 销毁导致页面回调被整批清理,某些挂起中的协程可能永远不会再恢复,finally 不一定执行。
所以页面级资源不要完全依赖协程 finally 清理。和页面强相关的资源,更稳妥的做法是放在 Pager 的销毁钩子里处理。协程适合管理异步控制流,不适合承担所有生命周期清理责任。
协程让异步代码看起来像顺序代码,但异常处理不能只看表面。业务在协程里抛异常时,如果这个异常只停留在协程结果里,框架统一的异常上报不一定能感知到。
KuiklyUI 的处理方式比较工程化:如果框架开启了异常捕获,协程代码块里的异常会被放到下一帧再抛。这样它会进入普通 callback 的执行路径,最终走到框架原有的异常上报逻辑。
业务里可以按两类错误处理。预期内的失败,比如接口返回错误、登录态失效,适合自己 try/catch 后更新页面状态:
lifecycleScope.launch {
try {
val user = requestUser()
showUser(user)
} catch (e: Throwable) {
showError("加载失败")
}
}预期外的异常,比如解析数据时写错字段、业务代码里抛出运行时错误,就交给框架统一上报。这个设计不是为了补齐完整的协程异常体系,而是解决实际问题:业务异步代码出错后,框架仍然能收敛到统一的异常上报,而不是让错误静默地变成一个协程结果。
回到前面的问题:为什么不用完整的 kotlinx.coroutines。
一方面是体积和跨端一致性。KuiklyUI 面向动态化、多端编译和不同运行时,完整协程库带来的依赖体积、平台差异和调度行为都不是免费的。
另一方面是需求本身很收敛。KuiklyUI 不需要在线程池之间切来切去,也不需要完整异步流能力。它需要的是启动异步逻辑、延迟、等待结果、callback 转 suspend、取消和 Pager 生命周期绑定。
自研轻量实现的收益是可控:代码少、行为简单、和 Bridge 回调、页面生命周期贴得很近。代价是能力边界窄,业务不能把它当成完整 kotlinx 使用。
这个取舍和 KuiklyUI 其他部分保持一致:不是追求通用库的完整性,而是围绕跨端 UI 运行时需要的那部分能力做收缩实现。
KuiklyUI 的协程不是多线程框架,而是一套单线程控制流抽象。它借用 Kotlin 协程的挂起和恢复能力,把 Bridge 回调、Native 定时器和 Module 异步结果组织成顺序代码。
它的调度靠 Native 定时器和 callback 恢复,不提供复杂线程调度,也不完整维护父子协程关系。这样做的收益是体积小、行为直接、线程安全问题少;代价是能力边界明确,CPU 密集任务、复杂并发控制、严格父子协程管理都不适合放在这里。
从框架设计看,这套轻量协程是 KuiklyUI 一贯取舍的延续:把能力做在够用的地方,把复杂度留在确实需要的边界上。
下一篇会切到另一条 DSL 路径,看标准 Compose 是怎么通过 Applier 和 KNode 接到 KuiklyUI view 树上的。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。