
上一篇文章讲了 KuiklyUI 的布局引擎 FlexNode 怎么根据样式约束算坐标和尺寸,Text 怎么通过 Bridge 拿到 Native 侧的真实文字尺寸。那篇讨论的是静态 view 树:所有节点的位置和大小都确定之后,布局算法一次跑完。
真实页面很少是静态的。列表里加一条数据,view 树里要多一行 item;某个条件从 false 变成 true,对应的 UI 区域要出现;一个值从浅色切成深色,整段布局都要换掉。这些变化发生之后,就需要修改已经建好的 view 树:往里面插入节点、移除节点、甚至把整棵子树拆了重建。
但 KuiklyUI 的 view 树不在浏览器里,也不在 Native 侧。它在 Kotlin 共享层的内存中。改完树之后,变更还要通过 Bridge 翻译成指令下发给 Native。监听数据变化、修改 Kotlin 侧的 view 树、把结果同步到 Native——这三件事就是指令系统要解决的问题。
第四篇响应式系统讲过:observable 变化后,ReactiveObserver 会找到相关的 attr 块并重新执行。指令系统接在这套通知机制后面,只是它处理的不再是某个普通属性,而是一段 view 子树的增删、切换或重建。
这一篇从指令系统的公共基础开始,然后分别看 vfor 怎么做增量列表更新、vif 怎么把 if-else 链串成一组、vbind 怎么在值变化时重建子树,最后再看 vforLazy 怎么处理大列表。
vfor、vif、vbind 做的事,在 UI 框架里有一个更通用的名字:数据驱动 UI 更新。每个框架都要回答同一个问题:数据变了,UI 怎么跟着变。
先看其他框架怎么做的:
notifyItemInserted/notifyItemRemoved,iOS UITableView 用 insertRows/deleteRows。业务代码显式告诉框架第几个 item 做了什么,控制细但手动管理成本高。KuiklyUI 如果照搬原生命令式,容易把更新逻辑拆到不同平台里,和跨端共享冲突。如果沿用 Virtual DOM 式的全局重建和 diff,又需要在 Kotlin 内存树和 Native Render 树之间维护更重的对账过程。
所以 KuiklyUI 选了第三条路:指令直接监听数据变化,只操作受影响的子树,不做全局 diff。后面几类指令的差别,也都落在三个问题上:改哪棵子树、改多少、什么时候同步到 Native。先放一张小地图:
指令 | 触发来源 | view 树动作 |
|---|---|---|
|
| 增量插入或删除子节点 |
| 条件变化 | 切换一组候选子树 |
| 绑定值变化 | 整棵子树重建 |
| 列表数据和滚动位置 | 维护可视窗口附近节点 |
有了这张图,再看两个公共底座。后面的 vfor、vif、vbind、vforLazy 虽然更新策略不同,但都依赖这两件事。
第一个是 VirtualView。指令节点只存在于 Kotlin view 树里,对 Native 透明。比如 vfor 自己不会变成一个 Native View,它只是负责根据列表数据创建、插入或移除真正的子 View。这样做的好处是 Native 视图层级不会因为嵌套了很多指令而变深,最终下发到 Native 的仍然是业务真正需要显示的节点。
这也带来一个代价:Kotlin 侧看到的是带指令节点的逻辑树,Native 侧看到的是穿透指令后的真实树。后续做插入、删除和下发时,不能直接用原始 children,而要先把这些逻辑节点穿透掉,得到真正会出现在 Native 侧的子节点列表。源码里承担这类工作的,就是后面会提到的 domChildren() 和 renderChildren()。
第二个是 延迟任务。第四篇讲 attr 更新时提到过,ReactiveObserver 会在执行闭包时收集 observable 依赖。指令也复用这套机制监听数据变化。区别在于,指令收到通知后要改的是 view 树:创建子树、删除子树、插入列表项。如果这个时候立刻动树,新建子树里的 attr 又可能继续读取 observable,容易把一次依赖收集嵌套到另一次依赖收集里。KuiklyUI 的做法是先记下要做的树操作,等当前依赖收集结束后再执行。这样指令既能响应数据变化,又不会打乱响应式系统自己的收集边界。
所以,VirtualView 解决的是“指令节点要不要出现在 Native 树里”的问题;延迟任务解决的是“什么时候安全地修改 Kotlin view 树”的问题。后面几类指令的差异,都是建立在这两个底座之上的。
vfor 是使用频率最高的指令。它监听一个 ObservableList,列表变化后增量同步到 view 子树。
业务代码和 Vue 几乎一致:
var items by observableList<String>()
override fun body(): ViewBuilder = {
List {
vfor({ items }) { item ->
Text {
attr { text(item) }
}
}
}
}items.add("hello") 触发后,ObservableList 不会让整棵列表重新创建,而是先生成一条列表操作记录。源码里这类记录叫 CollectionOperation,里面会标出这次是 add 还是 remove、从哪个 index 开始、影响多少个元素。vfor 对应的内部节点叫 LoopDirectivesView,它收到这条记录后,再决定怎么修改自己的子树。整个增量更新链路如下:

