虚拟列表与渲染合并:从算法到 Cocos 完整实现
对应月影课程:第 19~22 章(大规模数据渲染、列表优化)
目标:掌握虚拟列表的完整算法实现、与 Cocos Batcher2D 的协同优化、以及内存与性能的平衡策略。
前置知识:你需要先知道这些
什么是 DrawCall
DrawCall 是 CPU 通知 GPU "画这个东西"的一次命令。每次 DrawCall,CPU 都要告诉 GPU:用什么纹理、用什么 Shader、顶点数据在哪里。
类比:DrawCall 就像你给厨房下订单。每道菜都是一个 DrawCall。如果 1000 个客人每人点一道菜,厨房要接 1000 次订单(1000 个 DrawCall),非常忙。如果能把相同的菜合并成"炒 100 份宫保鸡丁",订单数就少了(合批)。
为什么 DrawCall 是性能瓶颈?
- CPU 准备一次 DrawCall 需要设置很多状态
- CPU 和 GPU 之间的通信有延迟
- 移动端 CPU 更弱,DrawCall 开销更明显
什么是节点(Node)
在 Cocos 中,Node 是场景中的基本单位。一个 Sprite、一个 Label、一个 Button 都是 Node。
每个 Node 都有:
- 位置、旋转、缩放(Transform)
- 渲染组件(Sprite、Label 等)
- 每帧需要更新 transform
类比:Node 就像舞台上的演员。1000 个演员同时上台,导演(CPU)要逐个告诉他们站哪里、做什么动作,非常累。虚拟列表就是只让舞台上能看到的演员上台,其他的在后台休息。
什么是合批(Batching)
合批是把多个可合并的绘制命令合并成一个 DrawCall。
合批的条件(Cocos Batcher2D):
- 相同的纹理(Texture)
- 相同的材质(Material / Effect)
- 连续的节点(siblingIndex 连续)
类比:合批就像拼单购物。你和朋友都买同一家店的东西,可以合并成一个订单(1 个 DrawCall)。如果买不同店的东西,就必须分开下单(多个 DrawCall)。
一、问题定义:为什么需要虚拟列表
1.1 直观场景
一个背包界面有 1000 个物品格子:
传统做法:
- 创建 1000 个 Sprite 节点
- 每个节点 = 1 个 DrawCall(无合批时)
- 总 DrawCall = 1000+
- 每帧遍历 1000 个节点计算 transform
- 内存:1000 × 节点开销 ≈ 几十 MB
虚拟列表:
- 只创建可见区域能容纳的节点(如 15 个)
- 滚动时复用节点,更新数据
- DrawCall = 15(合批后可能 1~3)
- 每帧遍历 15 个节点
- 内存:15 × 节点开销 ≈ 几百 KB
1.2 性能数据对比
| 指标 | 传统列表(1000 项) | 虚拟列表(15 可见项) | 提升倍数 |
|---|---|---|---|
| 节点数量 | 1000 | 15 | 66× |
| DrawCall | 1000+ | 1~3 | 300×+ |
| 每帧遍历开销 | 1000 次 | 15 次 | 66× |
| 内存占用 | ~30 MB | ~500 KB | 60× |
| 初始化时间 | 500~1000ms | 50~100ms | 10× |
| 低端机帧率 | 10~15 FPS | 55~60 FPS | 4× |
测试环境:Cocos Creator 3.8.2,Web 平台,小米 8(骁龙 845)
1.3 如果不懂虚拟列表会怎样
场景 1:你做了一个背包系统,2000 个物品
- 玩家打开背包,卡了 2 秒才显示
- 滑动背包时帧率掉到 15 FPS
- 低端机直接闪退(内存不足)
- 玩家差评:"什么垃圾优化"
场景 2:你做了一个排行榜,10000 名玩家
- 初始化时创建 10000 个节点,游戏卡住 5 秒
- 每帧遍历 10000 个节点,CPU 占用 90%
- 即使大部分玩家看不到,GPU 还是要处理
学会虚拟列表后:
- 2000 个物品 = 只创建 15 个节点
- 滑动流畅 60 FPS
- 内存占用从 30MB 降到 500KB
- 低端机也能流畅运行
二、虚拟列表核心算法
2.1 算法思想
视口(Viewport):屏幕上可见的矩形区域
内容(Content):所有数据项组成的虚拟区域
关键洞察:
用户只能看到视口内的内容
视口外的节点对用户不可见,无需渲染
核心操作:
1. 计算视口在内容坐标系中的范围
2. 确定哪些数据项与视口相交
3. 只创建/更新这些项对应的节点
4. 滚动时回收离开视口的节点,分配给新进入的项
类比:虚拟列表就像电影院的座位安排。电影院有 1000 个座位(数据项),但观众只能看到银幕前的几十排(视口)。不需要把 1000 个座位都打扫干净,只需要打扫观众能看到的那几十排。当观众走动时(滚动),打扫工人跟着移动,只打扫新的可见区域。
2.2 坐标系与变量定义
视口坐标系(Viewport Space):
┌─────────────────────────────┐ ← top = 0
│ │
│ 可见区域 (Viewport) │ height = viewHeight
│ │
│ ┌─────┐ ┌─────┐ │
│ │ item│ │ item│ │
│ │ 5 │ │ 6 │ │
│ └─────┘ └─────┘ │
│ │
└─────────────────────────────┘
内容坐标系(Content Space):
所有 item 按顺序排列的虚拟坐标
item 0: y = 0 ~ itemHeight
item 1: y = itemHeight ~ 2×itemHeight
item 2: y = 2×itemHeight ~ 3×itemHeight
...
item N: y = N×itemHeight ~ (N+1)×itemHeight
滚动偏移(ScrollOffset):
content.y = -scrollOffset // Cocos 中 Content 向上移动
为什么 content.y = -scrollOffset? 在 Cocos 的 UI 坐标系中,y 轴向上为正。但滚动时,内容应该"向下移动"(露出上方的内容)。所以 Content 的 y 坐标是负的滚动偏移量。就像你拿着一张长纸条,向上拉(scrollOffset 增加),纸条在视觉上向下移动。
2.3 可见范围计算
/**
* 给定滚动偏移,计算可见的数据索引范围
*
* 核心思想:
* 1. 视口顶部对应的 item 索引 = scrollOffset / itemHeight
* 2. 视口底部对应的 item 索引 = (scrollOffset + viewHeight) / itemHeight
* 3. 加上缓冲,避免快速滚动时白屏
*
* @param scrollOffset 当前滚动偏移(像素),表示内容向上滚动了多少
* @param viewHeight 视口高度(像素),屏幕上可见区域的高度
* @param itemHeight 每项高度(像素),每个列表项占多少像素
* @param totalCount 总数据项数,数据数组的总长度
* @param buffer 上下缓冲行数,额外渲染的不可见项数量
* @returns [start, end] 可见范围的起始和结束索引(闭区间)
*/
function getVisibleRange(
scrollOffset: number, // 当前滚动偏移(像素)
viewHeight: number, // 视口高度
itemHeight: number, // 每项高度
totalCount: number, // 总数据项数
buffer: number = 2 // 上下缓冲行数
): [number, number] {
// 第一步:计算第一个可见项的索引
// Math.floor:向下取整,因为即使只露出一点也算可见
// 例如:scrollOffset=350, itemHeight=100 → 350/100=3.5 → floor=3
// 表示第 3 项(从0开始)的顶部在视口内或刚好在视口上方
const firstVisible = Math.floor(scrollOffset / itemHeight);
// 第二步:计算最后一个可见项的索引
// Math.ceil:向上取整,因为即使只露出底部一点也算可见
// 例如:scrollOffset=350, viewHeight=600, itemHeight=100
// (350+600)/100 = 950/100 = 9.5 → ceil=10
// 表示第 10 项的底部在视口内或刚好在视口下方
const lastVisible = Math.ceil((scrollOffset + viewHeight) / itemHeight);
// 第三步:增加缓冲区域
// 缓冲的作用:当用户快速滚动时,新进入视口的项已经提前创建好了
// 不会出现"白屏"或"空白"
// Math.max(0, ...):确保不越界到负数索引
const start = Math.max(0, firstVisible - buffer);
// Math.min(totalCount - 1, ...):确保不越界到超过数据总量的索引
const end = Math.min(totalCount - 1, lastVisible + buffer);
return [start, end];
}
// 示例(逐步推导)
// 假设:
// scrollOffset = 350(内容向上滚动了 350 像素)
// viewHeight = 600(视口高度 600 像素)
// itemHeight = 100(每项高度 100 像素)
// buffer = 2(上下各缓冲 2 行)
// 第一步:firstVisible = floor(350 / 100) = floor(3.5) = 3
// 解释:第 3 项的顶部在 y=300,视口顶部在 y=350
// 所以第 3 项已经部分滚出视口,但第 4 项完全在视口内
// 第二步:lastVisible = ceil((350 + 600) / 100) = ceil(950 / 100) = ceil(9.5) = 10
// 解释:第 10 项的底部在 y=1000,视口底部在 y=950
// 所以第 10 项部分在视口内
// 第三步:加缓冲
// start = max(0, 3 - 2) = max(0, 1) = 1
// end = min(totalCount - 1, 10 + 2) = min(999, 12) = 12
// 最终结果:[1, 12](共 12 个 item)
// 实际可见的是 [3, 10](8 个 item)
// 缓冲的是 [1, 2] 和 [11, 12](各 2 个 item)
为什么需要 buffer? 想象你在快速滑动朋友圈。如果没有 buffer,当你滑到一个新位置时,新的内容还没创建,你会看到一片空白(白屏)。buffer 就是提前在视口上下多创建几个项,让你滑动时有"提前量"。
2.4 节点池(Node Pool)机制
/**
* 节点池:管理可复用的节点对象
*
* 核心思想:
* 1. 节点创建/销毁是昂贵操作,应该复用
* 2. 离开视口的节点不销毁,放入池中
* 3. 新进入视口的节点优先从池中取,不够再新建
*
* 类比:节点池就像剧院的演员候场区。上一场戏结束的演员(离开视口的节点)
* 不回家(不销毁),而是去候场区休息(放入池中)。下一场戏需要演员时(新项进入视口),
* 优先从候场区叫人(从池里取),没人了再招新人(新建节点)。
*/
class NodePool {
private _pool: Node[] = []; // 池中待复用的节点数组
private _prefab: Prefab; // 节点模板,用于创建新节点
private _parent: Node; // 节点的父节点
constructor(prefab: Prefab, parent: Node) {
this._prefab = prefab;
this._parent = parent;
}
/**
* 获取一个节点(从池中取或新建)
*
* 逻辑:
* 1. 如果池中有节点,取出一个并激活
* 2. 如果池为空,根据 prefab 创建新节点
*/
get(): Node {
if (this._pool.length > 0) {
// 从池尾取出一个节点(pop 是 O(1) 操作)
const node = this._pool.pop();
// 激活节点(让它可见并可交互)
node.active = true;
return node;
}
// 池为空,实例化一个新的 prefab
return instantiate(this._prefab);
}
/**
* 回收节点到池中
*
* 逻辑:
* 1. 禁用节点(隐藏)
* 2. 从场景中移除(setParent(null))
* 3. 放入池中等待复用
*
* 注意:不调用 destroy()!destroy 会永久销毁节点,无法复用
*/
put(node: Node) {
node.active = false; // 隐藏节点
node.setParent(null); // 从场景中移除,但保留对象
this._pool.push(node); // 放入池中
}
/**
* 清空池:销毁所有节点
* 通常在列表关闭或场景切换时调用
*/
clear() {
for (const node of this._pool) {
node.destroy(); // 永久销毁
}
this._pool.length = 0; // 清空数组
}
/** 获取池中当前节点数量 */
get size(): number {
return this._pool.length;
}
}
为什么不用 destroy() 而用 active = false?
destroy()会永久销毁节点,释放所有资源。如果频繁创建/销毁,会产生大量垃圾回收(GC)压力,导致卡顿。active = false只是隐藏节点,保留对象供下次复用。
2.5 完整虚拟列表实现(TypeScript / Cocos)
import { _decorator, Component, Node, Prefab, instantiate,
ScrollView, Vec3, UITransform, Layout } from 'cc';
const { ccclass, property } = _decorator;
/**
* 列表数据项的接口定义
* 实际项目中可以根据需求扩展
*/
interface ListData {
id: number; // 唯一标识
icon: string; // 图标资源路径
name: string; // 显示名称
count: number; // 数量
}
/**
* 虚拟列表组件
*
* 核心逻辑:
* 1. 维护一个数据数组(_data),包含所有数据项
* 2. 维护一个活跃节点映射(_activeNodes),记录当前可见项对应的节点
* 3. 维护一个节点池(_nodePool),存放可复用的节点
* 4. 滚动时计算新的可见范围,回收旧节点,创建/复用新节点
*/
@ccclass('VirtualList')
export class VirtualList extends Component {
@property(ScrollView)
scrollView: ScrollView = null; // 绑定的 ScrollView 组件
@property(Prefab)
itemPrefab: Prefab = null; // 列表项的预制体模板
@property
itemHeight: number = 100; // 每项固定高度(像素)
@property
bufferCount: number = 2; // 上下缓冲行数
// ========== 内部状态 ==========
private _data: ListData[] = []; // 所有数据项
private _nodePool: Node[] = []; // 节点池(可复用节点)
private _activeNodes: Map<number, Node> = new Map(); // 索引 → 节点的映射
private _content: Node = null; // ScrollView 的 Content 节点
private _viewHeight: number = 0; // 视口高度
private _lastStartIndex: number = -1; // 上次可见范围起始索引
private _lastEndIndex: number = -1; // 上次可见范围结束索引
onLoad() {
// 获取 ScrollView 的 Content 节点
// Content 是所有列表项的父节点,它的 y 坐标变化实现滚动效果
this._content = this.scrollView.content;
// 获取视口高度(view 是 ScrollView 中定义可见区域的节点)
this._viewHeight = this.scrollView.view.getComponent(UITransform).height;
// 监听滚动事件
// 'scrolling' 事件在滚动过程中持续触发
this.scrollView.node.on('scrolling', this._onScrolling, this);
}
/**
* 设置数据并初始化列表
*
* 步骤:
* 1. 保存数据
* 2. 设置 Content 的总高度(让 ScrollView 知道可滚动范围)
* 3. 重置滚动位置到顶部
* 4. 首次刷新可见项
*/
setData(data: ListData[]) {
this._data = data;
// 设置 Content 总高度
// 总高度 = 数据项数 × 每项高度
// 这样 ScrollView 才能正确计算滚动条位置和可滚动范围
const totalHeight = data.length * this.itemHeight;
const contentTransform = this._content.getComponent(UITransform);
contentTransform.height = totalHeight;
// 重置滚动位置到顶部
// 参数 0 表示不播放动画,立即跳转
this.scrollView.scrollToTop(0);
// 首次刷新可见项
this._refreshVisibleItems();
}
/**
* 滚动回调
* 当用户拖动或惯性滚动时触发
*/
private _onScrolling() {
this._refreshVisibleItems();
}
/**
* 核心方法:刷新可见项
*
* 步骤:
* 1. 计算当前可见范围 [start, end]
* 2. 如果范围未变化,跳过(优化)
* 3. 回收不再可见的节点
* 4. 创建/复用新可见项
*/
private _refreshVisibleItems() {
// 获取当前滚动偏移(Content 的 y 坐标取绝对值)
// Cocos 中 Content 向上滚动时 y 为负,所以取 abs
const scrollOffset = Math.abs(this._content.position.y);
// 计算可见范围
const [start, end] = this._getVisibleRange(scrollOffset);
// 优化:如果可见范围没有变化,不需要任何操作
// 这在滚动停止或微小移动时非常重要
if (start === this._lastStartIndex && end === this._lastEndIndex) {
return;
}
// 更新上次的范围记录
this._lastStartIndex = start;
this._lastEndIndex = end;
// ========== 第一步:回收不再可见的节点 ==========
// 遍历当前活跃节点,找出不在新可见范围内的
const toRemove: number[] = [];
for (const [index, node] of this._activeNodes) {
// 如果节点的索引不在 [start, end] 范围内,标记为待回收
if (index < start || index > end) {
toRemove.push(index);
}
}
// 执行回收
for (const index of toRemove) {
this._recycleNode(index);
}
// ========== 第二步:创建/复用新可见项 ==========
// 遍历新的可见范围,为每个索引创建或复用节点
for (let i = start; i <= end; i++) {
// 如果该索引还没有对应的活跃节点,创建它
if (!this._activeNodes.has(i)) {
this._createItem(i);
}
}
}
/**
* 计算可见范围
*
* 逻辑同 2.3 节的 getVisibleRange 函数
*/
private _getVisibleRange(scrollOffset: number): [number, number] {
// 第一个可见项索引
const firstVisible = Math.floor(scrollOffset / this.itemHeight);
// 最后一个可见项索引
const lastVisible = Math.ceil((scrollOffset + this._viewHeight) / this.itemHeight);
// 加缓冲
const start = Math.max(0, firstVisible - this.bufferCount);
const end = Math.min(this._data.length - 1, lastVisible + this.bufferCount);
return [start, end];
}
/**
* 创建/复用一个 item
*
* 步骤:
* 1. 从池中获取节点(复用或新建)
* 2. 设置父节点为 Content
* 3. 计算并设置位置
* 4. 更新数据
* 5. 记录到活跃节点映射中
*/
private _createItem(index: number) {
// 从池中获取节点
const node = this._getNodeFromPool();
// 设置父节点为 Content
// 这样节点会跟随 Content 的滚动而移动
node.setParent(this._content);
// 计算节点在 Content 中的位置
// 第 index 项的顶部在 y = -index * itemHeight
// 减去 itemHeight / 2 是因为节点的锚点通常在中心
const y = -index * this.itemHeight - this.itemHeight / 2;
node.setPosition(new Vec3(0, y, 0));
// 更新节点显示的数据
this._updateItem(node, this._data[index], index);
// 记录到活跃节点映射
this._activeNodes.set(index, node);
}
/**
* 回收节点
*
* 步骤:
* 1. 从活跃映射中取出节点
* 2. 从场景中移除
* 3. 禁用(隐藏)
* 4. 放入池中
* 5. 从活跃映射中删除
*/
private _recycleNode(index: number) {
const node = this._activeNodes.get(index);
if (node) {
node.setParent(null); // 从 Content 中移除
node.active = false; // 隐藏
this._nodePool.push(node); // 放入池中
this._activeNodes.delete(index); // 从活跃映射中删除
}
}
/**
* 从池中获取节点
*
* 逻辑:
* 1. 如果池中有节点,取出并激活
* 2. 如果池为空,实例化新的 prefab
*/
private _getNodeFromPool(): Node {
if (this._nodePool.length > 0) {
const node = this._nodePool.pop();
node.active = true; // 激活节点
return node;
}
// 池为空,创建新节点
return instantiate(this.itemPrefab);
}
/**
* 更新 item 显示
*
* 这里只是一个示例实现,实际项目中通常通过:
* 1. 获取 item 上的自定义组件,调用其更新方法
* 2. 或者派发事件,让 item 自己更新
*/
private _updateItem(node: Node, data: ListData, index: number) {
// 示例:假设 itemPrefab 中有 Label 和 Sprite
// 实际项目中通过组件引用或事件回调更新
const label = node.getChildByName('nameLabel')?.getComponent(Label);
if (label) label.string = data.name;
const countLabel = node.getChildByName('countLabel')?.getComponent(Label);
if (countLabel) countLabel.string = `×${data.count}`;
// 可以派发事件让外部更新
node.emit('update-item', data, index);
}
/**
* 跳转到指定索引
*
* @param index 目标索引
* @param time 动画时间(秒),0 表示立即跳转
*/
scrollToIndex(index: number, time: number = 0.3) {
// 计算目标滚动偏移
const targetY = index * this.itemHeight;
// 计算最大可滚动偏移
// 如果内容高度小于视口高度,不能滚动
const maxOffset = Math.max(0, this._data.length * this.itemHeight - this._viewHeight);
// 限制在有效范围内
const clampedY = Math.min(targetY, maxOffset);
// 调用 ScrollView 的滚动方法
this.scrollView.scrollToOffset(new Vec2(0, clampedY), time);
}
/**
* 获取当前可见项索引
* @returns 可见项索引数组(已排序)
*/
getVisibleIndices(): number[] {
return Array.from(this._activeNodes.keys()).sort((a, b) => a - b);
}
/**
* 组件销毁时清理资源
*/
onDestroy() {
// 销毁所有活跃节点
for (const node of this._activeNodes.values()) {
node.destroy();
}
// 销毁池中节点
for (const node of this._nodePool) {
node.destroy();
}
// 清空数据结构
this._activeNodes.clear();
this._nodePool.length = 0;
}
}
代码执行流程图解:
用户滚动列表 ↓ _onScrolling() 被调用 ↓ _refreshVisibleItems() ↓ 1. 获取 scrollOffset(Content.y 的绝对值) 2. 调用 _getVisibleRange() 计算 [start, end] 3. 范围变化了吗? ├─ 没变 → 直接返回(优化) └─ 变了 → 继续 4. 回收不在新范围内的节点 ├─ 从 _activeNodes 中移除 ├─ node.active = false └─ 放入 _nodePool 5. 为新范围内的索引创建节点 ├─ 优先从 _nodePool 取(复用) ├─ 池为空则 instantiate 新建 ├─ 设置位置和父节点 └─ 调用 _updateItem() 更新数据
2.6 不等高项的虚拟列表
实际项目中 item 高度往往不固定(如聊天消息、评论列表):
/**
* 不等高虚拟列表
*
* 与固定高度列表的区别:
* 1. 需要记录每项的实际高度
* 2. 需要累积高度数组来快速定位
* 3. 可见范围计算从 O(1) 变为 O(logN)(二分查找)
*
* 类比:固定高度列表就像一排等间距的路灯,你知道第 10 盏灯在哪里(10 × 间距)。
* 不等高列表就像一排不等间距的树木,你需要查地图(累积高度数组)才能知道第 10 棵树在哪里。
*/
class VirtualListVariableHeight extends Component {
private _itemHeights: number[] = []; // 每项的实际高度
private _cumulativeHeights: number[] = []; // 累积高度数组
/**
* 设置数据时同时计算每项高度
*
* @param data 数据数组
* @param getHeight 获取每项高度的回调函数
*/
setData(data: ListData[], getHeight: (data: ListData) => number) {
this._data = data;
// 计算每项高度
this._itemHeights = data.map(getHeight);
// ========== 构建累积高度数组 ==========
// 累积高度的含义:
// _cumulativeHeights[i] = 第 0 项到第 i 项的高度之和
//
// 例如:
// 高度:[50, 80, 60, 100]
// 累积:[50, 130, 190, 290]
//
// 用途:
// - 第 i 项的顶部 Y = -(i === 0 ? 0 : _cumulativeHeights[i-1])
// - 总高度 = _cumulativeHeights[last]
this._cumulativeHeights = [];
let sum = 0;
for (const h of this._itemHeights) {
sum += h;
this._cumulativeHeights.push(sum);
}
// 设置 Content 总高度
const totalHeight = sum;
this._content.getComponent(UITransform).height = totalHeight;
// 刷新可见项
this._refreshVisibleItems();
}
/**
* 二分查找:找到包含指定偏移量的项索引
*
* 问题:给定一个偏移量 offset,找到满足以下条件的索引 i:
* _cumulativeHeights[i-1] < offset <= _cumulativeHeights[i]
*
* 例如:
* 累积高度:[50, 130, 190, 290]
* offset = 150
* 返回值应该是 2,因为 150 落在第 2 项的范围(130 ~ 190)
*
* 时间复杂度:O(logN)
*/
private _findIndexAtOffset(offset: number): number {
let left = 0;
let right = this._cumulativeHeights.length - 1;
// 标准的二分查找
while (left < right) {
// 取中间值
const mid = Math.floor((left + right) / 2);
// 比较中间累积高度和偏移量
if (this._cumulativeHeights[mid] < offset) {
// 中间值小于偏移量,目标在右半部分
left = mid + 1;
} else {
// 中间值大于等于偏移量,目标在左半部分(包括 mid)
right = mid;
}
}
return left;
}
/**
* 获取某一项的 Y 坐标
*
* 第 index 项的顶部位置 = 前 index-1 项的高度之和
* 节点中心位置 = 顶部 + 高度/2
*/
private _getItemY(index: number): number {
if (index === 0) return 0;
// 前 index-1 项的总高度
const top = this._cumulativeHeights[index - 1];
// 当前项的高度
const height = this._itemHeights[index];
// 返回中心位置(负号因为 Cocos UI 坐标系)
return -(top + height / 2);
}
/**
* 计算可见范围(不等高版本)
*/
private _getVisibleRange(scrollOffset: number): [number, number] {
// 使用二分查找定位起始和结束索引
const start = this._findIndexAtOffset(scrollOffset);
const end = this._findIndexAtOffset(scrollOffset + this._viewHeight);
return [
Math.max(0, start - this.bufferCount),
Math.min(this._data.length - 1, end + this.bufferCount)
];
}
}
不等高列表的复杂度从 O(1) 变为 O(logN)(二分查找),但对于 10000 项仍然极快。
三、虚拟列表与 Batcher2D 的协同优化
3.1 为什么虚拟列表能减少 DrawCall
传统列表 1000 项:
item 0 (texture A) → item 1 (texture B) → item 2 (texture A) → ...
Batcher2D 的 batching 条件:
1. 相同 texture → 可以 batch
2. 不同 texture → 必须 break batch
结果:几乎每个 item 都触发 batch break
DrawCall ≈ 1000
虚拟列表 15 项:
只有 15 个节点参与渲染
即使纹理交替,最多 15 个 DrawCall
如果按纹理排序,可能降到 1~3 个 DrawCall
类比:传统列表就像 1000 个人排队过安检,每个人带不同的包(纹理),安检员每次都要换检查流程(切换状态)。虚拟列表只有 15 个人排队,而且可以把带相同包的人排在一起(排序),安检员可以批量处理。
3.2 进一步优化:按纹理排序节点
/**
* 按纹理排序的虚拟列表
*
* 核心思想:
* 在创建可见项时,不按数据索引顺序,而是按纹理类型排序。
* 这样相同纹理的节点会连续排列,Batcher2D 可以合并为一个 DrawCall。
*
* 代价:
* - 节点的 siblingIndex 会变化
* - 可能影响点击事件(需要额外处理)
*/
class OptimizedVirtualList extends VirtualList {
@property
sortByTexture: boolean = true;
private _refreshVisibleItems() {
// ... 先执行父类的范围计算 ...
if (this.sortByTexture) {
// 收集所有可见项的纹理信息
const items: { index: number; textureKey: string }[] = [];
for (let i = start; i <= end; i++) {
const textureKey = this._getTextureKey(this._data[i]);
items.push({ index: i, textureKey });
}
// 按纹理排序(相同纹理的排在一起)
items.sort((a, b) => a.textureKey.localeCompare(b.textureKey));
// 按排序后的顺序创建节点
for (const item of items) {
if (!this._activeNodes.has(item.index)) {
this._createItem(item.index);
}
}
}
}
private _getTextureKey(data: ListData): string {
// 根据数据返回纹理标识
return data.icon || 'default';
}
}
注意:按纹理排序会改变节点的 siblingIndex,可能影响点击事件。需要在数据层维护点击映射。
3.3 动态图集(Dynamic Atlas)+ 虚拟列表
如果所有 item 使用动态图集:
- 所有图标被打包到同一张大纹理
- Batcher2D 可以将所有 item 合并为 1 个 DrawCall
- 虚拟列表的 15 个节点 → 1 个 DrawCall
Cocos 动态图集配置:
Project Settings → Macro Configurations → ENABLE_DYNAMICS_ATLAS
限制:
- 单张图集最大 2048×2048
- 大图(>512×512)不会进入动态图集
- 频繁变化的纹理不适合(每变一次重新打包)
类比:动态图集就像把很多小照片拼成一张大合影。原本每个人(每个纹理)都要单独拍照(单独 DrawCall),现在大家一起拍合影(一个 DrawCall),效率大幅提升。
四、广度优先渲染合并:从 DFS 到 BFS 的 DrawCall 优化
虚拟列表深度优化的核心:用广度优先遍历让相同类型节点相邻,实现渲染合并。虚拟列表减少了节点数量,广度优先进一步减少了 DrawCall。
4.1 深度优先(DFS) vs 广度优先(BFS)
"挖洞 vs 扫地"比喻
- 深度优先(DFS):像挖洞——沿着一条路挖到底,再回来挖另一条。先处理第一个孩子的所有后代,再处理第二个孩子。
- 广度优先(BFS):像扫地——先扫第一层所有房间,再扫第二层所有房间。先处理所有第一层孩子,再处理所有第二层孩子。
遍历顺序对比
场景树:
Root
├── Item1
│ ├── Icon(Sprite, 图集A)
│ ├── Name(Label)
│ └── Frame(Sprite, 图集B)
├── Item2
│ ├── Icon(Sprite, 图集A)
│ ├── Name(Label)
│ └── Frame(Sprite, 图集B)
└── Item3
├── Icon(Sprite, 图集A)
├── Name(Label)
└── Frame(Sprite, 图集B)
深度优先遍历顺序:
Item1.Icon(A) → Item1.Name(Label) → Item1.Frame(B) →
Item2.Icon(A) → Item2.Name(Label) → Item2.Frame(B) →
Item3.Icon(A) → Item3.Name(Label) → Item3.Frame(B)
渲染顺序:A → Label → B → A → Label → B → A → Label → B
纹理切换: 1 2 3 4 5 6 7 8 9
DrawCall = 9个!(每次切换都是新DrawCall)
广度优先遍历顺序:
第1层:Item1, Item2, Item3
第2层:Item1.Icon(A), Item2.Icon(A), Item3.Icon(A), ← 先渲染所有Icon
Item1.Name(Label), Item2.Name(Label), Item3.Name(Label), ← 再渲染所有Name
Item1.Frame(B), Item2.Frame(B), Item3.Frame(B) ← 最后渲染所有Frame
渲染顺序:IconA → IconA → IconA → Label → Label → Label → FrameB → FrameB → FrameB
DrawCall:└── 1个 ──┘ └── 1个 ──┘ └── 1个 ──┘ = 3个DrawCall!
效果对比:9个DrawCall → 3个DrawCall,减少67%!
4.2 核心原理:广度优先如何让列表 item 下相同 node 合并渲染
深度优先的渲染顺序问题
CocosCreator 默认使用深度优先遍历场景树。这导致:
问题:Item1的Icon和Item2的Icon纹理相同,但中间被Item1的Name和Frame打断了!
Item1.Icon(A)
Item1.Name(Label) ← 打断!纹理从A切换到Label,再切换到B
Item1.Frame(B)
Item2.Icon(A) ← 和Item1.Icon纹理相同,但因为被打断,无法合批!
根本原因:深度优先遍历是"纵向"的,先处理完一个item的所有子节点,再处理下一个item。这导致不同item的同类型节点不相邻。
广度优先如何解决
广度优先遍历是"横向"的,先处理所有item的第1层子节点,再处理第2层:
第1步:遍历所有Item的第1个子节点(Icon)
Item1.Icon(A) → Item2.Icon(A) → Item3.Icon(A)
→ 三个Icon相邻,纹理相同 → 合批!1个DrawCall
第2步:遍历所有Item的第2个子节点(Name)
Item1.Name(Label) → Item2.Name(Label) → Item3.Name(Label)
→ 三个Label相邻 → 合批!1个DrawCall
第3步:遍历所有Item的第3个子节点(Frame)
Item1.Frame(B) → Item2.Frame(B) → Item3.Frame(B)
→ 三个Frame相邻,纹理相同 → 合批!1个DrawCall
总计:3个DrawCall(vs 深度优先的9个)
图示说明
深度优先(DFS)渲染顺序:
┌──────────────────────────────────────────────────────┐
│ IconA │ Label │ FrameB │ IconA │ Label │ FrameB │ ... │
│ DC1 │ DC2 │ DC3 │ DC4 │ DC5 │ DC6 │ │
└──────────────────────────────────────────────────────┘
↑ 纹理切换频繁,无法合批
广度优先(BFS)渲染顺序:
┌──────────────────────────────────────────────────────┐
│ IconA │ IconA │ IconA │ Label │ Label │ Label │ ... │
│ ──── 1个DrawCall ──── │ ── 1个DrawCall ── │ ... │
└──────────────────────────────────────────────────────┘
↑ 同类型节点相邻,完美合批
4.3 CocosCreator 3.8.x 中实现广度优先虚拟列表
⚠️ 重要前提:本节代码仅支持等高item的虚拟列表。如果你的列表项高度不固定(如聊天消息),需要额外处理高度缓存,本文暂不涉及。
核心思路:分层方案
由于 Cocos 的 Batcher2D 默认使用 DFS 遍历,无法直接切换为 BFS。实现 BFS 效果的方法是:把子节点按类型拆分到不同层,每层内的节点类型相同,自然可以合批。
传统结构(DFS,无法合批):
Content
├── Item1 (Icon + Name + Frame)
├── Item2 (Icon + Name + Frame)
└── Item3 (Icon + Name + Frame)
分层结构(模拟BFS,可以合批):
Content
├── IconLayer ← 所有Icon在同一层,纹理相同,合批!
│ ├── Icon1
│ ├── Icon2
│ └── Icon3
├── NameLayer ← 所有Name在同一层,合批!
│ ├── Name1
│ ├── Name2
│ └── Name3
└── FrameLayer ← 所有Frame在同一层,合批!
├── Frame1
├── Frame2
└── Frame3
完整代码实现(可直接复制运行)
import { _decorator, Component, Node, Prefab, instantiate, Sprite, Label,
UITransform, ScrollView, Vec3 } from 'cc';
const { ccclass, property } = _decorator;
interface VirtualListItem {
bg: Sprite;
icon: Sprite;
nameLabel: Label;
descLabel: Label;
bgLocalPos: Vec3;
iconLocalPos: Vec3;
nameLocalPos: Vec3;
descLocalPos: Vec3;
}
@ccclass('BFSVirtualList')
export class BFSVirtualList extends Component {
@property(ScrollView)
scrollView: ScrollView = null;
@property(Prefab)
itemPrefab: Prefab = null;
@property
itemHeight: number = 120;
@property
spacing: number = 10;
private _dataList: any[] = [];
private _itemPool: VirtualListItem[] = [];
private _visibleStartIndex: number = 0;
private _visibleCount: number = 0;
private _bgLayer: Node = null;
private _iconLayer: Node = null;
private _nameLayer: Node = null;
private _descLayer: Node = null;
private _scrollThrottleTimer: number = 0;
private _scrollThrottleInterval: number = 0.016;
start() {
this.initLayers();
this.initVirtualList();
}
onDestroy() {
if (this.scrollView) {
this.scrollView.node.off(Node.EventType.SCROLL_END, this.onScroll, this);
this.scrollView.node.off('scrolling', this.onScrolling, this);
}
}
initLayers() {
const content = this.scrollView.content;
this._bgLayer = new Node('BGLayer');
this._iconLayer = new Node('IconLayer');
this._nameLayer = new Node('NameLayer');
this._descLayer = new Node('DescLayer');
content.addChild(this._bgLayer);
content.addChild(this._iconLayer);
content.addChild(this._nameLayer);
content.addChild(this._descLayer);
const contentTransform = content.getComponent(UITransform);
const setupLayer = (layer: Node) => {
const transform = layer.addComponent(UITransform);
transform.setContentSize(contentTransform.contentSize);
};
setupLayer(this._bgLayer);
setupLayer(this._iconLayer);
setupLayer(this._nameLayer);
setupLayer(this._descLayer);
}
initVirtualList() {
const viewHeight = this.scrollView.node.getComponent(UITransform).height;
this._visibleCount = Math.ceil(viewHeight / (this.itemHeight + this.spacing)) + 2;
for (let i = 0; i < this._visibleCount; i++) {
this.createItem();
}
this.scrollView.node.on(Node.EventType.SCROLL_END, this.onScroll, this);
this.scrollView.node.on('scrolling', this.onScrolling, this);
}
createItem() {
const itemNode = instantiate(this.itemPrefab);
const bg = itemNode.getChildByName('BG');
const icon = itemNode.getChildByName('Icon');
const name = itemNode.getChildByName('Name');
const desc = itemNode.getChildByName('Desc');
const bgLocalPos = bg ? bg.position.clone() : Vec3.ZERO;
const iconLocalPos = icon ? icon.position.clone() : Vec3.ZERO;
const nameLocalPos = name ? name.position.clone() : Vec3.ZERO;
const descLocalPos = desc ? desc.position.clone() : Vec3.ZERO;
if (bg) { bg.removeFromParent(); this._bgLayer.addChild(bg); }
if (icon) { icon.removeFromParent(); this._iconLayer.addChild(icon); }
if (name) { name.removeFromParent(); this._nameLayer.addChild(name); }
if (desc) { desc.removeFromParent(); this._descLayer.addChild(desc); }
itemNode.destroy();
this._itemPool.push({
bg: bg?.getComponent(Sprite),
icon: icon?.getComponent(Sprite),
nameLabel: name?.getComponent(Label),
descLabel: desc?.getComponent(Label),
bgLocalPos,
iconLocalPos,
nameLocalPos,
descLocalPos,
});
}
onScroll() {
this.updateVisibleItems();
}
onScrolling() {
const now = Date.now();
if (now - this._scrollThrottleTimer < this._scrollThrottleInterval * 1000) return;
this._scrollThrottleTimer = now;
this.updateVisibleItems();
}
updateVisibleItems() {
const scrollOffset = this.scrollView.getScrollOffset().y;
this._visibleStartIndex = Math.max(0, Math.floor(scrollOffset / (this.itemHeight + this.spacing)));
for (let i = 0; i < this._visibleCount; i++) {
const dataIndex = this._visibleStartIndex + i;
const item = this._itemPool[i];
if (dataIndex < this._dataList.length) {
const data = this._dataList[dataIndex];
const yPos = -dataIndex * (this.itemHeight + this.spacing);
this.showItem(item, yPos, data);
} else {
this.hideItem(item);
}
}
}
showItem(item: VirtualListItem, baseY: number, data: any) {
if (item.bg) {
item.bg.node.active = true;
item.bg.node.setPosition(item.bgLocalPos.x, baseY + item.bgLocalPos.y, 0);
}
if (item.icon) {
item.icon.node.active = true;
item.icon.node.setPosition(item.iconLocalPos.x, baseY + item.iconLocalPos.y, 0);
}
if (item.nameLabel) {
item.nameLabel.node.active = true;
item.nameLabel.node.setPosition(item.nameLocalPos.x, baseY + item.nameLocalPos.y, 0);
if (data.name !== undefined) item.nameLabel.string = data.name;
}
if (item.descLabel) {
item.descLabel.node.active = true;
item.descLabel.node.setPosition(item.descLocalPos.x, baseY + item.descLocalPos.y, 0);
if (data.desc !== undefined) item.descLabel.string = data.desc;
}
}
hideItem(item: VirtualListItem) {
if (item.bg) item.bg.node.active = false;
if (item.icon) item.icon.node.active = false;
if (item.nameLabel) item.nameLabel.node.active = false;
if (item.descLabel) item.descLabel.node.active = false;
}
setData(dataList: any[]) {
this._dataList = dataList;
const content = this.scrollView.content;
const transform = content.getComponent(UITransform);
transform.setContentSize(
transform.contentSize.width,
dataList.length * (this.itemHeight + this.spacing)
);
this.updateVisibleItems();
}
}
代码改动说明(相比朴素虚拟列表的修复)
| 朴素虚拟列表问题 | BFS 方案修复 | 为什么这样改 |
|---|---|---|
只监听SCROLL_END,滚动中不更新 |
同时监听scrolling事件+节流 |
滚动过程中也要实时更新可见项,否则出现空白 |
itemNode.destroy()后子节点可能异常 |
先移除子节点再destroy空壳 | 确保子节点安全移出后再销毁空容器 |
| 位置计算硬编码偏移量 | 保存每个子节点的localPos,用baseY + localPos.y计算 |
不同item布局不同,硬编码无法复用 |
缺少onDestroy清理 |
增加事件监听移除 | 防止组件销毁后回调报错 |
| 缺少Label内容变化检查 | 增加if (data.name !== undefined)判断 |
避免每帧重复设置Label.string触发脏标记 |
更简洁的实现方式:利用setSiblingIndex(不可行)
⚠️ 勘误:旧版本中有一个"利用setSiblingIndex实现BFS"的方案,但该方案不可行。因为setSiblingIndex只能调整同一父节点下的渲染顺序,而子节点仍然在各自的Item父节点下,DFS遍历时仍然先遍历Item1的所有子节点。要真正实现BFS效果,必须把子节点移到同一父节点下(即本节的分层方案)。
4.4 渲染合并的本质
回到 Batcher2D 的合批逻辑:
if (this._currHash === dataHash &&
this._currMaterial === mat &&
this._currDepthStencilStateStage === depthStencilStateStage) {
// 合批!顶点数据追加到当前VBO
}
关键:合批的判断是"当前状态"和"上一个状态"比较。如果相邻的两个节点状态相同,就能合批。
深度优先:
IconA(hash=1) → Name(hash=2) → FrameB(hash=3) → IconA(hash=1)
↑ hash变了! ↑ hash变了! ↑ hash又变了!
打断合批 打断合批 打断合批
广度优先:
IconA(hash=1) → IconA(hash=1) → IconA(hash=1) → Name(hash=2)
↑ hash相同! ↑ hash相同! ↑ hash变了
合批成功 合批成功 打断,但只有1次
渲染合并 = 把多个小DrawCall合并成一个大DrawCall = 把多个小VBO合并成一个大VBO
不合批:
DrawCall1: VBO[4个顶点] → 画Icon1
DrawCall2: VBO[4个顶点] → 画Icon2
DrawCall3: VBO[4个顶点] → 画Icon3
合批后:
DrawCall1: VBO[12个顶点] → 画Icon1+Icon2+Icon3
代价:VBO变大了,但GPU处理大VBO的效率远高于处理多个小VBO+多次DrawCall提交。
4.5 性能对比数据
测试场景:50个列表项,每项3个组件
| 指标 | 深度优先(DFS) | 广度优先(BFS) | 效果 | 说明 |
|---|---|---|---|---|
| DrawCall | 50+ | 3-5 | 90%↓ | 从50个降到3-5个 |
| CPU耗时/帧 | 12ms | 4ms | 67%↓ | CPU从12ms降到4ms |
| 帧率 | 40fps | 58fps | 45%↑ | 从40fps升到58fps |
| VBO数量 | 50+ | 3-5 | 90%↓ | VBO数量和DrawCall一致 |
测试场景:100个列表项,每项4个组件
| 指标 | 深度优先(DFS) | 广度优先(BFS) | 效果 | 说明 |
|---|---|---|---|---|
| DrawCall | 100+ | 4-6 | 95%↓ | 从100+降到4-6个 |
| CPU耗时/帧 | 25ms | 5ms | 80%↓ | CPU从25ms降到5ms |
| 帧率 | 25fps | 57fps | 128%↑ | 从25fps升到57fps |
4.6 踩坑经验
1. 坐标计算变复杂
问题:拆分节点后,每个子节点需要独立计算世界坐标,不再跟随父item节点。
// 原来的坐标计算(简单):
item.setPosition(0, -index * itemHeight, 0);
// 所有子节点自动跟随item移动
// 拆分后的坐标计算(复杂):
// 每个子节点需要独立计算位置 = item的Y + 子节点在item内的偏移Y
bg.setPosition(bgLocalPos.x, baseY + bgLocalPos.y, 0);
icon.setPosition(iconLocalPos.x, baseY + iconLocalPos.y, 0);
解决方案:在createItem时保存每个子节点的localPos,更新时用baseY + localPos.y计算。上面的完整代码已经实现了这个方案。
2. 交互事件处理
问题:拆分后,点击Icon和点击Name不再在同一个item节点下,事件冒泡路径变了。
// 原来的事件处理(简单):
itemNode.on(Node.EventType.TOUCH_END, () => {
// 直接处理点击
});
// 拆分后的事件处理(复杂):
// 需要手动关联数据索引
@ccclass('BFSItemTouch')
export class BFSItemTouch extends Component {
private _dataIndex: number = -1;
set dataIndex(index: number) {
this._dataIndex = index;
}
onLoad() {
this.node.on(Node.EventType.TOUCH_END, this.onTouch, this);
}
onTouch() {
console.log(`Clicked item: ${this._dataIndex}`);
}
}
3. 动画处理
问题:拆分后,对整个item做动画(如淡入淡出)需要同时操作多个层的节点。
// 原来的动画(简单):
tween(itemNode)
.to(0.3, { scale: new Vec3(1.1, 1.1, 1) })
.start();
// 拆分后的动画(复杂):
// 需要同时操作所有层的节点
playItemAnimation(itemIndex: number, animType: string) {
const itemData = this._itemPool[itemIndex];
const targets = [itemData.bg?.node, itemData.icon?.node, itemData.nameLabel?.node, itemData.descLabel?.node];
for (const target of targets) {
if (target) {
tween(target)
.to(0.3, { scale: new Vec3(1.1, 1.1, 1) })
.to(0.2, { scale: new Vec3(1, 1, 1) })
.start();
}
}
}
4. Mask和ScrollView裁剪
问题:拆分后,每个层的节点可能不在ScrollView的裁剪区域内。
解决方案:确保所有层都在ScrollView的Content下。ScrollView的裁剪是基于Content的子节点,只要层节点是Content的子节点,裁剪就能正常工作。
5. 变高item的处理(进阶)
问题:聊天消息等场景item高度不固定,但上面的代码只处理了等高情况。
变高item的思路:
- 额外维护一个
_itemHeights: number[]数组,记录每个item的实际高度 - 额外维护一个
_itemPositions: number[]数组,记录每个item的Y坐标(累加计算) - 滚动时通过二分查找定位可见区域
变高item的完整实现较复杂,建议先掌握等高版本,再尝试扩展。
4.7 广度优先虚拟列表总结
1. 把列表item的子节点按类型拆分到不同层
2. 每层内节点类型相同 → 可以合批
3. 层与层之间只有少量纹理切换 → DrawCall少
4. 结合虚拟列表 → 只渲染可见项 → 节点总数少
5. 最终效果:DrawCall从N×M降到M(N=列表项数,M=组件类型数)
适用场景:
- ✅ 长列表(聊天、背包、排行榜、邮件列表)
- ✅ 每个列表项有多个不同类型的组件
- ✅ 大量列表项使用相同图集
- ❌ 列表项很少(<10个),优化收益不大
- ❌ 列表项组件类型单一(已经能合批)
- ❌ 列表项需要复杂交互和动画
五、网格虚拟列表(Grid Virtual List)
4.1 二维网格的可见性计算
/**
* 网格虚拟列表
*
* 与单列列表的区别:
* 1. 数据按二维网格排列
* 2. 需要同时考虑行和列
* 3. 可见范围计算要考虑水平方向
*
* 类比:单列列表是一排竖着放的书,网格列表是一面墙的书架。
* 你不仅要看上下哪些书在视线内,还要看左右哪些书在视线内。
*/
class GridVirtualList extends Component {
@property
colCount: number = 5; // 每行列数(书架每层放几本书)
@property
itemWidth: number = 120; // 每项宽度
@property
itemHeight: number = 120; // 每项高度
@property
spacingX: number = 10; // 水平间距
@property
spacingY: number = 10; // 垂直间距
/**
* 计算可见范围(网格版本)
*
* 步骤:
* 1. 计算有效 item 尺寸(包含间距)
* 2. 计算可见行范围
* 3. 将行范围转换为数据索引范围
*/
private _getVisibleRange(scrollOffset: number): [number, number] {
// 有效高度 = 项高度 + 垂直间距
// 间距只算在项之间,所以不需要额外处理
const effectiveItemHeight = this.itemHeight + this.spacingY;
const effectiveItemWidth = this.itemWidth + this.spacingX;
// ========== 计算可见行范围 ==========
// 第一个可见行
const firstRow = Math.floor(scrollOffset / effectiveItemHeight);
// 最后一个可见行
const lastRow = Math.ceil((scrollOffset + this._viewHeight) / effectiveItemHeight);
// ========== 将行范围转换为数据索引 ==========
// 第 firstRow 行的第一个数据索引 = firstRow × 每行列数
// 第 lastRow 行的最后一个数据索引 = (lastRow + 1) × 每行列数 - 1
const start = Math.max(0, firstRow * this.colCount);
const end = Math.min(this._data.length - 1, (lastRow + 1) * this.colCount - 1);
return [start, end];
}
/**
* 获取某一项在网格中的位置
*
* 计算:
* - 行号 = index / colCount(整数除法)
* - 列号 = index % colCount(取余)
*/
private _getItemPosition(index: number): Vec3 {
// 计算行号和列号
const row = Math.floor(index / this.colCount);
const col = index % this.colCount;
// 计算 x 坐标(水平位置)
// 第 col 列的 x = col × (宽度 + 间距) + 宽度/2
// 加宽度/2 是因为节点锚点通常在中心
const x = col * (this.itemWidth + this.spacingX) + this.itemWidth / 2;
// 计算 y 坐标(垂直位置,负号因为 Cocos UI 坐标系)
const y = -(row * (this.itemHeight + this.spacingY) + this.itemHeight / 2);
return new Vec3(x, y, 0);
}
}
网格列表的可见范围为什么是一整个矩形? 在单列列表中,我们只需要知道上下范围。在网格列表中,由于所有列都在同一行同时出现,所以只要某一行部分可见,这一行的所有列都要创建节点。这就是为什么
start = firstRow * colCount,end = (lastRow + 1) * colCount - 1。
六、内存管理与性能监控
6.1 节点池大小控制
/**
* 带内存管理的虚拟列表
*
* 问题:如果用户反复快速滚动,节点池会无限增长
* 解决:设置池大小上限,超出时直接销毁节点
*/
class MemoryManagedVirtualList extends VirtualList {
@property
maxPoolSize: number = 30; // 池大小上限
private _getNodeFromPool(): Node {
if (this._nodePool.length > 0) {
const node = this._nodePool.pop();
node.active = true;
return node;
}
return instantiate(this.itemPrefab);
}
private _recycleNode(index: number) {
const node = this._activeNodes.get(index);
if (node) {
node.setParent(null);
node.active = false;
// 池满时直接销毁,不放入池中
if (this._nodePool.length >= this.maxPoolSize) {
node.destroy();
} else {
this._nodePool.push(node);
}
this._activeNodes.delete(index);
}
}
}
maxPoolSize 怎么定?
- 一般设为 "可见项数 + 2×bufferCount×colCount" 的 1.5~2 倍
- 例如:可见 15 项,buffer 2 行 → 最大约 30 个节点
- 如果内存紧张,可以设更小;如果滚动频繁,可以设更大
5.2 性能监控组件
import { _decorator, Component, Label, game } from 'cc';
/**
* 虚拟列表性能监控器
*
* 用途:在开发阶段监控虚拟列表的性能表现
*/
@ccclass('VirtualListProfiler')
export class VirtualListProfiler extends Component {
@property(Label)
infoLabel: Label = null;
@property(VirtualList)
targetList: VirtualList = null;
private _frameCount: number = 0;
private _lastTime: number = 0;
private _fps: number = 60;
update(dt: number) {
this._frameCount++;
const now = performance.now();
// 每秒更新一次显示
if (now - this._lastTime >= 1000) {
this._fps = this._frameCount;
this._frameCount = 0;
this._lastTime = now;
this._updateDisplay();
}
}
private _updateDisplay() {
if (!this.infoLabel || !this.targetList) return;
// 获取当前可见项
const visible = this.targetList.getVisibleIndices();
const total = this.targetList['_data']?.length || 0;
// 获取 Cocos 内置统计
const device = game.frame;
this.infoLabel.string =
`FPS: ${this._fps}\n` +
`Visible: ${visible.length} / ${total}\n` +
`Indices: ${visible.join(', ')}\n` +
`DrawCall: ${(window as any).__cocos_draw_call || 'N/A'}`;
}
}
5.3 滚动性能优化技巧
/**
* 平滑滚动虚拟列表
*
* 优化点:
* 1. 区分"核心可见区"和"缓冲区"
* 2. 核心可见区立即更新
* 3. 缓冲区可以延迟更新(下一帧)
*
* 适用场景:
* - 列表项创建开销大(如需要加载图片、复杂布局)
* - 快速滚动时优先保证流畅度
*/
class SmoothVirtualList extends VirtualList {
@property
useAsyncUpdate: boolean = false; // 是否异步更新非关键项
/**
* 优化:滚动时降低非可见项的更新频率
*/
private _refreshVisibleItems() {
const scrollOffset = Math.abs(this._content.position.y);
const [start, end] = this._getVisibleRange(scrollOffset);
// 核心可见区(无缓冲)
// 这部分是用户直接看到的,必须立即更新
const coreStart = Math.max(0, start + this.bufferCount);
const coreEnd = Math.min(this._data.length - 1, end - this.bufferCount);
// 1. 立即更新核心可见区
for (let i = coreStart; i <= coreEnd; i++) {
if (!this._activeNodes.has(i)) {
this._createItem(i);
}
}
// 2. 延迟更新缓冲区的项(下一帧)
// 这部分用户在快速滚动时可能看不到,可以延迟
if (this.useAsyncUpdate) {
this.scheduleOnce(() => {
for (let i = start; i < coreStart; i++) {
if (!this._activeNodes.has(i)) this._createItem(i);
}
for (let i = coreEnd + 1; i <= end; i++) {
if (!this._activeNodes.has(i)) this._createItem(i);
}
}, 0);
} else {
// 同步更新所有
for (let i = start; i <= end; i++) {
if (!this._activeNodes.has(i)) {
this._createItem(i);
}
}
}
// 3. 回收不在范围内的
for (const [index, node] of this._activeNodes) {
if (index < start || index > end) {
this._recycleNode(index);
}
}
}
}
七、实战:背包系统完整实现
7.1 场景结构
Canvas
└── BackpackPanel
├── ScrollView
│ ├── view (Mask + UITransform)
│ │ └── content (Layout)
│ └── scrollBar
├── TitleLabel
└── CloseButton
Prefab: ItemCell
├── bg (Sprite)
├── icon (Sprite)
├── nameLabel (Label)
├── countLabel (Label)
└── qualityFrame (Sprite)
7.2 背包数据与虚拟列表结合
// BackpackManager.ts
@ccclass('BackpackManager')
export class BackpackManager extends Component {
@property(VirtualList)
virtualList: VirtualList = null;
@property(Prefab)
itemPrefab: Prefab = null;
private _items: BackpackItem[] = [];
onLoad() {
// 模拟大量数据
this._generateTestData(2000);
// 初始化虚拟列表
this.virtualList.setData(this._items);
}
private _generateTestData(count: number) {
const qualities = ['white', 'green', 'blue', 'purple', 'orange'];
const types = ['weapon', 'armor', 'potion', 'material'];
for (let i = 0; i < count; i++) {
this._items.push({
id: i,
name: `Item ${i}`,
icon: `icons/${types[i % 4]}_${i % 10}`,
count: Math.floor(Math.random() * 99) + 1,
quality: qualities[i % 5],
description: `Description for item ${i}`
});
}
}
/** 添加物品 */
addItem(item: BackpackItem) {
this._items.push(item);
this.virtualList.setData(this._items);
}
/** 删除物品 */
removeItem(index: number) {
this._items.splice(index, 1);
this.virtualList.setData(this._items);
}
/** 排序 */
sortByQuality() {
const qualityOrder = { white: 0, green: 1, blue: 2, purple: 3, orange: 4 };
this._items.sort((a, b) => qualityOrder[b.quality] - qualityOrder[a.quality]);
this.virtualList.setData(this._items);
}
}
7.3 性能测试数据
| 场景 | 传统实现 | 虚拟列表 | 虚拟列表 + 动态图集 |
|---|---|---|---|
| 2000 物品初始化 | 1200ms | 80ms | 80ms |
| 静止帧 DrawCall | 2000+ | 12 | 1 |
| 滚动时帧率(低端机) | 8 FPS | 45 FPS | 58 FPS |
| 内存占用 | 45 MB | 2 MB | 2.5 MB |
八、常见问题与解决方案
8.1 快速滚动白屏
原因:缓冲行数不足,新项来不及创建。
解决:
// 增加 bufferCount
bufferCount = Math.ceil(viewHeight / itemHeight) + 2; // 至少 1 屏缓冲
// 或根据滚动速度动态调整
private _getDynamicBuffer(): number {
const velocity = Math.abs(this._lastScrollOffset - this._currentScrollOffset);
return Math.max(2, Math.ceil(velocity / this.itemHeight));
}
类比:buffer 就像高速公路的应急车道。正常情况下不需要,但车速快(滚动快)时,应急车道可以提供缓冲空间。
8.2 点击事件错位
原因:节点复用后位置变化,但事件系统可能缓存旧位置。
解决:
// 确保节点位置更新后再启用交互
node.setPosition(newPos);
node.getComponent(Button)?.updateRenderer(); // 强制更新碰撞区域
8.3 内容高度计算错误
原因:UITransform 的 anchor 设置导致位置计算偏差。
解决:
// Content 的 anchor 应设为 (0.5, 1) 或 (0, 1)
// 确保 y=0 对应内容顶部
const transform = this._content.getComponent(UITransform);
transform.anchorX = 0.5;
transform.anchorY = 1; // 顶部对齐
九、初学者常见错误
9.1 错误 1:销毁节点而不是回收
// ❌ 错误:直接销毁节点
private _recycleNode(index: number) {
const node = this._activeNodes.get(index);
node.destroy(); // 错误!销毁后无法复用
}
// ✅ 正确:回收节点到池中
private _recycleNode(index: number) {
const node = this._activeNodes.get(index);
node.setParent(null);
node.active = false;
this._nodePool.push(node); // 放入池中复用
}
9.2 错误 2:忘记设置 Content 高度
// ❌ 错误:不设置 Content 高度
setData(data: ListData[]) {
this._data = data;
// 忘记设置 contentTransform.height!
this._refreshVisibleItems();
}
// ✅ 正确:设置 Content 高度,让 ScrollView 知道可滚动范围
setData(data: ListData[]) {
this._data = data;
const totalHeight = data.length * this.itemHeight;
this._content.getComponent(UITransform).height = totalHeight;
this._refreshVisibleItems();
}
9.3 错误 3:滚动偏移计算错误
// ❌ 错误:直接使用 content.y,不取绝对值
const scrollOffset = this._content.position.y; // 错误!y 是负数
// ✅ 正确:取绝对值
const scrollOffset = Math.abs(this._content.position.y);
9.4 错误 4:每次滚动都全量刷新
// ❌ 错误:不检查范围是否变化,每帧都回收+创建
private _refreshVisibleItems() {
const [start, end] = this._getVisibleRange(scrollOffset);
// 直接回收所有再创建所有,性能极差
}
// ✅ 正确:范围未变化时跳过
private _refreshVisibleItems() {
const [start, end] = this._getVisibleRange(scrollOffset);
if (start === this._lastStartIndex && end === this._lastEndIndex) {
return; // 范围没变,跳过
}
// ... 只处理变化的部分
}
9.5 错误 5:不等高列表不使用累积高度
// ❌ 错误:不等高列表还用固定高度公式
const firstVisible = Math.floor(scrollOffset / this.itemHeight); // 错误!
// ✅ 正确:使用累积高度 + 二分查找
const firstVisible = this._findIndexAtOffset(scrollOffset);
9.6 错误 6:节点池无限增长
// ❌ 错误:不限制池大小
private _recycleNode(index: number) {
this._nodePool.push(node); // 无限增长,内存泄漏
}
// ✅ 正确:限制池大小
private _recycleNode(index: number) {
if (this._nodePool.length >= this.maxPoolSize) {
node.destroy(); // 池满时销毁
} else {
this._nodePool.push(node);
}
}
十、自问自答(Q&A)
Q1:虚拟列表和分页加载有什么区别?
A:
- 分页加载:一次只加载一页数据(如 20 条),滑到底部再加载下一页。数据是分批的,用户能看到"加载中"。
- 虚拟列表:所有数据都在内存中,但只渲染可见部分。用户感知不到分页,滑动是连续的。
适用场景:
- 数据量中等(几百~几千条)→ 虚拟列表
- 数据量极大(几万条以上)→ 虚拟列表 + 分页加载
Q2:为什么虚拟列表的节点池不直接用对象池(NodePool)?
A:Cocos 有内置的 NodePool,但虚拟列表通常需要自己管理,因为:
- 需要维护
index → Node的映射关系 - 需要自定义回收逻辑(如限制池大小)
- 节点需要保留数据状态(如图片加载状态)
不过你可以基于 Cocos 的 NodePool 封装自己的实现。
Q3:虚拟列表适合所有场景吗?
A:不适合。以下场景不需要虚拟列表:
- 数据量很少(<20 项)→ 直接创建所有节点更简单
- 每项高度差异极大且不可预测 → 计算复杂度高
- 需要同时看到所有项(如地图上的标记)→ 用其他优化手段
Q4:bufferCount 设多少合适?
A:
- 默认 2 行足够大多数场景
- 快速滚动场景可以增加到 3~5 行
- 不要设太大,否则失去虚拟列表的意义
- 可以根据滚动速度动态调整
Q5:为什么虚拟列表能减少 DrawCall?
A:DrawCall 的数量和渲染的节点数量直接相关。虚拟列表把 1000 个节点减少到 15 个节点,DrawCall 就从 1000 减少到 15。如果再加上动态图集,15 个节点可以合并为 1 个 DrawCall。
Q6:节点复用时,图片怎么更新?
A:通常有两种方式:
- 同步更新:在
_updateItem()中直接加载新图片。简单但可能有闪烁。 - 异步更新:先显示默认图,再异步加载真实图片。体验更好但代码更复杂。
private _updateItem(node: Node, data: ListData, index: number) {
const sprite = node.getComponent(Sprite);
// 方式 1:同步加载
sprite.spriteFrame = this._getSpriteFrame(data.icon);
// 方式 2:异步加载
sprite.spriteFrame = this._defaultSpriteFrame; // 先显示默认图
this._loadSpriteAsync(data.icon).then(sf => {
if (this._activeNodes.get(index) === node) {
sprite.spriteFrame = sf;
}
});
}
Q7:虚拟列表和对象池(Object Pool)是什么关系?
A:虚拟列表的节点池就是一种对象池。对象池是更通用的设计模式,可以用于:
- 子弹、敌人等游戏对象的复用
- UI 节点的复用(虚拟列表)
- 任何创建/销毁开销大的对象
虚拟列表是对象池在 UI 列表场景的具体应用。
Q8:如何处理列表项的动画效果?
A:虚拟列表中的动画需要注意:
- 节点复用时,动画状态要重置
- 不要在不可见项上播放动画(浪费性能)
- 入场动画可以在
_updateItem()中触发
private _updateItem(node: Node, data: ListData, index: number) {
// 重置动画状态
const tween = node.getComponent(Tween);
tween?.stop();
// 更新数据...
// 播放入场动画
node.scale = new Vec3(0.8, 0.8, 1);
tween(node)
.to(0.2, { scale: new Vec3(1, 1, 1) })
.start();
}
十一、知识图谱
虚拟列表
├── 核心算法
│ ├── 可见范围计算(O(1) 或 O(logN))
│ ├── 节点池(Node Pool)
│ └── 数据-视图分离
│
├── 变体实现
│ ├── 固定高度列表
│ ├── 不等高列表(二分查找)
│ └── 网格列表(二维索引)
│
├── 渲染协同
│ ├── 减少 DrawCall(节点数量↓)
│ ├── 按纹理排序(合批优化)
│ └── 动态图集(1 DrawCall)
│
├── 性能优化
│ ├── 异步更新缓冲区
│ ├── 池大小控制
│ └── 滚动速度自适应缓冲
│
└── Cocos 实践
├── ScrollView 集成
├── Prefab 复用
├── UITransform 坐标计算
└── 事件系统兼容
十二、自检清单
| 检查项 | 说明 |
|---|---|
| □ 理解虚拟列表的核心思想 | 只渲染可见项,复用节点 |
| □ 掌握可见范围计算 | scrollOffset / itemHeight |
| □ 会实现节点池 | get/put 机制 |
| □ 能处理不等高列表 | 累积高度 + 二分查找 |
| □ 理解为什么能减少 DrawCall | 节点数量减少 → batch break 减少 |
| □ 知道动态图集的作用 | 将多纹理合并为单 DrawCall |
| □ 会控制内存 | 池大小上限、及时销毁 |
| □ 能解决快速滚动白屏 | 增加 bufferCount |
| □ 能处理点击事件 | 位置更新后再启用交互 |
| □ 理解 Content 的 anchor 设置 | (0.5, 1) 顶部对齐 |