9.3 引擎升级策略
9.1 教你拉一份源码、9.2 教你定制它。但真正让技术负责人睡不着的,是第三件事:这份引擎迟早要升级。 升级的难点从来不是「下载新版本」,而是「升完之后,我怎么知道哪些地方被改坏了、出问题能不能退回去」。
这一节不讲怎么点「升级」按钮——那是编辑器操作,卷二已经覆盖。本节讲的是一套能落地的升级方法论:先判断该不该升,再评估差异面,然后用类型系统把 API 变更钉出来,最后用冒烟清单与帧预算验收,并留好退路。
9.3.1 什么时候该升级、什么时候不该
升级不是「有新版本就升」。先分清驱动因素,再决定节奏:
| 升级的驱动 | 紧迫度 | 建议节奏 |
|---|---|---|
| 官方修复了影响你的安全/崩溃问题 | 高 | 尽快升到含该修复的补丁版 |
| 新版本带来你急需的能力(如某平台支持) | 中 | 按项目里程碑安排,别在发布前夜升 |
| 只是「想用最新版」 | 低 | 攒着,等一个稳定窗口一起升 |
| 生态/工具链倒逼(插件只支持新版) | 中高 | 评估插件收益后再定 |
有两条经验值得记住:
- 升级要「跳小步」而不是「跨大步」。 从 3.8.8 升到下一个 3.8.x 补丁版,风险远小于跨到 3.9 或 4.x。官方文档的升级指南本身就是按小版本组织的(材质、动画剪辑、资源分包、已构建工程各有单独一篇),这说明跨版本升级从来不是一步到位的事。
- 升级和「自定义引擎」要一起规划。 如果你像 9.2 那样改过源码,升级就意味着要把你的补丁 rebase 到新版本上。补丁越多、越深,升级成本越高——这也是 9.2 反复强调「最小改动」的原因。
还有一点常被忽视:升级的收益和风险并不对称。 收益是「可能用上新能力」,风险是「可能改坏已经稳定的东西」。对一个临近发布或已上线的项目,除非升级能修掉一个具体问题,否则「不升」往往才是更稳的决策。所以判断的第一问不是「新版本有什么」,而是「我们现在有什么非升不可的理由」。
9.3.2 升级前评估:差异面清单
决定要升之后,先别动工程。把「可能被影响的面」列成清单,逐项过:
| 差异面 | 会被什么影响 | 核对手段 |
|---|---|---|
| TypeScript API | 组件脚本里用到的 cc 符号 | 两份 cc.d.ts 对比 + tsc --noEmit |
| Effect / 着色器 | 材质、后处理、自定义 Effect | 逐版本看材质升级指南,重编译 Effect |
| 工程文件格式 | .scene / .prefab / .meta | 用编辑器打开,看是否有升级提示 |
| 构建配置 | settings/v2/packages/*.json | 对比新旧构建面板选项 |
| 原生工程 | native/ 模板、JSB 绑定 | 看原生平台升级指南(如 3.8 Android 工程升级) |
| 第三方插件 | 只支持特定版本的扩展 | 逐个确认插件的版本兼容声明 |
这张表的价值在于:它把「升级」这个模糊的担心,拆成了六个可以单独验证的具体问题。 每一项都有明确的核对手段,而不是笼统地「试试看」。
还要补一句判断标准:差异面的大小,决定升级该「小步走」还是「立项做」。 如果六项里只有第一项(TypeScript API)有变化,那多半是一个下午的事;如果工程文件格式、构建配置、原生模板同时变,那就应该当成一个独立任务排期,而不是顺手升一下。先量差异面,再定投入, 这个顺序不能反。
9.3.3 API 变更核对:两份 cc.d.ts 对着看
六项里最容易被忽略、也最容易自动化的,是 TypeScript API 这一项。思路很简单:把新旧两个版本的 cc.d.ts 各取一份,提取导出符号,做差集。 移除的符号就是「升级后可能编译不过」的高危点,新增的符号则是「可以用的新能力」。
先做那个差集函数。它是纯逻辑,与引擎无关,可以直接单测:
// src/core/api-diff.ts —— 与引擎无关,可直接单测
export interface ApiDiff {
readonly added: string[];
readonly removed: string[];
}
/** 比较新旧两份导出符号清单,返回新增与移除(结果排序,便于 diff 阅读) */
export function diffExports(
oldList: readonly string[],
newList: readonly string[],
): ApiDiff {
const oldSet = new Set(oldList);
const newSet = new Set(newList);
const added: string[] = [];
const removed: string[] = [];
for (const s of newSet) {
if (!oldSet.has(s)) {
added.push(s);
}
}
for (const s of oldSet) {
if (!newSet.has(s)) {
removed.push(s);
}
}
return { added: added.sort(), removed: removed.sort() };
}
removed 非空就是警报:任何一个被移除的符号,只要你在工程里 import 过,升级后就会编译失败。 拿到这份清单,再回去 grep 工程代码,就能在升级前定位到受影响的位置。
符号清单怎么来?从 cc.d.ts 里把顶层导出抓出来即可。因为 @cocos/creator-types 是按引擎版本发布的,你可以把两个版本的声明文件各放一份再跑:
# 提取某版本 cc.d.ts 的顶层导出符号名(示意,实际正则按声明文件格式调整)
grep -oE 'export (class|interface|function|const|type|enum|namespace) [A-Za-z_]+' \
node_modules/@cocos/creator-types/engine/cc.d.ts \
| awk '{print $3}' | sort -u > symbols-new.txt
把上一版的 symbols-old.txt 与新版的 symbols-new.txt 喂给 diffExports,得到的就是这份「API 变更地图」。注意:符号存在不等于签名不变——参数增删、类型收紧这类变更,差集看不出来,仍要靠下一步的 tsc --noEmit 兜底。
两份 cc.d.ts 从哪来?最省事的办法是按版本从 npm 取 @cocos/creator-types:
# 取两个版本的声明文件(示意)
npm pack @cocos/creator-types@3.8.8
npm pack @cocos/creator-types@<目标版本>
# 各自解包后的 engine/cc.d.ts,就是对应版本的 API 全貌
如果你像 9.1 那样拉了引擎源码,也可以直接用源码目录里生成的那份声明文件——两者应当一致,若不一致,说明你手上的类型包和引擎版本没对齐,先解决这个再谈升级。
9.3.4 回归验证:冒烟清单 + 帧预算复测
差集和类型检查解决的是「编译得过吗」,但升级真正的风险在「跑起来对不对」。这一步要用冒烟清单加性能基线复测。
冒烟清单按《方块冲刺》的形态来定,覆盖每条主链路:
| 序号 | 冒烟项 | 通过标准 |
|---|---|---|
| 1 | 冷启动到主界面 | 无报错,首屏进度正常走满 |
| 2 | 场景切换(boot -> main -> 关卡) | 不黑屏、不丢资源 |
| 3 | 核心玩法(跑酷 + 收集 + 道具) | 与升级前表现一致 |
| 4 | 存档读写(blockdash.save.v1) | 旧存档能读、新存档能写 |
| 5 | 分包加载(bundle-levels) | 加载成功、无模块缺失 |
| 6 | 三端构建(H5 / 微信 / 原生) | 都能构建并启动 |
性能这一项,沿用卷二 13.3 定的帧预算作为判据:60 FPS 目标下每帧 16.6 ms,中低端机 30 FPS 目标下每帧 33.3 ms。升级前后在同一台设备、同一场景下各跑一遍,对比主循环耗时——如果升级后帧预算被吃掉超过一档,就要回头查是哪个子系统变了。
为了让「跑的是哪个版本」可追溯,在启动期打一行运行时戳。下面这段是 L2 组件脚本,settings、SettingsCategory、sys 都查过 cc.d.ts:
import { _decorator, Component, settings, SettingsCategory, sys } from 'cc';
const { ccclass } = _decorator;
@ccclass('RuntimeStamp')
export class RuntimeStamp extends Component {
start(): void {
// 目标帧率与平台信息,配合 7.3 的性能采样一起看
const frameRate = settings.querySettings<number>(SettingsCategory.SCREEN, 'frameRate');
console.log('[runtime] platform =', sys.platform, 'native =', sys.isNative);
console.log('[runtime] target frameRate =', frameRate);
console.log('[runtime] os =', sys.os, sys.osVersion);
}
}
这行日志的用途很直接:升级验证时,先用它确认「当前跑的确实是新版本引擎」,再看表现。否则你很可能对着一份没生效的旧引擎白测一轮——这个坑在 9.1.7 已经埋过一次。
复测必须有记录表,否则「升级前后」很快会变成两笔糊涂账。固定三列就够用:
| 指标 | 升级前 | 升级后 |
|---|---|---|
| 主循环耗时(ms/帧) | 基线值 | 复测值 |
| Draw Call(典型场景) | 基线值 | 复测值 |
| 内存峰值(MB) | 基线值 | 复测值 |
| 包体大小(MB) | 基线值 | 复测值 |
前三项来自第 7 章的采样手段,包体来自构建产物对比。判据用 16.6 ms / 33.3 ms 这两档帧预算,而不是「感觉差不多」。 数值化的好处是:一旦超档,你能立刻判断是「升级引入的回归」还是「本来就该优化」,而不是含糊地归因。
9.3.5 升级执行 runbook
评估和核对做完,真正动手时按下面的顺序走,能把「升级」从一次性的冒险变成可重复的流程。关键原则是:每一步都可单独验证,出问题立刻停。
1. 建升级分支 git checkout -b upgrade/3.8.8 -> 下一版本
2. 记录当前基线 版本号、补丁清单、帧预算实测值
3. 升引擎 自定义引擎:切 tag 后重新编译;内置引擎:换 Creator 版本
4. 升工程 用编辑器打开,按提示处理工程文件升级
5. 跑类型检查 tsc --noEmit,对着 9.3.3 的差集逐条确认
6. 跑冒烟清单 9.3.4 的六项,逐项记录通过与否
7. 复测帧预算 同设备、同场景对比升级前后
8. 合并或回退 全绿才合并;任何一项红,回退到升级前
这个流程里有几个容易被跳过的点:
- 第 2 步不能省。 没有基线,第 7 步的「复测」就没有参照物,你只能凭感觉说「好像没变慢」。
- 第 3 步之后先别急着改代码。 先让工程在新引擎上「原样跑起来」,把「引擎本身带来的问题」和「你的适配改动带来的问题」分开,否则出问题时根本分不清是谁的锅。
- 第 8 步的「回退」要真能执行。 如果分支策略没准备好,这时候才发现退不回去,就只能硬着头皮往前修。
9.3.6 常见升级失败与对策
升级失败往往不是「编译不过」这种明显错误,而是下面几类隐蔽问题:
| 现象 | 根因 | 对策 |
|---|---|---|
| 编译全绿,但某效果在真机上变了 | Effect / 着色器随版本改动,编译期看不出来 | 按材质升级指南逐项比对,重编译 Effect |
| 场景能开,但资源引用丢失 | 工程文件(.scene / .meta)格式升级不完整 | 用编辑器完整打开一遍,检查资源引用 |
| 老存档读不出来 | 存档结构或序列化行为变了 | 用 9.3.4 的冒烟第 4 项专门覆盖 |
| 原生构建报错 | 原生模板 / JSB 绑定随版本变化 | 查原生平台升级指南,重新生成原生工程 |
| 帧率掉了一档 | 某子系统实现变化(如渲染、动画求值) | 用第 7 章的性能剖析手段定位到具体子系统 |
| 自定义引擎的补丁 rebase 冲突 | 改动侵入过深 | 回到 9.2 的原则:最小改动、独立成 patch |
这几类的共性是:它们都能通过「先量差异面、再走 runbook」提前暴露。 升级失败之所以可怕,多半是因为跳过了验证、直接在生产分支上动手。
9.3.7 回退方案:锁 tag 与分支策略
升级必须默认「会失败」,所以回退方案要在动手之前就准备好。核心是两点:锁版本和可回滚。
- 锁 tag。 无论是自定义引擎还是内置引擎,都明确记下当前版本号(如
v3.8.8),并把它写进项目文档与 CI。升级时先在新分支上升,验证通过再合并。 - 工程与引擎一起回退。 如果升了引擎也改了工程文件(
.scene、settings),两者必须成套回退。只退引擎不退工程,工程文件可能已经被新版格式改写过,退回旧引擎反而打不开。 - 补丁单独管理。 自定义引擎的补丁要独立成 patch 集,升级时在新版本上重新应用;如果冲突严重,说明补丁侵入过深,需要重新评估是否值得继续维护。
- 保留一个「已知良好」的构建产物。 线上出问题时,能立刻切回上一个已验证的版本,而不是现编译。
把这几点整理成一张回退清单,升级前逐项确认:
| 回退要素 | 具体要求 |
|---|---|
| 版本号 | 当前引擎 tag(如 v3.8.8)明确记录,并写进 CI |
| 分支 | 升级在独立分支进行,验证通过才合并主干 |
| 补丁 | 自定义引擎的改动以独立 patch 形式管理 |
| 产物 | 保留上一个已验证的构建产物,可随时切换 |
| 工程 | 工程文件与引擎成套回退,不单独退其一 |
9.3.8 工程决策
把整节收成一张可执行的清单:
| 阶段 | 动作 | 产出 |
|---|---|---|
| 决策 | 判断升级驱动与紧迫度 | 升 / 不升 / 推迟到哪个窗口 |
| 评估 | 过一遍 9.3.2 的六项差异面 | 差异面清单 |
| 核对 | 跑 diffExports + tsc --noEmit | API 变更地图 + 编译结果 |
| 验证 | 冒烟清单 + 帧预算复测 | 验收记录 |
| 回退 | 锁 tag、分支隔离、补丁管理 | 可回退的升级分支 |
一句话:升级的功夫不在「升」,而在「评估—核对—验证—回退」这四个可重复的步骤。 把它们固化成流程,升级就从「赌一把」变成「走一遍」。
再补一条经验:升级完成后,把这次的经验回写到流程里。 哪一项差异面是这次真正踩到的、runbook 里哪一步救了命、补丁冲突有多严重——这些记录会直接降低下一次升级的成本。升级能力本身就是靠一次次升级积累出来的。
到这里,第 9 章关于引擎源码与定制的部分就讲完了;下一节开始进入第 10 章,把视角从引擎内部拉回到工程架构,看 ECS 与数据驱动架构怎么组织一个大型项目。
阅读导航:上一节:9.2 自定义引擎与模块裁剪 · 下一节:10.1 ECS 与数据驱动架构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。