《Cocos 游戏开发实战》16.3 崩溃上报与埋点

CI 里的冒烟能挡住白屏,却挡不住真实用户那边偶发的崩溃与卡死。本节把线上问题变得「看得见」:用 window 事件与引擎事件捕获异常、用纯逻辑模块定义埋点结构与批量编码、用 sys.platform 采集环境上下文,并讲清三端差异、本地队列、上报时机与隐私边界。

16.3 崩溃上报与埋点

16.1 的类型门禁与 16.2 的冒烟,都在发布之前拦问题。但真实的线上问题,绝大多数在发布之后才出现:某个安卓机型进关卡黑屏、某个用户在切后台时崩溃、某次激励视频回调没触发。这些场景你不在场,如果没有采集机制,就只能靠用户差评来「感知」。

与站内 game/client/ 的 LiveOps 文章的关系:那些文章讲的是运营策略与数据分析方法;本节讲的是工程侧怎么把数据采上来——捕获、组装、批量、上报、脱敏,这一整条链路。

崩溃上报和埋点是两件事,但共用一套采集框架:崩溃回答「为什么挂了」,埋点回答「用户都干了什么」。本节先搭框架(捕获 + 队列 + 上报),再分别接两类数据。目标是让 1.1 里 NFR-04「崩溃免疫率 ≥ 99.5%」有据可查。

16.3.1 线上问题为什么「看不见」

在浏览器里开发时,console.error 就在你眼前。但线上不一样:

  • H5 用户不会开控制台,异常只进他们本地的 console,你看不到。
  • 微信小游戏运行在宿主里,异常可能被吞掉,连日志都没有。
  • 原生端崩溃直接杀进程,栈信息不主动上报就永远丢了。

所以「看不见」是默认状态,「看得见」要靠主动采集。采集的第一步,是把三类异常都接住。

先明确「崩溃免疫率」怎么算,因为它决定了你要采什么:1 − 崩溃会话数 / 总会话数。想让它有意义,必须同时采到分母(总会话数,即启动次数)和分子(崩溃会话数)。只采崩溃、不采启动,算出来永远是 100% 或无穷大。这也是为什么「埋点」和「崩溃上报」要用同一套框架——两者共享同一个会话上下文。

关于采集的三条边界——采集不是「越多越好」,下面三条要先划清:

  • 不阻塞主流程:捕获与入队必须同步且极快,真正的网络上报异步进行,绝不能让上报拖慢一帧。
  • 不无限堆积:队列要有上限(比如 200 条),满了丢最旧的,避免内存被撑爆。
  • 不采集敏感数据:只采环境与堆栈,不采用户身份与内容。

16.3.2 三类异常要分别捕获

异常类型触发时机捕获方式
同步异常代码抛错未捕获window.onerror / addEventListener('error')
Promise 拒绝Promise.reject 无人处理unhandledrejection 事件
引擎异常组件生命周期里抛错引擎 error 日志 + 场景事件上下文

前两类是 Web 标准事件,第三类靠引擎。把它们统一到一个 capture 入口,再一起入队——不要每处都直接上报,那样会打爆网络、也会让崩溃本身被上报逻辑拖慢。

16.3.3 崩溃上报组件

下面这个组件捕获 Web 异常、在切后台时落盘、在场景启动时记一条上下文。它可过 tsc --noEmit:

import { _decorator, Component, director, Director, game, Game, sys, error as ccError } from 'cc';

const { ccclass } = _decorator;

export interface CrashPayload {
  readonly message: string;
  readonly stack: string;
  readonly platform: string;
  readonly os: string;
  readonly at: number;
}

@ccclass('CrashReporter')
export class CrashReporter extends Component {
  private queue: CrashPayload[] = [];

  onLoad(): void {
    if (typeof window !== 'undefined') {
      window.addEventListener('error', (e) => this.capture(e.message, e.error?.stack ?? ''));
      window.addEventListener('unhandledrejection', (e) => this.capture('unhandledrejection', String(e.reason)));
    }
    game.on(Game.EVENT_HIDE, this.flush, this);
    director.on(Director.EVENT_AFTER_SCENE_LAUNCH, this.reportScene, this);
  }

  private capture(message: string, stack: string): void {
    this.queue.push({
      message,
      stack,
      platform: sys.platform,
      os: sys.os,
      at: sys.now(),
    });
    ccError(`[block-dash] crash: ${message}`);
  }

  private reportScene(): void {
    this.capture('scene-launched', '');
  }