这条链路里最重要的判断只有一个:列表对象有没有换。
如果只是对同一个 ObservableList 做 add/remove,vfor 可以拿到这次变更的操作记录,只创建或删除变化的 item。比如追加一条数据,就只创建这一条对应的子 View,再把它插到正确位置。
如果业务直接把整个 list 换成一个新对象,框架就拿不到细粒度的增删记录,只能把旧子树全部移除,再按新列表全部创建一遍。这就是全量重建。
真正插入一个新 item 时,内部只做三件事:创建子 View、放到 Kotlin 侧 children 的正确位置、把插入动作同步到 Native。因为 vfor 本身是 VirtualView,不会出现在 Native 侧,计算插入位置时还要先穿透这些逻辑节点。源码里 domChildren() 负责得到真实可插入的节点顺序,insertDomSubView 再把这个插入动作转成 Bridge 指令。
这也解释了 vfor 的两条限制。第一,itemCreator 必须且只能创建一个子节点,因为列表里的一个元素要对应一个子 View。第二,这个子节点不能继续嵌套 vfor 或 vif,否则真实插入位置会变得不确定。
业务侧最容易踩的坑,是用 ctx.list = newList 做整列表替换。这会触发全量销毁和全量重建。如果只是少量追加或删除,直接操作同一个 ObservableList 更合适;如果拿到的是一份新的列表数据,更适合用 diffUpdate() 先算出差异,再转成 add/remove 操作。
前面讲了指令系统的公共底座:VirtualView 和延迟任务。vfor 主要处理列表增删,vif 和 vbind 处理的是另一类变化:一段子树要不要出现,或者要不要按新值重建。

先看 vif。vif、velseif、velse 不是三段互不相关的 UI,而是一组候选分支。同一时间只会有一个分支活跃。条件变化时,框架会重新判断这一组条件,选出第一个为 true 的分支;旧分支的子树会被移除,新分支的子树会被创建。
源码里,每个条件分支对应一个 ConditionView。为了让这些分支知道彼此属于同一组,KuiklyUI 会让它们共享一个 root 引用。发布版里不需要记住每个字段名,只要理解这一点:vif 解决的是“在多个候选分支里切换哪一个”。
vbind 不是显示和隐藏。它把一段子树和一个值绑定起来,值变了,就用新值把这段子树整体重建。它适合那种结构本身会随值变化的场景,比如不同状态展示完全不同的区域。
所以两者的区别很清楚:vif 是分支切换,vbind 是按新值重建。
vforLazy 处理的是大数据量列表场景。全量 vfor 会把所有 item 都创建出来,数据量大时 view 树膨胀到不可接受。vforLazy 只渲染可视区域附近的一小批节点,默认最多 30 个。
和 vfor 只响应列表增删不同,vforLazy 同时受列表数据和滚动位置驱动。数据决定总量,滚动位置决定当前应该保留哪一段窗口。

实现思路是在 view 树里放两个占位 DivView,一个放在可见窗口前面,一个放在可见窗口后面。源码里这两个占位节点叫 itemStart 和 itemEnd,它们夹住中间真正创建出来的 item。树结构始终是 [占位头, item0, item1, ..., itemN, 占位尾]。
滚动时,ListView 会先收到 contentOffset 变化。源码里入口是 onContentOffsetDidChanged,随后 createItemByOffset 把 contentOffset 换算成目标 index,最后 createItemByPosition 决定窗口怎么移动:新进入窗口范围的 item 被创建,滑出范围的 item 被销毁。
窗口调整有三个细节。第一,目标 item 放在窗口中间 1/3 处,上下各留 1/3 缓冲,避免往回滚一点就要重建。第二,抗抖动阈值设为 2,窗口索引变化不超过 2 时跳过更新,避免滚动微调导致频繁创建销毁。第三,占位尺寸的精确修正:头尾占位初始用平均 item 高度估算,实际渲染后拿到精确尺寸再修正,同时调整 ListView 的 contentOffset 防止视觉跳变。这个修正逻辑占据了 vforLazy 源码里很大一部分篇幅,跨端浮点精度和异步 scroll 事件过滤让它远比窗口管理本身复杂。
回到第一节提到的两个公共基础:这些策略都建立在 VirtualView 和延迟任务之上。区别只在于每种指令选择怎样修改子树。
vfor 的增量更新和 vbind 的整体重建,代表了两种不同的同步策略。vfor 靠 ObservableList 维护操作队列做增量,少量 add/remove 只影响局部。vbind 每次值变化都整棵子树重建,简单直接,大子树时开销高。选哪个取决于数据变化的频率和子树的复杂度,而不是哪个在技术上更先进。
使用上有几个容易出错的点。vfor 的 itemCreator 只能创建一个孩子,因为增删定位依赖一对一索引关系;这个孩子也不能继续嵌套 vfor 或 vif,否则下游找不到正确的真实插入位置。vif、velseif、velse 之间不能插入其他非指令节点,否则前面提到的 prevDirectivesView 找不到前一个条件节点,组队会失败。vfor 的数据源类型就是 ObservableList,普通 List 不能提供 collectionOperation,也就不能进入这套增量更新机制。列表同步时要区分少量增删、差异更新和整体重置,不要把整列表赋值当成默认写法。
指令系统解决的核心问题是:数据变了之后,view 树怎么高效地跟着变。
vfor 根据 ObservableList 记录的增删操作,只同步变化的部分到 view 树和 Native 侧。vif 在多个候选分支里切换当前活跃子树。vbind 做整体重建,用新值重新创建子树。vforLazy 则把列表更新和滚动位置结合起来,只维护可视窗口附近的一小段节点。它们都统一在指令基类 DirectivesView 框架下:对 Native 透明,靠延迟任务操作树,避免和依赖收集冲突。
从整体架构看,指令系统和响应式系统、布局系统是紧密衔接的。响应式系统告诉指令数据变了,指令系统执行树操作,树操作产生的节点增删最终走到布局引擎重新算 frame。下一篇会沿着这个衔接点继续,看 Bridge 通信链路的具体实现。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。