《Cocos 游戏开发入门》11.2 列表虚拟化与滚动性能

《方块冲刺》的排行榜有一千条记录,屏幕只放得下十几条。本节给 mini2d 实现虚拟列表:用前缀和与二分查找算出可视区,只渲染可见项;用节点池复用实例避免反复创建;再补上滚动惯性与越界回弹。末节对照 Cocos Creator 3.8 的 ScrollView、Layout 与 PageView。

11.2 列表虚拟化与滚动性能

《方块冲刺》的排行榜要展示一千条好友与全服记录,道具商店要展示几百件商品。如果老老实实为每条记录创建一个节点,一千个节点意味着每次滚动都要重算一千份布局、绘制一千份文字——在手机上必然掉帧,内存也会被撑爆。

但玩家一次只能看见十几条。虚拟化的核心判断是:屏幕外的项不需要存在。 只渲染可视区内的那十几项,滚动时把滑出视野的项回收、把滑入视野的项复用,无论列表有一千条还是一百万条,实际参与渲染的节点数都是常数级。

本节新增 src/ui/virtual-list.ts:用前缀和 + 二分查找定位可视区,用节点池复用渲染实例,用摩擦模型实现惯性滚动。上一节已经把 arrange 的游标逻辑备好,这一节把它升级成「按需排列」。下一节用栈来管理弹窗层级。

11.2.1 度量表:前缀和定位

要只渲染可见项,先得回答「滚动到 5000 像素时,第一项是哪一项」。等高铁列表里每项高度相同,用除法即可;一旦出现变高项(不同长度的名字、多行描述),除法就失效。

通用解法是前缀和 + 二分查找:维护一个偏移数组 offsets[i],表示第 i 项的顶边距内容原点的距离,offsets 天然有序,二分即可定位。

// src/ui/virtual-list.ts
export class ItemMetrics {
  private offsets: number[] = [0];
  private heights: number[];
  private totalHeight = 0;

  constructor(heights: readonly number[]) {
    this.heights = heights.slice();
    this.rebuild();
  }

  private rebuild(): void {
    let acc = 0;
    this.offsets = [0];
    for (const h of this.heights) {
      acc += h;
      this.offsets.push(acc);
    }
    this.totalHeight = acc;
  }

  get length(): number {
    return this.heights.length;
  }

  get total(): number {
    return this.totalHeight;
  }

  top(index: number): number {
    return this.offsets[index];
  }

  height(index: number): number {
    return this.heights[index];
  }

  /** 二分查找 scrollTop 落在哪一项(返回项索引,闭区间语义) */
  indexAt(scrollTop: number): number {
    let lo = 0;
    let hi = this.heights.length - 1;
    while (lo < hi) {
      const mid = (lo + hi + 1) >> 1;
      if (this.offsets[mid] <= scrollTop) lo = mid;
      else hi = mid - 1;
    }
    return lo;
  }
}

indexAt 用的是「找最后一个满足 offsets[mid] <= scrollTop 的项」这一变体,因此 mid 要向上取整((lo + hi + 1) >> 1),否则在只剩两项时会死循环。这是二分查找最常见的坑,值得单测覆盖。

11.2.2 可视区计算

有了度量表,可视区就是两个边界的二分:上边界 scrollTop,下边界 scrollTop + 视口高。再向外扩一圈 overscan,让快速滚动时不至于在边缘露出空白。

export interface VisibleRange {
  first: number;
  last: number; // 闭区间
}

/**
 * 计算当前需要渲染的项区间。
 * overscan 以「视口高」为单位,0.5 表示上下各多渲染半屏。
 */
export function computeVisibleRange(
  metrics: ItemMetrics,
  scrollTop: number,
  viewportHeight: number,
  overscan: number,
): VisibleRange {
  const pad = overscan * viewportHeight;
  const top = Math.max(0, scrollTop - pad);
  const bottom = scrollTop + viewportHeight + pad;

  const first = Math.max(0, metrics.indexAt(top));
  let last = first;
  while (last < metrics.length - 1 && metrics.top(last + 1) < bottom) {
    last += 1;
  }
  return { first, last };
}

first 和 last 都做了边界钳制,因此列表为空或滚动超出范围时返回的是合法索引,调用方不必再判空。overscan 之所以用「视口高的倍数」而不是像素,是因为它表达的是「多渲染几屏」这个意图,与具体分辨率解耦。

