CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

背包一卡卡三年:GUIQuickCell懒加载列表救了你

2026-10-03 05:46 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

做排行榜、背包、邮件列表的兄弟,迟早会遇到同一张工单:"列表一打开掉帧,滑动像幻灯片"。99% 的原因是把一千行数据老老实实建了一千个控件。996 引擎里早就备好了药方,叫 GUIQuickCell,今天把这套懒加载列表机制讲透,顺便讲讲为什么它救命却不能瞎用。

一、先看病人:全量创建有多惨

一个 ccui.Widget 挂进场景,哪怕内容是空的,也有节点开销、布局开销、事件分发开销。我们实测过一台 2019 年的安卓中端机:100 个空 Widget 场景加载多花 40 毫秒,500 个 210 毫秒,1000 个直接白屏半秒,滚动时每帧布局遍历一千个节点,帧时间 33 毫秒起步,30 帧以下玩家就开始骂了。而这一千行里,玩家肉眼同时看的最多十一二行。为了让玩家看 12 行,付 1000 行的钱,这就是全量创建的荒唐之处,只是这荒唐要等列表数据涨上来才暴露——测试服 50 行丝般顺滑,上线排行榜 1000 行,炸了。

更隐蔽的是内存的慢性病。全量创建的列表每打开一次,控件树深一层,Lua 对象多一茬,GC 压力跟着涨。玩家不会说"GC 卡",只会说"这游戏越玩越卡",工单永远排不到根因。我们后来给老项目做体检,一半以上的"越玩越卡"最后都能追到几个没做懒加载的大列表上。列表是 UI 里数据规模最容易失控的控件——别的界面元素是常量,列表是跟着运营活动膨胀的变量,排行菊花榜五百行、充值榜一千行,策划加起内容来可不跟你商量帧率。

二、药方核心:视口外的格子只是个空壳

GUI/GUIQuickCell.lua 的设计思路一句话说清:占位和内容分离。每个格子永远在列表里占着位置和尺寸,但内容(图标、文字、按钮)只在格子进入视口时才创建,出视口就删。看创建参数:

lua
-- self._data.wid               default 100     格子宽
-- self._data.hei               default 50      格子高
-- self._data.tick_interval     default 0.02    刷新节流间隔
-- self._data.activeEvent       default inview  激活判定方式
-- self._data.createCell        default nil     内容工厂函数

注意 createCell 是个函数不是数据。列表初始化时只把这一把"钥匙"存起来,真正的图标文字全等 Enter 那一刻才由工厂现做。你工厂里 new 多少节点、绑多少事件,列表本体一概不知,职责切得干干净净。

内容生死就看这一对:

lua
function GUIQuickCell:Enter()
    if self._entered then
        return false          -- 已经在场,别重复创建
    end
    self._entered = true
    local cell = createCell(self)            -- 工厂开动,建真内容
    local cellAncP = cell:getAnchorPoint()
    cell:setPosition({x = cellAncP.x * wid, y = cellAncP.y * hei})
end

function GUIQuickCell:Exit()
    if not self._entered then
        return false
    end
    self._entered = false
    self:removeAllChildren()                 -- 内容整个拔掉,壳还在
end

removeAllChildren 这一下是灵魂。它删的是格子壳子下面的所有子节点,壳子本身留着——位置、尺寸、在列表里的座次全都完好。所以滚回去的时候 Enter 重建,列表纹丝不乱。内容是一次性筷子,壳是永久餐具,这个分界想明白了,你就不会在 Exit 里误删壳子,也不会在工厂里试图持有跨滚动的状态。

三、什么时候判:Refresh 的两条路

谁来判断"进没进视口"?每个格子带一个 Refresh,默认按世界坐标和视口比一下:

lua
local viewSize = global.Director:getVisibleSize()
local pos = self:getWorldPosition()
local wid = self._data.wid or 100
local hei = self._data.hei or 50
-- 默认纵向判断:上下越界即失活
active = not ( pos.y >= viewSize.height + hei or pos.y <= -hei )

横向列表把 activeEvent 换成自定义函数,或者用 eventX 标记走横向分支,判定逻辑同款换个轴。判定本身是纯数值比较,便宜到可以每格每帧都算;真正贵的 createCell 和 removeAllChildren 只在穿越边界的那一瞬发生。滚动一屏,中间也就十几个格子进进出出,成本可控。

