《Cocos 游戏开发实战》17.2 热更与线上修复

本节讲《方块冲刺》的热更与线上修复:先划清热更能改什么、绝不能改什么,再看资源热更的 manifest 与版本比对机制、原生端 native.AssetsManager 的完整调用时序、bundle 热更与脚本热更的边界,并给出一次线上修复的可执行流程与可类型检查的 HotUpdate 组件。

17.2 热更与线上修复

17.1 的回滚是「退回去」,热更是「往前修」。两者常被混为一谈,但工程上它们是两条完全不同的链路:回滚动的是产物指向,热更动的是用户设备上已下载的资源。这一节把热更讲清楚,重点是边界——热更能改什么、绝不能改什么。

本节把《方块冲刺》推进到「线上可修」:资源热更走 manifest 比对,原生端用 native.AssetsManager,H5 与微信端走 bundle 重新加载;同时明确脚本不能热更,所以「线上修复」必须落在资源和配置上。收口时工程里会多出 assets/scripts/platform/hot-update.ts 与一套版本比对约定。

与站内 game/server/ 那 315 篇的分工:那些文章讲服务端怎么配合热更做版本协商,本节只讲客户端侧怎么做资源比对与下载。

17.2.1 热更能改什么、不能改什么

这是本节最重要的一张表。很多团队第一次做热更时,以为「改完代码一推就完事」,结果线上毫无变化,因为代码根本不在热更范围内:

内容能否热更原因
关卡配置 configs/levels.json能纯数据,走资源热更
远端开关 config/remote.json能已在 17.1 讲过,走运行时拉取
纹理、图集、音效能是资源,走 bundle 热更
场景 .scene / 预制体 .prefab能序列化数据,随资源走
脚本逻辑 assets/scripts/**/*.ts不能编译进引擎包 / 首包,不在资源清单里
引擎版本不能换引擎等于换包
原生代码 native/ 下的 C++/Java不能需重新出包过审

结论一句话:热更修的是「数据」,不是「逻辑」。 想修逻辑,只能靠 17.1 的开关让老逻辑接管,或者重新发版。

这也解释了为什么本书从第 7 章开始就把玩法参数往 configs/levels.json 里赶——能配置化的东西才可热更。手感参数、金币布局、难度曲线,全都该是数据。

17.2.2 manifest 与版本比对

热更的机制是「版本清单比对」:包内有一份 project.manifest,CDN 上有一份新的,两者一比,差集就是需要下载的文件。Cocos Creator 3.8 里这份清单由构建后运行版本生成脚本产出,字段是标准的:

{
  "packageUrl": "https://cdn.example.com/blockdash/",
  "remoteManifestUrl": "https://cdn.example.com/blockdash/project.manifest",
  "remoteVersionUrl": "https://cdn.example.com/blockdash/version.manifest",
  "version": "1.3.1",
  "assets": {
    "bundle-levels/import/00/00abc.json": { "md5": "9f2c...", "size": 1824 }
  },
  "searchPaths": []
}

三段关键信息:

  • version:当前清单版本,比对的第一顺位。版本没变,后面全不看。
  • assets:每个资源文件按 md5 比对,md5 不同才下载。所以改一个关卡只下一份文件。
  • packageUrl:资源下载的基址,必须指向你的 CDN,而不是构建机本地路径。

热更不是「下一整个新包」,而是「下一份差异文件」。这是它比重新发版快的原因。

17.2.3 版本比对:又一次踩到语义化版本

清单里的 version 是形如 1.3.1 的字符串。热更管理器默认会用引擎自带的比较函数判断「远程版本是否更新」,而这个默认比较在某些写法下会出问题——1.10.0 和 1.9.0 谁大?

这正是 17.1 里 compareVersion 要解决的同一个坑。热更这边也要显式注入比较函数,别用默认字符串比较:

// core/version-compare.ts —— 与引擎无关,可单测
export function compareVersion(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 x = pa[i] ?? 0;
    const y = pb[i] ?? 0;
    if (x !== y) return x < y ? -1 : 1;
  }
  return 0;
}

