做手游的兄弟应该都体会过这种痛:版本计划排得好好的,结果渠道审核卡了你三周,等你更新包终于上架,活动档期早过了。2025年了,用 cocosCreator3.8.7 开发的项目还在走“发版 = 提审 = 等待”这条路,产品不被喷才怪。热更新要解决的就是这件事:让线上版本绕过应用商店审核,直接把新资源、新配置、新 Bundle 下发到玩家手机里。标题看着简单,但实际落地牵扯到构建管线、版本号规范、CDN 资源组织、文件系统路径、引擎资源检索机制,很多团队在 3.x 上折腾了很久都搞不透。这篇文章以 Cocos Creator 3.8.7 为基准,把热更新整条链路拆开讲清楚,从原理到步骤到坑,适合刚接手热更模块的客户端同学,也适合想评估工作量的小团队负责人。
1. 为什么必须把热更新提前想清楚
1.1 热更新解决的三个实际问题
很多刚接触热更新的人容易把它当成一个“技术炫技点”,实际上它是个业务刚需,而且越晚接入成本越高。
第一个是审核周期和迭代速度的矛盾。原生手游走 App Store 审核,快则一两天,慢则一两周,赶上节假日甚至更久。你的运营活动可能需要提前三天才确定具体规则、奖励、UI,这时候走提审根本不现实。有了热更新,客户端只保证“骨架”稳定,所有活动内容都能在活动开始前几小时下发到位。
第二个是包体控制。把商城、活动、大图集、过场 CG 这些低频内容全部打进安装包,会让首包体积膨胀得很厉害。移动端用户对安装包大小的敏感程度远超想象,每大 10MB,转化率都可能掉一截。通过热更新把重资源放到 CDN,按需加载,首包可以控制在很小的体量。
第三个是线上事故的快速修复。配置表填错了、某个资源包贴图灰了、活动数值超了,这些事故如果都要重新过审发版,玩家早就流失一大半。热更可以在几十分钟内把新的配置和资源推下去,把损失降到最低。我见过最典型的案例是线上商城某商品价格少写一个零,如果当天没热更能力,第二天对账能对到怀疑人生。
1.2 资源热更新与代码热更新的边界
这里必须先泼一盆冷水:热更新不是万能的,尤其不要指望在 iOS 上热更代码。App Store 审核条款里明确禁止下载后执行代码,JS 脚本热更在 iOS 上一直处于高风险区,被拒甚至下架的例子不少。所以 3.8.7 项目里,凡是涉及逻辑代码的动态替换方案,我都不建议你碰。
真正安全且被行业普遍采用的是资源热更新:图片、音频、Prefab、图集、JSON 配置表、场景资源这些纯资源文件,完全可以通过热更下发。只要你的项目架构遵循“数据驱动 + 扩展点预埋”的原则,大部分线上改动都能靠配置和资源解决,不需要动代码。
举个例子:你要在活动页面新增一个礼包。代码侧提前把礼包列表做成配置驱动,客户端启动时拉取最新的礼包配置;配置里引用的图标、描述、价格、跳转场景都是资源。这样“新增礼包”这个需求,服务器改一份 JSON 就能搞定,完全不需要代码热更。这个架构思路比任何热更框架都重要。
1.3 热更新整体链路
先把整条链路摆出来,后面所有内容都是在给这条链路填充细节:
- 客户端启动,读取本地版本号。
- 请求 CDN 上的版本清单文件。
- 将远程版本与本地版本对比,判断是否存在更新。
- 存在更新时,下载增量资源包。
- 将下载的资源解压或写入本地可写目录。
- 记录新版本号,重启游戏或重新加载对应 Bundle。
- 新资源生效。
其中第 2、3、5 步是纯客户端逻辑,也是你写代码的重点;第 4 步涉及网络层;第 6 步涉及引擎资源加载机制。这几个环节我会在下面逐个展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 3.8.7 项目侧的准备:从构建管线开始
2.1 Asset Bundle 拆分是热更的前提
很多人在这一步就栽了:项目所有资源全放在 assets 根目录,没有任何分包,到了做热更的时候傻眼,因为引擎的更新单位是 Bundle,不是单张图片。如果你没有拆包,等于热更无从下手。
Asset Bundle 是 Creator 3.x 里资源和代码的分包加载单元,它决定了哪些内容进首包、哪些走远程加载。我的建议是只把“必须能跑起来”的内容留在主包:
- launch 场景、loading 场景、登录页。
- 热更逻辑本身所在的模块。
- 核心战斗或主玩法循环。
- 全局基础 UI 图集。
其余内容全部拆到独立 Bundle,比如:
text复制assets/
bundles/
activity/ # 活动系统
shop/ # 商城
audio/ # 背景音乐和音效
config/ # JSON 配置表
story/ # 剧情、过场
在 3.8.7 的资源管理器里选中 activity 文件夹,右侧属性检查器勾选“配置为 Bundle”,然后给它起一个固定的 Bundle 名(比如也叫 activity),勾选“远程包”。这个“远程包”开关决定了构建时这个 Bundle 是打进安装包,还是输出到 remote 目录等着被热更。
这里有个很容易踩的坑:远程 Bundle 里的东西不会被打进原生包,所以如果 App 没网、CDN 又挂了,这部分功能直接进不去。必须给所有远程 Bundle 设计“资源缺失”的兜底 UI,而不是让玩家面对一片空白场景。
2.2 构建产物与服务器目录结构
在 3.8.7 中构建原生平台,勾选了“远程包”的 Bundle 会被输出到 build/{platform}/remote 目录下,主包资源仍然在 assets 目录。这个 remote 目录,就是你上传到 CDN 的核心内容。
以 Android 构建为例,构建产物大致长这样:
text复制build/android/
assets/ # 首包资源,打进安装包
remote/ # 远程 Bundle,上传到 CDN
activity/
shop/
audio/
src/ # 原生工程相关
我建议服务器目录结构和构建产物保持一致,减少出错概率:
text复制https://your-cdn.example.com/game/android/
remote/version.json
remote/activity/
remote/shop/
每次发布新版本,把最新的 remote 目录整体同步到 CDN 上一个带版本号的路径,比如 release/v122/remote/,然后在 CDN 做一个稳定的别名指向当前版本。这样做的好处是:出问题时可以一键把版本别名切回上一个目录,实现秒级回滚,而不是覆盖式发布后找不回旧资源。
一个平台一套 CDN 目录,不要把 Android 的远程包给 iOS 用。不同平台纹理压缩格式、资源格式可能不同,混用的结果是 iOS 玩家花流量下载了一堆用不了的东西。
2.3 本地版本号的存储规范
热更新必须有一个可靠的“当前版本号”来源。主包版本和热更版本要分开管理。
主包版本是 version,写在构建配置里,代表安装包本身的版本。热更版本号单独维护,代表远程资源的迭代次数。热更版本号建议用一个常量文件或者构建脚本生成,而不是手写,因为手写必然会出现“忘了改版本号导致玩家永远收不到更新”的事故。
text复制本地持久化存储示范:
key = "hot_update_version"
value = "1.0.0"
很多团队把热更版本号和主包版本号混用一个字符串,这样做的问题是不好判断“客户端版本太老,必须去商店更新”还是“只需要热更资源”。我建议两个维度分开:主包版本用于强更判断,热更版本用于资源增量判断。
3. 版本对比逻辑:核心到底在比什么
3.1 版本清单文件的设计
热更的“眼睛”是版本清单文件,我习惯叫它 version.json,放在 CDN 的 remote 根目录下。它描述的是“当前 CDN 上的资源处于什么版本”。
一个比较实用的结构长这样:
json复制{
"version": "1.2.3",
"min_app_version": "1.0.0",
"bundles": [
{
"name": "activity",
"version": 5,
"url": "https://your-cdn.example.com/game/android/release/v123/activity.zip"
},
{
"name": "shop",
"version": 3,
"url": "https://your-cdn.example.com/game/android/release/v123/shop.zip"
}
]
}
这里 version 是整个远端资源集的版本号,min_app_version 是强更底线,bundles 里记录了每个 Bundle 的独立版本和下载地址。
为什么要给每个 Bundle 独立版本?因为实际项目里经常只改一个活动的资源,单独发布一个 Bundle 的 zip,比全量下载整个 remote 目录省流量得多。玩家按需下载自己当前缺失的 Bundle,而不是把所有远程包都拉一遍。
注意,如果你在搜索热更新时看到一些“配置秒级生效”的工具,比如 nacos 热更新、前端热更新框架,它们指的是服务端或开发期的配置热替换,和游戏客户端资源热更新完全不是一回事。游戏热更必须处理文件落地、运行期加载、平台路径、重启生效这些环境问题,不能照搬服务端思路。
3.2 客户端拉取版本清单的健壮实现
在 3.8.7 里拉取 version.json,最直接的方式是用 assetManager.loadRemote。这里有个新手容易卡的细节:不同 3.x 小版本里,loadRemote 加载 JSON 后回调返回的数据结构不一样,有的版本返回 string,有的版本返回解析好的对象,有的返回 { json: {...} }。
所以解析函数必须兼容多种情况,否则你按网上的老教程写完,在自己的 3.8.7 上跑起来什么都拿不到。
typescript复制import { assetManager, sys } from 'cc';
import { Config } from './Config';
const LOCAL_VERSION_KEY = 'hot_update_version';
export function getLocalVersion(): string {
return sys.localStorage.getItem(LOCAL_VERSION_KEY) || '0.0.0';
}
function parseVersionData(data: any): string {
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
return parsed.version || '';
} catch (e) {
return '';
}
}
if (data && data.json) {
return data.json.version || '';
}
if (data && typeof data === 'object') {
return data.version || '';
}
return '';
}
export function fetchRemoteVersion(): Promise<string> {
return new Promise((resolve) => {
assetManager.loadRemote(
`${Config.REMOTE_HOST}/version.json`,
(err: Error | null, data: any) => {
if (err) {
console.warn('[HotUpdate] 拉取远程版本失败:', err.message);
resolve('');
return;
}
const version = parseVersionData(data);
resolve(version);
}
);
});
}
在写 Config.REMOTE_HOST 时,一定要把 CDN 域名独立成一个配置项。上线后发现 CDN 域名要切,你不想为这个重新发一次包。
3.3 版本号比较:别用字符串
拿到远程版本号和本地版本号之后,最直接的错误是:
typescript复制if (remoteVersion > localVersion) { // 错误
}
字符串比较在版本号上会出大问题。比如 "9.0.0" > "10.0.0" 在字符串比较下是成立的,但实际 10.0.0 显然更新。版本号比较必须按数字逐段处理。
typescript复制export function compareVersion(remote: string, local: string): number {
const r = remote.split('.').map(Number);
const l = local.split('.').map(Number);
const len = Math.max(r.length, l.length);
for (let i = 0; i < len; i++) {
const rv = r[i] || 0;
const lv = l[i] || 0;
if (rv > lv) return 1;
if (rv < lv) return -1;
}
return 0;
}
返回 1 表示远程版本新,-1 表示本地版本新,0 表示相同。
这里还要想清楚一个细节:远程版本比本地旧的时候,客户端应该怎么做。很多人只处理“远程新”的情况,忽略了“远程旧”这种事。实际上只会在 CDN 回滚的时候发生:服务器把版本别名指回旧目录,客户端发现远程版本比自己本地还旧。正确的策略是不推送、不报错、继续用本地已是最新的运行,避免把玩家的版本改回去。
4. 下载、解压与落地:资源文件怎么进到设备
4.1 更新包的组织方式
我这里采用每个远程 Bundle 单独打 zip 的方案。这样做的好处很明显:哪个 Bundle 更新就只下哪个,流量消耗可控;下载完成后的解压和替换也相对独立。
CDN 上每个 Bundle 的 zip 名字里最好带上版本号,比如 activity_v5.zip,防止浏览器和 CDN 边缘节点对同名文件做缓存导致拿到了旧资源。有些 CDN 对 url 后的 ?v= 参数不敏感,文件名带版本号是最稳妥的。
zip 包里应保持 Bundle 目录的相对路径,比如:
text复制activity.zip
activity/
config.json
prefabs/
textures/
解压后的目录结构要和构建产物里 remote/activity 对得上,搜索引擎才会在正确的位置找到对应资源。
4.2 用 XMLHttpRequest 下载并写入本地
在 3.8.7 的纯 TypeScript 侧,下载 zip 文件我推荐用原生 XMLHttpRequest,把响应类型设为 arraybuffer。assetManager.loadRemote 主要面向资源加载,不适合用来处理“下载一个二进制 zip 然后另行处置”的场景。
typescript复制export function downloadZip(url: string, destPath: string): Promise<boolean> {
return new Promise((resolve) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'arraybuffer';
xhr.timeout = 15000;
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const bytes = new Uint8Array(xhr.response);
const ok = writeFileToNativePath(destPath, bytes);
resolve(ok);
} else {
console.warn('[HotUpdate] 下载失败 status:', xhr.status);
resolve(false);
}
};
xhr.onerror = () => {
console.warn('[HotUpdate] 网络错误');
resolve(false);
};
xhr.ontimeout = () => {
console.warn('[HotUpdate] 下载超时');
resolve(false);
};
xhr.send();
});
}
writeFileToNativePath 这一步在 3.8.7 里要特别小心。引擎在不同小版本里的原生文件写入 API 变动过,jsb.fileUtils 在 3.x 里改成了 native.fileUtils,但不同平台、不同 3.x 版本表现并不完全一致。我建议你在原生层用 FileUtils 封装一个写入方法,暴露给 ts 调用,而不是在 ts 里调用可能已经废弃的接口。工程里不确定的时候,可以先 console.log(native.fileUtils) 看下当前版本有哪些可用方法,再决定具体调哪个。
下载失败的重试策略也值得设计。我常用的策略是:同一个 zip 最多重试三次,每次失败后等待时间指数增长,比如第一次等 2 秒,第二次等 4 秒,第三次等 8 秒。如果三次都失败,就提示玩家“网络不稳定,请检查网络后重试”,但千万不能把本地已存在的版本目录清掉。
4.3 为什么不能直接覆盖包内 assets
新手最容易犯的错误是:想着把下载来的资源直接覆盖到安装包里的 assets 目录。这在 Android 上看起来可行,但 iOS 上安装包内的文件是签过名的,根本不能写。就算 Android 上能写,也是完全不安全的做法,一旦用户清缓存或者系统更新时校验包完整性,你的应用会直接崩溃。
正解是:把下载好的资源解压到系统的可写目录,比如 native.fileUtils.getWritablePath() 返回的目录,然后通过引擎的搜索路径机制,让它优先从可写目录里读取同名资源。
搜一遍 Cocos 3.x 的资源加载机制你就会发现,最终决定资源读到哪个位置的是一份“搜索路径列表”。引擎加载资源时,会按顺序在这些路径里找同名文件,找到就用第一个。热更框架要做的,就是把热更目录插到搜索路径列表的最前面。这样,包内有一个 activity/config.json,热更目录下也有一份 activity/config.json,实际加载时用热更的,没有热更时才回落到包内的。
typescript复制import { native } from 'cc';
const writablePath = native.fileUtils.getWritablePath();
const hotUpdateRoot = `${writablePath}hotupdate/`;
const oldPaths = native.fileUtils.getSearchPaths();
native.fileUtils.setSearchPaths([hotUpdateRoot, ...oldPaths]);
这段代码展示的是思路,API 名和参数以你当前 3.8.7 的实际 d.ts 为准。我在好几个 3.x 版本上见过平台差异,所以建议把这段逻辑单独封装,并在各真机平台都测一遍。搜索路径是热更新的地基,这里出问题,后面全是白搭。
5. 重启生效与后续版本管理
5.1 下载完成后为什么必须重启
很多人在这一步会疑惑:文件都落地了,为什么界面上的东西还是旧的?因为引擎在运行期已经把资源加载进内存,AssetManager 有缓存,磁盘上的文件变化不会自动触发内存刷新。你直接调用 director.loadScene 切场景,很多时候也还是旧资源,因为 Bundle 缓存还在。
所以稳妥的做法是:提示玩家重启游戏。
typescript复制import { director, game } from 'cc';
export function restartGame() {
director.loadScene('boot', () => {
// 重新走启动流程,让资源管理器重新读取搜索路径
});
}
不过重启也要小心。如果热更没有完成校验就重启,可能会造成玩家进入一个半新半旧的状态。我建议在下载和解压完成后,先写一个“热更完成标记”到本地存储里,下一次启动时读到这个标记才让热更资源生效。这样比下载完立刻重启更可控。
5.2 多 Bundle 的顺序加载与并发控制
项目大了以后,远程 Bundle 可能十几个。每次启动都把所有远程 Bundle 拉下来,既费流量又容易在弱网环境下一堆超时。正确做法是按需更新:启动阶段只更新启动流程必然用到的 Bundle,其他 Bundle 在使用前检查本地版本,不一致再临时下载。
十几个 Bundle 一起并发下载另一个问题:CDN 压力大,客户端内存占用高,弱网环境下很容易出现雪崩。我一般用一个任务队列来串行控制:
typescript复制export class DownloadQueue {
private tasks: Array<() => Promise<boolean>> = [];
private index = 0;
add(task: () => Promise<boolean>) {
this.tasks.push(task);
}
async start(): Promise<boolean> {
for (; this.index < this.tasks.length; this.index++) {
const ok = await this.tasks[this.index]();
if (!ok) {
return false;
}
}
return true;
}
}
每下载完成一个 Bundle,就立即把解压后的新版本号写入本地存储。这样即便下载到一半崩溃,下次启动时已经完成的 Bundle 不会重复下载,只有那些没标记完成的还会再拉。这就是“断点续传”在 zip 整包策略下的最简实现。
5.3 灰度、回滚与强更
热更不是一把梭。大型项目通常有正式环境、灰度环境、测试环境三套 CDN 目录。灰度发布时,只让一部分用户(比如按 userId 取模)解析到新版 CDN 路径,观察部分玩家的反馈,再决定全量放量或者立即回滚。
回滚的关键是服务器端保留旧版本目录。我前面强调过,CDN 上用一个稳定的版本别名指向当前发布目录。发现新版资源有严重问题,直接把别名指回上个版本目录,客户端下次拉版本清单时发现远程版本比自己本地低,不会执行降级,问题资源至少不会继续扩散。
强更逻辑放在 version.json 的 min_app_version 字段里。客户端比较的是“主包版本号”,如果本地安装包版本低于这个值,说明热更已经救不了它,直接弹窗引导玩家去应用商店更新。这种方案比在代码里写死一个最低版本号要灵活得多,因为强更策略也可以走热更下发。
typescript复制import { Config } from './Config';
import { compareVersion } from './VersionUtil';
const remoteMin = versionData.min_app_version;
const appVersion = Config.APP_VERSION;
if (compareVersion(remoteMin, appVersion) > 0) {
// 弹出强更界面,跳转应用商店
showForceUpdateUI();
}
6. 我踩过的坑和最后的建议
6.1 平台路径差异是事故第一大来源
我见过最离谱的一次热更事故:开发在预览模式下调通了热更下载和搜索路径,但预览模式底层是浏览器环境,路径规则和原生完全不一样。他直接打包上线,结果所有 Android 真机都加载不了热更资源,活动开天窗。
Android、iOS、Windows、macOS 的可写目录、路径大小写、文件名合法性规则都不一样。iOS 对目录大小写敏感,Android 的路径拼接稍不小心就会多一个斜杠少一个斜杠。我在项目里会专门写一个 PathUtil,把所有路径拼接集中管理,并且强制要求每个新平台接入时,先跑一遍热更路径自测用例,不通过不允许发版。
6.2 版本号不自增导致线上不更新
这是所有热更事故里最低级但发生率最高的一种:开发改完资源,忘了在构建配置或者 version.json 里递增版本号,结果玩家版本对比之后发现没有变化,永远不下载。
我后来强制要求:版本号由构建脚本从 git branch 和提交号自动生成,不允许手工填写。每次构建 remote 产物时,脚本自动生成包含新版本号的 version.json。只要产物目录里有任何文件变化,脚本就执行一次版本号递增。这样从流程上杜绝了“忘改版本号”。
6.3 热更验收清单
每次发版前,建议按这个清单过一遍再放量:
- 老版本包 + 新 CDN:能正常检测到更新,下载成功,重启后新资源生效。
- 最新包 + 老 CDN:检测不到更新,不报错,不白屏。
- 断网启动:能正常进入游戏,不被热更流程卡住。
- 下载一半杀进程:重启后已完成的 Bundle 不重复下载,未完成的能继续。
- 远端 zip 文件损坏:能识别失败并提示重试,而不是静默替换本地文件。
- 多个 Bundle 大版本更新:下载队列顺序执行,内存不暴涨,CDN 不被打挂。
- 强更场景:主包版本过旧的玩家能正确看到强更页面。
这些场景每个都要在真机上验证一遍,预览模式和模拟器都不算数。
最后再分享一个实际体会:热更框架最好在项目第一个版本就搭进去,不要等项目上线了再补存量更新。我见到过不少团队上线半年后想接热更,结果发现玩家手里几十个历史版本,有的还带着半截 bug,迁移成本高到离谱,最后只能靠一次强更硬拉回来。如果你现在还在评估,建议直接用 3.8.7 建一个干净 Demo,按这篇文章的流程跑通版本对比、zip 下载、searchPaths 替换这一整套,再往正式项目迁移,是风险最低的路径。热更新这套东西,看着是技术活,其实是架构和流程的活,底子打好了,后面所有迭代都会轻松得多。
