玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都知道这有多难:三处显示、一份数据、一次变化,怎么做到谁也不掉队?996 引擎的答案是 util/CostItemCell.lua,五十行的"消耗格"控件,配上两条生命周期通知。今天把这个"全屏数字同步"的机制拆开。
先说这个需求为什么高频。"消耗品数量显示"遍布全游戏:元宝、金币、绑定元宝、体力、活跃度、公会贡献——每种货币都要在多个界面同时显示,而且货币变化的触发方遍布全服务端(扣款、奖励、兑换、返还)。统计过一个中等服:显示货币的控件位置平均 11 处,货币变化的触发点 40 多个。11 乘 40 的同步矩阵,靠人肉维护通知就是灾难,必须有一个机制性的答案——CostItemCell 就是那个答案。
这类"多点显示一份数据"的需求还有个阴险的变体:显示本身没问题,但每个界面自己缓存了一份货币数。界面 A 存了打开时的元宝数,界面 B 实时刷新,玩家在 A 界面看到旧数字、切到 B 看到新数字——两边都对,合起来就是精神分裂。缓存型显示是同步问题的第二病灶,治法和裸 label 一样:收编,把缓存剥掉,全部改成现查现显。数据只活在一个地方,这个原则没有例外条款。
成本再算一笔账打消顾虑:一个 CostItemCell 的构建成本和普通控件无异(走控件池),台账维护是两条通知一次加一减一,同步刷新是遍历加查表——整套机制运行一天的 CPU 开销,抵不过一次全量界面刷新。机制性方案最怕的就是"想当然觉得重",量完你会发现它比散装写法还轻。
CostItemCell 继承 CacheWidget(借还那篇的基类),生命周期钩子里各发一条通知:
function CostItemCell:OnEnter()
-- addTo manager
global.Facade:sendNotification(global.NoticeTable.AddCostItemCell, self)
end
function CostItemCell:OnExit()
-- remove
global.Facade:sendNotification(global.NoticeTable.RmvCostItemCell, self)
end
Init 里还有一段前置清洁也顺带看一眼:
function CostItemCell:Init(data)
self._itemClass = require(UIConst.LUAFile.LUA_FILE_COSTITEM).new(self, data)
self:Cleanup() -- 锚点归中、坐标归零
self:Update(data) -- 数据装载
return true
end
function CostItemCell:Cleanup()
self._data = nil
self:setAnchorPoint({x = 0.5, y = 0.5})
self:setPositionX(0)
self:setPositionY(0)
end
格子进场景发 AddCostItemCell,出场景发 RmvCostItemCell——消耗品管理器靠这两条通知维护一份"当前屏幕上有几个消耗格"的全局台账。这就是 CacheWidget 那篇埋的彩蛋的完整展开:生命周期钩子兼职当消息源。格子自己不知道别的格子存在,管理器不知道格子长什么样,双方只通过台账通知接头——又是一层标准的解耦。
台账有什么用?最典型的是"货币变更的全屏广播":元宝变化时,管理器遍历台账,对每个在屏格子调 OnUpdateShow。没有台账的话,元宝变化的通知得让每个格子各自订阅——三处订阅三处维护,加第四处界面就漏一个。订阅制变台账制,漏更新的问题从机制上消失,这是两条通知最大的价值。
格子怎么拿数字?看 Update 和显示刷新:
function CostItemCell:Update(data)
self._data = data or {}
-- notRefresh 开关:有些格子明确声明"别刷我"
self._notRefresh = self._data and self._data.notRefesh
end
function CostItemCell:OnUpdate()
if self._itemClass and self._itemClass.OnUpdateShow then
self._itemClass:OnUpdateShow() -- 刷新显示,数字现查
end
end
注意格子从头到尾没存过元宝数量——_data 里只有配置(显示哪个货币、要不要刷新),数字本身是 OnUpdateShow 时现查的(底层走 ItemConfigProxy 或货币 Proxy)。格子是穷的,Proxy 是富的:显示组件不持有数据,每次渲染向数据层借。这样元宝变化的同步问题被降维成"通知大家重新来借"——借多少是 Proxy 的事,格子永远显示最新的。
"格子是穷的"这条纪律还有个安全红利:显示组件不持有数据,就没有数据过期的问题,也就没有"界面上的数字是旧的"这类事故。富数据集中在一个地方(Proxy),数据变更只有一处真相源。穷显示加富数据源,是全屏同步的地基——先有这个结构,台账和通知才有用武之地。
notRefesh 这个开关(源码里就拼成这样,又一个祖传拼写)是个护栏:某些格子在特定状态下明确声明"别刷我"——比如拖拽确认中的数量格,刷新会打断输入。批量刷新必须留单点豁免,这是台账制的必要补丁,和视野管理的豁免名单是同族设计。
OnUpdateShow 里那句 self._itemClass and ...OnUpdateShow then 的双重判空也别嫌啰嗦:_itemClass 是动态 require 来的皮肤类,配置错误时它可能是 nil;皮肤类存在但没实现 OnUpdateShow 也要兜住。显示刷新是最低层,底层的每一次调用都要假设上层缺胳膊少腿——底层防御重、上层逻辑轻,这是分层系统的重量分布规律,倒过来分层系统必脆。
flowchart TD
A[CostItemCell OnEnter] --> B[发 AddCostItemCell]
B --> C[消耗品管理器台账 +1]
D[界面关闭 OnExit] --> E[发 RmvCostItemCell]
E --> F[台账 -1]
G[元宝变化: 服务端扣款/充值] --> H[货币 Proxy 更新]
H --> I[管理器遍历台账]
I --> J[逐格调 OnUpdate]
J --> K[每个格子 OnUpdateShow 现查现显]
L[notRefesh=true 的格子] -.->|豁免| J
CostItemCell:Init 的调用顺序藏着一点时序智慧:
function CostItemCell:Init(data)
self._itemClass = require(UIConst.LUAFile.LUA_FILE_COSTITEM).new(self, data)
self:Cleanup() -- 清空初始状态:锚点归中、坐标归零
self:Update(data) -- 数据装载
return true
end
Cleanup 在 Update 之前跑——先把上一次使用残留的锚点和坐标归零,再装新数据。这个顺序保证格子从池子里借出来时是"物理复位、数据干净"的白纸状态,和控件池那篇的"入池必须洗澡"正好是镜像:池子借出前洗澡(基类做),数据装载前也洗澡(自己做),双重清洁下才没有上一任的残影。
Init 里那句 require(UIConst.LUAFile.LUA_FILE_COSTITEM) 也值得多看一眼:格子的"业务皮肤"是按配置路径动态 require 的一个类,格子壳和显示逻辑分离——壳管生命周期和台账登记,皮肤类管长什么样、怎么刷。换肤需求(活动限定的金色元宝格)只换 LUAFile 配置,台账机制分毫不动。壳皮分离这个手法在背包格子的 GoodsItem 里也出现过,两者同源。
Cleanup 的三行复位(锚点归中、X 归零、Y 归零)看着像凑数,实际是池化格子的必做功课:上一个界面把格子摆在了任意位置、任意锚点,不复位的话新界面的布局会被继承来的坐标搅乱。控件池篇讲过"谁弄脏谁洗澡",Cleanup 就是洗澡的具体清单——每个借出的控件,归还和再借出各洗一次,双重保险。
最后提一句 Update 里的 _notRefresh 判断顺序:先 self._data and 再取字段,两段判空防的是"格子还没 Update 过就被刷"的时序边角。台账通知和初始化的先后在正常流程里不会错,但界面关闭瞬间的乱序通知真实存在——底层的判空永远不是多余的,这在消耗格上又应验了一次。
OnEnter 发台账通知发生在 Init 之后(enter 通知在 addChild 后才来),所以台账登记的格子一定是"数据已就绪"的格子——不会出现台账里有它、一刷新就空引用的半成品。先备货再挂牌,时序上永远留这个余量。
下面这个演示把台账制整个演活了:三个界面的元宝格读同一份数据,点"消耗"或"充值"看全屏同步跳字(黄框闪烁就是 OnUpdateShow);点"开关商店"看台账的进出——商店开着的格子才参与同步,关了就出账。数字永远一致,因为它们根本是同一个数:
skill-cost-cell-1003f
某服反馈"买药后充值面板的元宝数字要重开界面才变"。排查发现充值面板的元宝显示是手写的 label,没接 CostItemCell 体系——元宝变化的通知它听不懂,只有界面重开时的全量刷新救它。这类"游离于体系外的显示"是数字不同步的万恶之源。
治理两步。第一步,充值面板换用 CostItemCell,入台账受管理。第二步,全项目搜"元宝"相关的裸 label,逐个收编——共找到 7 处,收编工时一天。收编之后,"数字不同步"类工单从月均 11 条降到零,而且永久归零:因为新界面只要用标准格子,同步是白送的。
这次收编顺手把"体力""活跃度"也搬进了同一套体系——CostItemCell 的 data 里带显示哪种货币的配置,同一个格子类服务所有货币。一套代码五种货币,新货币上线是配置行为。体系的可扩展性在这一步彻底兑现:收编不只是修 bug,是把散兵游勇整编成军。
收编之后还有个长效机制:代码评审里加了一条"禁止裸 label 显示货币",评审员看到 label 直接问"为什么不是 CostItemCell"。挡新病比治老病便宜,这条规矩上线两年,同步类工单再没开过张。
那次排查的定位过程也值得一提,用的是"反向二分":先确认数据层没错(服务端扣款记录正确),再确认通知发了(日志里 RefreshHUDHP 之类全在),最后锁定是"收通知的人不在编制内"。三段排查每段十分钟,比在渲染层乱翻快十倍。数据-通知-显示的三段式排查,适用于一切"数字不对"类工单,先分段再深入,别一头扎进渲染代码。
三段式排查还能再进一步变成自动化:在测试包里给三段各埋一个计数器(扣款次数、通知次数、刷新次数),跑一遍买药流程,三个数字应该相等。不相等的那一段就是病灶——这套"三段计数对账"后来成了货币功能的固定测试项,新界面接入时跑一遍,漏接通知的立刻现形。对账的思想在 Buff 过期、掉落登记里都出现过,这里是它在 UI 层的变体。
那次治理还有个细节值得记录:收编的 7 处裸 label 里,有一处是活动界面的"限时礼包价格"——它显示的不是元宝余额而是商品价格,本来不该收编,差点被一刀切。区分"显示余额"和"显示价格"是收编工作的第一道判断题:余额走 CostItemCell(要同步),价格走静态配置(价格变化走活动更新)。收编之前先分类,不是所有数字都该活在一个体系里——分类错了,同步机制反而会把不该动的价格也刷了。
问:元宝变化特别频繁(连买十瓶药),会不会刷爆台账遍历?
每次变化遍历在屏格子(个位数到十几个),单次微秒级,完全无压力。真有高频场景(挂机使用千瓶),调度层还能套血条班车那篇的合并去重——台账制和合批制天然兼容。
问:notRefesh 的拼写错误为什么不改?
和 GetWiget 一样是祖传拼写,全项目引用点已成既成事实。改拼写的收益是舒服一秒,风险是漏改一处引用点全线失灵——祖传错误的价值在于稳定,新人入职第一周就会被告知这两个"官方错别字"。
问:格子从池子里借出来,台账会不会记串?
不会。台账记的是对象引用,池子借还走的是同一批对象——借出时它在屏上(OnEnter 入账),归还时出屏(OnExit 出账),台账和池子的状态天然同步。万一出现"台账里有、场景里没有"的鬼魂格子,一定是 OnExit 没被触发(节点被强制销毁),查清理链路而不是查台账。
问:两种货币想并排显示怎么办?
两个 CostItemCell 并排放,各自 data 里配货币类型,各刷各的互不干扰。不要做一个"能显示两种货币"的大格子——组合优于膨胀,两个标准格子的排版自由度和维护成本都优于一个特化格子。这个原则在 UI 层百试百灵。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…