原生端把它接到 AssetsManager.setVersionCompareHandle 上,比对就与引擎默认行为解耦了。这条在 17.1 已经埋过伏笔——同一个语义化版本的坑,在灰度版本门和热更版本比对里各出现一次,值得单独抽成模块。

17.2.4 原生端热更的调用时序

原生端热更是全流程最完整的一种:检查版本 → 准备清单 → 下载 → 校验 → 重启生效。L2 片段给出骨架,每个 cc 符号都对着 cc.d.ts 核过:

import { _decorator, Component, native, sys } from 'cc';

const { ccclass, property } = _decorator;

@ccclass('HotUpdate')
export class HotUpdate extends Component {
  @property
  manifestUrl = '';

  private updateNative(): Promise<boolean> {
    return new Promise((resolve) => {
      const storage = native.fileUtils.getWritablePath();
      const am = native.AssetsManager.create(this.manifestUrl, storage);
      am.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 x = pa[i] ?? 0;
          const y = pb[i] ?? 0;
          if (x !== y) return x < y ? -1 : 1;
        }
        return 0;
      });
      am.setEventCallback((event) => {
        const code = event.getEventCode();
        if (code === native.EventAssetsManager.UPDATE_FINISHED) resolve(true);
        else if (code === native.EventAssetsManager.UPDATE_FAILED) resolve(false);
      });
      am.update();
    });
  }

  async run(): Promise<boolean> {
    if (!sys.isNative) return true;
    return this.updateNative();
  }
}

时序上有几个必须踩对的点:

  1. native.fileUtils.getWritablePath() 是下载的存储目录,不是你的工程目录。原生端热更下来的资源放在可写路径,下次启动优先从那里读。
  2. 事件回调是唯一的进度与结果来源,没有 Promise 版本。UPDATE_FINISHED 之后资源已落盘,但要重启游戏才生效——因为引擎启动时就决定了加载哪个版本。
  3. 失败要能续传。UPDATE_FAILED 不代表全丢,可以调 am.downloadFailedAssets() 只重试失败项,而不是从头再来。

17.2.5 H5 与微信端的「热更」

H5 与微信小游戏没有原生那套 AssetsManager,但「热更」在它们这里的形态是bundle 重新加载 + 强缓存失效:

import { assetManager } from 'cc';

export function reloadBundle(name: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const old = assetManager.getBundle(name);
    if (old) {
      assetManager.removeBundle(old);
    }
    assetManager.loadBundle(name, (err) => {
      if (err) reject(err);
      else resolve();
    });
  });
}

要点:

  • 先 removeBundle 再 loadBundle,否则引擎会直接返回缓存里的旧 bundle,你以为「更新了」其实没有。
  • H5 的强缓存靠 URL 上的版本参数破,和 17.1 的 ?t= 一个道理;微信端则依赖 wx 的本地缓存策略,必要时用 wx.clearStorage 之外的方式清资源缓存。
  • 微信端首包与分包的关系决定了热更粒度:bundle-levels 分包可以整包换,首包里的代码换不了。

所以微信端的「线上修复」通常是:远端配置(改数据)+ 分包重下(改资源),代码逻辑动不了。

17.2.6 在编辑器里看什么

本节涉及构建配置,需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

打开构建面板(菜单「项目 → 构建发布」),在「原生平台」配置页里,与本节的项会看到这些值:

{
  "platform": "android",
  "md5Cache": true,
  "sourceMaps": false,
  "mainBundleCompressionType": "merge_all_json",
  "remoteServerAddress": "https://cdn.example.com/blockdash/",
  "packageName": "com.example.blockdash",
  "appABIs": ["arm64-v8a"]
}

逐项说明它在热更链路里的角色:

  • md5Cache: true:给每个资源文件名加内容 md5 后缀。这是热更能「只下差异文件」的前提——文件名变了才知道内容变了。开发期可以关,发布期必须开。
  • sourceMaps: false:发布包不带 source map。带的话包体变大,且源码泄露。
  • remoteServerAddress:对应 manifest 里的 packageUrl,必须和你 CDN 上的真实目录一致。填错的表现是「检查更新永远失败」。
  • packageName:与 §C 约定的 com.example.blockdash 一致,不能改。

