
前面十三篇文章按模块拆开讲了 KuiklyUI 的主要机制:Pager、响应式、布局、指令、Bridge、Render、协程、Compose 接入、工具链、Module 和动画。模块拆开之后,每个机制都能看清楚,但读者心里还会留下一个问题:这些机制在一次真实交互里怎么串起来。
这一篇把视角拉回到一段最普通的业务代码。页面上有一个按钮,点击后
count++,Text 从Count: 0变成Count: 1。这个动作看起来很小,底层却穿过了 Render、Bridge、Pager、事件系统、响应式系统、属性同步、布局测量和平台重绘。
先看一段简化后的页面代码:
class HomePager : Pager() {
var count by observable(0)
override fun body(): ViewBuilder = {
Text {
attr {
text("Count: ${ctx.count}")
}
}
Button {
attr {
title("Add")
}
event {
click {
ctx.count++
}
}
}
}
}页面创建阶段,body() 在 Kotlin 侧构建内存 view 树。Text 的 attr 块读取了 ctx.count,响应式系统记录了依赖。Button 的 event 块注册了 click 事件,Core 会把这件事下发到 Render,让平台原生 View 监听点击。
页面首帧完成后,Native 侧已经有真实按钮和文本。接下来点击发生,链路开始反向流动。
把这段代码放进运行时里,大致会形成下面这条闭环:事件先从 Render 回到 Kotlin Core,状态变化后再把更新结果同步回 Render。后面的章节就按这张图的顺序展开。

