Slate 常被一句话概括成"立即模式 UI",这个说法只对了一半。它确实有立即模式的骨架——每帧从根遍历执行布局与绘制——但 widget 树本身是持久保留的:SNew(SVerticalBox) 创建的 SWidget 挂在树上直到显式移除,持有槽位、属性和子节点。真正的机制是保留树 + 每帧遍历 + 失效驱动的增量跳过。Slate 代码在 Engine/Source/Runtime/SlateCore 和 Slate,这篇讲核心三件套。
一次帧内遍历的三个阶段
FSlateApplication::Tick 驱动 UI 帧,内部依次走:
- Tick:每个 widget 的
SWidget::Tick被调(输入处理、动画更新)。大部分 widget 不重写它。 - Prepass(布局):自顶向下算期望尺寸(
OnComputeDesiredSize被缓存,只有 layout 失效才重算),然后OnArrangeChildren自上而下分配实际几何FGeometry。这就是 UI 版的 measure/arrange,和 WPF/Android 同款两段式。 - Paint(绘制):从根递归
OnPaint,把图元(box/text/shader)追加进FSlateWindowElementList,之后交给 Slate 渲染器批处理成 draw call。
三个阶段各有独立的失效域,这是 Slate 最重要的设计决定。
Invalidate:失效标志位
SWidget::Invalidate(EInvalidateWidget::Type) 接收一组位标志(EInvalidateWidgetReason),每个标志对应一个可跳过的阶段:
Layout:几何依赖变了(文字内容、子节点数量),Prepass 需要重算这条链。Paint:外观变了(颜色、笔刷),布局没变,只重绘。ChildOrder:子节点增删。Visibility:可见性切换(影响布局跳过)。AttributeRegistration:绑定的属性(TAttribute)需要重新求值。
标志沿 widget 的无效化路径向上冒泡:子节点 Paint 失效会让父链上所有 paint 缓存失效,但不会触碰 layout 链。这样一次失效只触发必要阶段的局部重算。UE5 的 FSlateInvalidationWidgetList 把树上每个 widget 登记成带序号的槽位,失效记录到 FWidgetProxy,遍历时用 proxy 的 FastPath 判断"这一段缓存能不能直接用"——这就是所谓 Fast Path:缓存有效的子树整棵跳过 OnPaint。
Volatility:缓存的例外条款
纯缓存模型有个致命问题:有些 widget 的绘制结果不可预测地变——绑定属性的外部数据每帧都可能不同。这类 widget 必须声明 SetVolatile(true)(或 SetVolatileIndividually), volatile 节点及其子树永远绕过缓存,每次 Paint 都真算。
这是性能优化里最常见的坑:SInvalidationPanel 把子树画进缓存,只要里面任何一个 widget 是 volatile,整棵子树的缓存就形同虚设。排查套路是用 Slate 的 invalidation 调试 HUD(Slate.EnableInvalidationPanelDebugging 控制台变量)看哪些节点走了慢路径——肉眼可见一片红色 volatile 时,说明绑定写法出了问题(见 UMG 属性绑定那篇)。
和 UMG 的衔接
UMG 的 UUserWidget 并不是 Slate 的替代品,而是包了一层:UMG 对象树(UWidgetTree)在 RebuildWidget 时生成对应的 Slate widget 树,UWidget 负责状态和序列化,Slate 负责帧内布局与绘制。所以 UMG 的性能问题最终几乎都能翻译成 Slate 的问题:绑定太频繁 → volatile 太多;层级太深 → Paint 遍历基数大;Retainer Box → 强制离屏渲染整块子树。
给使用者的三条推论
第一,UI 卡顿先怀疑 Paint 遍历基数:widget 数量 × 每帧真实执行 OnPaint 的比例。HUD 上几百个血条不重缓存,任何旗舰机都顶不住。
第二,失效标志比手动刷新细。改颜色就让它只失效 Paint;想清楚你改的东西影响哪一阶段,比无脑 Invalidate 全部省的是 Prepass 整棵链。
第三,层级深本身不贵,贵的是每层都失效。把高频变化的东西(伤害数字、进度条)隔离在浅层小缓存里,让低频的框架部分永远走 Fast Path,比到处贴 Retainer Box 干净得多。