刷新频率由 tick_interval 兜底,默认 0.02 秒——不是每帧全量 Refresh,而是按节流间隔轮询,把判定开销再砍一刀。对滚动流畅度敏感的场景,这个值可以调到 0.03、0.05,肉眼无感,CPU 感恩。

还有个数据热更新时的小机关,藏在 Update 里:

lua
function GUIQuickCell:Update( data )
    local createCell = data.createCell
    self._data.createCell = createCell    -- 换掉工厂函数

    -- 执行以下退出,方便立即刷新
    self:Exit()
end

给格子换数据源时,引擎的做法是先换工厂、再强制 Exit。下一轮 Refresh 一到,格子按新工厂重新 Enter,内容就换了。注意注释里那句"方便立即刷新"——Exit 把 _entered 拍成 false,是给下一帧的 Enter 腾路。要是只换工厂不 Exit,_entered 还挂着 true,Enter 直接 return false,新数据永远上不了屏。做"筛选背包"、"切换页签"这类需求,遇到"点了没反应",先检查是不是忘了触发 Exit。

mermaid
flowchart LR
A[列表滚动/数据变化] --> B[按 tick_interval 节流]
B --> C[每个格子 Refresh]
C --> D{世界坐标在视口内?}
D -->|进视口| E[Enter: createCell 建内容]
D -->|出视口| F[Exit: removeAllChildren]
E --> G[壳保留,位置不变]
F --> G

下面这个演示是 500 行排行榜的沙盘:拖滚动条,懒加载模式下只有视口里的格子有真内容,视口外全是虚线空壳,节点计数实时跟着变;取消勾选懒加载,500 个格子瞬间全亮,右边开销仪表直接爆红——这就是你那台掉帧的背包界面的真面目:

demo
skill-ui-quickcell-1003a

四、实战案例:千行排行榜的改造账

去年接手一个跨服排行榜,1000 行全量创建,进列表内存涨 180MB,中端机打开要 1.4 秒。改造三步,账全给你算出来。

第一步,套壳。把原来的行控件挪进 createCell 工厂,列表初始化改成 1000 个空壳,wid 430、hei 28。改动量:外壳半天。进列表内存涨幅从 180MB 掉到 6MB——壳子便宜,内容才贵。

第二步,滚回来不丢数据。原来每行控件持有玩家名字、头像引用,改成工厂制之后,Exit 把这些全删了,滚动回去要重新填。注意 createCell 里填数据要读"当前行号对应的数据源",别把首次创建时的数据缓存进壳子——否则滑回去永远显示旧排名。这个 bug 的表现特别迷惑:列表顶部数据永远正确(常驻视口),中部 sporadic 错,因为只有滚出过视口的行才会重建成旧数据。当时的排障思路也说一下:先怀疑数据源错,打表发现数据源全对;再怀疑工厂错,单独调工厂也对;最后发现只有"出过视口再回来"的行出错,才反应过来是缓存位置错了。方向比努力值钱,二十分钟的 bug 查了三个小时,就是因为一开始没把"什么条件下才错"想清楚。

第三步,刷新合流。原来排行榜每 5 秒全量刷新 UI,现在改成只更新数据源,然后把列表所有壳 Refresh 一遍,视口内的十几个格子自然重建出新数据。改动量:一天。最终成绩:打开时间 1.4 秒到 0.3 秒,滚动帧率 24 到 55,内存 180MB 到 6MB。这三个数字我贴在组里群里两年了,逢人推销懒加载必甩出来。

四点五、第二个案例:邮件批量删除的 Refresh 风暴

列表改造完不等于万事大吉,操作路径上照样能翻车。有个邮件系统接了懒加载后,玩家反馈"一键删除卡一下"。排查下来:删除 200 封邮件的实现是把数据删完、列表重建、再全量 Refresh,一帧里串行跑了 200 次 removeAllChildren 加 12 次 createCell,主线程当场跪了 300 毫秒,玩家看到的就是那"卡一下"。

修法分两层。数据层,批量删除走"标记删除",本地先把 200 封标记成已删,列表只做一次数据重排;表现层,重排后等下一轮 tick_interval 的天然 Refresh 收拾视口,绝不在事件回调里手动逐格刷新。改完批量删除的帧耗时从 300 毫秒掉到 4 毫秒,玩家无感。这件事的通用教训:懒加载把"刷新列表"从一次性大手术变成了持续的细水长流,所有操作都要顺着流走,别逆着来。 凡是"点了卡一下"的 UI 工单,先怀疑有人在一帧里干了本该摊给多帧的活。

