《Cocos 游戏开发实战》15.2 远程资源与 CDN

上一节把资源从首包挪进了 Bundle,但只要分包还打进包体,用户就只是把 4 MB 的红线换成了 20 MB 的总量红线。本节把 bundle-levels 改成远程包、托管到 CDN,给出构建面板里的远程地址与版本目录配置、可编译的加载脚本,以及一张按文件类型划分的缓存头策略表。

15.2 远程资源与 CDN

15.1 把《方块冲刺》的关卡资源从首包挪进了 bundle-levels。但如果这个分包仍然跟着小游戏包体一起上传,用户点开游戏时下载的还是「主包 + 全部分包」——只是把 4 MB 的主包红线换成了 20 MB 的总量红线。真正的首包瘦身,必须让分包资源「不随包走」,而是托管到 CDN、按需从远端拉取。

与站内 game/publish/ 那些发行文章的关系:它们讲的是把构建产物上传到 Steam;本节讲的是小游戏与 H5 的资源托管、版本目录与缓存头——这是把「资源分发」当工程问题处理,而不是当运营问题处理。

本节的目标是把 bundle-levels 变成一个远程包:构建时它被输出到 remote/ 目录,你把它上传到 CDN;运行时引擎按「远程服务器地址 + 版本号」拼出 URL 去下载。改完之后,同一个玩法包能通过换 CDN 上的资源目录来更新内容,而不用重新提交审核。

15.2.1 远程包解决的三个问题

本地 Bundle 和远程 Bundle 的区别,本质是「资源从哪来」。把 Bundle 设成远程包之后,一次改动同时解决三件事:

问题本地 Bundle远程 Bundle
首包体积分包仍占总量额度首包只留主包,分包不占额度
内容更新必须重新提审发版换 CDN 目录即可,不发版
加载时机只能随包体一起下进关卡前才下,可控

代价也要说清:远程资源依赖网络。用户断网、CDN 抖动、DNS 被劫持时,加载会失败。所以远程包必须配降级策略(15.2.8),不能默认「网络一定通」。

还要区分两种托管形态,它们决定了你怎么上传和回滚:

  • 对象存储 + CDN:把 remote/ 传到对象存储的某个前缀,前面挂一层 CDN。最常见,成本低,回滚靠换前缀。
  • 专用静态托管:把产物推到静态站,CDN 自动回源。省事,但版本目录要自己管。
  • 自建 Nginx:最灵活,但要自己配缓存头与证书,容易在 Cache-Control 上出错。

本书默认第一种:remote/ 上传到对象存储,路径形如 block-dash/1.0.0/。

从代码角度看,本地包与远程包的差别只有一处——加载时的第一个参数:

维度本地 Bundle远程 Bundle
加载 APIloadBundle('bundle-levels')loadBundle(url, { version })
资源落点包体内 assets/CDN remote/
失败概率极低必须配重试与降级
更新方式重新构建发版换 CDN 版本目录

也就是说,把本地包改成远程包,代码改动很小,工程改动很大:真正要花时间的不是写代码,而是把 CDN、版本目录、缓存头和域名白名单这一套配齐。这也解释了为什么很多团队先做本地分包(15.1)见效快,却在改远程包时踩坑——难点从来不在脚本里。

15.2.2 在编辑器里把 Bundle 改成远程包

以下操作需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:

  1. 资源管理器里选中 assets/bundle-levels/,在属性检查器勾选 「配置为 Bundle」(15.1 已勾过)。
  2. 在展开的 Bundle 设置里,把 「是否为远程包」 勾上。
  3. 打开菜单 项目 → 构建发布,在「平台」里选 微信小游戏(或 Web Mobile)。
  4. 展开 「资源服务器地址」,填 https://cdn.example.com/block-dash/。这是远程包的根地址。
  5. 展开 「主包压缩类型」,选 Merge Depended Assets;勾上 「MD5 Cache」。
  6. 点「构建」。构建结束后在产物目录里,你会看到多出一个 remote/ 文件夹。

这几步做完,assets/bundle-levels/.meta 的 userData 里 isRemoteBundle 变成 true,而「资源服务器地址」这个值被写进 settings/v2/packages/builder.json。这个文件需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现(它由构建面板写入),内容如下:

{
  "__version__": "1.3.4",
  "options": {
    "remoteServerAddress": "https://cdn.example.com/block-dash/",
    "md5Cache": true,
    "mainCompressionType": "merge_depended_assets",
    "sourceMaps": false
  }
}

在编辑器里打开构建面板,你会看到:右侧顶部是「平台」下拉,下方依次是「构建选项」「资源服务器地址」「主包压缩类型」等折叠区;「MD5 Cache」勾上后,右侧会多出一行提示「构建产物中的文件名将携带 MD5 哈希」。这些值不要手改 builder.json——构建面板读的是它,手改会在下次打开面板时被覆盖。

15.2.3 远程 URL 的拼装规则

远程包的 URL 由三段拼成:远程服务器地址 + 版本号 + Bundle 名。这段拼装是纯逻辑,不碰引擎、不碰 DOM,所以它可以被单测直接断言——这正是卷二里 L1 该待的位置:

export interface RemoteLayout {
  readonly base: string;
  readonly version: string;
}

export function isValidVersion(version: string): boolean {
  return /^\d+\.\d+\.\d+$/.test(version);
}

export function resolveRemoteUrl(layout: RemoteLayout, bundle: string): string {
  if (!isValidVersion(layout.version)) {
    throw new Error(`invalid remote version: ${layout.version}`);
  }
  const base = layout.base.replace(/\/+$/, '');
  return `${base}/${layout.version}/${bundle}`;
}

它明确了两条规则:base 末尾的斜杠会被规范化(避免出现 //1.0.0 这种双斜杠),版本号必须是三段式(1.0.0 而不是 v1 或 1.0.0-beta)。把版本格式卡死在纯函数里,比在运行时到处拼字符串可靠得多——15.2.4 的加载器会复用同一个 REMOTE_VERSION 常量。

15.2.4 运行时加载远程 Bundle

远程包的加载与本地包几乎一样,区别只在 URL。下面这段脚本拼出远程地址、加载 Bundle,并顺手把下载并发调到一个更保守的值:

import { _decorator, Component, assetManager, AssetManager, sys, error } from 'cc';

const { ccclass } = _decorator;

export const REMOTE_BASE = 'https://cdn.example.com/block-dash/';
export const REMOTE_VERSION = '1.0.0';

@ccclass('RemoteBundleLoader')
export class RemoteBundleLoader extends Component {
  loadRemoteBundle(name: string): Promise<AssetManager.Bundle> {
    const url = `${REMOTE_BASE}${name}`;
    return new Promise<AssetManager.Bundle>((resolve, reject) => {
      assetManager.loadBundle(url, { version: REMOTE_VERSION }, (err, bundle) => {
        if (err) {
          error(`[block-dash] remote bundle ${name} failed: ${err.message}`);
          reject(err);
          return;
        }
        resolve(bundle);
      });
    });
  }

  tuneDownloader(): void {
    const downloader = assetManager.downloader;
    downloader.maxConcurrency = sys.isNative ? 4 : 6;
    downloader.maxRetryCount = 2;
  }
}

三点说明。第一,loadBundle 的第二个参数是 options,里面 version 会参与 URL 拼接,最终请求形如 https://cdn.example.com/block-dash/1.0.0/bundle-levels/config.json——版本目录是 CDN 上真实存在的目录名,不是你随便填的字符串。第二,sys.isNative 是原生平台判定,原生端磁盘与内存都比浏览器紧张,所以并发压到 4;H5 / 小游戏给 6。第三,downloader.maxRetryCount = 2 是网络抖动时的自动重试次数,别设太大,否则弱网下用户会一直卡在加载。

一次完整的远程加载,在时间轴上分成四步,每一步失败都可能表现为「卡住」:

  1. DNS 解析:把 cdn.example.com 解析成 IP。被劫持时会解析到错误地址,表现为「资源 404 或超时」。
  2. 请求 config.json:引擎先下 Bundle 的清单,里面列了所有资源的 uuid 与哈希文件名。
  3. 按清单并发下载资源:受 maxConcurrency 限制,一次最多 6 个(H5 / 小游戏)。
  4. 解析与回调:下载完的二进制被解析成 SpriteFrame / AudioClip,loadBundle 的 Promise 才 resolve。

排查「加载慢」时,先看卡在第几步:如果卡在 1、2,是网络或配置问题;如果卡在 3,多半是并发太低或资源太大;如果卡在 4,则可能是图片解码慢(大图未压缩)。把四步分开计时,比笼统地说「加载慢」有用得多。

15.2.5 目录结构与版本目录

构建产物里,远程包被单独放到 remote/ 下。你要把整个 remote/ 目录上传到 CDN 的对应版本目录:

# 构建产物(参考结构,实际以你的构建版本为准)
build/wechatgame/
#   assets/             本地资源(主包)
#   remote/             远程资源(要上传到 CDN)
#     bundle-levels/    远程分包
#     bundle-audio/     远程分包
#   cocos-js/
#   game.js

# 目标路径: https://cdn.example.com/block-dash/1.0.0/
# 把 remote/ 下的内容上传到上面这个版本目录

1.0.0 这个版本目录名,必须与 15.2.4 里 REMOTE_VERSION 完全一致。推荐用发版号而非时间戳:发版号可读、可回滚(把 CDN 上的旧版本目录留着即可),时间戳则会让缓存失效变得难以推理。回滚时只要把 REMOTE_VERSION 改回旧值,或让 CDN 的默认路径指回旧目录。

15.2.6 缓存策略:按文件类型分档

CDN 缓存配错,会出现两种相反的故障:缓存太短,每次进游戏都重新下载;缓存太长,改了资源用户还在用旧的。正确做法是按文件类型分档,而不是全局一个值:

文件Cache-Control理由
game.js / index.htmlno-cache入口必须每次校验,否则新包上不去
settings.json / application.jsmax-age=60版本指针,短缓存
remote/**/*.json(配置)max-age=300配置可能微调,中等缓存
remote/**/*.png/.mp3(资源)max-age=31536000, immutable文件名带 MD5,内容变则名变
assets/**(本地资源)跟随包体,不单独配已在包内

关键是最后两行的组合:开 MD5 Cache + 长缓存。文件名带哈希后,内容一变文件名就变,于是浏览器 / CDN 可以放心地用「一年 + immutable」缓存;而没变的文件继续命中缓存,不浪费流量。这比「缩短缓存时间」高效得多——缩短缓存换来的「及时更新」,代价是每次都回源。

如果 CDN 背后是自建 Nginx,缓存头就是在配置里写死的。下面这段按上面的分档给出规则,可作为起点(把域名与目录替换成你自己的):

# /etc/nginx/conf.d/block-dash.conf(示例,需在你的服务器上按实际路径调整)
location ~* \.(png|jpg|mp3|ogg)$ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}
location ~* ^/block-dash/.*\.json$ {
  add_header Cache-Control "public, max-age=300";
}
location = /game.js {
  add_header Cache-Control "no-cache";
}

用对象存储时不需要手写这些——对象存储控制台里按前缀设 Cache-Control 元数据即可,但每条规则都要在上传时带上,事后补设要重新触发一次元数据更新。

15.2.7 跨域、HTTPS 与 CDN 选型

H5 端从 https://cdn.example.com 拉资源时,浏览器会做跨域检查。三条必须配好:

项要求配错的表现
协议页面是 HTTPS,CDN 也必须 HTTPS混合内容被浏览器拦,资源加载失败
CORS响应头 Access-Control-Allow-Origin 覆盖你的域名fetch 被拦,控制台报 CORS
类型.json 返回 Content-Type: application/json部分浏览器按 MIME 拒绝解析

小游戏与原生端不经过浏览器 CORS,但协议一致仍然要满足:微信小游戏只允许 HTTPS 域名,且域名要在后台「服务器域名」里白名单登记。这一步漏了,真机上所有远程资源都会 404——本地调试却一切正常,是典型的「本地能跑、真机不行」。

15.2.8 失败与降级

远程资源一定会遇到加载失败。三种情况要分别处理:

情况表现对策
首次下载超时loadBundle 回调带 err重试 2 次(maxRetryCount),仍失败则提示「检查网络」
CDN 命中 404版本目录上传不完整构建产物校验:remote/ 下每个 Bundle 都必须有 config.json
网络恢复后重进上次没下完依赖 cacheManager 的断点,已下载文件不重复请求

最稳的降级是「关键资源双份」:把第一关的贴图同时打进首包,这样即使远程全部失败,用户至少能进第一关看到玩法,而不是白屏。代价是首包多几百 KB,值不值取决于你的容错要求——这是产品决策,不是技术决策。

重试逻辑本身也是纯逻辑,不该和引擎缠在一起。下面这个 withRetry 可以直接单测:

export interface RetryOptions {
  readonly times: number;
  readonly delayMs: number;
}

export async function withRetry<T>(
  task: () => Promise<T>,
  options: RetryOptions,
): Promise<T> {
  let last: unknown;
  for (let attempt = 0; attempt <= options.times; attempt += 1) {
    try {
      return await task();
    } catch (err) {
      last = err;
      await new Promise<void>((r) => setTimeout(r, options.delayMs));
    }
  }
  throw last instanceof Error ? last : new Error(String(last));
}

把 15.2.4 的 loadRemoteBundle 包进 withRetry(() => loader.loadRemoteBundle('bundle-levels'), { times: 2, delayMs: 300 }),就把「引擎侧重试」换成了「可测的业务侧重试」——maxRetryCount 管的是单个文件的下载重试,withRetry 管的是整包加载失败后的整体重试,两者不冲突。

上线前把下面这张清单过一遍,能挡掉绝大多数远程资源事故:

  • remoteServerAddress 与实际 CDN 路径一致,末尾斜杠已规范化
  • REMOTE_VERSION 与 CDN 上的版本目录名逐字一致
  • 微信后台「服务器域名」已登记 CDN 域名,且是 HTTPS
  • remote/ 下每个 Bundle 都含 config.json
  • 入口文件 no-cache,资源文件长缓存 + MD5
  • 断网启动有提示,不是白屏

15.2.9 工程视角的收口

这一节做完,工程里多了:settings/v2/packages/builder.json(构建面板的持久化配置,含 remoteServerAddress 与 md5Cache)、一个纯逻辑的 resolveRemoteUrl、一个可编译的 RemoteBundleLoader、一条「上传 remote/ 到 CDN 版本目录」的发布动作,以及一张缓存头策略表。bundle-levels 的 .meta 里 isRemoteBundle 从 false 变成了 true。

到这里,首包体积已经压下来了,分包也走远程了。但用户从「点开」到「看到第一屏」仍然要等——等的是引擎初始化、脚本执行、首场景加载这一连串动作。这些动作里哪些能砍、哪些能提前,就是启动耗时优化要解决的问题。下一节把首屏时间拆开,逐段削减。

阅读导航:上一节:15.1 分包策略与首包体 · 下一节:15.3 启动耗时优化 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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