UI 小经验:别把“移除”当“销毁”,也别把“刷新”写成轮询
5 小时前13 浏览
做游戏 UI 时,有两类问题很容易在开发初期被忽略:
- 一个界面看起来已经消失了,但它占用的对象并没有真正释放。
- 界面明明没有变化,却仍在每帧查询数据、创建字符串,甚至重建整棵控件树。
它们通常不会立刻造成明显错误。游戏刚启动时一切正常,只有在反复打开界面、持续弹出提示、长时间运行之后,才逐渐表现为内存增长、GC 变频繁、UI 耗时升高,最终拖慢 FPS。
这篇文章结合一次海岛生存项目的实际排查,聊一聊 UrhoX UI 制作中最值得注意的两个问题:生命周期和刷新机制。
一、UI 消失了,不等于 UI 被销毁了
这是这次排查中最直接的生命周期问题。
UrhoX 新 UI 中,下面几个方法的语义不同:
Hide():暂时隐藏
luawidget:Hide() widget:Show()
控件仍然存在,只是不显示,也不占用布局空间。适合频繁开关、内容需要保留的固定 HUD。
Remove():从父容器脱离
luawidget:Remove() anotherParent:AddChild(widget)
Remove() 的设计目的是让控件稍后还能重新挂载,例如:
- 页签切换后保留页面;
- 对象池回收;
- 将控件移动到另一个容器。
它只解除父子关系,不释放 Yoga 节点。
Destroy():永久销毁
luawidget:Destroy() widget = nil
Destroy() 会:
- 停止控件动画和过渡;
- 递归销毁所有子控件;
- 从父容器移除;
- 调用 YGNodeFree() 释放 Yoga 原生节点。
如果一个 Toast 已经过期、一个临时弹窗以后不会再使用,或者一批旧列表项马上会被新列表替代,就应该调用 Destroy()。
ClearChildren() 也只是“全部移除”
这一点最容易误判:
luacontainer:ClearChildren()
它会把所有子控件从 Yoga 树中取下来,但不会销毁这些子控件。它适合“先暂存,稍后重新挂载”,不适合永久丢弃动态列表。
永久清空列表时,应明确销毁:
luafor i = container:GetNumChildren(), 1, -1 do local child = container:GetChildAt(i) if child then child:Destroy() end end
项目后来将这段逻辑统一封装为 Components.DestroyChildren(container),避免不同界面重复犯错。
一个实用判断
删除一个控件前,问自己一句:
我以后还会把这个对象重新挂回来吗?
- 会:使用 Hide() 或 Remove()。
- 不会:使用 Destroy()。
- 不确定:先理清谁拥有它,不要依赖 GC 猜测生命周期。
另外,销毁 Widget 只负责 UI 自身资源。如果界面曾订阅数据层事件、注册外部回调或加入其他管理器,关闭时还要主动取消订阅,否则数据层仍可能持有这个 UI 的 Lua 引用。
不要混淆场景 Node
上述规则针对的是 urhox-libs/UI 的 Widget。
场景对象 Node:Remove() 本身就是场景节点的销毁语义,不能因为名字相同,就把场景中的敌人、掉落物或建筑节点也改成 Destroy()。排查生命周期时,必须先确认对象类型。


