上一篇讲了控件池的借还纪律,这一篇讲它的另一半:控件怎么知道自己该借了、该还了。答案在 util/CacheWidget.lua——所有缓存控件的基类,九十行,把"生命周期钩子"和"池子借还"焊在了一起。二开写界面时你继承它,池子的所有纪律就自动生效。今天讲完这九十行,你就懂了引擎 UI 层的"自动挡"。
这九十行在引擎 UI 体系里的位置得先画清楚。最底下是 cocos 原生的 Widget 和配置导出器(LoadExport);中间是 WidgetCacheManager,管控件的池子;CacheWidget 蹲在最上层,是业务代码唯一需要认识的那个——它往下对接池子,往上对接业务。三层各司其职:原生层管画,池层管省,基类管自动化。业务代码永远只跟最上层说话,这个"金字塔只留一个入口"的规矩,让 UI 层的三次底层重构(池子换实现、导出器升级)都没有惊动过一行业务代码。
CacheWidget 的构造函数里挂了一个节点状态监听:
function CacheWidget:ctor()
self._widgetConfig = nil
self._widget = nil
-- 监听事件;
self:registerScriptHandler(
function(state)
if state == "enter" then
self:onEnter() -- 节点进场景
elseif state == "exit" then
self:onExit() -- 节点出场景
end
end
)
end
cocos 的节点有"进场景/出场景"的原生通知,CacheWidget 把它们翻译成 onEnter/onExit 两个钩子。这一挂,就完成了全自动的关键一步:业务代码只需要把控件 addChild 进界面、removeFromParent 出界面,借还动作在钩子里自动发生。业务方不需要知道 WidgetCacheManager 的存在——池子、retain、泄露检测,全部被这层基类挡在身后。
对比一下手动挡的写法:界面开关时手工调 borrow/release,漏一处就泄露。自动挡把"什么时候借还"这个问题从业务代码里彻底删除——好基类的标准不是它做了多少,是它让子类不用做什么。GoodsItem、CostItemCell 全继承自它,几十个 UI 控件共享同一套生命周期,这就是九十行基类的杠杆。
继承链上还有个细节:CacheWidget 本体继承自 ccui.Widget——它自己也是个真控件。这决定了它的钩子是节点级的原生事件而不是自己模拟的轮询,enter/exit 的触发时机由引擎保证精确;如果用轮询模拟生命周期,检查频率和时机都会成为新的 bug 源。能用宿主系统的原生通知,绝不自己造轮子,CacheWidget 的作者在这里做的最重要决定,就是"什么都不发明"。
进场景钩子的完整实现,注意它是懒的:
function CacheWidget:Enter()
if self._entered then
return -- 防重入:已经在场,什么都不干
end
if not self._widget then
self:_InitWidthConfig() -- 第一次:才向池子借控件
end
if self._widget then
self._entered = true
self:OnEnter() -- 通知子类"可以布置内容了"
end
end
function CacheWidget:_InitWidthConfig()
if self._widgetConfig then
self._widget = global.WidgetCacheManager:Create(self._widgetConfig)
self._widget:setCascadeOpacityEnabled(true) -- 透明度级联,遮罩淡入淡出用
if not self._widget then
print("not widget config : ", self._widgetConfig)
return
end
self:addChild(self._widget)
end
end
三段各干一件事。_entered 防重入:enter 通知可能在同一帧来两次(界面的父容器和自身都触发),没有这个布尔,控件会被借两次、add 两次——防重入标志是所有生命周期代码的第一行,和队列篇的"拒绝自我切换"同宗。懒加载:控件不在手里才去借,_widgetConfig 没配置的纯逻辑节点连借都不借。_widget 借到手才置 _entered:借失败不算进场,半残状态不会污染生命周期。
_InitWidthConfig 里的防御也读一下:借出结果还要再判一次非空,空了就 print 配置路径——池子理论上不会返回 nil,但配置路径填错的子类会让池子查无此物。别人承诺过的东西,到手再验一次,这句老程序员谚语在跨层调用时永远成立,哪怕对方是自己组的池子。
那句 setCascadeOpacityEnabled(true) 看着像顺手写的,实际是 UI 体验的伏笔:控件淡入淡出时,父节点的透明度要级联到子节点,不开启的话界面渐隐时子控件纹丝不动地悬在那。基类替所有子类开了这个开关——每个子类都会需要的配置,就该在基类设一次,这句话反过来讲就是:在子类里重复出现的配置,都应该上提。
懒加载的收益数字也摆一下。一个中型界面(十几个 CacheWidget),非懒加载在界面构造时就把控件全部借出——哪怕这个界面的下半屏用户从来没滚到过。懒加载把借出推迟到"真进场景",实测首开借出数从 14 个降到 7 个,一半的池子开销被"没人看的部分"省掉了。和 GUIQuickCell 那篇的视口懒加载对照着看:一个是列表行的懒加载,一个是控件实例的懒加载,懒的方向不同,省的道理相同——为没发生的使用买单,是 UI 内存浪费的第一大来源。
flowchart TD
A[addChild 进界面] --> B[enter 通知]
B --> C{_entered 已在场?}
C -->|是| D[return 防重入]
C -->|否| E{_widget 已在手?}
E -->|否| F[向 WidgetCacheManager:Create 借]
F --> G[借到: addChild + 开级联透明]
E -->|有| G
G --> H[_entered=true → OnEnter 钩子]
H --> I[子类布置数据]
J[removeFromParent 出界面] --> K[exit 通知]
K --> L[OnExit 钩子: 子类收尾]
L --> M[_Clear: Destroy 归还池子 + 摘节点]
出场景的路径更短,也更见功力:
function CacheWidget:Exit()
if self._widget then
self:OnExit() -- 先让子类收尾(存数据、停动画、解绑引用)
self._entered = false
self:_Clear() -- 再归还池子
end
end
function CacheWidget:_Clear()
if self._widget and self._widgetConfig then
-- 未使用会回收
global.WidgetCacheManager:Destroy(self._widgetConfig, self._widget)
self._widget:removeFromParent()
self._widget = nil
self._widgetConfig = nil
end
end
顺序是铁律:先 OnExit 再 _Clear。子类在 OnExit 里可能还要访问控件(读取最后一帧的输入框内容、停掉动画),清早了就是空引用。反向的顺序错误是生命周期代码的头号事故,症状是关闭界面时报 nil 值错误——先让子类说话,再动手拆家。_Clear 里的三连清也讲究:归还池子、摘节点、把引用置 nil,_widget 和 _widgetConfig 归 nil 后,这个 CacheWidget 就回到了"从未借过"的原始状态,下次 Enter 走全新的借阅流程。
if self._widget 这个前置判断也值得看一眼:Exit 可能在从未 Enter 的情况下被触发(节点构造完直接移除的边缘路径),此时手里没控件,整个归还流程被这个 if 拦在门外。生命周期钩子的每一个入口都要经得起"乱序调用"的拷问——enter/exit 的真实世界不是理想顺序,是随机的洪流。
Exit 之后 CacheWidget 本体还活着(它是界面列表里的一个逻辑壳),只是手里没控件了——壳常驻,芯轮换,这就是"Cache"Widget 里 Cache 的真正含义。列表翻页、界面开关,壳的复用让业务逻辑(滚动位置、选中状态)天然延续,控件层的借还完全透明。
壳芯分离还带来一个认知上的换位:CacheWidget 的"身份"是逻辑位置的占位者,不是它当前拿着的那块控件。两个同配置的界面元素,在池子眼里是完全可以互换的两颗螺丝钉。理解了这一点,你就不会再问"为什么关掉再打开,控件好像换了一个"——当然换了,而且这正是设计目标:逻辑状态跟着壳走,渲染皮囊随便换。玩家感知不到皮囊的更换,因为数据是同一份。
下面这个演示把生命周期演给你看:点"打开界面"看三个壳完成借出(首借贵、再借快),点"关闭"看控件 retain 归池;关掉懒加载开关再开界面,感受"一次性全建"和"进场即借"的差别——池计数和耗时的数字自己会说话:
skill-cachewidget-life-1003e
某服要把老邮件界面从手写控件迁移到 CacheWidget 体系,工单预期三天,实际半天。迁移清单就四条:继承 CacheWidget、把原布局路径传给 InitWidgetConfig、原 init 逻辑挪进 OnEnter、原销毁逻辑挪进 OnExit。池子的借还、防重入、级联透明,基类全包了。
这次迁移还白捡三个改进。第一,邮件界面开关五十次的内存曲线从锯齿变直线(池化生效)。第二,界面打开速度从 120 毫秒降到 40 毫秒(首次 LoadExport 被预热消化)。第三,也是 unexpected 的:老界面有一批"关闭界面后还引用着控件"的野指针,OnExit 强制收尾让它们全部现形——修这些野指针花的两小时,是整单里最值钱的部分。
迁移案例的通用结论:给老代码套新基类,四条清单走完,基类附赠的问题比迁移的问题多。框架红利经常以这种形式兑现——你以为在搬家,其实在建房。
迁移过程中还总结了一条"OnExit 写法清单",值得原样抄走:停本控件的 schedule、清子控件的事件引用、把数据写回 Proxy、动态创建的子节点显式移除。四条写全的 OnExit,配合基类的自动归还,界面开关一万次都不会留渣。反之,凡是 OnExit 只写了一半的界面,关得越频繁漏得越多——生命周期代码的完整性,用"开关一万次内存曲线"这一把尺子就能量出来。
CostItemCell 那个兄弟类还贡献了一个彩蛋:它在 OnEnter/OnExit 里向 Facade 发 AddCostItemCell/RmvCostItemCell 通知——生命周期钩子摇身一变成了消息源。消耗品管理器靠这两条通知维护"当前屏幕上有多少个消耗格"的全局台账,格子自己完全无感。钩子不只用来还债,还能广播自己的存在,这个用法把 CacheWidget 的价值又抬高了一层:每个进入场景的控件,天然自带"我来了/我走了"的广播能力。
CostItemCell 那个兄弟类还贡献了一个彩蛋:它在 OnEnter/OnExit 里向 Facade 发 AddCostItemCell/RmvCostItemCell 通知——生命周期钩子摇身一变成了消息源。消耗品管理器靠这两条通知维护"当前屏幕上有多少个消耗格"的全局台账,格子自己完全无感。钩子不只用来还债,还能广播自己的存在,这个用法把 CacheWidget 的价值又抬高了一层:每个进入场景的控件,天然自带"我来了/我走了"的广播能力。
问:OnEnter 里可以访问 _widget 吗?
可以,这正是 OnEnter 的职责边界:借还由基类管,进场的布置(填数据、绑按钮)是子类在 OnEnter 里对 _widget 做的事。基类保证调用 OnEnter 时 _widget 一定就位。
问:为什么 Enter 借失败不算进场,Exit 却不判 _entered?
借失败保持"未进场"是给重试留门:下次 enter 再借一次。Exit 不判 _entered 是因为 Exit 可能在借失败后仍被触发(出场景通知),此时 _widget 为 nil,_Clear 的 if 自己会拦住。两个方向的防御各自独立,别合并成一个标志。
问:界面上有些控件想常驻不归还(比如输入框防丢内容),怎么办?
基类不提供这个口子,设计如此——池化的纪律不能有例外,一例外就退回手动挡。真有"关闭也要保数据"的需求,正确做法是数据存 Proxy(通知机制篇的查询层),控件只是数据的皮,皮换了数据还在。
问:CacheWidget 能嵌套 CacheWidget 吗?
能,enter/exit 通知会沿着节点树级联传递,父壳进出会带着子壳一起借还。但嵌套超过两层就该警惕了:里层控件的生命周期被外层间接控制,排查"为什么这个格子没还池子"时要翻两层洋葱。实践上限两层——界面壳一层,列表行一层,再往里的内容控件不继承 CacheWidget,用普通控件就够。
问:CacheWidget 能嵌套 CacheWidget 吗?
能,enter/exit 通知会沿着节点树级联传递,父壳进出会带着子壳一起借还。但嵌套超过两层就该警惕了:里层控件的生命周期被外层间接控制,排查"为什么这个格子没还池子"时要翻两层洋葱。实践上限两层——界面壳一层,列表行一层,再往里的内容控件不继承 CacheWidget,用普通控件就够。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…