10.2 异步加载、进度与预加载
上一节把资源收进了清单,也知道了「加载关卡 1 要拉哪些、按什么顺序」。但清单只是纸面计划,真正把几十上百份资源拉进内存,还得处理三件麻烦事:并发太多会把浏览器连接池堵死、进度条不能只数「第几个」而要按字节、网络会失败。这一节逐一解决,末节给出 Cocos Creator 3.8 的对照。
10.2.1 加载任务的统一形态
先把「一次加载」抽象成一个任务对象。它必须携带 uuid(用于记账与去重)和 weight(字节数,用于加权进度),以及一个真正干活的 run:
// src/assets/loader.ts
export interface LoadTask {
uuid: string;
/** 权重,通常等于清单里的 size,用于加权进度 */
weight: number;
run: () => Promise<unknown>;
}
export interface LoadProgress {
/** 已完成的加权字节数 */
loaded: number;
/** 总加权字节数 */
total: number;
/** 已完成的任务数 */
done: number;
/** 任务总数 */
count: number;
}
weight 与 size 分开是有意的:进度条用 weight,可以给首屏关键资源放大权重(比如主角色贴图乘 2),让它「早到」比「平均到」体验更好;而 size 只反映真实体积。默认两者相等即可。
10.2.2 并发池:限制同时在飞的数量
最省事的写法是 Promise.all(tasks.map(t => t.run())),但一次性发起 200 个请求会触发浏览器同域连接数上限(通常 6 个),后面的请求全在排队,还会把首屏关键资源的请求挤到队尾。正确做法是并发池:只维持固定数量的「在飞」任务,完成一个补一个。
export async function runPool(
tasks: LoadTask[],
limit: number,
onProgress: (p: LoadProgress) => void,
): Promise<{ failed: string[] }> {
const total = tasks.reduce((s, t) => s + t.weight, 0);
const failed: string[] = [];
let loaded = 0;
let done = 0;
let cursor = 0;
const worker = async (): Promise<void> => {
while (cursor < tasks.length) {
const task = tasks[cursor++];
try {
await task.run();
} catch (err) {
failed.push(task.uuid);
console.warn(`[mini2d] 加载失败:${task.uuid}`, err);
}
loaded += task.weight;
done += 1;
onProgress({ loaded, total, done, count: tasks.length });
}
};
const workers = Math.min(limit, tasks.length);
await Promise.all(Array.from({ length: workers }, () => worker()));
return { failed };
}
关键点:cursor++ 在单线程 JS 里是原子的,多个 worker 共享同一个游标即可,不需要锁。limit 取 6 与浏览器连接上限对齐;limit 设得比连接数大并不会更快,只会让请求在浏览器内部排队,反而让进度反馈变迟钝。
失败的任务被记进 failed 而不是让整个 Promise.all reject——一份资源挂了不该拖垮整个加载流程,这点在 10.2.6 会展开。
10.2.3 按字节加权的总进度
如果按「完成几个 / 共几个」算进度,进度条会跳得很不均匀:前 9 个小 JSON 刷到 90%,最后一张大图卡在 90% 不动几十秒。按字节加权就平滑得多——loaded / total 天然反映真实工作量。
但还有一层:单张资源的内部进度。一张 8 MB 的贴图,在 run() 内部从 0 到 8 MB 的过程中,外部进度条是不动的。要让进度条连续,就得让 run() 上报内部进度。我们给任务加一个可选回调:
export interface LoadTask {
uuid: string;
weight: number;
run: (report?: (partial: number) => void) => Promise<unknown>;
}
图片加载可以用 fetch + ReadableStream 读出已下载字节数(Content-Length 已知时就能算比例),音频用 XMLHttpRequest 的 onprogress。把它折算进 loaded 即可:
const task = {
uuid: 'img:blocks',
weight: entry.size,
run: async (report?: (partial: number) => void) => {
const res = await fetch(entry.path);
const reader = res.body!.getReader();
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
received += value.byteLength;
report?.(received);
}
return received;
},
};
进度条更新的频率也要克制:onProgress 每字节都调一次会疯狂触发重排。惯例是每帧只刷一次——把最新进度存进变量,在 requestAnimationFrame 回调里读出来渲染,这样每秒最多 60 次,且与帧率对齐。
10.2.4 同一资源的并发去重
还有一种浪费比重复加载更隐蔽:同一份资源在同一帧被两处代码同时请求。比如进入关卡时,关卡加载器和角色初始化各自发起了一次 img:blocks 的加载,池子里就排了两个任务,等于白下一遍。
解决办法是维护一张「在飞请求」表,后来的请求直接复用前一个的 Promise:
export class DedupLoader {
private readonly inflight = new Map<string, Promise<unknown>>();
load(uuid: string, factory: () => Promise<unknown>): Promise<unknown> {
const existing = this.inflight.get(uuid);
if (existing) return existing; // 命中在飞请求,直接复用
const p = factory().finally(() => {
this.inflight.delete(uuid); // 无论成败都要清理,否则会永久占用
});
this.inflight.set(uuid, p);
return p;
}
}
注意 finally 里的清理不能省:如果请求失败后不删表项,这个失败的 Promise 会被后续所有请求复用,等于把一次偶发失败扩散成永久失败。而 runPool 的加权进度也要相应调整——被去重的任务不应重复计入 total,否则进度永远到不了 100%。
10.2.5 首屏预加载与按需加载
不是所有资源都该在启动时拉。《方块冲刺》 的做法是分两档:
- 首屏预加载(preload):主菜单要用的 Logo、字体、BGM。这些在「开始游戏」按钮出现之前必须就绪,否则用户看到的是闪烁的空白。
- 按需加载(on-demand):每个关卡的图集与音效。进入关卡时才拉,加载页承担进度条。
用清单的依赖闭包切分这两档最自然:给清单加一个 boot: true 标记,启动时只对标记项求闭包:
export function bootTasks(manifest: Manifest): LoadTask[] {
const roots = Object.values(manifest.entries)
.filter((e) => e.boot === true)
.map((e) => e.uuid);
const order = topoOrder(manifest, roots);
return order.map((uuid) => makeTask(manifest, uuid));
}
topoOrder 保证了依赖先于被依赖者进池,配合并发池,纹理与图集天然分两批到达。按需加载则复用同一套 runPool,只是 roots 换成当前关卡。
预加载还有一个收益:避免加载期间的卡顿。如果第一关的图在玩家按下「开始」的瞬间才拉,中间会有一段黑屏。把下一关的清单提前在结算页预拉,就能做到「点开即玩」——这也是 10.3 引用计数要解决的问题:提前拉进来的资源,得有人记着什么时候释放。
10.2.6 失败重试与降级
移动网络下 5% 的请求失败是常态。对失败不能一弃了之,两级策略:
第一级,重试。 网络抖动造成的失败重试一两次往往就好了,用指数退避避免「一拥而上」:
export function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
export async function withRetry<T>(
fn: () => Promise<T>,
retries = 2,
baseDelay = 120,
): Promise<T> {
let last: unknown;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
last = err;
if (attempt < retries) await sleep(baseDelay * 2 ** attempt);
}
}
throw last;
}
第二级,降级。 重试仍失败时,用占位资源顶上,保证游戏能进得去。贴图给一张 1×1 的洋红色方块,音频给静音,配置给内置默认值:
export const PLACEHOLDER_IMAGE = 'data:image/png;base64,iVBORw0KGgo...';
export async function loadWithFallback(
uuid: string,
primary: () => Promise<unknown>,
fallback: () => Promise<unknown>,
): Promise<unknown> {
try {
return await withRetry(primary);
} catch {
console.warn(`[mini2d] ${uuid} 降级为占位资源`);
return fallback();
}
}
降级不等于隐瞒:在开发者控制台(第 15 章)里要能看到「哪些资源走了降级」,否则线上偶发缺图会变成幽灵 bug。能进游戏永远优先于资源完整——这是手游的一条硬经验。
10.2.7 Cocos 对照:resources.load 与 preload
Cocos Creator 3.8 的加载 API 是回调式(内部也返回 Promise),并发控制与重试交给引擎。cc.d.ts 中 Bundle.load 有带 onProgress 的重载,进度回调签名是 (finished, total, item):
import { resources, SpriteFrame } from 'cc';
resources.load(
['textures/coin/spriteFrame', 'textures/gem/spriteFrame'],
SpriteFrame,
(finished: number, total: number) => {
console.log(`进度 ${finished}/${total}`);
},
(err, frames) => {
if (err) {
console.error(err);
return;
}
// frames 与传入路径顺序一一对应
},
);
注意 Cocos 的 onProgress 按资源个数计(finished / total),不按字节;要按字节加权,得自己拿 assetManager 的资源信息换算。这和我们在 10.2.3 里手写加权进度是一回事。
预加载对应 Bundle.preload:它只把资源拉进内存缓存、不返回实例,之后再 load 时命中缓存、瞬时完成。这正是 10.2.5 里「结算页提前拉下一关」的引擎级做法:
import { resources, SpriteFrame } from 'cc';
// 预加载下一关的贴图,不产生实例
resources.preload('textures/level-2', SpriteFrame, (err) => {
if (err) return;
// 真正进入关卡时再 load,此时命中缓存
resources.load('textures/level-2', SpriteFrame, (e, sf) => {
if (e) return;
});
});
对照表:
| 我们的做法 | Cocos 3.8 | 说明 |
|---|---|---|
runPool(tasks, limit, cb) | 引擎内部并发控制 | 无需手动限流 |
LoadProgress.loaded / total | onProgress(finished, total) | Cocos 按个数,我们按字节 |
bootTasks 首屏预加载 | resources.preload | 预加载只入缓存不实例化 |
withRetry + 降级 | 需自行封装 | 引擎只回一次回调 |
一个必须记住的差异:Cocos 的 preload 会把资源放进缓存并增加引用计数,如果你 preload 后忘了 release,这些资源会一直占着内存。这恰好把我们引向下一个话题——谁来记住「这份资源还有人用」,又该在什么时候放它走。
阅读导航:上一节:10.1 资源清单与依赖图 · 下一节:10.3 引用计数与资源释放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。