二、更合理的刷新方式:变化时通知,运行时更新进度
UI 状态可以分为三类,不应该全部采用同一种刷新策略。
第一类:离散变化——发生时立即刷新
例如:
- 玩家添加了燃料;
- 收集网增加了一个物品;
- 制作开始或完成;
- 玩家取走产物;
- 背包数量变化;
- 按钮从可用变为不可用。
这类状态应该由数据层在修改成功后主动通知。下面的 SubscribeChanged / UnsubscribeChanged 是推荐的改造接口示意:
luaFacilityManager.SubscribeChanged(facility, function(reason) if reason == "items" then refreshItemList() elseif reason == "state" then refreshButtons() end end)
界面打开时订阅,关闭时取消订阅:
luafunction FacilityUI.Open(data) data_ = data FacilityManager.SubscribeChanged(data_, onFacilityChanged) refreshAll() end function FacilityUI.Close() FacilityManager.UnsubscribeChanged(data_, onFacilityChanged) data_ = nil end
通知应放在唯一的数据写入层,而不是让 UI 自己修改数据后再猜测谁需要刷新。
第二类:连续变化——只更新必要的显示值
例如:
- 进度条从 30% 走到 31%;
- 倒计时从 12.4 秒变为 12.3 秒;
- 动画和过渡持续播放。
这类变化确实需要随时间更新,但也不应该重建整棵 UI。应保留控件引用,只更新具体属性:
luafunction FacilityUI.Update(dt) if not isOpen_ or not data_.processing then return end local percent = data_.processing.elapsed / data_.processing.duration progressBar_:SetValue(percent) local seconds = math.ceil(data_.processing.duration - data_.processing.elapsed) if seconds ~= lastDisplayedSeconds_ then lastDisplayedSeconds_ = seconds countdownLabel_:SetText(tostring(seconds) .. " 秒") end end
进度条可以每帧平滑更新;只按整数秒显示的文字,则只在显示值真正变化时更新。
第三类:结构变化——只在结构变化时重建
例如:
- 配方列表换了一批;
- 收集网新增或删除物品种类;
- 种植状态从“空地”变为“生长中”,操作按钮组合发生变化。
这时可以重建局部子树,但应由结构变化事件触发,而不是定时触发。
列表不大时,收到变化事件后“销毁旧项并重建”通常足够简单可靠。列表很大时,应使用 UI.VirtualList、对象池或按 ID 增量更新。


三、推荐的最终职责划分
一个更清晰的设施 UI 架构可以这样分工:
数据层
- FacilityManager 是设施状态的唯一写入者;
- Inventory 是库存状态的唯一写入者;
- 离散状态改变后发送通知;
- 不允许 UI 直接回写共享数据 table。
UI 层
- 创建时保留需要动态更新的控件引用;
- 收到变化通知后调用具体 setter;
- 只有结构变化时才重建局部子树;
- 只有正在运行且界面可见时才更新连续进度;
- 关闭时取消订阅;
- 永久废弃的控件调用 Destroy()。
Update 循环
- 用于动画、平滑进度和真正连续的显示;
- 不负责发现所有业务数据变化;
- 不在每帧构造不必要的 table、排序列表或格式化相同字符串。
可以将原则概括成一句话:
数据变化由数据层主动通知,连续视觉由 Update 驱动,UI 结构只在结构变化时重建。


四、制作 UI 时的检查清单
生命周期
- 这个控件以后还会重新挂载吗?
- 临时 Toast、弹窗和列表项是否调用了 Destroy()?
- 是否误用 ClearChildren() 永久清空动态列表?
- 关闭界面时是否取消了外部订阅和回调?
- 是否区分了 UI Widget 和场景 Node?
刷新
- 数据没变化时,Refresh() 是否仍在运行?
- 只是改文字或图片时,是否直接使用了 setter?
- 是否为了更新一个进度值而重建整个 Panel?
- 离散变化能否由数据层主动通知?
- 连续变化是否只更新必要控件?
- 大列表是否需要 VirtualList 或对象池?
结语
UI 性能问题往往不是“画得太复杂”,而是生命周期和数据流不清楚:该销毁的对象只是被移走,该主动通知的变化却靠轮询发现,该更新一个属性的地方却重建了整棵树。
解决这些问题不需要把 UI 写得很复杂。相反,最有效的原则通常很朴素:
- 谁创建,谁负责销毁;
- 谁修改数据,谁负责通知;
- 能改属性,就不要重建结构;
- 没有变化,就不要刷新。
当这些边界清楚以后,UI 不但更省性能,也会更容易维护和排查。