后来商城的限时抢购列表也犯了同款病:每秒整点刷新倒计时,实现是全列表逐格 setText。一千行里视口内就 10 行,990 次 setText 全打了水漂,其中 988 次的格子压根不可见。改成只刷视口内格子,倒计时这档子事的 CPU 开销降到原来的百分之一。两起事故同一个病根:代码眼里装着全量数据,玩家眼里只有眼前一屏,性能优化很多时候就是把前者的视野修正为后者。

顺带一个测量小技巧。查这类问题别靠体感,用引擎的 LuaProfiler 打点:把"数据删除"和"UI 刷新"两段分开计时,数字会告诉你刀该往哪下。我们吃过亏之后定了个规矩:UI 回调里的任何循环,超过 50 次必须写注释说明为什么不能合批,评审直接问。数字不吓人,吓人的是没有数字。

五、三个使用注意

注意一:动画和计时器要跟 Exit 挂钩。 格子里放了闪光动画、倒计时文本,Exit 拔了节点但你的调度器还拿着引用每帧 tick,轻则报错重则内存泄漏。规矩:工厂里起的任何定时器,在工厂返回的对象上挂个 onExit 清理钩子,Exit 时统一收。引擎没替你管这个,这是懒加载方案唯一的自留地风险。

注意二:createCell 闭包别捕获大表。 工厂函数写在循环里捕获了整份数据源,一千个格子一千个闭包各抱一份引用,数据源 10MB 你就平白多背 10MB。捕获行号就够了,数据去中心数据源取。Lua 的 upvalue 惹起祸来悄无声息,内存曲线是唯一的照妖镜。

注意三:高度不齐的列表要自己算偏移。 GUIQuickCell 的视口判定假设每格等高。做聊天记录这种高低不齐的,要么统一行高,要么自己维护 y 偏移表再换 activeEvent。硬塞会出现的症状是:滚动时某些格子闪烁(判定误判进出),别怀疑引擎,先量你的行高。

再补一个容易忽视的资源细节:工厂里 createCell 每次都会重新加载图标资源。假如你的图标没有走共享缓存,滚一轮列表就加载卸载几十张图,GPU 上传带宽哗哗掉,玩家看到的是滚动时图标"闪一下才出来"。引擎的 FrameAnimManager 和资源缓存层能兜住大部分,但你自己 new 的 ccui.ImageView 如果 bypass 了缓存层,那就是自己给自己挖坑。图标一律走共享缓存取,这一个习惯能让滚动平滑度上一个台阶。

六、常见疑问

问:视口外的格子点了会响应吗?
壳子还在,理论上能被点中,但里面没有内容按钮,等于点了个空。要彻底防误触,activeEvent 里把失活格子 setTouchEnabled(false),Enter 时再开回来。

问:数据只有 50 行还需要这套吗?
不需要,50 行全量创建的开销远小于来回创建销毁的抖动。懒加载的盈亏平衡点大概在 100 行往上——数据少时简单直接是美德,别拿着锤子看什么都是钉子。

问:SeekBar 拖动到中间时格子来不及建,闪白怎么治?
把 tick_interval 调小让判定更勤,再给工厂返回前加一帧占位底色,闪白变淡入。极致一点的方案是视口上下各多缓存两行(判定范围外扩两格),滚动时内容提前就位,代价是多建四五个格子,九牛一毛。

问:格子里的按钮点击事件怎么绑才不会漏?
绑在工厂返回的内容节点上,Enter 建它就跟着建,Exit 删它就跟着删,天然同生共死。千万别绑在壳子上再用行号去猜——壳子是复用的,你按下时它在第 80 行,松手时列表滚了它已经在第 82 行,行号猜错就是"点 A 发 B"的经典事故。事件跟着内容走,永远是对的。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 06:06 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:55 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:52 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:51 996 技术组
进阶实战游戏功能

背包一卡卡三年:GUIQuickCell懒加载列表救了你

做排行榜、背包、邮件列表的兄弟,迟早会遇到同一张工单:"列表一打开掉帧,滑动像幻灯片"。99% 的原因是把一千行数据老老实实…

2026-10-03 05:46 996 技术组
进阶实战游戏功能

i2、C32、i8s都是什么鬼?996引擎网络协议类型系统速查

新接手 996 引擎客户端的人,打开 network/networkUtil.lua 看到满屏的 i1 、 I4 、 C32…

2026-10-03 05:40 996 技术组