diff 引擎
这一页解释为什么控件状态能在重渲染后保留 —— 这是声明式方案最核心的机制。
问题
view() 每帧执行一次(约 60 次/秒),每次都返回一棵全新的控件树。
如果每帧都"把旧控件全删、按新树重建",会发生什么?
- 你正在输入的文字光标跳回开头
- 表格的选中行消失
- 滚动位置弹回顶部
- 每秒 60 次重建 200 个控件 —— 卡
所以必须差异更新。
做法:按 id diff
C++ 侧维护一个 id → 控件 的表。每帧收到新树后:
新树里有的 id,旧表里也有 → 只更新变化的属性(控件本身不动)
新树里有、旧表里没有的 id → 建控件
旧表里有、新树里没有的 id → 删控件(先从布局摘除,再 deleteLater)
控件本身不重建,所以它的内部状态(光标、选中、滚动、输入法状态)全部原样保留。
稳定 id 是前提
diff 认的是 id,所以 id 必须跨帧稳定 —— 同一个逻辑控件,这一帧和下一帧要拿到同一个 id。
显式 id
你写的 ['id' => 'name_input'] 就是它。
自动 id:结构路径
没写 id 的节点,C++ 按结构位置生成:_p0.1.2(第 0 个根的第 1 个子的第 2 个子)。
因为是从位置推的,只要树的结构不变,同一个节点每帧拿到同一个 id。
反例:id 每帧都变
这是真实踩过的坑
早期实现给无 id 节点用一个自增计数器生成 id。结果:每帧 id 都不同 → diff 认为"旧的全消失、全是新的" → 每帧重建整棵树。
后果不只是慢:重建时旧控件被删,而布局里还留着指向它的悬垂指针 —— 第二次渲染直接崩(0xC0000005)。
教训:自动 id 必须从结构推导,不能从执行顺序推导。
选中的保留
表格/列表/树的选中是个特例:用户点选后,选中状态在控件里,不在你的状态里。
diff 引擎的做法:
- 重渲染前,记下当前选中项的行 id / 节点 id。
- 重建表格数据。
- 按记下的 id 找回那一行,重新选中。
所以插行、换数据都不会选中错位 —— 前提是你给了 row_ids:
// 有 row_ids:选中跟着 r2 走
WidgetTree::table(['名称'], $rows, ['id' => 'tbl', 'row_ids' => ['r0', 'r1', 'r2'], 'current' => 'r2']);
// 没有 row_ids:行 id 退化成索引 '0'、'1'…,插一行后选中会跳到别处
WidgetTree::table(['名称'], $rows, ['id' => 'tbl']);
这就是为什么数据控件反复强调要给 row_ids。
属性级别的 diff
同一个控件内部,也是按属性比对的:只有值真的变了才写回 Qt。
所以 view() 里写:
WidgetTree::label('点击次数:' . $state['clicks'], ['id' => 'count'])
每帧都构造一个新的 label 节点,但 diff 发现 text 没变就什么都不做 —— 不会引起 Qt 重绘。
patch() 为什么是"旁路"
patch() 直接改控件,跳过了树。执行后,diff 引擎记录的"该控件上次的属性签名"就和实际不符了。
所以框架把签名作废 —— 下一次 render() 一律以树为准重新同步。
这解释了 增量补丁 那条核心约束:
追加的行、清空的内容都只在这次渲染之前有效。要长期存在就得写回状态。
代价与收益
| 声明式 diff | 命令式句柄 | |
|---|---|---|
| 应用代码量 | 只描述"界面该长什么样" | 要自己维护控件生命周期、增量同步 |
| 状态保留 | 自动(靠 id) | 要手动处理 |
| 每帧开销 | 遍历树 + 比对属性 | 只有你显式改的才动 |
| 出错方式 | id 不稳 → 重建(可见的卡) | 漏同步 → 界面与数据不一致(难查) |
框架选了前者:应用代码最省事,代价是每帧一次树遍历。实测这个开销远低于"写命令式同步代码"的心智负担 —— 而且热路径可以用 patch() 单独优化。