关于 makepad-2.0-performance
面向 Makepad 2.0 开发者的性能与调试技能,聚焦 UI 渲染管线和 Splash VM。内容说明绘制批处理、纹理缓存、垃圾回收、局部渲染及诊断工具。
主要功能
- 01讲解绘制批处理与顺序
- 02指导使用纹理缓存
- 03介绍 Splash VM 垃圾回收
- 04优化指定子树的渲染
- 05涵盖日志与 GC 诊断
适用场景
- 01排查文本被背景遮挡
- 02减少动态视图的重复渲染
- 03诊断长时间运行应用的内存回收
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ zhanghandong/ makepad-skills --skill makepad-2.0-performance安装后在 Claude Code 中输入/makepad-2.0-performance确认已加载。
附带文件references/optimization-guide.md
详细介绍
Makepad 2.0 性能与调试技能
1. 概述
Makepad 2.0 使用独特的渲染管线,并结合 Splash 脚本 VM。性能取决于是否理解以下三个关键子系统:
- 绘制批处理 - Makepad 如何对 GPU 绘制调用进行分组,以及为什么
new_batch: true很重要 - 垃圾回收 - Splash VM 的标记-清除 GC,采用按类型分桶的阈值
- 渲染触发器 - 控制何时重建子树的
on_render/.render()系统
与传统的保留模式 UI 框架不同,Makepad 使用受即时模式启发的绘制管线:控件会将绘制命令写入经过排序的批次列表。理解此管线对于诊断文本不可见、闪烁和性能回退至关重要。
2. 绘制批处理系统
工作原理
Makepad 会自动将使用相同着色器的连续绘制调用合并为一次 GPU 绘制调用。这是一项重要的性能优化,但有一个关键的副作用:绘制顺序可能出人意料。
Draw pipeline (simplified):
Widget tree: GPU batches (default):
View (bg shader) Batch 1: all bg shaders
Label (text) --> Batch 2: all text shaders
View (bg shader)
Label (text) Result: ALL backgrounds draw first,
then ALL text draws second
当 View 设置了 show_bg: true 且包含文本子项时,文本可能会被绘制在背景后面,因为文本绘制可能会被合并到同一次绘制调用中,而这次调用会在背景绘制调用之前(或之后)执行。
new_batch: true
在 View 上设置 new_batch: true 会强制 Makepad 从该位置开始一个新的绘制批次。这会在内部创建一个 ViewOptimize::DrawList,确保该 View 子树中的绘制顺序正确。
// PROBLEM: Label text is invisible - batched behind the background
RoundedView{
width: Fill height: Fit
draw_bg.color: #1e1e2e
Label{text: "This text is INVISIBLE"}
}
// FIX: new_batch ensures background draws before text
RoundedView{
width: Fill height: Fit
new_batch: true
draw_bg.color: #1e1e2e
Label{text: "This text is VISIBLE"}
}
何时需要 new_batch: true
| 场景 | 是否需要? | 原因 |
|---|---|---|
设置了 show_bg: true 且包含 Labels 的 View | 是 | 文本批次会被绘制在背景后面 |
| 带有悬停动画器和文本子项的 View | 是 | 激活时悬停背景会覆盖文本 |
| 包含多个带背景项的容器 | 是 | 每个项和容器都需要设置 |
透明 View(没有 show_bg)且包含 Labels | 否 | 没有背景会发生重叠 |
| 只包含非文本子项(例如图标)的 View | 否 | 着色器类型相同,不存在重叠问题 |
| 每层都有背景的深层嵌套 View | 每层都需要 | 每个背景层都需要独立批次 |
悬停效果与 new_batch
这是可悬停列表项中最常见的错误。当 View 设置了 show_bg: true,并带有一个从透明(#0000)过渡到悬停时不透明的悬停动画器时,悬停时文本会消失,因为新变为不透明的背景会覆盖批处理后的文本。
// CORRECT: Hoverable item with new_batch
let HoverItem = View{
width: Fill height: Fit
new_batch: true
show_bg: true
draw_bg +: {
color: uniform(#0000)
color_hover: uniform(#fff2)
hover: instance(0.0)
}
animator: Animator{
hover: {
default: {
from: {all: Forward{duration: 0.1}}
apply: {draw_bg: {hover: 0.0}}
}
on: {
from: {all: Forward{duration: 0.1}}
apply: {draw_bg: {hover: 1.0}}
}
}
}
label := Label{text: "item" draw_text.color: #fff}
}
// Parent container of hover items also needs new_batch
RoundedView{
flow: Down height: Fit new_batch: true
draw_bg.color: #2a2a3d
draw_bg.border_radius: 8.0
HoverItem{label.text: "First item"}
HoverItem{label.text: "Second item"}
}
ViewOptimize 内部机制
new_batch 和 texture_caching 属性映射到一个 ViewOptimize 枚举:
ViewOptimize::None - Default. No special draw ordering.
ViewOptimize::DrawList - Created by new_batch: true. Starts a new DrawList2d.
ViewOptimize::Texture - Created by texture_caching: true. Renders to offscreen texture.
优先级:如果两者都已设置,texture_caching 的优先级高于 new_batch。
3. 纹理缓存
工作原理
在 View 上设置 texture_caching: true 会将整个子树渲染到离屏 GPU 纹理中。后续帧中,如果子树没有任何变化,Makepad 就可以跳过对子项的重新渲染,只需绘制缓存纹理。
// Cache a complex but rarely-changing sidebar
sidebar := View{
width: 280 height: Fill
texture_caching: true
flow: Down spacing: 4
// ... many child widgets ...
}
预构建的缓存视图
Makepad 提供了预先设置样式的缓存视图:
| 控件 | 描述 |
|---|---|
CachedView | 使用纹理缓存的矩形容器 |
CachedRoundedView | 使用纹理缓存的圆角矩形 |
何时使用纹理缓存
适合的场景:
- 很少变化的复杂静态侧边栏或工具栏
- 包含许多嵌套背景和文本的大型控件子树
- 带有着色器效果的装饰面板
不适合的场景:
- 频繁更新的视图(例如动画目标、实时数据)
- 简单的小型视图(开销大于收益)
- 包含滚动内容的视图(CachedView 会包装整个内容,而非视口)
权衡
| 优势 | 成本 |
|---|---|
| 减少每帧的绘制调用次数 | 缓存纹理会占用 GPU 内存 |
| 避免重新遍历大型子树 | 内容变化时必须使纹理缓存失效 |
| 可以消除批处理问题(纹理会解决绘制顺序问题) | DPI 系数会影响纹理分辨率 |
4. 垃圾回收(mod.gc)
架构
Splash VM 使用标记-清除垃圾回收器,并为不同值类型隔离堆:
Heap Layout:
+-- Objects (ScriptObject) -- Primary allocation type
+-- Arrays (ScriptArray) -- Typed arrays and value arrays
+-- Strings (ScriptString) -- Interned strings
+-- Pods (ScriptPod) -- Pod values (vec2, vec3, vec4, etc.)
+-- Handles (ScriptHandle) -- Native Rust handles
+-- Regexes (ScriptRegex) -- Interned regex patterns
自动触发 GC
GC 使用基于增长的启发式策略,类似于 Lua 和 V8:
- 增长因子: 2 倍——任意堆类别自上次 GC 后增长到两倍时,触发 GC
- 最小阈值(避免小堆频繁触发):
| 类别 | GC 可触发前的最小数量 |
|---|---|
| 对象 | 1,024 |
| 字符串 | 256 |
| 数组 | 128 |
| Pod | 128 |
| 句柄 | 64 |
满足以下条件时触发 GC:current_count >= MIN_THRESHOLD AND current_count >= last_gc_count * 2
脚本 API
mod.gc.run() - 立即强制执行一次 GC。不会输出日志。
mod.gc.run_status() - 强制执行一次 GC 并打印详细统计信息:
GC 142us: obj[S:1200 A:340 R:89] arr[S:45 A:12 R:3] str[S:890 A:120 R:15] ...
其中 S=静态(永久),A=存活(已保留),R=已移除(已释放)。
mod.gc.set_static(value) - 将一个值及其整个可达对象图标记为静态。静态对象:
- 永不被 GC 回收(永久保留)
- 在 GC 标记阶段跳过(加快 GC 遍历)
- 无法取消标记(在 VM 生命周期内不可逆)
mod.gc.dump_tag(value) - 调试工具。打印对象的内部标签信息:类型索引、静态标志、原型链。
最佳实践
模式:静态 UI 树
对于大型且稳定的 UI 树定义(例如包含许多标签页的 Dock),定义完成后立即将其标记为静态。这是 Studio 和 UIZoo 示例中使用的标准模式:
// Define a large widget tree
let AppDock = Dock{
// ... tabs, splitters, content templates ...
TabEditor := TabEditor{}
TabFileTree := TabFileTree{}
TabSettings := TabSettings{}
}
// Mark the entire tree as static - it will never be GC'd
mod.gc.set_static(AppDock)
// Run GC immediately to clean up any temporaries from tree construction
mod.gc.run()
// Now start the app
startup() do #(App::script_component(vm)){
ui: Root{
main_window := Window{
body +: {
// ... use AppDock here ...
}
}
}
}
模式:动态内容
对于动态内容(列表、用户生成的项目、聊天消息),让自动 GC 负责清理:
// Dynamic data - no need to call mod.gc manually
var todos = []
fn add_todo(text) {
todos.push({text: text done: false})
ui.main_view.render()
// Automatic GC will clean up old unreachable objects
}
fn delete_todo(index) {
todos.splice(index, 1)
ui.main_view.render()
// Old todo object becomes unreachable, will be collected automatically
}
模式:长时间运行的应用定期手动触发 GC
对于会创建和销毁大量对象的应用(例如带有流式响应的聊天应用):
var message_count = 0
fn on_new_message(msg) {
messages.push(msg)
message_count += 1
// Every 100 messages, run GC to reclaim temporary parsing objects
if message_count % 100 == 0 {
mod.gc.run()
}
ui.message_list.render()
}
GC 标记阶段详情
标记阶段从根开始遍历:
- 类型检查原型
- 类型默认对象
- Pod 类型默认值和对象
- 根对象(由 Rust 通过
ScriptObjectRef持有) - 根数组(由 Rust 通过
ScriptArrayRef持有) - 根句柄(由 Rust 通过
ScriptHandleRef持有) - 线程栈(VM 执行栈上的所有存活值)
- 线程作用域
- 方法调用上下文
- 循环源值
- 陷阱错误/返回/退出值
- 脚本体作用域和分词器字符串字面量
- 原生类型表对象
遍历时会跳过静态对象,因为它们只引用其他静态值。
5. 渲染优化
on_render / .render() 系统
Makepad 2.0 对动态内容采用拉取式渲染模型。只有在对某个 View 调用 .render() 时,该 View 上的 on_render 回调才会执行。
// Define a reactive view
counter_view := View{
on_render: || {
Label{
text: "Count: " + state.counter
draw_text.color: #fff
}
}
}
// In event handler - only re-render what changed
fn increment() {
state.counter += 1
ui.counter_view.render() // Only this view re-renders
}
高效渲染规则
-
绝不要无故调用
.render()- 每次调用都会完全重建该子树的 widget 输出。 -
仅渲染受影响的子树 - 如果只有列表发生变化,只渲染列表视图,而非整个 UI。
-
避免在紧密循环中渲染 - 批量修改状态,然后只渲染一次:
// BAD: renders 100 times
for i in 0..100 {
items[i].value = compute(i)
ui.item_list.render() // WASTEFUL - rebuilds list 100 times
}
// GOOD: render once after all changes
for i in 0..100 {
items[i].value = compute(i)
}
ui.item_list.render() // Render once with all changes applied
- 使用
on_startup进行初始渲染 - 应用启动时触发首次渲染:
ui: Root{
on_startup: || {
ui.main_view.render()
}
main_window := Window{
body +: {
main_view := View{
on_render: || {
// ... dynamic content ...
}
}
}
}
}
渲染作用域
对某个 View 调用 .render() 时,仅执行该 View 的 on_render 回调。具有自身 on_render 回调的子 View 不会自动重新渲染,除非也对它们调用 .render()(或由父级的 on_render 重新构建)。
6. 调试日志
Rust 端日志
使用 Makepad 错误日志系统中的 log! 宏:
use makepad_widgets::*;
// In Rust code
log!("Button clicked, counter = {}", self.counter);
log!("Widget action: {:?}", action);
脚本端日志
在 Splash 脚本中,可以使用 log() 或字符串插值进行调试:
fn handle_click() {
let value = compute_something()
// Log values during development
log("computed value: " + value)
}
GC 状态日志
使用 mod.gc.run_status() 获取 GC 活动的详细信息:
// Output example:
// GC 142us: obj[S:1200 A:340 R:89] arr[S:45 A:12 R:3] str[S:890 A:120 R:15]
// hdl[S:8 A:2 R:0] pod[S:200 A:45 R:10] rex[S:3 A:0 R:0]
字段:
- Time(142us)- GC 周期持续时间,单位为微秒
- S(Static)- 永久标记、永不回收的对象
- A(Alive)- 在此 GC 周期中存活的对象
- R(Removed)- 在此周期中释放的对象
标签调试
如需深入调试特定对象,请使用 mod.gc.dump_tag(value):
let my_widget = View{...}
mod.gc.dump_tag(my_widget)
// Output: obj 4523 type_index=Some(12) is_static=false proto=Some(89) ...
7. 常见性能问题及修复方法
| 问题 | 原因 | 修复方法 |
|---|---|---|
| 文本不可见 | 缺少 new_batch | 在父级 View 中添加 new_batch: true,并设置 show_bg: true |
| 悬停时文本消失 | 悬停动画期间批次重叠 | 在可悬停的 View 中添加 new_batch: true |
| UI 卡顿/停顿 | .render() 调用过多 | 批量处理状态更改,仅渲染发生更改的子树 |
| 内存持续增长 | GC 未运行或存在大量静态泄漏 | 对稳定树使用 mod.gc.set_static(),让自动 GC 处理动态内容 |
| 初始加载缓慢 | 启动时脚本求值量过大 | 拆分为多个模块,采用延迟加载模式 |
| 滚动卡顿 | 渲染的项目过多 | 使用 PortalList 进行虚拟化渲染 |
| 悬停无响应 | View 缺少 show_bg: true | View 需要设置 show_bg: true 才能接收鼠标悬停事件 |
| 运行时找不到 Widget | 命名运算符错误 | 对具名/可寻址子项使用 :=(而非 :) |
| 样式覆盖未生效 | 缺少合并运算符 | 使用 +: 合并属性;使用 : 会完全替换属性 |
| 布局高度收缩为零 | 缺少 height: Fit | 所有容器都需要显式设置 height: Fit 或固定高度 |
8. 用于大型列表的 PortalList
为什么使用 PortalList
PortalList 会对渲染进行虚拟化——只绘制视口中可见的项目。对于包含 100 个以上项目的列表,必须使用它。否则,无论项目是否可见,每一帧都会绘制所有项目。
脚本端 PortalList(使用 on_render)
对于由 Splash 驱动的列表,请使用模板定义 PortalList,并使用 on_render:
list := PortalList{
width: Fill height: Fill
flow: Down spacing: 4
scroll_bar: ScrollBar{}
Item := View{
width: Fill height: Fit
padding: 8
new_batch: true
draw_bg.color: #2a2a3d
label := Label{text: "" draw_text.color: #ddd}
}
}
Rust 端 PortalList(使用 Widget trait)
对于由 Rust 驱动的渲染,请实现 Widget trait:
impl Widget for MyList {
fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
while let Some(item) = self.view.draw_walk(cx, scope, walk).step() {
if let Some(mut list) = item.borrow_mut::<PortalList>() {
list.set_item_range(cx, 0, self.items.len());
while let Some(item_id) = list.next_visible_item(cx) {
let template = id!(Item);
let item = list.item(cx, item_id, template);
item.label(ids!(label)).set_text(cx, &self.items[item_id].text);
item.draw_all(cx, &mut Scope::empty());
}
}
}
DrawStep::done()
}
}
FlatList 与 PortalList
| 特性 | FlatList | PortalList |
|---|---|---|
| 虚拟化 | 否 | 是 |
| 适用范围 | 少于 100 个项目 | 任意数量的项目 |
| 内存用量 | 所有项目都在内存中 | 仅可见项目 |
| 滚动性能 | 随项目数量增加而下降 | 恒定 |
9. ViewOptimize 选项摘要
| 属性 | ViewOptimize 值 | 效果 |
|---|---|---|
| (默认) | None | 标准批量绘制 |
new_batch: true | DrawList | 新建绘制批次,确保绘制顺序正确 |
texture_caching: true | Texture | 将子项渲染到离屏纹理 |
visible: false | 不适用 | 完全跳过渲染 |
优先级: 如果同时设置了 texture_caching 和 new_batch,则以 texture_caching 为准(变为 ViewOptimize::Texture)。
10. 调试检查清单
UI 未显示
- 检查所有容器是否设置了
height: Fit(在Fit父级中,默认的height: Fill会导致高度为 0) - 检查根容器是否设置了
width: Fill(最外层元素不要使用固定像素宽度) - 确认脚本顶部有
use mod.prelude.widgets.*
文本不可见
- 对任何包含文本且设置了
show_bg: true的 View,添加new_batch: true - 检查
draw_text.color是否为透明色或与背景色相同 - 确认 Label 是直接子项,或位于经过适当批次处理的子项中
悬停时文本消失
- 在可悬停的 View 中添加
new_batch: true - 确保可悬停项目的容器也设置了
new_batch: true
点击无效
- 检查
:=与:的区别——对需要引用的具名/动态子项使用:= - 确认可接收鼠标事件的 View 已设置
show_bg: true - 如果需要键盘事件,请检查
grab_key_focus
未找到 Widget
- 在 script_mod 中验证
#(WidgetName::register_widget(vm))注册 - 检查是否在自定义注册之前调用了
crate::makepad_widgets::script_mod(vm) - 确认 Cargo.toml 中已将 widget crate 添加为依赖项
脚本错误
- 使用
log()在执行期间调试值 - 使用
mod.gc.run_status()检查堆统计信息 - 使用
mod.gc.dump_tag(value)检查对象内部结构
样式未生效
- 使用
+:合并运算符扩展现有样式:draw_bg +: { color: #fff } - 仅当要完全替换某个属性时才使用
: - 检查点路径语法:
draw_bg.color: #fff是draw_bg +: { color: #fff }的简写
11. 使用 Studio 进行性能分析
Makepad Studio 内置了性能分析器,可用于监控应用性能。
Studio 远程协议
Studio 可以连接到正在运行的应用,并提供:
- 屏幕截图 - 捕获当前帧状态
- Widget 树转储 - 检查实时 Widget 层级结构
- Widget 查询 - 按 ID 查找特定 Widget
- 性能监控 - 帧耗时、绘制调用次数
使用 Studio 调试性能
- 启动 Studio 远程连接:
cargo run -p cargo-makepad --release -- studio --studio=127.0.0.1:8001
- 通过 Studio 运行应用:
{"Run":{"mount":"makepad","process":"makepad-example-myapp","args":[]}}
- 捕获 Widget 树,以识别渲染结构:
{"WidgetTreeDump":{"build_id":BUILD_ID}}
- 截取屏幕截图,以确认视觉状态:
{"Screenshot":{"build_id":BUILD_ID}}
识别渲染热点路径
- 使用
WidgetTreeDump查看树中有多少个 Widget - 查找可从
texture_caching中受益的深层嵌套 View 层级结构 - 找出应使用
PortalList而非手动循环的重复项 - 检查设置了
new_batch: true但可能不需要的 View(每个新批次都会创建一个新的绘制列表)
12. 快速参考卡
View 上的性能属性
// Force new GPU draw batch (fixes text-behind-background)
new_batch: true
// Cache children to GPU texture (reduces draw calls for stable subtrees)
texture_caching: true
// Hide without removing from tree (skip rendering entirely)
visible: false
GC API
mod.gc.set_static(value) // Mark value tree as permanent
mod.gc.run() // Force GC cycle (silent)
mod.gc.run_status() // Force GC cycle with log output
mod.gc.dump_tag(value) // Debug: print object tag info
渲染 API
ui.widget_name.render() // Trigger on_render for specific widget
GC 阈值(自动触发)
Objects: >= 1024 AND >= 2x since last GC
Strings: >= 256 AND >= 2x since last GC
Arrays: >= 128 AND >= 2x since last GC
Pods: >= 128 AND >= 2x since last GC
Handles: >= 64 AND >= 2x since last GC