← 返回索引

范围:为组件添加每帧状态,或修改 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);

它的口径是根树在前、浮层树在后,各自前序深度优先:

  1. root 非空则 visit(root);
  2. overlays 非空则按层列表的加入顺序逐层 visit(layer.root()),即最后推入的最上层最后遍历;
  3. 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 时吸附滚轮目标值持续变化,显示位置始终无法到达
TooltipanimW/animH/animAlpha 逼近,收起到底后 hideInstance()提示框收起动画停在半空,节点无法卸载
MessageFlyoutsuper.tick() 后按 autoDismissMs 定时 hide()自动消失的消息条永不消失
AutoCompleteBox.DropdownOverlay关闭动画完成后 detachDropdown()下拉框收起后仍留在层列表里

层根本身的 tick 也必须送达,否则关闭动画完成后浮层无法移除,这是「tick 必须覆盖浮层树」最直接的证据。Animator 刻意不在此链上,它按墙钟懒更新,读取时才推进,两者不应互相耦合。


递归位置的分工

同一棵树上三种遍历的递归位置不同,属于重构后有意的分工:

遍历递归在哪依据
tickTickWalker.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、不命中、不布局、不绘制,只有父组件自身实现的那部分仍在更新。

相关文档