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 |
|---|---|---|
| 加载 API | loadBundle('bundle-levels') | loadBundle(url, { version }) |
| 资源落点 | 包体内 assets/ | CDN remote/ |
| 失败概率 | 极低 | 必须配重试与降级 |
| 更新方式 | 重新构建发版 | 换 CDN 版本目录 |
也就是说,把本地包改成远程包,代码改动很小,工程改动很大:真正要花时间的不是写代码,而是把 CDN、版本目录、缓存头和域名白名单这一套配齐。这也解释了为什么很多团队先做本地分包(15.1)见效快,却在改远程包时踩坑——难点从来不在脚本里。
15.2.2 在编辑器里把 Bundle 改成远程包
以下操作需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现:
- 资源管理器里选中
assets/bundle-levels/,在属性检查器勾选 「配置为 Bundle」(15.1 已勾过)。 - 在展开的 Bundle 设置里,把 「是否为远程包」 勾上。
- 打开菜单 项目 → 构建发布,在「平台」里选
微信小游戏(或Web Mobile)。 - 展开 「资源服务器地址」,填
https://cdn.example.com/block-dash/。这是远程包的根地址。 - 展开 「主包压缩类型」,选
Merge Depended Assets;勾上 「MD5 Cache」。 - 点「构建」。构建结束后在产物目录里,你会看到多出一个
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 是网络抖动时的自动重试次数,别设太大,否则弱网下用户会一直卡在加载。
一次完整的远程加载,在时间轴上分成四步,每一步失败都可能表现为「卡住」:
- DNS 解析:把
cdn.example.com解析成 IP。被劫持时会解析到错误地址,表现为「资源 404 或超时」。 - 请求
config.json:引擎先下 Bundle 的清单,里面列了所有资源的 uuid 与哈希文件名。 - 按清单并发下载资源:受
maxConcurrency限制,一次最多 6 个(H5 / 小游戏)。 - 解析与回调:下载完的二进制被解析成
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.html | no-cache | 入口必须每次校验,否则新包上不去 |
settings.json / application.js | max-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 启动耗时优化 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。