《Cocos 游戏开发实战》17.1 灰度与回滚

《方块冲刺》到了上线阶段,本节讲灰度与回滚:为什么小游戏比原生更需要灰度、H5 与微信小游戏与原生三端各自能用的灰度手段、远端开关与版本门的实现、按设备哈希确定性分桶、灰度期的观测指标,以及 P0/P1/P2 三套回滚预案,并给出可类型检查的 RemoteConfig 组件与纯逻辑版本门。

17.1 灰度与回滚

《方块冲刺》在第 14 到 16 章已经能构建、能发布、能被冒烟测试盯住。但「能发布」和「敢发布」是两件事:一次 100% 全量推送,如果第 30 秒就发现新版本在低端安卓机上崩,你手上没有刹车。这一节补上刹车——灰度与回滚。

本节把《方块冲刺》推进到「可控上线」:先想清楚三端各自能用的灰度手段,再落一个远端配置组件做开关与版本门,用设备哈希做分组,最后备好三套回滚预案。收口时工程里会多出 assets/scripts/platform/remote-config.ts 与一份 configs/remote.json 的约定。

与站内 game/client/ 下那 116 篇运营类文章的分工:那些文章讲「运营策略该怎么做」,本节讲的是工程侧怎么让策略落地——开关放在哪、谁来读、读不到时的兜底是什么。

17.1.1 为什么小游戏比原生更需要灰度

原生 App 的发布链路里,商店审核本身就是一道缓冲:版本提交后要等审核,你还有时间改主意。小游戏和 H5 没有这道缓冲——wx 后台点一下「发布」,几分钟内所有用户就是新版本,而且是热的,用户不会重启。

这带来两个后果:

  1. 坏版本传播速度 = 你的 CDN 传播速度。 原生要等用户手动更新,小游戏是「下次进入即新版」。
  2. 回滚同样快,但前提是你留了后门。 如果新版把启动逻辑写死了,你连一个「切回旧版」的开关都推不下去。

所以卷二把灰度当成发布链路的默认组成部分,而不是「大厂才用的高级玩法」。哪怕你只有几千用户,一次 10% 灰度也能让你在崩之前先听到响。

17.1.2 三端各自能用的灰度手段

三端的灰度能力差别很大,不能一套方案打天下。下面这张表是《方块冲刺》上线前实测的结论:

端灰度手段生效速度粒度备注
H5入口 HTML 上的远端开关 + 版本门刷新即生效用户级最容易做,改 CDN 上一个 JSON 即可
微信小游戏后台「分阶段发布」+ 远端开关后台分钟级,开关刷新级后台按比例 / 开关按用户后台能力用足,开关做兜底
原生远端开关 + 应用商店分阶段发布开关刷新级,商店天级开关按用户商店回滚慢,开关是主力

三条结论:

  • 远端开关是三端的公共底座。 微信和原生都有各自的分阶段发布,但它们都慢或不可控,真正能让你在 5 分钟内刹车的只有「游戏启动时读一个远端 JSON」。
  • H5 不要只依赖入口 HTML。 入口 HTML 缓存策略一旦出错,开关改不动,所以开关必须由运行时的代码去拉,而不是编译进 HTML。
  • 原生不要指望商店回滚。 商店审核周期是「天」,你要么用开关绕过坏逻辑,要么接受坏版本活几天。

17.1.3 版本门:先确认「该不该放行」

灰度分两层:第一层是版本门——这个包到底允不允许运行;第二层是功能开关——允许运行了,某个功能开不开。先写版本门。

版本门解决的问题很具体:你在 1.3.0 里发现了一个致命 bug,想直接拦住这批用户,让他们走降级逻辑(提示更新、或者进入只读的离线模式)。这比「回滚」还早一步。

先看纯逻辑部分。分桶与版本比较与引擎无关,可以直接单测——这正是卷二里 L1 的位置:

// core/remote-gate.ts —— 与引擎无关,可 vitest 直接测
export interface GateConfig {
  minVersion: string;
  killSwitch: boolean;
}

/** 语义化版本比较:a < b 返回 -1,相等 0,a > b 返回 1 */
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;
}

export function shouldBlock(current: string, gate: GateConfig): boolean {
  if (gate.killSwitch) return true;
  return compareVersion(current, gate.minVersion) < 0;
}

compareVersion 故意不用 localeCompare:版本号 1.10.0 和 1.9.0 用字符串比会得出错误结论,必须逐段转数字。这个坑在卷二第 17.2 节的热更版本比对里还会再遇到一次。

版本门读到的 minVersion 一旦上调,低于它的客户端就被拦。这个开关的危险之处在于:一旦推错,等于全量停服。所以它必须和 17.1.5 的回滚预案绑定——推 minVersion 之前先确认你能在 60 秒内把它改回来。

