《Cocos 游戏开发实战》6.2 列表与滚动容器

本节拆解 Cocos Creator 3.8 的 ScrollView 节点结构与属性面板取值,用 Layout 与 Prefab 搭出关卡选择列表,用 NodePool 复用列表项控制 Draw Call,并给出滚动结束事件驱动的分页与吸顶实现,以及可在命令行单测的可见项区间算法。

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开启松手后按惯性继续滑,手感接近原生
Brake0.7惯性衰减速度,1 表示立即停下
Elastic开启到边界后回弹
Bounce Duration0.23回弹时长,越小越干脆
Content拖入 view/content 节点滚动的内容容器
Scroll Events留空,改由代码注册见 6.2.5

再选中 content,配 Layout:

属性取值理由
TypeVERTICAL纵向排列
Resize ModeCONTAINER撑大 content 自身,让滚动范围随之变化
Spacing Y12项间距
Padding Top / Bottom16 / 16首尾留白
Vertical DirectionTOP_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 弹窗栈与多语言 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画