☰
Cocos Creator 3.8接入Bugly:崩溃监控与符号还原实战
2026/10/14 4:59:39 网站建设 项目流程

我之前做的一个小游戏项目,用的是 Cocos Creator 3.8,双端发布(Android + iOS)。游戏上线后,最怕的不是玩法没人玩,而是玩家玩着玩着就闪退了。更让人头大的是,你在后台根本看不到崩溃堆栈,只能看着留存曲线掉头,然后猜是机型适配、内存问题,还是哪个回调写崩了。后来我花了一两天时间把腾讯 bugly 接进了 Cocos Creator 工程,解决的不只是“看得见崩溃”的问题,关键是能把崩溃堆栈还原到源码行,再也不用对着十六进制地址发呆。这篇文章就是把我当时的接入过程、踩坑点、以及符号还原的完整流程分享出来。适合刚接触 Cocos Creator 原生打包、想给游戏接入崩溃监控的客户端开发,也适合那些已经接了 bugly 但发现堆栈看不懂的人。

1. 为什么选 bugly,以及 Cocos 项目接入时的特殊背景

1.1 崩溃监控的选型逻辑

做游戏客户端的人都会有一个共识:崩溃问题必须靠数据说话。没有崩溃监控,你完全依赖测试人员复现,效率低到离谱。尤其是移动端,用户的机型、系统版本、运存、网络环境千差万别,很多崩溃只在特定环境触发,测试真机根本摸不到。

自建崩溃监控是有可能的,但成本实在太高。你需要的不仅是崩溃时的堆栈采集,还有用户操作路径、设备信息、日志拉取、堆栈符号还原、后台管理、多渠道包对应版本管理。整套下来是大半年的工作量,与做游戏本身的生产力目标背道而驰。所以直接用现成的崩溃监控服务是绝大多数团队的选择。

当时摆在我面前的主要方案有两个:腾讯 bugly 和 Firebase Crashlytics。最终选了 bugly,原因很实际:

  1. 国内访问稳定,上报不依赖额外网络环境,对接入国内发行渠道的包很友好。
  2. 免费额度足够用,对于中小型游戏项目没有成本压力。
  3. 符号还原的工具链相对完整,支持 Android、iOS,而且文档对“非标准 Android 工程”(比如 Cocos 这种自动生成的工程)有兼容空间。
  4. 后台反应速度快,报警策略比较灵活。

1.2 Cocos 项目接入 bugly 的特殊性

Cocos Creator 做出来的游戏,逻辑层跑的是 JavaScript/TypeScript,但真正让 App 跑起来的壳,还是原生工程。这就导致崩溃形态分两种:

  • 原生崩溃:C++ 层、Java 层、iOS Objective-C/Swift 层的异常,比如纹理上传内存溢出、原生插件空指针、OpenGL 底层中断等。
  • JS 层异常:Cocos 引擎里的脚本执行报错,多数情况下不会直接让 App 闪退,但会造成游戏卡死、黑屏、逻辑中断,这种“软崩溃”用户一样会流失。

bugly 原生 SDK 能自动捕捉第一种,但捕捉不到第二种。所以我在接入时做了一层 JS 主动上报能力,把游戏脚本里的异常也送到 bugly 后台,这样后台才能把两类问题都汇总到一张图里看。

另外还有个大前提:Cocos Creator 构建出来的原生工程是自动生成的,每次构建都可能会覆盖。你如果直接改 build 目录下的原生工程,下次构建就白改了。正确做法是改脚本模板,或者用构建插件去注入原生代码。这一点我必须放在前面说,因为很多第一次接入的人就是在这上面反复踩坑。

2. Android 侧接入:工程配置、初始化与混淆处理

2.1 依赖引入与权限配置

先说我用的版本:Cocos Creator 3.8.x,构建后生成的 Android 工程在build/android目录下。原则上 Android Studio 可以直接打开它修改,但注意我前面说的覆盖问题,所以直接改这个工程只适合验证,不适合作为长期方案。

bugly 官方推荐用 Gradle 依赖接入,比手动拷贝 jar 和 so 方便得多。我当时的build.gradle里加了这样一段:

repositories { maven { url "https://gitee.com/bughub/bugly/raw/master" } } dependencies { implementation 'com.tencent.bugly:crashreport:latest.release' }

这里有个细节,bugly 的仓库地址并不是默认的 mavenCentral,国内几个大仓库历史原因很多不在中央仓库。所以repositories这一步不加,依赖是拉不下来的。

接着是权限。Cocos 生成的工程一般默认带了INTERNET权限,但ACCESS_NETWORK_STATE、READ_PHONE_STATE这类权限不一定有,需要确认AndroidManifest.xml里是否包含:

<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />

READ_PHONE_STATE主要用来获取设备标识,对于崩溃数据归因和用户维度的堆排有用。如果你担心隐私合规问题,也可以在初始化时通过配置项关闭设备信息采集。我在项目里选择保留,但做了合规声明,这个就看各自产品需求了。

2.2 初始化时机与 Application 改造

这是最容易出问题的环节。bugly 官方要求尽早初始化,理想位置是 Application 的onCreate,而且要用Application上下文,不能用Activity。

Cocos 生成的工程默认会有一个继承自Application的入口类。如果你是 3.8 版本,构建出来的工程里通常叫GameApplication,它负责初始化引擎。我的处理方式是:直接在这个类里加入 bugly 初始化代码。

public class GameApplication extends Application { @Override public void onCreate() { super.onCreate(); CrashReport.initCrashReport(getApplicationContext(), "你的BuglyAppID", false); } }

第三个参数是是否开启调试模式,正式发布包务必传false,否则影响上报策略且会输出额外日志。

这里有几个坑我单独说一下:

  • 别放在 Activity 的onCreate里做初始化。崩溃可能在任何生命周期阶段触发,Activity 还没启动就崩了,你压根不会有机会走初始化代码。
  • 第一次启动初始化后,bugly 会把崩溃信息缓存在本地,等下次启动再上报。这是正常机制,不要看到后台没有立刻出数据就以为接失败了。
  • 如果你用的是多进程,需要额外设置CrashReport.initCrashReport在所有进程初始化。我当时游戏主进程和分包进程如果没处理,会导致某个进程的崩溃永远丢失。

2.3 混淆规则与 release 包配置

Cocos 构建 release 包时往往开着混淆,如果不处理,bugly 自己的代码会被混淆掉,导致初始化失败。需要把官方提供的混淆规则合并到proguard-rules.pro:

-dontwarn com.tencent.bugly.** -keep public class com.tencent.bugly.** { *; }

另外强调一个和 Cocos 相关的重点:JS 代码混淆不会影响 bugly 对原生堆栈的还原,但如果你开了 Cocos 的脚本加密/混淆,原生的 Java 层堆栈仍然能看懂,JS 异常堆栈就不好说了。后面我会单独讲 JS 层堆栈的处理。

3. iOS 侧接入:Pod 集成、初始化与 dSYM 准备

3.1 Pod 依赖防覆盖处理

iOS 那边比 Android 稍微省心一点,Cocos 构建后生成的 iOS 工程支持 CocoaPods,所以我可以直接在Podfile里加:

pod 'Bugly'

然后在生成的工程里找到AppDelegate的application:didFinishLaunchingWithOptions:方法,加上初始化代码:

#import <Bugly/Bugly.h> - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { [Bugly startWithAppId:@"你的iOS_BuglyAppID"]; return YES; }

用 Pod 接入的好处是,下次 Cocos 构建时只要Podfile里还留着这行依赖,pod install会自动把它重新链接进工程。比手动拖 framework 靠谱很多。

3.2 符号还原的前置要求

iOS 崩溃堆栈如果不做符号还原,你看到的多半是一堆二进制地址和十六进制偏移,根本定位不到具体方法。bugly 后台需要 dSYM 文件来还原符号。

所以我在打包机上加了一步自动化脚本,每次构建出.xcarchive后,把对应的.dSYM文件上传到 bugly 平台。upload 脚本 bugly 官方是提供的,但我没有直接用网页手动上传,因为手动上传很麻烦,一旦忘记,这个包的崩溃数据就是废的。

我当时的思路是:在 CI 流程里,构建结束之后调用官方提供的上传命令行工具,把 dSYM 和版本号绑定。你可以手写一个 shell 脚本,也可以在构建流程管理器里加单独任务。这一步做好之后,iOS 侧的符号还原才对得上。

这里特别提醒:每次打包必须保证 dSYM 和二进制包版本一一对应。如果你做了 bitcode 支持,dSYM 可能要到 App Store 处理完之后才生成完整版,上传时机要同步调整。游戏包一般不出海,而且关闭 bitcode 也问题不大,省了这层麻烦。

4. 让 JS 层也能上报:桥接封装与主动上报

4.1 我为什么单独做一层 JS 上报

原生崩溃交给 bugly 本身就够,但我在实际运营中发现,很多严重事故是 JS 层异常引起的。比如网络请求返回了异常结构,脚本里没做空值处理,导致一个巨大的卡顿甚至白屏,然后玩家被迫杀进程。这类问题原生层不会崩,bugly 记录不到。