  private flush(): void {
    if (this.queue.length === 0) return;
    const pending = this.queue;
    this.queue = [];
    sys.localStorage?.setItem('block-dash:crash-queue', JSON.stringify(pending));
  }

  onDestroy(): void {
    game.off(Game.EVENT_HIDE, this.flush, this);
    director.off(Director.EVENT_AFTER_SCENE_LAUNCH, this.reportScene, this);
  }
}

几个必须写对的符号:sys.platform 是平台枚举(15.1 的平台探测用的就是它),sys.os 是操作系统枚举,sys.now() 是毫秒时钟,Game.EVENT_HIDE 在切后台时触发。error as ccError 是因为 error 与常见的变量名冲突,导入时重命名。window 相关代码必须包在 typeof window !== 'undefined' 里——原生端没有 window,不判断会在启动时直接抛错,那就成了「上报组件自己制造崩溃」。

这里刻意没有在 capture 里直接发网络请求,而是先入队、切后台时才落盘。原因是崩溃往往发生在渲染线程卡死或内存告急时,此时发起网络请求极易失败,反而掩盖了原始问题。把「采集」与「上报」解耦,采集必须快、必须可靠,上报可以慢、可以重试。这个设计还有一个好处:队列是可测的纯数据(CrashPayload[]),你可以在 Node 里构造一串 payload,断言序列化结果,而不必真的触发一次崩溃。

另外注意 flush 用的是「先取出、再清空」的写法(const pending = this.queue; this.queue = [];),而不是遍历后逐个删除。前者是原子的:即使 setItem 抛错(比如存储配额满),队列也已经被清空,不会重复上报同一批数据。后者在半途失败时容易把同一条崩溃上报多次。

16.3.4 埋点的数据结构

埋点比崩溃更频繁,结构也更规整。把它定义成纯逻辑模块(core/),就能单测序列化与批量逻辑:

export type EventName = 'level_start' | 'level_end' | 'revive' | 'ad_show' | 'coin_pick';

export interface AnalyticsEvent {
  readonly name: EventName;
  readonly ts: number;
  readonly props: Readonly<Record<string, string | number>>;
}

export function makeEvent(
  name: EventName,
  ts: number,
  props: Record<string, string | number>,
): AnalyticsEvent {
  return { name, ts, props };
}

export function encodeBatch(events: readonly AnalyticsEvent[]): string {
  return JSON.stringify(events);
}

事件名用联合类型而不是裸字符串,这样 makeEvent('level_strt', ...) 这种拼写错误会在编译期就被 tsc 拦下——这是把 16.1 的类型门禁用到埋点上的直接收益。props 限定为 string | number,避免有人往里塞对象,导致上报体积失控。字段口径建议全项目统一:

字段类型含义
name枚举事件名
tsnumbersys.now() 时间戳
props.levelIdnumber关卡号
props.durationMsnumber本局时长
props.resultstringwin / lose / quit

16.3.5 从埋点到漏斗

埋点不是「事件越多越好」,而是要能拼出一条漏斗。跑酷的主漏斗只有四步:

步骤事件关注点
打开游戏level_start(levelId=1)冷启动成功率
进入关卡level_start首屏到进关卡的流失
完成一局level_end(result=win/lose)关卡难度是否过高
复活 / 放弃revive / level_end(quit)变现路径是否顺畅

漏斗的价值在于定位流失发生在哪一步。如果「打开游戏」到「进入关卡」掉了 40%,问题在启动流程或首屏引导;如果「进入关卡」到「完成一局」大量 quit,问题在难度或手感。单看一个事件的数量说明不了问题,看相邻两步的比值才有意义。这也解释了为什么事件命名要统一:level_start 在四步里出现两次,靠 props.levelId 区分首次与后续。

16.3.6 三端差异

同一套采集代码,三端能拿到的信息不同,要分别处理:

平台异常捕获上报通道注意
H5window 事件齐全fetch / navigator.sendBeacon页面卸载用 sendBeacon 更可靠
微信小游戏wx.onError 补充宿主层错误wx.request域名要进后台白名单
原生引擎 + 原生桥接XMLHttpRequest / 原生 SDK进程被杀前可能来不及上报

用 sys.platform 分流:sys.Platform.WECHAT_GAME 走 wx.*,sys.Platform.ANDROID / IOS 走原生桥接,其余走 Web。判错平台是卷二反复出现的 bug(15.1 提过同一件事),这里的后果更隐蔽:微信端走了 fetch,异常被静默丢弃,你以为「没有崩溃」,其实只是没采到。

