《Cocos 游戏开发入门》10.2 异步加载、进度与预加载

清单能列出要拉哪些资源,接下来把它们真正并发拉进来:写一个限制并发数的 Promise 池,按字节加权汇报总进度,区分首屏预加载与按需加载,再加失败重试与降级占位;末节对照 Cocos Creator 3.8 的 resources.load 与 preload。

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 / totalonProgress(finished, total)Cocos 按个数,我们按字节
bootTasks 首屏预加载resources.preload预加载只入缓存不实例化
withRetry + 降级需自行封装引擎只回一次回调

一个必须记住的差异:Cocos 的 preload 会把资源放进缓存并增加引用计数,如果你 preload 后忘了 release,这些资源会一直占着内存。这恰好把我们引向下一个话题——谁来记住「这份资源还有人用」,又该在什么时候放它走。

阅读导航:上一节:10.1 资源清单与依赖图 · 下一节:10.3 引用计数与资源释放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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