11.2.3 节点池:复用而非重建

可视区算出来只有十几项,但如果每帧都 new 十几个节点、再丢掉十几个,GC 压力依然可观。正确做法是维护一个节点池:滑出的项归还到池里,滑入的项从池里取,只在池空时才真正创建。

export class NodePool<T> {
  private free: T[] = [];

  acquire(create: () => T): T {
    const reused = this.free.pop();
    return reused !== undefined ? reused : create();
  }

  release(node: T): void {
    this.free.push(node);
  }

  get size(): number {
    return this.free.length;
  }
}

配合可视区使用时的调度逻辑是这样的:每帧算出新的 { first, last },把「上一帧有、这一帧没有」的索引归还到池里,把「这一帧新出现」的索引取出节点并重新绑定数据。只要列表项的尺寸不突变,池的大小会稳定在可视项数的 1.5 倍左右,几乎不再分配新对象。

11.2.4 滚动惯性与边界回弹

滚动要「像原生的」,就得有惯性。手指抬起后,速度按指数衰减,同时把位置积分推进;当位置越过 [0, 内容高 - 视口高] 时,用一个弹簧刚度把它拉回边界内,产生回弹。

export interface ScrollState {
  offset: number;
  velocity: number;
  dragging: boolean;
}

export interface ScrollConfig {
  friction: number; // 每秒速度衰减到原来的几分之几
  minVelocity: number; // 低于此速度直接归零
  bounce: number; // 回弹刚度,越大回得越快
}

export function stepScroll(
  state: ScrollState,
  contentHeight: number,
  viewportHeight: number,
  dt: number,
  cfg: ScrollConfig,
): ScrollState {
  const max = Math.max(0, contentHeight - viewportHeight);
  let { offset, velocity } = state;

  if (!state.dragging) {
    offset += velocity * dt;
    velocity *= Math.pow(cfg.friction, dt);
    if (Math.abs(velocity) < cfg.minVelocity) velocity = 0;
  }

  // 越界回弹:把超出边界的距离按刚度逐帧收敛回 0
  if (offset < 0) {
    offset += -offset * Math.min(1, cfg.bounce * dt);
  } else if (offset > max) {
    offset += (max - offset) * Math.min(1, cfg.bounce * dt);
  }

  return { offset, velocity, dragging: state.dragging };
}

Math.pow(cfg.friction, dt) 是关键:它让衰减与帧率解耦。如果写成 velocity *= 0.9,60 帧设备上每秒衰减到 0.9^60,而 120 帧设备上会衰减到 0.9^120——同一段滑动在两种设备上的滑行距离会差一倍。第 4.1 节讲过的 deltaTime 思想,在滚动里同样成立。

11.2.5 变高项与高度估算

真实列表里,项的高度往往事先未知:名字长度不定、描述可能折行。工程上有两条路:

方案做法优点代价
固定高度所有项统一高度定位 O(1),实现最简长内容被截断或留白
预估 + 修正先按经验值估高,渲染后回写真实高度并重建前缀和视觉准确滚动中可能跳动

《方块冲刺》的排行榜用固定高度,道具商店用预估 + 修正。修正的做法是:项渲染完成后测量实际高度,若与预估不同,就调用 ItemMetrics 的 rebuild 重算前缀和。重建是 O(n),所以不要在滚动中每项都重建,而要攒一帧批量重建一次——这是「正确性」与「流畅度」之间的取舍。

11.2.6 单元测试

// tests/virtual-list.test.ts
import { describe, it, expect } from 'vitest';
import {
  ItemMetrics,
  computeVisibleRange,
  NodePool,
  stepScroll,
} from '../src/ui/virtual-list';

describe('virtual-list', () => {
  const metrics = new ItemMetrics(Array.from({ length: 1000 }, () => 50));

  it('只渲染可视区内的项', () => {
    const r = computeVisibleRange(metrics, 5000, 600, 0);
    expect(r.first).toBe(100);
    expect(r.last).toBe(111);
  });

  it('二分定位在变高列表上也正确', () => {
    const m = new ItemMetrics([10, 20, 30, 40]);
    expect(m.indexAt(0)).toBe(0);
    expect(m.indexAt(35)).toBe(2);
    expect(m.indexAt(99)).toBe(3);
    expect(m.total).toBe(100);
  });

  it('节点池复用不再新建', () => {
    const pool = new NodePool<{ id: number }>();
    let created = 0;
    const a = pool.acquire(() => {
      created += 1;
      return { id: created };
    });
    pool.release(a);
    const b = pool.acquire(() => {
      created += 1;
      return { id: created };
    });
    expect(b).toBe(a);
    expect(created).toBe(1);
  });

  it('惯性滚动在摩擦下减速到 0', () => {
    let s = { offset: 0, velocity: 2000, dragging: false };
    for (let i = 0; i < 120; i += 1) {
      s = stepScroll(s, 100000, 600, 1 / 60, {
        friction: 0.02,
        minVelocity: 10,
        bounce: 12,
      });
    }
    expect(s.velocity).toBe(0);
    expect(s.offset).toBeGreaterThan(0);
  });
});