在层级管理器里选中常驻节点,属性面板的 HotUpdate 组件会显示 manifestUrl 字段,值应是 https://cdn.example.com/blockdash/project.manifest。这个地址和构建配置里的 remoteServerAddress 是同一台服务器——两者不一致是「本地能更、线上不能更」的典型原因。

17.2.7 热更失败时的降级

热更本身也会失败,而且失败原因比你想的多。下表是《方块冲刺》上线首周真实遇到过的几类:

现象根因降级策略
检查更新永远失败manifestUrl 与 remoteServerAddress 不在同一域回落到包内版本,不阻塞启动
下载卡在 99%CDN 上缺了某个差异文件downloadFailedAssets() 重试,两次后放弃
更新完重启又变回旧版可写路径没权限 / 存储被清记一次失败,下次不重复尝试
微信端更新后白屏分包与新首包版本不匹配分包校验失败则整包重下
iOS 审核期热更被拒热更改动了可执行逻辑只热更资源,代码不动(17.2.1 的红线)

两条硬规则:

  1. 热更失败绝不能阻塞启动。 更新检查是「尽力而为」,超时就跳过,用包内版本进游戏。把用户卡在更新页,比让他玩旧版本糟糕得多。
  2. 失败要有次数上限。 同一设备连续失败两次就停手,否则每次启动都重试一次下载,白白耗流量,还会把 CDN 的错误放大。

17.2.8 一次线上修复的完整流程

把上面的东西串成值班流程:

  1. 定位:从 16.3 的崩溃上报里确认问题版本与影响面。
  2. 判断能否热更:查 17.2.1 那张表。是数据问题就走热更,是逻辑问题回 17.1。
  3. 改数据:改 configs/levels.json 或远端配置,本地校验 schema。
  4. 重生成 manifest:跑版本生成脚本,version 递增(例如 1.3.0 → 1.3.1)。
  5. 上传:把差异资源与新的 project.manifest 传到 CDN 同一目录。
  6. 自测:用一台旧版本设备启动,确认「检查更新 → 下载 → 重启生效」。
  7. 观察:热更完成率、失败率、重启率三项看板指标。

第 7 步的「重启率」值得单独说:热更完成后要提示用户重启,但用户可能不重启。所以热更资源的加载逻辑必须兼容「旧资源仍在内存」的状态——下一节讲活动配置时,我们会用「配置版本号随每次加载校验」来规避这个问题。

17.2.9 热更上线前自检

  • 构建配置里 md5Cache 已开,remoteServerAddress 指向真实 CDN
  • 包内 project.manifest 的 version 与线上 CDN 的能正确比对
  • 注入的 setVersionCompareHandle 能正确处理 1.10.0 > 1.9.0
  • 热更失败时跳过、不阻塞启动,且重试有次数上限
  • 确认本次修复只动了资源与数据,没有改脚本逻辑
  • 用旧版本设备完整演练过一次「检查 → 下载 → 重启生效」

第三条尤其要专门测:写一个 1.9.0 对 1.10.0 的用例,跑一遍 compareVersion,返回 1 才算对。

17.2.10 工程收口

这一节做完,工程里多了这些东西:

  • assets/scripts/platform/hot-update.ts:热更管理器,原生走 native.AssetsManager,H5/微信走 bundle 重载。
  • assets/scripts/core/version-compare.ts:语义化版本比较,灰度与热更共用。
  • 构建配置约定:md5Cache: true、remoteServerAddress 指向 CDN。
  • 发布流程约定:改数据 → 递增 manifest 版本 → 传 CDN → 自测。

到这里,你已经能「不改包修数据」了。但运营要的不止于此——他们还要「随时上活动、随时关活动、随时看数据」。下一节把配置下发和数据看板补齐。

阅读导航:上一节:17.1 灰度与回滚 · 下一节:17.3 活动配置与数据看板 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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