下载 App
10/163 浏览案例分享
【案例分享】UrhoX UI 实战:Yoga 默认值反着来、静默失效、长列表性能,我踩过的坑
一句话结论
UrhoX 的 UI 用 Yoga 做布局,Yoga 的默认值和 CSS 是反的,再加上一套「能自动兜底但只打印一行提示」的容错机制,导致大部分 UI bug 表现为「画面正常但功能没有」,而不是报错。
一、三个必须步骤,少一步全白
UI.Init → 建树 → UI.SetRoot。第三步最容易漏,因为漏了不报错,屏幕上什么都没有,控制台也干干净净。React/Vue 有隐式挂载,UrhoX 没有。
local UI = require("urhox-libs/UI")
UI.Init({
theme = "default-dark", -- 内置主题自带字体
scale = UI.Scale.DEFAULT, -- 1 基准像素 = 1 CSS 像素 + 小屏密度自适应
})
local root = UI.Panel {
width = "100%", height = "100%",
justifyContent = "center", alignItems = "center",
children = {
UI.Button {
text = "开始", variant = "primary",
onClick = function(self, event) print("clicked") end,
},
},
}
UI.SetRoot(root) -- 漏掉这行 = 全白
场景退出时调 UI.Shutdown()。
内置主题就两个:default-dark(深海军蓝、钢蓝主色、Noto Sans SC)和 default-taptap(薄荷绿、青色主色、1px 细边框、Resource Han Rounded CN)。不传 theme 会落到一个已废弃的主题上。字体优先级是 options.fonts > theme.fonts > 内置 MiSans 兜底。
二、Yoga 的三个默认值跟 CSS 相反
这张表我贴在工位上:
属性:CSS Yoga(UrhoX) 后果
flexShrink:1 0 子元素直接溢出,不收缩
flexDirection:row column 你以为横排,实际竖排
盒模型:content-box border-box width=100, padding=20 总宽还是 100,内容区 60
第一条害我最惨。200px 高的容器塞 150 + 100 两个子元素,合计 250,超出去 50,屏幕上就是画到容器外面去了。
-- ❌ 溢出
container:AddChild(UI.Panel { height = 150 })
container:AddChild(UI.Panel { height = 100 })
-- ✅ 显式允许收缩
container:AddChild(UI.Panel { height = 150, flexShrink = 1 })
container:AddChild(UI.Panel { height = 100, flexShrink = 1 })
第二条的坑在于:UI.Row 等价于手写 flexDirection = "row",但你要是直接用 UI.Panel 装一排东西,默认是竖的,出来的效果和你想的完全两样。
三、ScrollView 的两个坑:不滚,和滚成 0 高
坑 A:不滚动
flexGrow = 1 给了高度,但没给起点。
-- ❌ 高度约束不成立,滚不动
UI.ScrollView { flexGrow = 1 }
-- ✅ flexBasis = 0,或者干脆给固定高度
UI.ScrollView { flexGrow = 1, flexBasis = 0 }
UI.ScrollView { height = 400 }
引擎检测到 flexGrow > 0 但没设 flexBasis 时会自动补 0 并打印一行提示。所以它不会彻底不工作,但提示刷屏的时候你会以为别的地方出了问题。
坑 B:flexGrow 子元素塌成 0 高
这个更阴。弹窗想限制最高 85% 屏高,于是写 maxHeight = "85%",里面一个 56px 的 header、一个 flexGrow = 1 的内容区。结果只看得见 header。
-- ❌ 内容区高度 = 0
UI.Panel {
maxHeight = "85%",
children = {
UI.Panel { height = 56 },
UI.ScrollView { flexGrow = 1, flexBasis = 0 },
},
}
-- ✅ 换成 height
UI.Panel {
height = "85%",
children = {
UI.Panel { height = 56 },
UI.ScrollView { flexGrow = 1, flexBasis = 0 }, -- 85% - 56px
},
}
原因:flexGrow 要算「剩余空间」,前提是父容器在主轴上有确定尺寸。maxHeight 只是上界,不是确定尺寸,Yoga 算不出可分配空间,于是分到 0。这是 Flexbox 模型的固有限制,CSS 浏览器一样中招。规则一句话:容器里有 flexGrow 子元素,父容器就得用 height/width,别用 maxHeight/maxWidth。
四、variant / color / severity,三个词别混
-- ❌ primary 是颜色,不是外观变体
UI.Chip { label = "标签", variant = "primary" }
-- ✅ 拆开写
UI.Chip { label = "标签", variant = "filled", color = "primary" }
各控件的 variant 取值对不上号:
控件:variant 取值
Button:primary / secondary / danger / success
Chip:filled / outlined / soft(颜色走独立的 color)
Card:elevated / outlined / filled
Tabs:line / enclosed / pills
Accordion:default / outlined / separated
Alert:不用 variant,用 severity
Badge / ProgressBar:primary / success / warning / error
Button 的 variant 里带颜色语义(primary = 蓝、danger = 红),这是设计如此,别再去加 color。
五、事件签名有两套,写反了静默不触发
-- onClick / onChange 这类:function(widget, event)
UI.Button {
onClick = function(self, event) print(self.props.text) end,
}
-- Pointer / Gesture 这类:function(event, widget)
UI.Panel {
onPointerDown = function(event, widget) print(event.x, event.y) end,
onTap = function(event, widget) print("tap") end,
}
两个参数顺序反过来,运行时不会报错。self 拿到的是 event,event 拿到的是 widget,然后 event.x 是 nil,你在日志里看到一堆 nil 却不知道从哪来。
OnEvent / OffEvent 是命令式版本,事件名全小写:pointerenter、pointerdown、click、tap、panmove。不调用 OnEvent 时内部监听表是 nil,零开销。
六、列表性能:别用 List / Table 堆千条
UI.List、UI.Table 是把所有数据一次性建成控件。1000 条就是 1000 个控件树节点,每帧走一遍布局和命中测试。背包、排行榜、聊天记录这类长列表,用 UI.VirtualList——它内部是对象池,只渲染可见项。
UI.VirtualList {
data = items,
itemHeight = 40, -- 必须是固定值
createItem = function()
return UI.Panel { height = 40 }
end,
bindItem = function(widget, data, index)
-- 只改数据,不要在这里重建控件树
widget:FindById("name"):SetText(data.name)
end,
onItemClick = function(data, index) end,
}
itemHeight 固定是有原因的:虚拟列表靠它算总高和可见区间。
滚动容器里要脱离滚动流、相对视口定位的元素用 position = "fixed",它渲染在普通树之上、浮层之下。想在 ScrollView 里做吸顶表头用 position = "sticky",但只对垂直方向有效。
七、浮层:先 Close 再 Destroy
浮层控件(Modal、Dropdown、Popover、Drawer、DatePicker、TimePicker、ColorPicker)走一套浮层栈。Open() 内部就是 UI.PushOverlay(widget),Close() 就是 UI.PopOverlay(widget),用内置控件时不用自己调这两个。
销毁的坑在于顺序:
-- ❌ 直接销毁,控件还挂在浮层栈上,栈里留了个悬空引用
modal:Destroy()
-- ✅ 先出栈再销毁
modal:Close()
modal:Destroy()
图省事的写法是在 onClose 里销毁,这样点遮罩、按 Esc、点关闭按钮三条路径都能走到:
local modal = UI.Modal {
title = "编辑资料",
size = "md",
closeOnOverlay = true,
closeOnEscape = true,
onClose = function(self)
self:Destroy()
end,
}
Modal 有 sm / md / lg / xl / fullscreen 五档尺寸。内容用 modal:AddContent(widget) 加,底部用 modal:SetFooter(widget)。只想弹个确认框,直接 UI.Modal.Confirm({ title = "...", message = "...", onConfirm = fn }),不用自己拼按钮。
八、事后改控件:两种取引用的方式
元素少(一个 FPS Label、一个血条),留 local 引用就行,LSP 类型明确,变量名拼错立刻报错。
HUD 元素多、创建和更新分散在不同函数,就给控件加 id,用 :FindById 递归查。
local uiRoot = UI.Panel {
children = {
UI.Label { id = "score", text = "Score: 0" },
UI.ProgressBar { id = "hp", value = 100, max = 100 },
}
}
UI.SetRoot(uiRoot)
function UpdateHUD(state)
local score = uiRoot:FindById("score") --[[@as Label?]]
if score then score:SetText("Score: " .. state.score) end
local hp = uiRoot:FindById("hp") --[[@as ProgressBar?]]
if hp then hp:SetValue(state.hp) end
end
FindById 返回 Widget|nil。调基类方法(SetVisible / Hide / SetStyle)不用管类型;调子类方法(SetText / SetValue / SetFontColor)必须在行尾加 --[[@as Label?]] 收窄,否则静态检查报 undefined-field ERROR。id 字符串写错运行时返回 nil,所以 if 判断不能省。
九、输入协调和背景图的两个细节
UI.SetRoot 会自动把根容器的 pointerEvents 设成 "box-none"(没显式设置时)。根容器自己不收点击,子控件正常收——这样全屏根节点不会把游戏输入全吃掉。想手动控制:"auto" 都收、"none" 穿透、"box-none" 自己不受但子控件受、"box-only" 反过来。Label 默认就是 "none"。
游戏层屏蔽输入用 UI.IsPointerOverUI():
if UI.IsPointerOverUI() then return end
-- 指针不在 UI 上,处理游戏输入
背景图那条:设了 backgroundImage 就别再设 backgroundColor。两者同时生效、底色画在图片下层,图片有透明区或者 backgroundFit = "contain" 留白时,底色会从透出来的地方显形,表现为头像/卡片周围一圈难看的色块底座。只有需要图片加载失败时的纯色兜底,才显式加 backgroundColor。
十、尺寸和缩放:一套能直接抄的数字
scale 决定基准像素到物理像素:物理像素 = 基准像素 × scale。
UI.Scale.DEFAULT(即 DPR_DENSITY_ADAPTIVE)的算法:
local dpr = graphics:GetDPR()
local shortSide = math.min(graphics.width, graphics.height) / dpr
local densityFactor = math.sqrt(shortSide / 720) -- 720 = PC 参考短边
densityFactor = math.max(0.625, math.min(densityFactor, 1.0))
return dpr * densityFactor
下限卡在 0.625 是有原因的:48px 的按钮 × 0.625 = 30px,再小触摸就不准了。
间距和圆角直接用主题取值,别自己拍脑袋。Spacing 是 xs 4 / sm 8 / md 16 / lg 24 / xl 32 / xxl 48,Radius 是 none 0 / sm 4 / md 8 / lg 12 / xl 16。字号语义:display 24 / headline 18 / title 15 / subtitle 14 / bodyLarge 12 / body 11 / bodySmall 10 / small 9 / caption 8。
刘海和 TapTap 胶囊用 UI.SafeAreaView 包住交互元素,背景图放外面铺满全屏:
UI.Panel {
width = "100%", height = "100%",
backgroundImage = "bg.png",
children = {
UI.SafeAreaView {
width = "100%", height = "100%",
nativeMenuInset = true, -- 同时避让 TapTap 胶囊
children = { hud },
}
}
}
十一、静默失效速查表
上面这些坑,共同点是不报错。整理成一张表,出问题时按症状查:
症状:先查这个 正确写法
全屏空白,控制台干净:漏了 UI.SetRoot 建树后必须调一次
子元素画到容器外面:flexShrink 默认 0 子元素加 flexShrink = 1
ScrollView 滚不动:缺 flexBasis flexGrow = 1, flexBasis = 0
内容区高度 0,只看得到表头:父容器用了 maxHeight 换成 height
百分比宽度没效果:父容器没有确定宽度 父容器给 width("100%" 也行)
点按钮没反应:回调签名写反 onClick(widget, event)
FindById 拿到 nil:id 字符串写错 / 不在该子树下 从祖先节点往下递归查
子类方法报 undefined-field:没做类型收窄 行尾加 --[[@as Label?]]
卡片头像周围一圈色块:同时设了背景图与背景色 只留 backgroundImage
浮层关闭后残留:没先 Close() 就 Destroy() 先出栈再销毁
弹性空白不生效:父容器主轴尺寸不确定 父容器给确定尺寸
注意表里「父容器给确定尺寸」出现了三次。这是 Yoga 这套 flex 模型里最高频的一类根因:百分比、flexGrow、aspectRatio 全部依赖它。父容器尺寸不确定,这三种写法都退化成「0 或者被忽略」,而且没有任何提示。
十二、适用边界
这套 UI 适合:2D 游戏 HUD、背包/商店/设置这类菜单界面、需要频繁改样式和加动画的界面。Yoga 的 flex 模型对「一列内容 + 撑满剩余空间」这种结构非常顺手,transition 直接写在属性上就能过渡,opacity、scale、rotate、borderRadius、backgroundColor、fontColor 等 14 个属性都支持。
不适合复杂二维表格。UI.SimpleGrid 底层是 flex-wrap,不是 CSS Grid,同一行里各项高度互相独立,没有跨轴对齐。真要做对齐的表格,用 UI.Table,或者自己算宽度。
几百上千个同质元素不要直接铺控件树,用 VirtualList。
逐帧刷新的元素(每帧变数字的血条、伤害数字)保留 local 引用直接调方法,别走 FindById——它是递归查找,每帧对着一棵大树调一次是纯浪费。
还有一条硬边界:cursor 属性当前是禁用状态,等引擎修复。
十三、可迁移的一条判断方法
UI 出问题,先分清是「没渲染」还是「渲染了但不对」。
什么都没显示 → 查 UI.SetRoot 调没调、控件是不是 visible = false、opacity 是不是 0。NanoVG 的 globalAlpha 是乘性的,父节点设了 0.5,整棵子树都半透明。
显示了但尺寸位置不对 → 查父容器有没有确定尺寸。百分比宽度、flexGrow 分配、aspectRatio 自动算的另一维,全都依赖父容器的确定尺寸。父容器没尺寸,子元素拿到的是 0,或者直接被忽略。
显示了、点了没反应 → 查三处:pointerEvents 是不是把事件吃了、回调签名有没有写反、控件是不是被同级 zIndex 更高的东西盖住。
按这个顺序走,大部分 UI 问题三步内能定位。
(完)