17.1.4 远端配置组件

把开关读进游戏,用一个挂在常驻节点上的组件。这是 L2 片段,每个 cc 符号都对着 cc.d.ts 核过:

import { _decorator, Component, JsonAsset, assetManager, sys } from 'cc';

const { ccclass, property } = _decorator;

@ccclass('RemoteConfig')
export class RemoteConfig extends Component {
  @property
  configUrl = '';

  private _flags: Record<string, boolean> = {};

  async load(): Promise<void> {
    const url = `${this.configUrl}?t=${Date.now()}`;
    await new Promise<void>((resolve) => {
      assetManager.loadRemote<JsonAsset>(url, (err, asset) => {
        if (!err && asset) {
          const data = asset.json as Record<string, unknown> | null;
          this._flags = (data?.flags ?? {}) as Record<string, boolean>;
        }
        resolve();
      });
    });
  }

  isOn(key: string): boolean {
    return this._flags[key] === true;
  }

  get channel(): string {
    if (sys.platform === sys.Platform.WECHAT_GAME) return 'wx';
    if (sys.isNative) return 'native';
    return 'web';
  }
}

几个刻意的选择:

  • URL 后面拼 ?t=Date.now()。 小游戏的 CDN 与浏览器缓存都可能在开关上帮你「省钱」,结果你改了远端 JSON 却看到旧值。加时间戳是最省事的破缓存手段。
  • 读不到就用默认值。 this._flags 初始是空对象,isOn('xxx') 返回 false。开关的兜底永远是「关」——因为「关」意味着走老逻辑,是已知安全的。
  • channel 用 sys.platform 判断,不用字符串。 卷二里判错平台是最高频的一类 bug,第 18.2 节会专门复盘。sys.Platform.WECHAT_GAME 与 sys.Platform.MOBILE_BROWSER 都是 3.8.8 里真实存在的枚举成员。

组件挂到常驻节点上,load() 在启动流程里 await 一次,之后全工程用 isOn() 读开关。

17.1.5 分桶:谁进灰度

有了开关,还要决定「谁」被开关命中。最朴素的做法是随机——但随机有个致命问题:同一个用户每次启动可能被分到不同组,体验前后不一致,日志也没法归因。

正确做法是确定性哈希分桶:用设备 ID 或用户 ID 哈希出一个稳定值,取模 100,落在 [0, 灰度比例) 区间内的用户进灰度。同一台设备永远在同一桶里:

// core/bucket.ts —— 纯函数,可单测
export function hashBucket(id: string, buckets: number): number {
  let h = 2166136261;
  for (let i = 0; i < id.length; i += 1) {
    h ^= id.charCodeAt(i);
    h = Math.imul(h, 16777619);
  }
  return Math.abs(h) % buckets;
}

export function inGray(id: string, percent: number): boolean {
  return hashBucket(id, 100) < percent;
}

用的是 FNV-1a 哈希。它不加密,只求「稳定 + 均匀」,正是分桶需要的性质。inGray(deviceId, 10) 就是 10% 灰度。

注意:Math.imul 保证 32 位整数乘法不丢精度,这是 FNV-1a 在 JS 里的标准写法;用普通 * 会在超过 2^53 时失真,导致分桶不均。

17.1.6 灰度期看什么指标

灰度不是「推出去等着」,而是「推出去盯着一张表」。这张表在 16.3 节的埋点里已经有了数据源,这里只需要定义判定阈值:

指标灰度组基线报警阈值含义
崩溃率< 0.3%> 1% 持续 10 分钟稳定性问题,先回滚再看日志
启动成功率> 99%< 97%资源加载或版本门出了问题
首局完成率> 85%比全量低 10 个百分点玩法回归,可能改坏了手感
平均帧率55–60 fps< 40 fps 占比超 20%性能回归,见第 13 章
次日留存与全量持平低 5 个百分点以上慢性问题,灰度期看不到

关键认知:前四项能在灰度窗口(通常 1–2 小时)内看出来,留存看不出来。 所以灰度的目的是抓「硬伤」,不是验证「产品好不好」。别把灰度窗口拉长到几天来等留存——那时坏版本早扩散了。

阈值要提前定死,不要现场拍脑袋。现场判断的结果往往是「再等等看」,而「再等等看」是小游戏灰度最常见的翻车方式。

17.1.7 回滚的三套预案

灰度一旦命中问题,回滚速度决定了损失。按「从快到慢」备三套:

预案手段生效时间代价何时用
P0 开关回滚把远端 JSON 里的 flags 改回 false分钟级无新功能有问题、老逻辑还在
P1 版本门拦停上调 minVersion,把坏版本用户拦住分钟级该版本用户全被拦新版启动即崩,无法开关绕过
P2 回滚构建产物CDN 上把旧版本目录换回来分钟级(H5)/ 天级(原生)需保留上一版产物P0/P1 都失效

P2 的前提是「保留上一版产物」,这条要写进发布流程:每次发布时,build/web-mobile/ 不要原地覆盖,而是按版本号归档,例如 build/web-mobile/1.3.0/,CDN 上只切换一个指向当前版本的入口。这样回滚就是「把入口指回 1.2.1」,而不是「重新构建一次」——重新构建至少几分钟,还可能因为源码已改而构建不出旧版本。

把预案写成值班同学能照做的步骤,而不是「看情况处理」:

P0 开关回滚(预计 3 分钟)
  1. 打开 CDN 上的 config/remote.json,把出问题的 flag 改成 false
  2. 用 ?t= 时间戳参数 curl 一次,确认返回已更新
  3. 微信后台「版本管理」里确认无需额外操作(开关是运行时读的)
  4. 在埋点看板确认灰度组崩溃率 10 分钟内回落

P1 版本门拦停(预计 5 分钟)
  1. 把 remote.json 的 minVersion 上调到「坏版本号 + 1」
  2. 自测:用坏版本号模拟请求,确认 shouldBlock 返回 true
  3. 确认兜底页(提示更新)已随旧包发布过,否则用户会看到黑屏

P2 产物回滚(H5 预计 3 分钟,原生天级)
  1. 把 CDN 入口从 /1.3.0/ 指回 /1.2.1/
  2. 验证首页返回的是 1.2.1 的资源
  3. 原生端走商店审核,同时用 P1 拦停降低损失

值班同学照这三段做就行。注意 P1 的第 3 步——兜底页必须提前随包发过,否则拦停后用户看到的是白屏,比崩溃更糟。

17.1.8 在编辑器里看什么

本节涉及两处工程文件,都需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

第一处是常驻节点上的组件挂载。打开 assets/scenes/main.scene,在层级管理器里会看到 Main 场景根下有一个名为 PersistRoot 的节点,它的 _components 数组里有一项:

{
  "__type__": "RemoteConfig",
  "_name": "",
  "node": { "__id__": 12 },
  "configUrl": "https://cdn.example.com/blockdash/config/remote.json"
}

__type__ 是脚本的类名(由 @ccclass('RemoteConfig') 决定),configUrl 是你在属性面板里填的完整地址。注意 configUrl 不能填相对路径——小游戏里没有「当前页面」这个概念,相对路径会解析失败。

第二处是 configs/remote.json,它放在 assets/configs/ 下,作为本地兜底配置:远端拉不到时用这份。它的结构是:

{
  "flags": {
    "new_level_select": false,
    "reward_double": false,
    "analytics_v2": true
  },
  "minVersion": "1.0.0",
  "killSwitch": false
}

打开编辑器后,选中 assets/configs/remote.json,属性面板会显示它是一个 JsonAsset,json 字段里就是上面这段对象。这份本地配置的版本必须永远比远端保守——远端出问题时它是最后一道防线。

17.1.9 灰度上线前的自检

按这几条逐项打勾,缺一条就不要开灰度:

  • remote.json 已在 CDN 上可访问,且返回 Content-Type: application/json
  • 游戏内读开关走的是运行时拉取,不是编译进包的常量
  • 读不到远端配置时,所有开关默认 false(走老逻辑)
  • 分桶用设备 ID 哈希,同一设备重复启动结果一致
  • 灰度比例先设 5%,观察 30 分钟再抬到 20%
  • 三条回滚预案各演练过一次,且值班同学手上有一份步骤
  • 兜底页(提示更新)已随当前线上包发布,P1 才可用

最后一条最容易漏。很多人第一次配版本门时,兜底页还没发出去就先把 minVersion 写高了,结果拦停的用户直接黑屏。

17.1.10 工程收口

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

  • assets/scripts/platform/remote-config.ts:远端配置组件,挂常驻节点。
  • assets/scripts/core/remote-gate.ts:版本门纯逻辑,可单测。
  • assets/scripts/core/bucket.ts:确定性分桶,可单测。
  • assets/configs/remote.json:本地兜底配置。
  • 发布流程约定:构建产物按版本号归档,CDN 入口可切换。

下一节接手时,你会需要「不改包也能修线上逻辑」——这就是热更。下一节我们讲热更的边界:哪些能热更、哪些绝对不能,以及版本比对为什么又一次踩了语义化版本这个坑。

阅读导航:上一节:目录 · 下一节:17.2 热更与线上修复 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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