分流代码本身也应该抽成纯函数,方便单测,避免 if 分支写反:

export type UploadChannel = 'beacon' | 'fetch' | 'wx-request' | 'native';

export function pickChannel(platform: string): UploadChannel {
  if (platform === 'WECHAT_GAME') return 'wx-request';
  if (platform === 'ANDROID' || platform === 'IOS') return 'native';
  return 'fetch';
}

把平台名当字符串传进去(而不是直接读 sys.platform),这个函数就能在 Node 里被穷举测试:微信、安卓、iOS、浏览器各一条断言。凡是「按平台分流」的逻辑,都值得这样抽出来测一遍,因为判错平台的 bug 在单端开发时根本暴露不出来。

16.3.7 上报的可靠性、时机与隐私

采集到不代表上报成功。三条工程纪律:

  1. 先落盘,再上报:切后台(Game.EVENT_HIDE)时把队列写进 sys.localStorage,下次启动先读出来补报。否则进程被杀,数据就没了。
  2. 批量上报:攒够 20 条或满 30 秒发一次,别每条都发。既省流量,也避免上报请求本身拖慢游戏。
  3. 失败重试有上限:上报失败重试 2 次即可,无限重试会在弱网下堆积请求。

隐私边界也要划清:只上报「设备与运行环境」这类非个人数据(平台、系统、版本、堆栈),不上报用户标识、账号、真实位置。堆栈里可能混入变量值,上报前要做一层过滤——把疑似手机号、token 的字段剔除。这不是可选项,而是合规底线。

过滤同样适合写成纯函数,先过滤再编码:

const SENSITIVE = /(token|phone|mobile|idcard|password)/i;

export function scrubProps(props: Readonly<Record<string, string | number>>): Record<string, string | number> {
  const out: Record<string, string | number> = {};
  for (const [key, value] of Object.entries(props)) {
    if (!SENSITIVE.test(key)) out[key] = value;
  }
  return out;
}

Object.entries 遍历、按 key 名过滤,规则简单但有效。它和 16.3.4 的 makeEvent 串在一起:采集 → 过滤 → 编码 → 入队 → 上报。每一步都是可测的纯函数,整条链路因此可以脱离引擎在 Node 里跑通测试——这正是把埋点放 core/ 的回报。

16.3.8 上线前的观测自检清单

  • 同步异常、Promise 拒绝、引擎异常三类都有捕获入口
  • 切后台会落盘,冷启动会补报上次未发出的队列
  • 上报走对了平台通道(sys.platform 分流,未判错)
  • 事件名是联合类型,拼错会被 tsc 拦下
  • 上报失败重试次数有上限(≤ 2)
  • 上报内容不含用户标识与敏感字段
  • 后台能看到崩溃列表与关键埋点漏斗
  • 上报通道分流已写成纯函数并单测过(微信 / 安卓 / iOS / Web)
  • 队列有上限,满了丢最旧,不会无限堆积

16.3.9 工程视角的收口

第 16 章到这里收口。三节下来,工程里多了:两份 devDependencies 门禁配置与 CI 工作流(16.1)、一套 Playwright 冒烟与基线截图(16.2),以及一个 CrashReporter 组件、一个 core/analytics.ts 埋点模块和一份上报纪律清单(本节)。质量保障形成了三层:提交时类型与单测、构建后冒烟与视觉回归、上线后崩溃与埋点——分别对应「写错、跑挂、线上坏」三类问题。

回到 1.1 的验收对账:NFR-04「崩溃免疫率 ≥ 99.5%」由本节的采集链路提供数据来源;FR-01 到 FR-08 的行为是否真的发生,由埋点漏斗验证。质量保障不是「多加几个测试」,而是让每一类问题都有对应的自动发现机制。

至此,包能发、能测、能观测。但线上出问题时,改动怎么安全地推给用户?直接全量更新风险太大。第 17 章讲上线与运营:先灰度一部分人、出问题能回滚、不改包也能修——下一节从灰度与回滚开始。

把第 16 章放回整卷的脉络里看,它其实回答了「凭什么敢发布」:类型门禁让你敢改代码,冒烟让你敢出包,崩溃上报让你敢上线。 三者缺一,发布就变成赌博。而下一章的灰度与回滚,则是「发布之后万一还是出事了怎么办」的兜底——观测告诉你「坏了」,灰度与回滚告诉你「怎么收」。

阅读导航:上一节:16.2 自动化冒烟与截图比对 · 下一节:17.1 灰度与回滚 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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