第一个用例最能说明虚拟化的收益:一千项的列表,scrollTop = 5000 时只渲染索引 100 到 111 共 12 项,与实际列表长度无关。这也是「列表变长,帧率不变」的来源。

11.2.7 Cocos 对照:ScrollView、Layout 与 PageView

Cocos Creator 3.8 的 ScrollView 已经把上面大部分工作做完了:它内置了 content 容器、vertical / horizontal 方向开关、inertia 惯性、brake 减速、elastic 回弹与 bounceDuration 回弹时长,对应关系几乎是逐项对上的。

import { _decorator, Component, ScrollView, Layout, UITransform } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('RankBoard')
export class RankBoard extends Component {
  @property(ScrollView)
  scroll: ScrollView | null = null;

  @property(Layout)
  content: Layout | null = null;

  start(): void {
    const scroll = this.scroll;
    const content = this.content;
    if (scroll === null || content === null) return;

    scroll.vertical = true;
    scroll.horizontal = false;
    scroll.inertia = true;
    scroll.brake = 0.75; // 对应 mini2d 的 friction
    scroll.elastic = true; // 越界回弹
    scroll.bounceDuration = 0.23;

    // 内容容器改用垂直排列,由 Layout 自动排布子节点
    content.type = Layout.Type.VERTICAL;
    content.resizeMode = Layout.ResizeMode.CONTAINER;
    content.spacingY = 8;

    // 内容高度由 Layout 撑出,ScrollView 据此算出可滚动范围
    const ui = content.node.getComponent(UITransform);
    if (ui !== null) {
      ui.setContentSize(ui.width, content.node.children.length * 56);
    }
  }

  scrollToTop(): void {
    this.scroll?.scrollToTop(0.3, true);
  }
}

几点对照值得记下:

  • ScrollView.content 是一个 Node,必须显式设置;ScrollView 通过它计算可滚动范围,等价于 mini2d 的 ItemMetrics.total。
  • brake 越大停得越快,与 mini2d 的 friction 方向一致(不过 Cocos 用「刹车强度」,mini2d 用「保留比例」)。
  • elastic 打开后越界会回弹,对应 stepScroll 里的越界分支。
  • Layout 负责排列,UITransform.setContentSize 负责声明内容总尺寸;两者配合才能让 ScrollView 知道「能滚多远」。

但 Cocos 的 ScrollView 默认并不虚拟化:content 下有多少子节点就渲染多少。列表上千项时,仍需要自己实现可见区回收——这正是本节 computeVisibleRange 与 NodePool 的用武之地,只是把「节点」换成 Cocos 的 Node,逻辑完全一样。分页场景另有 PageView(继承自 ScrollView),它提供 addPage、setCurrentPageIndex、getCurrentPageIndex 等接口,适合做图鉴与新手引导的分屏切换。

能力mini2dCocos Creator 3.8
滚动容器ScrollState + 手动裁剪ScrollView(content / vertical / horizontal)
惯性stepScroll 的 frictionScrollView.inertia + brake
越界回弹stepScroll 的 bounceScrollView.elastic + bounceDuration
项排列arrange + 前缀和Layout(type / spacingY)
可视区裁剪computeVisibleRange需自行实现(ScrollView 不内置)
节点复用NodePool自建池 + instantiate / Prefab
分页自定义PageView

下一节我们把视线从「列表」移到「层级」:当暂停面板、设置面板、确认框同时存在时,谁盖在谁上面、点击落在谁身上、返回键该关掉哪个——用弹窗栈和界面状态机把这些问题收束成一套可验证的规则。

阅读导航:上一节:11.1 布局系统:锚点、对齐与适配 · 下一节:11.3 弹窗栈与界面状态机 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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