4.3 远程资源与热更
4.1 和 4.2 讲的资源都在包里。但《方块冲刺》上线后会遇到两个新问题:首包放不下的资源怎么办(50 关的贴图不可能全塞进 4 MB),已经发布的包怎么修 bug(不能每次都重新提审)。这一节讲清 Cocos Creator 3.8 给出的两条路——远程 Bundle 和原生热更,以及它们各自在多端上的边界。
先给一个容易混淆的结论:这两条路不是二选一,而是各管一段。远程 Bundle 负责「资源不进包、按需从服务器拉」,H5、小游戏、原生都支持;原生热更(AssetsManager)负责「连代码带资源一起换掉」,只有原生平台有。H5 和小游戏里没有 AssetsManager,热更靠的是「页面/分包本身就是远程加载」这一天然属性。
4.3.1 两条路的适用场景
| 维度 | 远程 Bundle | 原生热更(AssetsManager) |
|---|---|---|
| 平台 | H5 / 小游戏 / 原生 | 仅原生(Android / iOS) |
| 能换脚本吗 | 否,脚本随包 | 是,可换 JS 与资源 |
| 配置成本 | 勾一个 isRemoteBundle | 配 manifest + 版本号 + CDN |
| 典型用途 | 关卡贴图、活动素材 | 线上 bug 修复、玩法改动 |
| 触发时机 | loadBundle 时 | 启动时 checkUpdate |
卷二里两者都用了:bundle-levels 是本地分包(第一版先随包发),后续关卡量大了再改成远程;热更只在原生包上启用,且默认关闭,由灰度开关控制(见第 17 章)。
一个常见的过度设计是「把一切都做成热更」。热更的代价不小:manifest 要维护、CDN 要跨域、失败要有重试、版本要递增、灰度要能回滚。能靠服务端配置解决的问题就别热更——活动开关、数值调整、文案修改都放进远程 JSON 配置(loadRemote 一个文件就够),只有真的要改玩法代码时才走 AssetsManager。这条边界划清楚,线上事故会少一半。
4.3.2 配置远程 Bundle(需编辑器,不可命令行复现)
本节涉及 .meta 工程文件与构建配置,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 步骤:
- 资源管理器选中
assets/configs/,属性检查器勾选「配置为 Bundle」。 Bundle 名称填bundle-levels,勾选「配置远程 Bundle」。- 打开「项目 → 构建发布」,在构建面板把
远程服务器地址填成https://cdn.example.com/block-dash/。 主包压缩类型选合并依赖,勾选MD5 Cache,Source Maps只在调试构建勾。
操作后 assets/configs.meta 里 isRemoteBundle 变为真:
{
"ver": "1.2.0",
"isBundle": true,
"bundleName": "bundle-levels",
"priority": 1,
"isRemoteBundle": true
}
打开构建面板会看到什么:远程服务器地址 是一个文本框,填的地址会被写进构建产物的 application.js 里作为 Bundle 的 base;构建输出的 bundle-levels/ 不再进包,而是需要你手动上传到 CDN 的同一路径下。勾了 MD5 Cache 后,每个资源文件名会带内容哈希,用于强缓存——文件名变了才是新资源,没变就能一直用 CDN 缓存。
4.3.3 loadRemote:加载包外单文件
有些资源不适合进任何 Bundle——比如玩家头像、活动临时素材。assetManager.loadRemote 直接按 URL 拉取:
import { assetManager, ImageAsset } from 'cc';
assetManager.loadRemote<ImageAsset>('https://cdn.example.com/avatar.png', { ext: '.png' }, (err, image) => {
if (err) { return; }
console.log(image.width, image.height);
});
options.ext 是关键:远程资源的类型靠扩展名推断,不传 ext 时引擎可能把图片当成未知二进制。远程加载不受 Bundle 索引管理,所以也不参与 Bundle 的引用计数,用完要自己 decRef。卷二只在头像、排行榜这类「随时可丢」的资源上用 loadRemote。
4.3.4 原生热更:AssetsManager
原生平台的热更入口是 native.AssetsManager。它的工作流是:用本地 manifest 和远程 manifest 比对 → 有新版本则下载差异资源 → 下载完重启场景。整套流程是事件驱动的:
import { native, sys, director } from 'cc';
export function startHotUpdate(storagePath: string): void {
if (!sys.isNative) { return; }
const manager = new native.AssetsManager(
'https://cdn.example.com/block-dash/project.manifest',
storagePath,
);
manager.setEventCallback((event: native.EventAssetsManager) => {
const code = event.getEventCode();
if (code === native.EventAssetsManager.NEW_VERSION_FOUND) {
manager.update();
} else if (code === native.EventAssetsManager.UPDATE_PROGRESSION) {
console.log(event.getPercent(), event.getPercentByFile());
} else if (code === native.EventAssetsManager.UPDATE_FINISHED) {
director.loadScene('main');
} else if (code === native.EventAssetsManager.UPDATE_FAILED) {
manager.downloadFailedAssets();
}
});
manager.checkUpdate();
}
三个要点:
sys.isNative是入口守卫。 非原生平台上native.AssetsManager不存在,直接调会抛错。- 事件常量在
native.EventAssetsManager上,常用的是NEW_VERSION_FOUND/ALREADY_UP_TO_DATE/UPDATE_PROGRESSION/UPDATE_FINISHED/UPDATE_FAILED/ERROR_UPDATING。 checkUpdate与update是两步。 先检查,发现新版本才下载——这样能先弹一个「发现新版本,是否更新」的确认框,而不是默默下载几十 MB。
4.3.5 热更 manifest 的结构(需编辑器,不可命令行复现)
本节涉及 project.manifest 工程文件,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 manifest 由构建时生成,结构如下:
{
"packageUrl": "https://cdn.example.com/block-dash/",
"remoteManifestUrl": "https://cdn.example.com/block-dash/project.manifest",
"remoteVersionUrl": "https://cdn.example.com/block-dash/version.manifest",
"version": "1.0.3",
"assets": {
"src/bundle-levels/config.json": { "md5": "9a1b...", "compressed": true, "size": 2048 }
},
"searchPaths": []
}
打开构建产物会看到什么:原生构建目录下有一个 project.manifest(完整清单,含每个资源的 md5)和一个 version.manifest(只含版本号,用于快速比对)。version.manifest 存在的意义是省流量:启动时先拉几十字节的版本文件,版本没变就不下载几 MB 的完整清单。
version 字段是热更的核心。引擎比对本地的 version 与远程的 version,不一致才进入下载流程。所以每次发新版必须改版本号,否则玩家永远停在旧版——这是热更最常见的一类事故。
4.3.6 版本比较与失败重试
默认的版本比较是字符串比较,1.0.10 会被判成小于 1.0.9。用 setVersionCompareHandle 换成语义化比较:
import { native } from 'cc';
export function createManager(url: string, storagePath: string): native.AssetsManager {
const manager = new native.AssetsManager(url, storagePath);
manager.setVersionCompareHandle((a: string, b: string): number => {
const pa = a.split('.').map((n) => parseInt(n, 10));
const pb = b.split('.').map((n) => parseInt(n, 10));
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
if (diff !== 0) { return diff > 0 ? 1 : -1; }
}
return 0;
});
return manager;
}
回调返回正数表示 a 更新,负数表示 b 更新,零表示相同。失败重试则靠 downloadFailedAssets()——它在 UPDATE_FAILED 事件里被调用,重试上一轮下载失败的资源,比整包重来省流量。
4.3.7 多端分流策略
把平台判断集中到一个函数,避免散落各处:
import { sys } from 'cc';
export type RemoteMode = 'assets-manager' | 'http-cache' | 'none';
export function pickRemoteMode(): RemoteMode {
if (sys.isNative) { return 'assets-manager'; }
if (sys.platform === sys.Platform.WECHAT_GAME) { return 'http-cache'; }
if (sys.platform === sys.Platform.MOBILE_BROWSER) { return 'http-cache'; }
if (sys.platform === sys.Platform.DESKTOP_BROWSER) { return 'http-cache'; }
return 'none';
}
sys.Platform.WECHAT_GAME、MOBILE_BROWSER、DESKTOP_BROWSER 都是 cc.d.ts 里真实存在的枚举成员。判错平台的后果很隐蔽:在小游戏里走 assets-manager 分支会因为 native.AssetsManager 不存在而崩,所以每个平台分支都要有测试用例覆盖,不能只在 H5 上点一遍。
4.3.8 发布检查清单
| 检查项 | 通过标准 |
|---|---|
| 远程 Bundle 已上传 CDN | 路径与构建面板地址一致,可公开访问 |
version.manifest 与 project.manifest 同步 | 版本号一致,md5 对得上 |
| 热更版本号已递增 | 比对线上 version 严格更大 |
| 灰度开关默认关闭 | 非灰度用户走旧逻辑 |
| 失败重试有上限 | UPDATE_FAILED 不会无限循环 |
| CDN 开启跨域 | H5 端响应头含 Access-Control-Allow-Origin |
4.3.9 一次完整的启动热更流程
把前面几节拼起来,就是一个标准的启动热更器。它把「是否原生」「是否有新版本」「下载进度」「失败」四件事用回调暴露给启动场景,启动场景只负责显示进度条:
import { native, sys } from 'cc';
export interface HotUpdateHooks {
onProgress: (percent: number) => void;
onDone: () => void;
onError: (message: string) => void;
}
export function runHotUpdate(storagePath: string, hooks: HotUpdateHooks): void {
if (!sys.isNative) { hooks.onDone(); return; }
const manager = new native.AssetsManager(
'https://cdn.example.com/block-dash/project.manifest',
storagePath,
);
manager.setEventCallback((event: native.EventAssetsManager) => {
const code = event.getEventCode();
if (code === native.EventAssetsManager.ALREADY_UP_TO_DATE
|| code === native.EventAssetsManager.UPDATE_FINISHED) {
hooks.onDone();
} else if (code === native.EventAssetsManager.NEW_VERSION_FOUND) {
manager.update();
} else if (code === native.EventAssetsManager.UPDATE_PROGRESSION) {
hooks.onProgress(event.getPercent());
} else if (code === native.EventAssetsManager.UPDATE_FAILED) {
hooks.onError(event.getMessage());
}
});
manager.checkUpdate();
}
这段代码有几个刻意的设计:
- 非原生平台直接
onDone(),让启动场景不必判断平台——热更器把平台差异吃掉了。 ALREADY_UP_TO_DATE与UPDATE_FINISHED走同一个出口,因为对启动场景来说「检查完没更新」和「更新完」都意味着可以进游戏了。- 进度用
getPercent()(字节百分比)而不是getPercentByFile()。文件数百分比会在下载大文件时长时间卡在某个值,体验差;字节百分比平滑得多。 - 失败不自动重试,而是把
getMessage()交给上层决定。热更失败可能是网络问题,也可能是 CDN 配置错了——自动重试会掩盖后者。
启动场景里的用法是:
import { runHotUpdate } from './hot-update';
runHotUpdate('hot', {
onProgress: (percent: number) => { console.log(`更新中 ${Math.floor(percent)}%`); },
onDone: () => { console.log('进入主菜单'); },
onError: (message: string) => { console.warn('热更失败', message); },
});
'hot' 是原生平台的可写目录名,热更下载的资源会落在这里,和包内资源分离——卸载重装时它会被清掉,重装后重新拉取。这条目录约定必须和构建面板里的热更配置一致,否则会出现「下载成功但读不到」的诡异现象。
下一节进入第 5 章,讲启动流程与首屏:main.scene 加载后到玩家能操作之间,引擎到底做了哪些事。
阅读导航:上一节:4.2 动态加载与引用计数 · 下一节:5.1 启动流程与首屏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。