范围:为组件添加每帧状态,或修改 tick 驱动本身。机制范围包括
UiTickDriver的步长、TickWalker的遍历口径、Element.tick()的调用时机,滚动弹簧与动画取值表不在其中。涉及每帧状态的来源、120 TPS 步长与渲染帧的关系、隐藏浮层仍接收 tick 的原因、给组件添加子元素时缺失children()的影响、步长改动的波及面。
tick 链的三级结构
每帧状态只经过一条链,三级职责分离,彼此不共享状态:
UiTickDriver(按真实时间决定走几步)
↓
TickWalker(决定这一步送到哪些元素)
↓
Element.tick()(元素自己的动作)
第一级 固定步长累加器
UiTickDriver 持有 lastNanos 与 accumulator 两个字段,在每次渲染帧被调用一次:
public void onRenderFrame(InteractionManager manager) {
if (manager == null) return;
long now = System.nanoTime();
if (lastNanos < 0) lastNanos = now; // 首帧:不计时长
long deltaNanos = now - lastNanos;
lastNanos = now;
double elapsed = deltaNanos / 1_000_000_000.0;
if (elapsed > 0.25) elapsed = 0.25; // 钳制:防止后台停帧后的大回补
accumulator += elapsed;
while (accumulator >= STEP_SECONDS) {
TickWalker.forEachElement(manager.root(), manager.overlays().tree(), Element::tick);
accumulator -= STEP_SECONDS;
}
}
三个细节决定了它的行为边界:
- 步长固定为 1/120 秒(
STEP_SECONDS)。帧率高低只改变一帧内执行的步数,不改变每步代表的时间,60 帧时一帧两步,20 帧时一帧六步。弹簧插值因此与帧率无关。 - 首帧不补:
lastNanos初值-1,第一次调用只记录时间戳、不累加,避免打开界面瞬间的一次性突跃。 - 单帧累加量钳在 0.25 秒(约 30 步):切后台、暂停、断点调试之后真实时差可能是几分钟,不钳制就会在回到界面的那一帧里连续执行上千步,导致该帧长时间阻塞。钳掉的时长是丢弃而不是补偿,停帧期间的界面状态会暂时落后,且不会以单帧卡顿补偿。
该机制不是游戏逻辑 tick。Minecraft 的逻辑节拍为 20 TPS;UI 若随逻辑节拍推进,按 0.3 系数逼近的弹簧约需 30 步才收敛,即 1.5 秒的可见迟滞。将推进迁移到渲染帧的真实时间后,UI 的动画响应不再受 TPS 波动与逻辑暂停影响。
第二级 统一遍历
TickWalker 是无状态的静态工具(私有构造,不可实例化),签名只收一个动作:
TickWalker.forEachElement(root, overlays, Element::tick);
它的口径是根树在前、浮层树在后,各自前序深度优先:
root非空则visit(root);overlays非空则按层列表的加入顺序逐层visit(layer.root()),即最后推入的最上层最后遍历;visit是标准前序:先对自身action.accept(el),再依次递归el.children()。
四处 null 都被静默跳过(root、overlays、层根、子项),调用方不需要预先判空。
可见性不参与判定,这是刻意的设计:tick 执行状态维护,与元素是否在屏幕上可见无关。隐藏的 Tooltip 依赖 tick 中的收敛判定完成收起,隐藏浮层中的滚动弹簧也需继续推进;若按可见性过滤,visible(false) 会使这些状态永久停滞。
第三级 元素侧钩子
Element.tick() 是空实现的开放钩子,默认什么都不做。当前框架内只有四处覆写:
| 覆写者 | 在 tick 里做什么 | 漏掉 tick 的后果 |
|---|---|---|
LayoutElement | 滚动弹簧:scroll += (target - scroll) * 0.3,残差小于 0.01 时吸附 | 滚轮目标值持续变化,显示位置始终无法到达 |
Tooltip | animW/animH/animAlpha 逼近,收起到底后 hideInstance() | 提示框收起动画停在半空,节点无法卸载 |
MessageFlyout | super.tick() 后按 autoDismissMs 定时 hide() | 自动消失的消息条永不消失 |
AutoCompleteBox.DropdownOverlay | 关闭动画完成后 detachDropdown() | 下拉框收起后仍留在层列表里 |
层根本身的 tick 也必须送达,否则关闭动画完成后浮层无法移除,这是「tick 必须覆盖浮层树」最直接的证据。Animator 刻意不在此链上,它按墙钟懒更新,读取时才推进,两者不应互相耦合。
递归位置的分工
同一棵树上三种遍历的递归位置不同,属于重构后有意的分工:
| 遍历 | 递归在哪 | 依据 |
|---|---|---|
| tick | TickWalker.visit | 统一收口,元素侧不写递归 |
layout() | 仍在 LayoutElement.layout() 内遍历子项 | 惰性布局按 dirty 触发,不按帧 |
extractRenderState() | 仍在 LayoutElement 内遍历子项 | 绘制顺序由容器自行决定 |
结果集中为一点:children() 是 tick 获取子元素的唯一途径。基类默认返回共享的 List.of(),LayoutElement 返回实际列表,自行持有子元素列表的非容器组件必须覆写该方法,例如 MessageFlyout 的 children() 返回 actions 列表。未覆写时,这些子元素既收不到 tick,也不会被 Hit 的命中遍历与父级的布局、绘制纳入。
Hit(输入命中)复用同一个 children(),因此「添加子元素需覆写 children()」这一规则同时约束四处,并非 tick 专属约定。
唯一入口
全框架只有一处启动该链:UiTickDriver.onRenderFrame(manager),挂载于渲染帧。
驱动器的依赖方向是单向的:interaction 包引用 tick 包,反向不成立,即 ui.tick 之外的类不引用遍历器。多接一个入口在编译期就不成立。
漏接或重复接线均不报错,只表现为行为异常:重复接线时两份推进叠加,弹簧速率与动画速度翻倍,现象通常是滚动异常加快、下拉关闭动画过快完成。若确实需要另一种节拍(例如 20 TPS),直接调用 TickWalker.forEachElement(root, overlays, Element::tick);该方法为 public static,可自行选择动作与频率。接线位置见 Developer: 快速开始。
不变量
- tick 必须同时覆盖根树与浮层树,且不看可见性:两条规则都在
TickWalker.forEachElement里,改它等于同时动摇滚动、提示框收起、浮层关闭三处行为。 - 递归只发生在
TickWalker:LayoutElement.tick()只更新自身状态,不得再遍历子元素。在元素侧恢复递归会让每个后代被 tick 两次。 - 每帧推进只有
UiTickDriver一个入口:InteractionManager不再有每帧方法,驱动器只借用它读取两棵树;同时接多个入口导致状态双倍推进的做法现在已无法通过编译。 - 只在渲染线程读写:
UiTickDriver与所有tick()覆写都没有同步,假定与Animator同样的单线程前提。不要从异步任务启动或读取每帧状态。 - 步长不是魔法数字:
STEP_SECONDS是滚动弹簧收敛速度、动画节奏、关闭判定时机的共同分母,修改前先查看「连带影响」。 - 不要在
tick()里做输入或命中判定:那属于InteractionManager的事件链;tick 是纯状态推进,两者混用会让「同一逻辑写两处」的问题重现。
连带影响
- 改
STEP_SECONDS(比如提到 1/60)会波及所有依赖步数收敛的行为:滚动弹簧到达目标的步数、Tooltip进出场节奏、AutoCompleteBox关闭动画的完成时机。这些组件没有任何一处写死时长,全部按「步」表达,所以步长一改它们的行为一起变。 - 改 0.25 秒钳制值:调大让停帧后的回补更久(一帧内执行更多步,掉帧风险),调小让长停顿后状态落后更明显。这个值只在大幅停帧后可见,日常帧率波动不会触发该路径。
- 去掉
TickWalker或给它加可见性过滤:会波及滚动插值、提示框收起、浮层自动关闭,其中提示框与浮层两条链在隐藏状态下仍要推进,过滤可见性等于让它们永久停在中间态。 - 改
children()的默认返回值(比如从List.of()换成可变空表)会波及TickWalker、Hit命中遍历、LayoutElement.layout()、extractRenderState()四处共享该方法的调用方。 - 给组件加子元素却不覆写
children():静默失效且不报错,子元素不 tick、不命中、不布局、不绘制,只有父组件自身实现的那部分仍在更新。
相关文档
- 布局系统:滚动弹簧在 tick 里的取值与裁剪口径
- 交互引擎:tick 链与输入链路互不干扰的分工
- 根树与层级系统:两棵树的所有权与渲染/输入流水线
- 动画系统:
Animator不在此链上的原因 - Developer: 快速开始:
onRenderFrame在 Screen 里的接线位置与集成检查清单 - Developer: 布局:滚轮步进与弹簧响应(使用侧口径)
- 改动记录:tick 迁移:旧
OverlayTree.tickAll()与LayoutElement自递归的移除过程