UI 小经验:别把“移除”当“销毁”,也别把“刷新”写成轮询

5 小时前13 浏览
做游戏 UI 时,有两类问题很容易在开发初期被忽略:
  1. 一个界面看起来已经消失了,但它占用的对象并没有真正释放。
  2. 界面明明没有变化,却仍在每帧查询数据、创建字符串,甚至重建整棵控件树。
它们通常不会立刻造成明显错误。游戏刚启动时一切正常,只有在反复打开界面、持续弹出提示、长时间运行之后,才逐渐表现为内存增长、GC 变频繁、UI 耗时升高,最终拖慢 FPS。
这篇文章结合一次海岛生存项目的实际排查,聊一聊 UrhoX UI 制作中最值得注意的两个问题:生命周期刷新机制

一、UI 消失了,不等于 UI 被销毁了

这是这次排查中最直接的生命周期问题。
UrhoX 新 UI 中,下面几个方法的语义不同:

Hide():暂时隐藏

lua
widget:Hide() widget:Show()
控件仍然存在,只是不显示,也不占用布局空间。适合频繁开关、内容需要保留的固定 HUD。

Remove():从父容器脱离

lua
widget:Remove() anotherParent:AddChild(widget)
Remove() 的设计目的是让控件稍后还能重新挂载,例如:
  • 页签切换后保留页面;
  • 对象池回收;
  • 将控件移动到另一个容器。
它只解除父子关系,不释放 Yoga 节点。

Destroy():永久销毁

lua
widget:Destroy() widget = nil
Destroy() 会:
  1. 停止控件动画和过渡;
  2. 递归销毁所有子控件;
  3. 从父容器移除;
  4. 调用 YGNodeFree() 释放 Yoga 原生节点。
如果一个 Toast 已经过期、一个临时弹窗以后不会再使用,或者一批旧列表项马上会被新列表替代,就应该调用 Destroy()

ClearChildren() 也只是“全部移除”

这一点最容易误判:
lua
container:ClearChildren()
它会把所有子控件从 Yoga 树中取下来,但不会销毁这些子控件。它适合“先暂存,稍后重新挂载”,不适合永久丢弃动态列表。
永久清空列表时,应明确销毁:
lua
for 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()。排查生命周期时,必须先确认对象类型。
horizontal linehorizontal line

二、更合理的刷新方式:变化时通知,运行时更新进度

UI 状态可以分为三类,不应该全部采用同一种刷新策略。

第一类:离散变化——发生时立即刷新

例如:
  • 玩家添加了燃料;
  • 收集网增加了一个物品;
  • 制作开始或完成;
  • 玩家取走产物;
  • 背包数量变化;
  • 按钮从可用变为不可用。
这类状态应该由数据层在修改成功后主动通知。下面的 SubscribeChanged / UnsubscribeChanged 是推荐的改造接口示意:
lua
FacilityManager.SubscribeChanged(facility, function(reason) if reason == "items" then refreshItemList() elseif reason == "state" then refreshButtons() end end)
界面打开时订阅,关闭时取消订阅:
lua
function 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。应保留控件引用,只更新具体属性:
lua
function 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 增量更新。
horizontal linehorizontal line

三、推荐的最终职责划分

一个更清晰的设施 UI 架构可以这样分工:

数据层

  • FacilityManager 是设施状态的唯一写入者;
  • Inventory 是库存状态的唯一写入者;
  • 离散状态改变后发送通知;
  • 不允许 UI 直接回写共享数据 table。

UI 层

  • 创建时保留需要动态更新的控件引用;
  • 收到变化通知后调用具体 setter;
  • 只有结构变化时才重建局部子树;
  • 只有正在运行且界面可见时才更新连续进度;
  • 关闭时取消订阅;
  • 永久废弃的控件调用 Destroy()

Update 循环

  • 用于动画、平滑进度和真正连续的显示;
  • 不负责发现所有业务数据变化;
  • 不在每帧构造不必要的 table、排序列表或格式化相同字符串。
可以将原则概括成一句话:
数据变化由数据层主动通知,连续视觉由 Update 驱动,UI 结构只在结构变化时重建。
horizontal linehorizontal line

四、制作 UI 时的检查清单

生命周期

  • 这个控件以后还会重新挂载吗?
  • 临时 Toast、弹窗和列表项是否调用了 Destroy()
  • 是否误用 ClearChildren() 永久清空动态列表?
  • 关闭界面时是否取消了外部订阅和回调?
  • 是否区分了 UI Widget 和场景 Node?

刷新

  • 数据没变化时,Refresh() 是否仍在运行?
  • 只是改文字或图片时,是否直接使用了 setter?
  • 是否为了更新一个进度值而重建整个 Panel?
  • 离散变化能否由数据层主动通知?
  • 连续变化是否只更新必要控件?
  • 大列表是否需要 VirtualList 或对象池?

结语

UI 性能问题往往不是“画得太复杂”,而是生命周期和数据流不清楚:该销毁的对象只是被移走,该主动通知的变化却靠轮询发现,该更新一个属性的地方却重建了整棵树。
解决这些问题不需要把 UI 写得很复杂。相反,最有效的原则通常很朴素:
  • 谁创建,谁负责销毁;
  • 谁修改数据,谁负责通知;
  • 能改属性,就不要重建结构;
  • 没有变化,就不要刷新。
当这些边界清楚以后,UI 不但更省性能,也会更容易维护和排查。
1
1