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 等接口,适合做图鉴与新手引导的分屏切换。
| 能力 | mini2d | Cocos Creator 3.8 |
|---|---|---|
| 滚动容器 | ScrollState + 手动裁剪 | ScrollView(content / vertical / horizontal) |
| 惯性 | stepScroll 的 friction | ScrollView.inertia + brake |
| 越界回弹 | stepScroll 的 bounce | ScrollView.elastic + bounceDuration |
| 项排列 | arrange + 前缀和 | Layout(type / spacingY) |
| 可视区裁剪 | computeVisibleRange | 需自行实现(ScrollView 不内置) |
| 节点复用 | NodePool | 自建池 + instantiate / Prefab |
| 分页 | 自定义 | PageView |
下一节我们把视线从「列表」移到「层级」:当暂停面板、设置面板、确认框同时存在时,谁盖在谁上面、点击落在谁身上、返回键该关掉哪个——用弹窗栈和界面状态机把这些问题收束成一套可验证的规则。
阅读导航:上一节:11.1 布局系统:锚点、对齐与适配 · 下一节:11.3 弹窗栈与界面状态机 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。