with one click
cocos-creator-ui-list
做 Cocos Creator 大量条目列表时使用。虚拟列表、节点复用。
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Menu
做 Cocos Creator 大量条目列表时使用。虚拟列表、节点复用。
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
Based on SOC occupation classification
做 Cocos Creator 多机型/多分辨率适配时使用。Canvas、Widget、安全区。
Cocos Creator 用 AssetBundle 做分包/远程资源时使用。加载、释放、依赖、缓存。
优化 Cocos Creator 渲染性能时使用。合批、图集、动静分离、Label。
给 Cocos Creator 原生包做热更新时使用。version manifest、增量、校验、回滚。
写 Cocos Creator 动效/动画时使用。tween、Animation、Spine、性能与清理。
写 Cocos Creator(TypeScript)时使用。组件、节点、prefab、事件、资源管理规范。
| name | cocos-creator-ui-list |
| description | 做 Cocos Creator 大量条目列表时使用。虚拟列表、节点复用。 |
| category | gamedev |
| tags | ["cocoscreator","性能","列表"] |
Layout 或 Sprite 更新耗时时。instantiate 了几百个 item 节点导致进入场景卡顿时。规则: 不论列表数据有多少条,同时存在于场景节点树中的 item 节点数量 = 可视区可容纳数量 + 固定缓冲量(通常 4~6 个);滚动时将移出可视区的节点重新定位到新进入可视区的位置并更新数据,不增减节点数量。
为什么: 这是长列表性能的根本问题。AI 生成列表代码时的惯用写法是循环 cc.instantiate(itemPrefab) 并 addChild 全部数据——500 条排行榜就创建 500 个节点,每个节点含 Sprite、Label、Button,光是节点树渲染遍历就够卡的。实测在中端安卓机上,一次性创建 200 个含图片的列表节点耗时超过 1 秒,直接导致进入场景的白屏卡顿。更致命的是:ScrollView 的 content 节点拖 Layout 组件后,每次 addChild 都会触发整个 Layout 的重排,200 次 addChild = 200 次全量重排,复杂度是 O(n²)。
怎么做:
visibleCount,加缓冲 visibleCount + 6 作为实际节点池大小。onLoad 里只创建这么多节点放入节点池,content 节点不挂 Layout 组件(手动控制 y 坐标)。content 节点高度设为 总条目数 × itemHeight,让 ScrollView 滚动条正确反映总量。SCROLL_EV 或在 update 里用节流检测滚动偏移,计算当前第一个可见索引 firstIndex,遍历节点池把每个节点的 y = -(firstIndex + i) * itemHeight,并调用 refreshItem(node, data[firstIndex + i]) 更新数据。规则: item 节点用 NodePool(或简单数组)管理复用,不在滚动回调里 instantiate/destroy;归还节点时在 unuse 钩子里重置所有可视状态(图片、文字、选中态),避免残留数据。
为什么: 即使实现了虚拟列表,如果每次 item 滑出可视区就 destroy、滑入就 instantiate,性能依然很差。instantiate 一个含多个子节点的 prefab 在移动端耗时 5~20 ms,60fps 的帧预算只有 16ms,几个 item 同时进入视野就直接掉帧。更隐蔽的问题:归还节点时不重置状态,下次从池里取出的节点仍然显示上一个位置的数据(旧头像、旧分数),等异步加载完成后才刷新,用户会看到数据"闪变"。
怎么做:
ItemController 组件,实现 unuse() 方法清空头像 Sprite(this.avatar.spriteFrame = null)、重置文字、隐藏选中态。private _itemPool: NodePool = new NodePool('ItemController');。const node = this._itemPool.size() > 0 ? this._itemPool.get()! : instantiate(this.itemPrefab);this._itemPool.put(node);(内部自动调用 ItemController.unuse())。onDestroy 里:this._itemPool.clear();,防止场景卸载后池内节点仍挂载于内存。规则: 列表的「数据模型」和「节点视图」严格分离;滚动时只调用 itemNode.getComponent(ItemController).refresh(data) 更新绑定数据,绝不销毁重建节点,也不对不可见节点做任何数据操作。
为什么: 数据视图没有分离时,刷新列表(如排行榜推送新数据)会触发对所有节点的重绑定,即便用户只看到前 10 条,后面 490 条的节点也在做无效的 label.string = ... 和 spriteFrame 赋值操作。AI 生成的"刷新列表"代码通常是 this.itemNodes.forEach((node, i) => node.getComponent(...).refresh(allData[i]))——如果 itemNodes 里有 500 个节点,这就是 500 次赋值,每次赋值都可能触发 Label 的 dirty 重排和 Sprite 的纹理更新。另一个常见错误:数据更新时直接 removeAllChildren + 重建,整个列表闪一下,所有滚动位置归零。
怎么做:
_dataList: ItemData[] 作为纯数据数组,不与节点一一对应。_visibleItems: Map<number, Node> 记录「数据索引 → 节点」的当前映射。refresh,对移出的索引取消映射并归还节点池。_dataList,然后对当前 _visibleItems 里的每个节点重新 refresh,不触碰不可见节点。refresh(data: ItemData) 方法内部做最小更新:只在数据真正变化时才赋值(可用简单的 if (this._lastData?.score !== data.score) 守卫)。规则: content 节点在需要插入/删除 item 时,批量操作完成后再调用一次 Layout.updateLayout(),不每次 addChild/removeChild 都触发重排;图片资源异步加载时先显示占位图,加载完成后更新,不阻塞主线程等待。
为什么: Layout 组件有一个不直观的特性:默认情况下每次子节点的 active、position、size 变化都会标记 dirty 并在下一帧重新计算所有子节点位置。如果在一次 update 里有 10 个 item 同时更新,就是 10 次 dirty 标记,触发 10 次全量 Layout 计算。对于虚拟列表来说,应该完全不使用 Layout 组件,手动计算每个节点的 y 坐标(-(index * itemHeight + itemHeight/2)),一次性批量设置,完全规避 Layout 的重排开销。图片方面,排行榜头像几十张全部同时加载会在加载完成瞬间引发纹理上传集中耗时(表现为滚动某个位置时掉一帧),用占位图 + 逐帧加载队列(每帧最多处理 2~3 个加载请求)可以平摊耗时。
怎么做:
content 子节点手动设置 y,彻底移除 Layout 组件。addChild 完成后调用 layoutComp.updateLayout(),而不是依赖自动触发。refresh() 里先将头像 Sprite 设为占位图,然后将真实 URL 加入加载队列;队列管理器每帧用 scheduleOnce 处理 2~3 个,避免同帧并发大量请求。content 的 ContentSize.height 设为 totalCount * itemHeight,让滚动条准确反映数据量。规则: 对于不需要惯性滑动的列表(如固定翻页、步进选择),关闭 ScrollView 的 inertia 属性;评估是否需要 Mask 组件——Mask 会产生一次 Draw Call 中断和模板测试开销,在节点复杂的列表上影响显著;必须遮罩时优先用 RenderTexture 裁切方案替代复杂嵌套 Mask。
为什么: inertia(惯性)在手指抬起后继续触发 SCROLLING 事件,虚拟列表的重定位逻辑会在惯性滑动期间持续执行。如果惯性阶段触发了图片加载、数据请求等重操作,会明显拖慢惯性停止。Mask 的性能问题更隐蔽:每一个 Mask 组件会打断批处理(Batching),在同一帧里造成额外的渲染状态切换,哪怕 Mask 遮盖的内容只有一个图片。常见错误:给 ScrollView 外层套 Mask 实现圆角容器,再在 ScrollView 内部每个 item 再套一个 Mask 实现圆角头像,两层 Mask 叠加,Profiler 里 Draw Call 数直接翻倍。
怎么做:
scrollView.inertia = false;,减少多余的 SCROLLING 回调。ScrollView 的 horizontalScrollBar/verticalScrollBar 时,评估滚动条本身的 Draw Call 代价,不需要时隐藏滚动条节点(node.active = false 而非 opacity = 0)。// 反例 — 500 条数据全部 instantiate,Layout 自动重排,进场白屏卡顿
import { _decorator, Component, instantiate, Node, Prefab, Layout } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('RankList')
export class RankList extends Component {
@property(Prefab) itemPrefab: Prefab = null!;
@property(Node) content: Node = null!;
private _dataList: RankData[] = [];
refreshList(dataList: RankData[]) {
this._dataList = dataList;
// ❌ 每次刷新先全删再全建,列表闪烁,滚动位置归零
this.content.removeAllChildren();
for (const data of dataList) {
// ❌ 500 条数据 = 500 次 instantiate,耗时数秒
const item = instantiate(this.itemPrefab);
this.content.addChild(item); // ❌ 每次 addChild 触发 Layout 全量重排
item.getComponent(ItemController)!.refresh(data);
}
// ❌ Layout 组件挂在 content 上,已被触发了 500 次 dirty
}
}
// 正例 — 虚拟列表:固定节点数,滚动时复用 + 手动坐标,不用 Layout
import { _decorator, Component, Node, Prefab, NodePool, ScrollView,
instantiate, UITransform } from 'cc';
const { ccclass, property } = _decorator;
const ITEM_HEIGHT = 120; // item 高度(设计分辨率单位)
const BUFFER_COUNT = 6; // 可视区外缓冲节点数
@ccclass('RankList')
export class RankList extends Component {
@property(Prefab) itemPrefab: Prefab = null!;
@property(Node) content: Node = null!;
@property(ScrollView) scrollView: ScrollView = null!;
private _dataList: RankData[] = [];
private _itemPool: NodePool = new NodePool('ItemController');
private _visibleMap: Map<number, Node> = new Map();
private _pooledNodes: Node[] = [];
private _visibleCount: number = 0;
onLoad() {
// ✅ 计算可视区能容纳的节点数
const viewHeight = this.scrollView.node.getComponent(UITransform)!.contentSize.height;
this._visibleCount = Math.ceil(viewHeight / ITEM_HEIGHT) + BUFFER_COUNT;
// ✅ 只创建固定数量的节点,不随数据量变化
for (let i = 0; i < this._visibleCount; i++) {
const node = instantiate(this.itemPrefab);
this.content.addChild(node);
node.active = false;
this._pooledNodes.push(node);
}
// ✅ content 不挂 Layout 组件,手动管理 y 坐标
}
refreshList(dataList: RankData[]) {
this._dataList = dataList;
// ✅ 更新 content 总高度让滚动条准确
const ut = this.content.getComponent(UITransform)!;
ut.setContentSize(ut.contentSize.width, dataList.length * ITEM_HEIGHT);
this.updateVisibleItems();
}
private updateVisibleItems() {
const scrollOffset = this.scrollView.getScrollOffset().y;
const firstIndex = Math.max(0, Math.floor(scrollOffset / ITEM_HEIGHT));
let poolIdx = 0;
for (let i = firstIndex; i < Math.min(firstIndex + this._visibleCount, this._dataList.length); i++) {
if (this._visibleMap.has(i)) continue; // ✅ 已显示则跳过,不重复刷新
const node = this._pooledNodes[poolIdx++];
if (!node) break;
node.active = true;
// ✅ 手动设置 y,不依赖 Layout
node.setPosition(0, -(i * ITEM_HEIGHT + ITEM_HEIGHT / 2));
node.getComponent(ItemController)!.refresh(this._dataList[i]);
this._visibleMap.set(i, node);
}
}
onDestroy() {
this._itemPool.clear(); // ✅ 场景卸载时清空节点池
}
}
// 反例 — 数据推送时对全部 500 个节点赋值,同帧加载所有头像
refreshAll(newData: RankData[]) {
this._dataList = newData;
// ❌ 无论节点是否可见,全部遍历赋值,500 次 dirty 操作
this._allItemNodes.forEach((node, i) => {
node.getComponent(ItemController)!.refresh(newData[i]);
// ❌ 同帧触发 500 个头像的网络/本地加载,纹理上传集中耗时
resources.load(`avatars/${newData[i].userId}`, SpriteFrame, (err, sf) => {
node.getComponent(ItemController)!.setAvatar(sf);
});
});
}
// 正例 — 只刷新可见节点 + 加载队列平摊纹理上传
refreshAll(newData: RankData[]) {
this._dataList = newData;
// ✅ 只更新当前可见节点
this._visibleMap.forEach((node, dataIndex) => {
if (dataIndex < newData.length) {
node.getComponent(ItemController)!.refresh(newData[dataIndex]);
}
});
}
// ItemController 内部:头像加载用占位 + 队列
refresh(data: RankData) {
this._nameLabel.string = data.name;
this._scoreLabel.string = String(data.score);
// ✅ 先显示占位图,不阻塞
this._avatar.spriteFrame = this._placeholderFrame;
// ✅ 将加载请求加入全局队列,由队列管理器每帧限速处理
AvatarLoadQueue.enqueue(data.avatarUrl, (sf: SpriteFrame) => {
if (this.isValid) this._avatar.spriteFrame = sf; // ✅ 判断节点是否仍有效
});
}
content 节点没有挂 Layout 组件,item 的 y 坐标由代码手动设置。NodePool 复用,unuse() 钩子里清空了图片、文字、选中态等可视状态。refresh(),不遍历全量数据节点。spriteFrame。onDestroy 里调用了 pool.clear(),场景卸载后节点池不残留内存。