解决办法是在 Cocos 引擎的全局异常捕获里加一层上报。Cocos Creator 的game.onPostBaseOnLoad或者通过director.on(Director.EVENT_AFTER_UPDATE)都能捕获部分脚本运行异常。更通用的是监听window.onerror和unhandledrejection,把错误消息、堆栈、脚本路径、行号、用户 ID、场景名、关卡信息一起上报。

4.2 桥接封装:从 TypeScript 到原生

Cocos Creator 3.x 里调用原生方法,我用的反射机制,大致封装是这样的:

import { native } from 'cc'; export class BuglyHelper { private static initialized = false; static init() { if (this.initialized) return; if (!sys.isNative) return; const appId = sys.os === sys.OS.IOS ? "iOS的AppID" : "Android的AppID"; native.reflection.callStaticMethod( "com/example/unity/BuglyBridge", "initBugly", "(Ljava/lang/String;Z)V", appId, false ); this.registerGlobalErrorHandler(); this.initialized = true; } static registerGlobalErrorHandler() { window.onerror = (message, source, lineno, colno, error) => { this.reportException(message, error?.stack || '', source, lineno); }; window.addEventListener('unhandledrejection', (event) => { const reason = (event as any).reason; this.reportException('UnhandledRejection', reason?.stack || String(reason), '', 0); }); } static reportException(message: string, stack: string, source: string, line: number) { native.reflection.callStaticMethod( "com/example/unity/BuglyBridge", "reportJSException", "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;I)V", message, stack, source, line ); } }

然后 Java 侧写一个BuglyBridge:

public class BuglyBridge { public static void initBugly(String appId, boolean debug) { CrashReport.initCrashReport(getAppContext(), appId, debug); } public static void reportJSException(String message, String stack, String source, int line) { CrashReport.postException(5, "JSException", message, stack, null); } private static Context getAppContext() { // 从 Cocos 的 Activity 拿全局 context try { Class<?> activityClass = Class.forName("com.cocos.game.AppActivity"); return activityClass.getMethod("getActivity").invoke(null).getApplicationContext(); } catch (Exception e) { return null; } } }

这里需要说清楚的是,postException的类别和塞入的堆栈信息,决定了你在 bugly 后台看到的堆栈格式。我自定义了一个类型5,库里没有预置,方便跟原生崩溃区分。你也可以用官方预留的CrashReport.CrashType枚举,但自定义类型更直观。

4.3 用户维度和自定义标签的实用玩法

只上报异常堆栈,对于游戏复盘还是不够。游戏里最有价值的信息是玩家行为路径、当前场景、角色状态、触发时间。bugly 提供setUserId和setSceneTag这两类接口,在 JS 层桥接里我也包了一层:

static setUserId(userId: string) { native.reflection.callStaticMethod( "com/example/unity/BuglyBridge", "setUserId", "(Ljava/lang/String;)V", userId ); } static setSceneTag(tagId: number) { native.reflection.callStaticMethod( "com/example/unity/BuglyBridge", "setSceneTag", "(I)V", tagId ); }

我习惯在玩家进入战斗、进入主城、切换地图、打开商城这几个关键节点打场景标签。线上崩溃后,后台就能看到崩溃用户的现场分布,比如“这个版本 90% 的崩溃发生在多人战斗场景”,那排查范围一下子就缩小了。

5. 符号还原:不看源码的崩溃堆栈等于白接

5.1 同一次崩溃,还原前后差距有多大

第一次接 bugly 时,我犯过一个错:只接 SDK、没传符号,结果后台显示的堆栈长这样:

java.lang.NullPointerException at com.game.demo.a.a(SourceFile:1) at com.game.demo.b.b(SourceFile:2)

这种堆栈基本没法看,你只能知道崩在哪个类,但看不到具体是哪个方法、哪一行。而正确还原之后的堆栈,每一行都是可读的函数名和行号。Android 的混淆映射、iOS 的 dSYM,就是用来做这个翻译的。

5.2 Android 符号表上传的正确姿势

Cocos 构建的 Android 发布包,如果开了压缩和混淆,会在构建产物里生成mapping.txt。这个文件在build/android/app/proguard或者build/android/app/build/outputs/mapping/release下面。

我当时是这么做的:

