《Cocos 游戏开发高级编程》9.3 引擎升级策略

本节给出引擎升级的完整决策链:先判断该不该升,再列差异面清单,用两份 cc.d.ts 做 API 变更核对,按冒烟清单与卷二 13.3 的帧预算做回归验证,最后落到锁 tag、分支与回退方案。文末提供一个纯 TypeScript 的导出符号差异比对器,可在升级前先跑一遍。

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 --noEmitAPI 变更地图 + 编译结果
验证冒烟清单 + 帧预算复测验收记录
回退锁 tag、分支隔离、补丁管理可回退的升级分支

一句话:升级的功夫不在「升」,而在「评估—核对—验证—回退」这四个可重复的步骤。 把它们固化成流程,升级就从「赌一把」变成「走一遍」。

再补一条经验:升级完成后,把这次的经验回写到流程里。 哪一项差异面是这次真正踩到的、runbook 里哪一步救了命、补丁冲突有多严重——这些记录会直接降低下一次升级的成本。升级能力本身就是靠一次次升级积累出来的。

到这里,第 9 章关于引擎源码与定制的部分就讲完了;下一节开始进入第 10 章,把视角从引擎内部拉回到工程架构,看 ECS 与数据驱动架构怎么组织一个大型项目。

阅读导航:上一节:9.2 自定义引擎与模块裁剪 · 下一节:10.1 ECS 与数据驱动架构 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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