点击最先发生在平台原生 UI 系统里。Android 侧会先经过 touch listener 和手势识别,再识别成 click;iOS 可以接 target-action,鸿蒙接 ArkUI 事件,Web 接 DOM event。不同平台入口不一样,但都会先落在 Render 层。
这些平台事件不会直接执行业务 Kotlin 代码。Render 在创建 Button 时已经收到过一条事件注册指令:
SET_VIEW_PROP
arg0 = pagerId
arg1 = buttonTag
arg2 = "click"
arg3 = 1
arg4 = isEvent = 1
arg5 = sync这里的 isEvent 表示这不是普通属性,而是事件订阅。Core 并没有把 Kotlin 闭包直接传给 Render,业务闭包仍然留在 Kotlin 侧的事件表里。Render 看到 isEvent = 1 后,会在平台侧包装出一个事件 callback,并把它注册到原生事件系统上。
点击发生时,Render 取到 pagerId、viewTag、eventName 和事件参数,通过上一篇 Bridge 文章里的 FIRE_VIEW_EVENT 往 Kotlin 侧回传,最终进入 BridgeManager.callKotlinMethod。
到这里,事件已经从平台 UI 系统穿过 Bridge 回到了 Core。
BridgeManager 收到 FIRE_VIEW_EVENT 后,先根据 pagerId 找到对应的 Pager。Pager 内部维护了 view 索引,能够用 buttonTag 找回当初创建的 Button View。
pagerId → Pager
buttonTag → Button View
eventName → click handler随后事件系统触发业务代码里的闭包:
click {
ctx.count++
}这一步看起来只是一次普通 Kotlin 函数调用,但它连接了两侧:事件在 Native 侧发生,业务逻辑在 Kotlin 侧执行。Bridge 和 Pager 的职责就是把这两侧按 pagerId 和 viewTag 对齐。
ctx.count++ 会被 Kotlin 委托属性拦截。observable 的写入最终进入 setValue,它会比较新旧值,如果值发生变化,就通知 ReactiveObserver。
响应式系统手里有一张依赖表。Text 的 attr 块在页面创建阶段读过 count,所以这张表里已经记录了:count 变化时,需要重新执行 Text 的那段 attr 闭包。于是 setValue 通知 ReactiveObserver,ReactiveObserver 从依赖表里找到那段 attr 块并重新执行,text 被重新计算为 Count: 1。
这里不会重新执行整个 body(),也不会重建整棵 view 树。KuiklyUI 的更新粒度收在 attr 块级别。对于一次文本更新,最终只需要重新计算 Text 的 text 属性。
这也是响应式系统和 Bridge 成本之间的配合:变化范围越小,下发到 Native 的指令越少。
Text 的 attr 重新执行后,text("Count: 1") 会进入属性系统。属性系统先比较旧值和新值。如果旧值是 Count: 0,新值是 Count: 1,这次变化就需要同步到 Render。
如果变化的是普通 View 属性,比如 opacity、backgroundColor、transform,路径会比较直接:Attr.setProp 更新本地属性缓存后,经由 RenderView.setProp 发出 SET_VIEW_PROP。
Text 稍微特殊一点。text、fontSize、textColor 这类属性会先进入 TextShadow,因为文本需要参与测量。也就是说,text("Count: 1") 不只是一个普通属性更新,它还可能让 Text 的测量结果变化。源码里这条路径会把属性写到 shadow,并把对应的 FlexNode 标脏。
这次方向和点击回传相反:从 Kotlin 到 Native。简化后可以看成:
Text.attr 重新执行
↓
Attr.setProp("text", "Count: 1")
↓
TextView.didSetProp
↓
TextShadow.setProp + FlexNode 标脏
↓
Bridge 同步 shadow / 布局 / 渲染属性所以一次点击里 Bridge 至少会穿两次:第一次是事件上行,第二次是状态变化之后的属性或 shadow 同步下行。
Render 收到下行指令后,会根据 textTag 找到对应的原生组件。普通属性会走组件的 setProp,Text 这类需要测量的组件还会结合 shadow 数据一起处理。不同平台实现不同,但目标一致:把 Kotlin 侧算出的文本、样式和布局结果同步到真实 UI 对象上。
Android 侧最终会落到 Render 文本组件和它的 shadow,iOS 侧会落到对应的文本控件和测量对象,Web 侧会修改 DOM 或内部节点数据,小程序则会更新 view tree 数据并在合适时机 setData。
这个过程不需要 Core 知道平台细节。Core 只关心 tag、属性名、shadow 数据和 frame,Render 负责把这些统一信息翻译成平台 API。
一次点击不一定触发布局。比如 Text 设置了固定宽高,只是内容从 Count: 0 变成 Count: 1,最终尺寸可能没有变化。这个时候平台 UI 系统完成文本重绘即可。
但如果 Text 的宽度依赖内容,比如没有固定宽度,文字变长后就可能影响测量结果。这个时候链路会继续进入布局系统:TextShadow 先根据约束计算尺寸,必要时触发 CALCULATE_RENDER_VIEW_SIZE,FlexLayout 据此重新计算 frame,最后通过 SET_RENDER_VIEW_FRAME 下发。
这就是前面布局文章讲过的同步测量和 frame 下发。一次状态变化是否进入布局系统,不由响应式系统决定,而由变化的属性、shadow 测量结果和 FlexNode 脏标记共同决定。
如果点击里不是 count++,而是 items.add(newItem),链路前半段一样:Render 捕获点击,Bridge 回传,Pager 找到 View,执行 click handler。差别从响应式更新开始。
ObservableList 会记录本轮集合操作,vfor 或 vforLazy 收到变化后,不是重新执行某个普通 attr,而是增量修改 view 树:创建新 item、插入到对应位置、必要时移除旧 item。随后下发的就不止 SET_VIEW_PROP,还会有 CREATE_RENDER_VIEW、INSERT_SUB_RENDER_VIEW、REMOVE_RENDER_VIEW 和布局 frame 更新。
如果点击里启动动画,前半段也一样。差别在属性下发前后会包一段 animation 属性:先告诉 Native 接下来的属性变化要走某个动画,再下发目标值,最后清掉动画上下文。动画本身由 Native 执行,Kotlin 不逐帧插值。
所以点击、列表、动画不是三套独立系统。它们共享同一条主链,只是在响应式之后走向不同的更新策略。
把一次点击串起来后,可以看到 KuiklyUI 的几个设计重心。
业务代码始终在 Kotlin 共享层执行。事件从 Native 回传到 Kotlin 后,状态变化和更新决策都在 Core 内完成,Render 不参与业务逻辑判断。
Bridge 只传必要信息。事件上行走 viewTag + eventName,属性下行走 tag + key + value,文本这类需要测量的内容再补一条 shadow 通道。它不传整棵 view 树,也不让 Render 知道业务状态。
Render 只负责平台落地。它知道怎么把属性、shadow 和 frame 变成平台文本、DOM 节点或小程序数据,但不知道 count 是什么、业务为什么要更新。
布局、指令和动画也不是三套独立系统。它们接在同一条更新主链上,只是被不同属性和不同数据结构变化所触发。
一次 ctx.count++ 背后,KuiklyUI 走过了完整的运行时链路:Render 捕获原生事件,通过 Bridge 回到 Core;Pager 根据 tag 找到 View 并执行业务事件;observable 写入触发响应式系统;相关 attr 块重新执行;变化后的属性、shadow 或 frame 再通过 Bridge 同步给 Render;Render 最后把统一信息应用到平台原生 View 上。
这条链路把前面的文章串了起来。Pager 负责实例和 view 树,响应式系统负责找到更新范围,Bridge 负责跨运行时通信,Render 负责平台落地,布局和动画则根据属性变化按需介入。
回头看整个系列,KuiklyUI 的核心取舍其实很清楚:Kotlin 共享层负责业务状态、更新决策和 view 树组织;Bridge 保持固定、克制的跨运行时协议;Render 负责平台事件、原生组件和最终绘制。一次点击之所以能串起这么多模块,是因为这些模块本来就围绕同一条运行时主链设计。
理解这条主链之后,再看列表更新、Module 调用、Compose 接入和动画,就不再是散开的机制,而是同一套运行时在不同场景下的分支。到这里,主系列也完成了从分层拆解到端到端闭环的收束。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。