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 | 枚举 | 事件名 |
ts | number | sys.now() 时间戳 |
props.levelId | number | 关卡号 |
props.durationMs | number | 本局时长 |
props.result | string | win / 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 三端差异
同一套采集代码,三端能拿到的信息不同,要分别处理:
| 平台 | 异常捕获 | 上报通道 | 注意 |
|---|---|---|---|
| H5 | window 事件齐全 | 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 上报的可靠性、时机与隐私
采集到不代表上报成功。三条工程纪律:
- 先落盘,再上报:切后台(
Game.EVENT_HIDE)时把队列写进sys.localStorage,下次启动先读出来补报。否则进程被杀,数据就没了。 - 批量上报:攒够 20 条或满 30 秒发一次,别每条都发。既省流量,也避免上报请求本身拖慢游戏。
- 失败重试有上限:上报失败重试 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 灰度与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。