  1. 在 bugly 后台进入“崩溃分析 -> 符号表管理”。
  2. 根据当前包对应的版本号,为 Android 应用上传mapping.txt。
  3. 上传工具可以用 bugly 官方的 Gradle 插件,也可以手动走命令行。

如果你用的是多渠道打包,每个渠道包如果做了不同混淆,就必须分别上传对应渠道的 mapping。这一点容易忽略,因为很多工程对 release 包统一开混淆,但将多个渠道包打出来之后使用了不同的签名配置或者混淆策略,要用对应的 mapping 才能还原。

注意:必须保证上传的 mapping 与线上包是同一构建产物,否则乱传还会导致还原结果错乱,比不传更误导人。

5.3 iOS dSYM 上传细节

iOS 的处理是找.xcarchive里的 dSYM。用 Xcode Organizer 导出包时,dSYM 在.xcarchive/dSYMs目录下。

上传方式我用的是官方脚本,大致如下:

./Bugly.dSYMUploadScript.sh yourAppId yourAppKey yourBundleId yourDsymPath

这个脚本不同版本有些差异,以官方文档为准。但因为 Cocos 构建时未必走 Xcode 的归档流程,有时候拿到的 dSYM 分散在一个个.framework目录里。我的做法是在脚本中输入 dSYM 所在目录,让脚本循环处理每个.dSYM文件。

如果这一步配置不到位,iOS 上线后一旦出现崩溃,你只能看到一堆0x1040c0000地址,时间线一长,版本一多,根本不可能定位。所以 iOS 接入里,dSYM 上传是我反复强调的高优先级环节。

6. 验证、常见问题与上线后的数据治理

6.1 用最小代价验证整条链路

接入完成后,我没有马上打正式包,而是在构建出的 debug 包和 release 包上分别做了一个“故意崩溃”的验证。最简单的测试方式是给一个按钮绑定原生崩溃触发:

CrashReport.testCrash();

这个测试方法会抛出一个 Java 层的空指针异常。注意它只在 debug 模式下生效,正式包里的玩家不可能通过这个方法测试。我在 UI 上临时写了一个隐藏按钮,触发后 App 会立刻闪退,重新打开后等 10 秒左右,bugly 后台就能看到一条新崩溃。

JS 层的测试也顺手做了。我在开发者菜单里加了一个“上报错误”的入口,直接调用BuglyHelper.reportException塞一条假堆栈,验证桥接方法名和参数类型没有拼错。

6.2 我遇到的常见问题速查

问题现象可能原因解决方式
后台完全看不到崩溃数据初始化代码未执行、AppID 填错、权限缺失先确认日志里有 “bugly” 初始化输出,再检查 manifest 和后台应用配置
堆栈全是混淆后的短名mapping / dSYM 未上传或上传版本不匹配重新上传当前包对应符号文件
JS 上报没反应桥接方法名拼写错误或参数签名不匹配用callStaticMethod时严格核对 JNI 签名格式,并在 Java 侧打日志确认
本地正常但部分渠道包崩溃不上报多渠道包的符号文件没有各自上传每个渠道单独上传对应 mapping
某版本崩溃突然暴增底包引擎变更、第三方 SDK 升级、混淆规则变化对比该版本上线前的崩溃基线,查看场景标签分布

有一个值得单独说的坑:如果你在 Cocos Creator 里开了“脚本引擎分离”或者改过包名,Java 侧调用CocosHelper.getActivity()时可能拿到不正确的上下文。我当时就是遇到getAppContext()返回 null,导致reportException直接空指针,但原生崩溃监控又正常工作,排查了很久才发现是反射取的 Activity 实例时机不对。后来我改成在BuglyBridge.initBugly里缓存getApplicationContext(),之后再用就不会拿到 null 了。

6.3 上线后的崩溃数据治理建议

崩溃监控接完,只是建立数据渠道。真正让这套体系起作用的是持续治理流程。我在项目里定义了一个简单规则:崩溃率超过 0.5% 的版本必须拉动风险会,崩溃 TOP5 每天看一遍。同时给 bugly 后台配置了日报和邮件告警,设置合理阈值,避免半夜被低优先级崩溃骚扰。

另外,我会定期把历史版本里已经修复的崩溃做一次“已修复”标记,这样后台的数据看板才会稳定。不然很多旧崩溃残留在同一条聚合里面,新版本修复后看起来数据仍然很难看。

还有个小技巧:利用场景标签做个简单的漏斗判断。比如某次更新后,看到“开场动画”场景崩溃突然上升,大概率是动效资源加载或者 shader 问题;看到“商店界面”场景集中崩溃,则优先怀疑 IAP SDK 或网页视图。按照这个思路去排查,比看版本号硬猜高效得多。

结尾的几句实在话

这套流程跑通之后,我最大的感受是:崩溃监控的核心价值不只是“出了问题能看到”,而是“出问题之后不再互相甩锅”。运营说玩家闪退,渠道说你的包有问题,测试说复现不了,我直接在后台把堆栈、现场、场景标签、机型和系统版本贴出来,整个链路就通顺了。

最后再分享一个细节:如果你把 bugly 接入脚本做成 Cocos Creator 构建插件,每次构建自动注入原生代码、自动匹配 AppID、自动上传符号表,那团队里其他同学就完全不用管这套东西了。我这个版本是先手动接通的,后续打算再抽成插件,到时候可以再写一篇分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询