6.2 列表与滚动容器
6.1 UI 层级与多分辨率适配
搭好了 Canvas 的层级骨架,接下来往里填界面。《方块冲刺》里最耗资源的一类界面是列表:关卡选择要列 60 个关卡,排行榜要列 100 名玩家,皮肤商店要列几十件道具。这些列表的共同点是屏幕只放得下十几项,数据却有上百条。
列表写不好,后果是立竿见影的:每个列表项一个 Sprite,一百项就是一百次额外的绘制调用(Draw Call),低端机上直接从 60 帧掉到 30 帧。这一节把列表拆成三层——ScrollView 提供滚动与裁剪、Layout 负责排布、复用池控制同屏节点数——逐层给出 Cocos Creator 3.8 的配置与代码。
与站内
game/client/那批文章的区分:那些文章讲虚拟列表的通用原理,本节给的是 Cocos Creator 3.8 的ScrollView+Mask+Layout具体节点结构与属性取值。
6.2.1 ScrollView 的节点结构
3.8 的 ScrollView 组件不自己裁剪内容,它依赖一个带 Mask 的 view 节点来裁剪。标准结构是三层:外层 ScrollView 节点、内层 view 节点(挂 Mask)、最内 content 节点(挂 Layout,所有列表项挂它下面)。
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 打开 assets/prefabs/LevelList.prefab,层级管理器里会看到:
{
"__type__": "cc.Node",
"_name": "LevelList",
"_components": [ { "__id__": 2 }, { "__id__": 3 } ],
"_children": [
{
"__type__": "cc.Node",
"_name": "view",
"_components": [ { "__id__": 4 }, { "__id__": 5 } ],
"_children": [
{
"__type__": "cc.Node",
"_name": "content",
"_components": [ { "__id__": 6 }, { "__id__": 7 } ]
}
]
}
]
}
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 在编辑器里选中各节点,属性面板显示的组件如下:
LevelList上挂cc.UITransform与cc.ScrollView。view上挂cc.UITransform与cc.Mask(Type选RECT,尺寸就是可视窗口大小)。content上挂cc.UITransform与cc.Layout(Type选VERTICAL)。
一个常见的错误是把 Mask 挂在 content 上。这样每个列表项都会被单独裁剪,不仅多一层渲染开销,滚动时还会出现内容在窗口外仍然可见的现象。Mask 只该挂在一层 view 节点上。
6.2.2 属性面板取值
在编辑器里选中 LevelList,按下面这张表配 ScrollView(需 Cocos Creator 3.8 编辑器):
| 属性 | 取值 | 理由 |
|---|---|---|
Horizontal | 关闭 | 关卡列表只纵向滚动 |
Vertical | 开启 | 纵向滚动 |
Inertia | 开启 | 松手后按惯性继续滑,手感接近原生 |
Brake | 0.7 | 惯性衰减速度,1 表示立即停下 |
Elastic | 开启 | 到边界后回弹 |
Bounce Duration | 0.23 | 回弹时长,越小越干脆 |
Content | 拖入 view/content 节点 | 滚动的内容容器 |
Scroll Events | 留空,改由代码注册 | 见 6.2.5 |
再选中 content,配 Layout:
| 属性 | 取值 | 理由 |
|---|---|---|
Type | VERTICAL | 纵向排列 |
Resize Mode | CONTAINER | 撑大 content 自身,让滚动范围随之变化 |
Spacing Y | 12 | 项间距 |
Padding Top / Bottom | 16 / 16 | 首尾留白 |
Vertical Direction | TOP_TO_BOTTOM | 第一项在顶部 |
Resize Mode 是最关键的一项。选 CONTAINER 时,Layout 会反过来把 content 的 Content Size 撑到刚好容纳所有子项,ScrollView 才能据此算出正确的滚动范围;选 CHILDREN 则是调整子项去适应容器,列表就会挤在一起。两者选错的症状很典型:滚动条拉到底却看不到最后几项。
6.2.3 关卡列表:从配置到列表项
列表数据来自 configs/levels.json(结构见 §C:每项含 obstacles / coins / goalX / speed)。生成列表项的标准做法是 instantiate 一个 Prefab,填数据,再 addChild 到 content 下。
import {
_decorator, Component, Node, Prefab, instantiate, ScrollView, Label,
Layout, JsonAsset, resources, assetManager,
} from 'cc';
const { ccclass, property } = _decorator;
interface LevelConfig {
readonly id: number;
readonly speed: number;
readonly goalX: number;
}
@ccclass('LevelListBuilder')
export class LevelListBuilder extends Component {
@property(Prefab) itemPrefab: Prefab | null = null;
@property(ScrollView) scroll: ScrollView | null = null;
private content: Node | null = null;
start(): void {
this.content = this.scroll?.content ?? null;
assetManager.loadBundle('bundle-levels', (err, bundle) => {
if (err !== null || bundle === null) {
console.warn('[LevelListBuilder] 分包加载失败', err);
return;
}
bundle.load('configs/levels', JsonAsset, (e, asset) => {
if (e !== null || asset === null) return;
const raw = asset.json as unknown as LevelConfig[] | null;
if (raw === null) return;
this.build(raw);
});
});
}
private build(levels: readonly LevelConfig[]): void {
const content = this.content;
const prefab = this.itemPrefab;
if (content === null || prefab === null) return;
for (const lv of levels) {
const item = instantiate(prefab);
const label = item.getChildByName('Index')?.getComponent(Label);
if (label) label.string = `第 ${lv.id} 关`;
content.addChild(item);
}
// 一次性刷新布局,避免每个 addChild 都触发一次重排
content.getComponent(Layout)?.updateLayout();
}
}
最后那句 updateLayout() 是刻意加的:Layout 在子节点变化时通常会自动重排,但批量添加几十个节点时,自动重排会被触发几十次,每一次都是全量遍历。先 addChild 完再手动调一次,能把重排从 O(n²) 降到 O(n)。
6.2.4 复用:NodePool 控制同屏节点数
60 个关卡全量生成节点,在低端机上会让 Draw Call 翻好几倍。Cocos Creator 3.8 提供了 NodePool 来复用节点:滚出可视区的项不销毁,而是 put 回池子,滚进来时再 get 出来。
import { _decorator, Component, Node, NodePool, Prefab, instantiate } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ListRecycler')
export class ListRecycler extends Component {
@property(Prefab) itemPrefab: Prefab | null = null;
private pool = new NodePool();
private active: Node[] = [];
/** 取一个列表项:优先复用池里的,池空则新建 */
acquire(): Node | null {
if (this.itemPrefab === null) return null;
const reused = this.pool.get();
const node = reused ?? instantiate(this.itemPrefab);
this.active.push(node);
return node;
}
/** 归还一个列表项:从父节点摘下后放回池 */
recycle(node: Node): void {
const i = this.active.indexOf(node);
if (i >= 0) this.active.splice(i, 1);
node.removeFromParent();
this.pool.put(node);
}
/** 清空:关卡切换时释放全部缓存节点 */
dispose(): void {
this.pool.clear();
this.active = [];
}
get poolSize(): number {
return this.pool.size();
}
}
NodePool 的接口只有四个:get() 取、put(node) 还、size() 看存量、clear() 清空。它内部会记住节点的组件状态,put 之后再 get 出来,节点上的 Sprite、Label 都还在——所以复用前必须把数据重新填一遍,否则会看到上一项残留的文字。这也是列表复用最常见的一类 bug:数据没刷新,视觉上表现为「文字串行」。
| 方案 | 节点数 | 内存 | 适用 |
|---|---|---|---|
| 全量生成 | 等于数据条数 | 高 | 数据量 < 20 |
NodePool 复用 | 约等于可视项数 + 少量缓冲 | 低 | 数据量 ≥ 50,本工程采用 |
| 完全虚拟化 | 仅可视项 | 最低 | 数据量上千,需自己算区间 |
本工程关卡数控制在百级以内,NodePool 复用已经够用,不必上完全虚拟化。
6.2.5 滚动事件:分页与吸顶
ScrollView 通过事件把滚动状态抛给业务层。事件类型在 ScrollView.EventType 上,常用的是 SCROLLING(每帧滚动中)、SCROLL_ENDED(松手后停止)、SCROLL_TO_BOTTOM(到底)。
import { _decorator, Component, ScrollView, Label } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('LevelListPager')
export class LevelListPager extends Component {
@property(ScrollView) scroll: ScrollView | null = null;
@property(Label) pageTip: Label | null = null;
private page = 1;
private readonly pageSize = 12;
onEnable(): void {
this.scroll?.node.on(ScrollView.EventType.SCROLL_ENDED, this.onScrollEnded, this);
}
onDisable(): void {
this.scroll?.node.off(ScrollView.EventType.SCROLL_ENDED, this.onScrollEnded, this);
}
private onScrollEnded(): void {
const view = this.scroll;
if (view === null) return;
const offset = view.getScrollOffset();
const max = view.getMaxScrollOffset();
// 滚到底部附近时自动翻页提示
if (max.y > 0 && offset.y > max.y * 0.9) {
this.page += 1;
if (this.pageTip !== null) {
this.pageTip.string = `已加载第 ${this.page} 页(每页 ${this.pageSize} 关)`;
}
}
}
backToTop(): void {
this.scroll?.scrollToTop(0.2);
}
}
SCROLL_ENDED 在惯性滑行也停下之后才触发一次,用它做「翻页」「加载更多」最合适;SCROLLING 每帧都触发,只适合做进度条、吸顶这类需要连续更新的效果。用错事件的典型症状是「加载更多」被连触发几十次——那是因为写在了 SCROLLING 上。
6.2.6 工程收口
这一节做完,工程里多了这些确定的东西:
assets/prefabs/LevelList.prefab:ScrollView+view(Mask)+content(Layout)三层结构。assets/prefabs/LevelItem.prefab:单个关卡项,挂Label与点击组件。assets/scripts/ui/level-list-builder.ts:从bundle-levels读配置生成列表。assets/scripts/ui/list-recycler.ts:NodePool复用器。assets/scripts/ui/level-list-pager.ts:滚动结束驱动的分页提示。
列表做完,PopupLayer 还是空的。下一节把弹窗补齐:暂停、结算、设置三个面板如何共用一个弹窗栈、如何保证只有最上层响应点击,以及界面文案怎么在中英文之间切换。
阅读导航:上一节:6.1 UI 层级与多分辨率适配 · 下一节:6.3 弹窗栈与多语言 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。