ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cocos Creator 接入 Android 渠道 SDK:原生工程、桥接与打包避坑

Cocos Creator 接入 Android 渠道 SDK:原生工程、桥接与打包避坑

做海外渠道的那段时间,最头疼的不是玩法逻辑,而是每次对接渠道方给的 SDK 都要在 Cocos Creator 和 Android Studio 之间来回横跳:引擎里点一下"构建",proj 目录被覆盖一遍,昨天改好的 Gradle 依赖、Manifest 权限、桥接类全部消失,第二天早上打包又是一堆ClassNotFoundException。这篇笔记就是那段时间攒下来的完整流程记录,讲清楚 Cocos Creator 导出的 Android 工程到底长什么样、SDK 的 aar 和 jar 该怎么塞进去、JS 和 Java 之间的桥接层怎么写才不掉链子、以及打包混淆和真机排查那些文档里不会写的细节。不管你是第一次接渠道 SDK 的新手,还是被"构建覆盖"折磨过几轮的老手,都可以照着走一遍。

1. 为什么原生工程接入仍然是主线方案

1.1 引擎构建产物和"你改的那份代码"是两回事

Cocos Creator 的构建流程本质上是"生成器":它读你的场景、脚本、资源,在build/目录下产出一个完整的 Android 工程骨架,包括settings.gradle、build.gradle、app/build.gradle、app/AndroidManifest.xml、gradle.properties,以及app/src下的 Java 源码,比如 3.x 默认是com.cocos.game.AppActivity,它继承引擎自己的CocosActivity。这个工程是"一次性生成物",你在里面写的任何东西,下次构建时都有被清掉的风险。

理解这一点非常关键:很多人第一次接 SDK 就是直接在build/android/proj里改,改完能跑,觉得成了,然后过两天改了个玩法重新构建,全部没了,还以为是引擎 bug。正确的认知是——build/是输出目录,不是源码目录。真正的源码在assets/下的 TypeScript,和引擎提供的工程模板定制目录build-templates/里。你要做的所有原生化改动,都得想办法落到这两个地方之一。

这个认知一旦建立,后面所有操作都会变得有章法:凡是要活过下一次构建的东西,都必须进build-templates,或者干脆把整个 Android 工程拆出去单独维护。前者适合改动量小的项目,后者适合接了三四个 SDK、Gradle 依赖一堆、还要频繁升级 Android 编译版本的重度场景。

1.2 三种接入路线的取舍对比

从业界常见做法看,接 SDK 大概有三条路,各有各的适用边界,我把它整理成一张表,选路线的时候直接对号入座就行。

接入路线典型做法优点主要代价适用场景
纯原生工程接入导出 Android 工程,用 Android Studio 打开,手动加依赖和桥接类控制力最强,任何 SDK 都能接构建会覆盖,需要模板化维护接多个渠道 SDK、需要自定义 Manifest
引擎插件/扩展一键集成装第三方封装好的面板插件,填 AppKey 自动改工程上手快,改动少依赖插件维护方,版本一升级就崩只接一两个标准 SDK、项目周期紧
引擎官方提供的原生扩展机制通过自定义原生模板目录维护工程官方支持,构建稳定需要理解模板目录结构中长期维护的项目

说句实话,第一和第三条本质是一回事,区别只在于你有没有把改动沉淀成模板。真正不建议的是第二条里的"黑盒插件":一旦 SDK 升级、或者需要在初始化顺序上加合规弹窗,插件改不动,你还得回来手工改,最后变成"插件改一半 + 手工改一半",维护成本翻倍。我的建议很简单——只要这个项目预计要接三个以上的 SDK,直接走原生工程 + 模板化的路子,前期多花两小时,后面省两周。

1.3 哪些 SDK 非原生不可,哪些可以在 JS 层绕过去

不是所有 SDK 都值得上原生。判断标准很朴素:有没有强依赖 Android 系统能力,有没有回调需要回到 Activity 生命周期。

必须原生的典型:账号登录(依赖宿主 Activity 回调)、支付(依赖 Activity 结果回传)、广告(依赖 View 层级和 Activity 上下文)、推送(依赖系统服务和后台进程)、统计分析(依赖设备信息和 Application 初始化)。这些 SDK 的 Java API 几乎都要求传入Activity或Context,JS 层完全摸不到,只能靠桥接。

可以只在 JS 层解决的:纯 HTTP 的排行榜上报、纯前端的分享文案生成、只做数据格式转换的工具类库。这类东西硬上原生,等于给自己加一层无意义的转换,调试还更麻烦。

我见过有团队为了"统一",把纯 HTTP 的埋点也包了一层原生,结果每次改埋点字段都要重新出包,白白把发版节奏拖慢了。判断清楚边界,比追求架构统一重要得多。

2. 动手前的环境基线与工程准备

2.1 版本对齐表:这一步没对齐,后面全是玄学报错

接 SDK 过程中 80% 的"莫名其妙的编译失败",根子都在版本不对齐。我整理了一张按经验总结的对齐表,具体数字要按你项目实际使用的引擎版本文档核对,下面是常见组合,供参考。

项目建议取值说明
JDK17(引擎较新版本)/ 11(较老版本)JDK 版本和 AGP 强绑定,对不上直接报 unsupported class file
Gradle与 AGP 匹配的版本Gradle 版本由 wrapper 决定,改之前先看 wrapper 配置
AGP(Android Gradle Plugin)7.x 及以上老版本 AGP 不支持 namespace 写法
compileSdk34 或以上低于 SDK 要求会直接拒绝编译
minSdk取所有 SDK 要求里的最大值多个 SDK 的 minSdk 取并集
targetSdk与 compileSdk 保持一致避免行为差异导致回调异常
NDK与引擎版本匹配引擎有明确推荐值,别乱升

最容易踩的是 minSdk。假设你的引擎默认配的是 21,接入的某个 SDK 要求 23,Gradle 会直接抛 manifest merger failed。解决方式不是把 SDK 降级,而是在app/build.gradle里把minSdkVersion提到 23,然后回头确认一下引擎本身在 23 上没问题。这个动作看着小,但如果项目已经上线,提升 minSdk 意味着放弃一部分低版本设备,属于产品决策,得提前和运营沟通。

还有一个隐性问题:多个 SDK 各自依赖不同版本的同一个基础库(比如某几个 SDK 都依赖网络库的不同版本),Gradle 会做版本仲裁,仲裁结果可能谁的都不满足。这种情况用resolutionStrategy强制统一版本,或者用exclude剔掉重复依赖,比无脑升版本靠谱。

2.2 首次构建导出与目录结构确认

流程本身不复杂:在引擎的构建面板里选 Android 平台,填好包名、目标 API 级别、签名(调试阶段可以先不签),点构建。构建完成后在输出目录里找到proj文件夹,这就是完整的 Android 工程。

用 Android Studio 打开这个proj目录时,注意要打开的是包含settings.gradle的那一层,不是app子目录。很多人打开错了层级,Android Studio 会把它当成普通文件夹,Gradle 同步按钮直接不出现,然后误以为是工程坏了。

打开后先做一次干净的同步和编译,确认在没有加任何 SDK 的情况下能出一个能装的包。这个"基线验证"步骤千万别跳过。一旦你先加了 SDK 再编译失败,你就分不清是环境问题还是 SDK 问题,只能在两个变量之间反复猜。先跑通空工程,再逐个加依赖,每次加完立即编译,这是排查成本最低的顺序。

2.3 用模板目录把改动固化下来

引擎提供了build-templates机制,把需要长期保留的文件按相同的相对路径放进去,构建时会覆盖到输出工程对应位置。不同引擎版本对 Android 平台的模板路径略有差异,常见的是build-templates/android/这样的层级,具体以你所用版本文档为准,但思路是通用的:你在输出工程里改了什么相对路径的文件,就在模板目录里建同样相对路径的文件。

我习惯在模板里放这几类东西:

  • app/build.gradle:依赖、签名、混淆、打包配置
  • app/src/main/AndroidManifest.xml:权限、Activity 声明、meta-data
  • 桥接用的 Java 类:放在app/src/com/cocos/game/下对应位置
  • libs/目录下的 aar 文件(体积大的话也可以用脚本在构建后拷贝)
  • proguard-rules.pro:混淆规则

提示:模板目录只覆盖文件,不删除文件。所以如果你在输出工程里手动新增了一个文件但忘了加进模板,下次构建它会消失;反过来,如果你从模板里删掉一个文件,输出目录里那份旧的不会自动清理,需要手动清一次build目录。

有个细节值得单独说:aar 文件通常比较大,直接放进模板目录会让仓库膨胀。我的做法是把 aar 放在工程外的一个third-party-sdk目录里,用符号链接或者构建后拷贝脚本引进去,仓库只存脚本和版本号说明。团队协作时,另一个人拉下代码后跑一次拷贝脚本就齐了,比在群里发压缩包靠谱得多。

3. 把 SDK 依赖正确塞进 Gradle 工程

3.1 三种依赖引入方式怎么选

SDK 厂商给的东西通常三种形态:.aar、.jar、Maven 仓库地址。对应的引入方式也不一样。

第一种,本地 aar 或 jar 放 libs 目录,在app/build.gradle的dependencies里写:

dependencies { implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) // 如果 aar 里带了 res 资源,需要额外声明 flatDir }

同时要在repositories里加上:

repositories { flatDir { dirs 'libs' } }

flatDir这一步经常被忘。忘了它的表现是:编译时提示找不到 aar 内部的类,但 aar 明明在 libs 目录里躺着。原因是没有仓库能解析这个 aar 的坐标,Gradle 认不出它。

第二种,Maven 仓库,厂商给你一个仓库地址和一行 implementation 坐标,这种最省事,在settings.gradle或根build.gradle里配好仓库地址即可。需要注意的是,有些仓库需要认证,凭据放在gradle.properties里,别硬编码进 build 文件然后提交到仓库。

第三种,先装一个壳工程再取产物,有些厂商只提供 demo 工程,你得自己从 demo 里把 aar 抽出来。抽的时候注意把libs和jniLibs里的 so 文件也一起带走,漏了 so 会在运行时报UnsatisfiedLinkError,编译阶段完全看不出来。

3.2 manifest 合并冲突的处理套路

多个 SDK 的 Manifest 会被 Gradle 合并成一份,合并规则是"取并集",但遇到属性冲突就直接报错。最常见的几种冲突和处理方式:

报错类型触发原因处理方式
android:allowBackup冲突两个 SDK 给了不同值在应用主 Manifest 用tools:replace="android:allowBackup"
android:theme冲突SDK 自带 Activity 主题不同用tools:replace="android:theme"并在主 Manifest 指定最终值
Application 类冲突多个 SDK 要求继承各自的 Application自己写一个 Application 继承其中一个,其余用初始化接口替代
权限重复声明多个 SDK 都声明同一权限无需处理,合并自动去重
android:exported缺失高版本系统要求显式声明给所有带 intent-filter 的组件补上exported

tools:replace需要在 Manifest 根节点声明xmlns:tools="http://schemas.android.com/tools",这个命名空间很多人漏写,报错信息也不直观。

Application 类的冲突是最麻烦的一种。如果两个 SDK 都硬性要求你的 Application 继承它自己的类,那就得看它们是不是提供了"不继承也能初始化"的入口——大部分成熟 SDK 都提供,只是文档写得深。真遇到两个都不让步的情况,只能考虑把其中一个换成不依赖 Application 的方案,或者用字节码插桩这种成本很高的手段。我个人的经验是:在选型阶段就问清楚"是否必须继承 Application",这一个问题能省掉后面很多麻烦。

3.3 namespace、签名和 so 架构的三个细节

新版 AGP 要求模块声明namespace,如果AndroidManifest.xml里还留着package属性,会在编译时报冲突。处理方式是把package属性删掉,在app/build.gradle的android {}块里加namespace "com.your.package"。这一步做完之后,Java 源码里的 R 类引用路径也要跟着检查一遍。

so 架构方面,Cocos Creator 的引擎本身会产出arm64-v8a和armeabi-v7a的 so,如果 SDK 只提供了其中一种,打包时要把abiFilters显式限定成双方的交集:

android { defaultConfig { ndk { abiFilters 'arm64-v8a' } } }

不限定的话,Gradle 会把所有架构的 so 都打进去,装到某些设备上运行时找不到对应架构的库,直接崩溃,日志里是一句很含糊的dlopen failed。

签名配置建议在调试阶段就用固定签名,别用 debug 签名凑合。原因是有不少 SDK 会校验应用签名(尤其是需要和后台注册信息匹配的),debug 签名天天变,导致一会儿能登录一会儿不能,排查起来非常折磨人。

android { signingConfigs { release { storeFile file("../keystore/release.jks") storePassword System.getenv("KS_PASS") keyAlias System.getenv("KEY_ALIAS") keyPassword System.getenv("KEY_PASS") } } buildTypes { release { signingConfig signingConfigs.release minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } }

密码从环境变量读,别写死在文件里。这不是洁癖,是团队协作的基本要求——一旦 keystore 密码进了代码仓库,等于永久泄露。

4. JS 与 Java 的桥接层怎么写才不出事

4.1 先搞清楚引擎给了哪两套桥

Cocos Creator 在较新版本里提供了两套原生通信机制,理解它们的差异很重要。

第一套是native.bridge,Java 侧对应JsbBridgeWrapper的简化用法,JS 侧只暴露一个onNative回调。特点是简单,缺点是单回调:你赋一次值就把上一次的覆盖掉了。如果登录模块和支付模块各自写了一次native.bridge.onNative = ...,后写的那个会把前面的顶掉,表现出来就是"支付能回调但登录没反应",而且日志里什么都不报。

第二套是JsbBridgeWrapper,支持按事件名注册多个监听器,本质上是个事件总线。JS 侧和 Java 侧都能addEventListener和dispatchEvent,事件名做区分,互不干扰。只要你的项目要接两个以上需要回调的 SDK,直接用第二套,别用第一套。

引擎版本差异也要留意:老版本(2.x 系列)用的是jsb.reflection.callStaticMethod这种同步反射调用,只能传字符串和数字,且必须调静态方法,返回值也只能是基本类型。新版本(3.x)推荐用 bridge 方案。写桥接代码前,先确认你项目的引擎版本,别照着 2.x 的教程在 3.x 上抄。

4.2 从 JS 调 Java:初始化与参数传递

JS 侧发起调用:

import { native } from 'cc'; // 事件名建议统一加前缀,避免和 SDK 内部事件撞名 export function initLoginSdk() { native.bridge.sendToNative('sdk_login_init', JSON.stringify({ appId: 'your_app_id', channel: 'channel_a' })); }

Java 侧接收:

JsbBridgeWrapper.getInstance().addNativeEventListener("sdk_login_init", arg -> { // arg 是字符串,复杂参数用 JSON 解析 final String payload = arg; CocosHelper.runOnGameThread(() -> { // 注意:初始化要在主线程调,且要在合规确认之后 LoginSdk.init(CocosHelper.getActivity(), payload); }); });

这里有两个必须注意的点。

第一,参数只能是字符串。bridge 的传输层是字符串,对象的、数组的、布尔的一律先JSON.stringify,Java 侧再解析。传二进制数据要么走 base64(性能差),要么干脆走文件,别硬塞。

第二,注册时机。addNativeEventListener必须在那条事件被 dispatch 之前注册。如果 Java 侧在onCreate里就dispatch了一个事件,而 JS 侧的引擎还没起来,这条事件就丢了,而且不会有任何提示。稳妥的做法是 JS 侧启动完成后主动发一条ready事件,Java 侧收到之后才开始往外发消息。这个握手看起来多此一举,但能省掉大量"偶现丢回调"的问题。

4.3 从 Java 调 JS:线程才是最大的坑

SDK 的回调线程五花八门:登录回调可能在主线程,广告回调可能在子线程,某些统计 SDK 甚至在单独的 HandlerThread 里。而 JS 侧的执行上下必须在引擎的主线程(也就是渲染线程,不是 Android 的 UI 线程)。直接跨线程 dispatch 会引发各种诡异问题,最常见的表现是回调偶尔进、偶尔不进,或者进去了但拿到的数据是脏的。

正确姿势是统一包一层切线程:

public void notifyJs(final String event, final String data) { CocosHelper.runOnGameThread(() -> { JsbBridgeWrapper.getInstance().dispatchEventToScript(event, data); }); }

反方向,如果 SDK 要求必须在 Android UI 线程操作(比如弹登录框、拉起支付页),就用activity.runOnUiThread(...):

CocosHelper.getActivity().runOnUiThread(() -> { PaymentSdk.pay(CocosHelper.getActivity(), orderInfo); });

所以一个完整链路里其实有三次线程切换:SDK 子线程 → Android UI 线程(调 SDK)→ 引擎 GL 线程(回 JS)。很多人在第二步就漏了,直接在 SDK 的子线程里dispatch,然后在 JS 里操作节点,报一句莫名其妙的 assert。记住这个原则:JS 侧永远是 GL 线程,Android 侧永远是 UI 线程,跨过去必须显式切。

4.4 上下文与内存:两个容易埋雷的地方

CocosHelper.getActivity()拿到的 Activity 是引擎托管的那一个。不要把它存成静态变量长期持有,一旦 Activity 因配置变更重建,你手里那个就是旧实例,用它弹对话框会直接崩(BadTokenException或者WindowManager$BadTokenException)。正确做法是用的时候现取,或者存WeakReference。

另一类雷是回调泄漏。举个例子:你在 Java 侧注册了一个广告回调,结果用户退出界面后这个回调还挂着,SDK 后续回调回来时去操作已经销毁的节点,引擎侧直接崩。处理方式是在 JS 侧的onDestroy里显式发一个unregister事件给 Java,让 Java 把监听器摘掉。这个清理动作写起来就三行,但不写的话,问题会在上线后以"偶现闪退"的形式出现,复现极难。

onDestroy() { native.bridge.sendToNative('sdk_ad_unregister', ''); }
JsbBridgeWrapper.getInstance().addNativeEventListener("sdk_ad_unregister", arg -> { JsbBridgeWrapper.getInstance().removeAllListenersForEvent("sdk_ad_callback"); });

5. 典型场景的接入顺序与实操记录

5.1 初始化顺序决定了你会不会白排查一整天

多个 SDK 之间存在隐藏的依赖关系。比如埋点 SDK 一般要在 Application 里最早初始化,登录 SDK 要在合规确认之后才能初始化(因为它会采集设备标识),广告 SDK 又要求先登录拿到用户标识。这个顺序排错了,表现是"某个 SDK 一直返回未初始化"。

我实际项目里的初始顺序是这样的:

  1. 引擎 Application 起来 → 只做最基础的环境准备,不碰任何会采集信息的 SDK
  2. 合规弹窗确认(JS 层完成,用户点了同意之后才继续)
  3. JS 发sdk_init_all给 Java
  4. Java 按顺序初始化:统计 → 登录 → 广告 → 支付
  5. 每个 SDK 初始化完成回调 Java,Java 记一个计数,全部完成后发sdk_all_ready给 JS
  6. JS 收到sdk_all_ready才开始走登录流程

第 5 步的计数很关键。如果 JS 侧不等就绪就调登录,会因为"SDK 未初始化完成"直接失败,而失败信息往往很含糊,你会以为是参数错了,实际只是早了 200 毫秒。

private readyCount = 0; private readonly totalSdks = 4; onSdkReady(name: string) { this.readyCount++; if (this.readyCount >= this.totalSdks) { this.enterLoginFlow(); } }

5.2 生命周期透传:onResume、onPause、onDestroy 一个都不能少

大部分 SDK 需要在宿主的生命周期方法里做资源管理,尤其是广告和推送。如果你只在AppActivity里调了onCreate,没处理其他生命周期,会出现"切后台回来广告拉不起来"或者"推送角标不更新"这类看起来毫无关联的问题。

在自定义的AppActivity里逐个透传:

@Override protected void onResume() { super.onResume(); AdSdk.onResume(); PushSdk.onResume(); } @Override protected void onPause() { AdSdk.onPause(); PushSdk.onPause(); super.onPause(); } @Override protected void onDestroy() { AdSdk.onDestroy(); super.onDestroy(); }

顺序上,onPause里 SDK 的调用一般放在super之前,onResume里放在super之后,这个细节各家文档写得不一样,按你用的 SDK 文档来。差别在于引擎在super里可能会做暂停渲染之类的操作,SDK 若依赖引擎状态,顺序就很重要。

另外,如果你要覆盖onActivityResult、onNewIntent这类回调,务必先调super,否则引擎自己处理分享或深度链接的能力就被你切断了,表现出来是"接了 SDK 之后原本好用的返回键失效了"。

5.3 混淆规则:打包成功但一跑就崩的元凶

开启minifyEnabled true之后,代码被压缩和重命名。SDK 的 aar 通常自带consumer-rules,会自动生效,但你自己写的桥接类、以及通过反射被调用的模型类,必须手动 keep。

# 保留桥接入口,反射调用依赖类名 -keep class com.cocos.game.** { *; } # 保留 SDK 的模型类(按厂商文档补充) -keep class com.vendorname.sdk.model.** { *; } # 保留通过 JNI 回调的类和方法 -keepclasseswithmembernames class * { native <methods>; } # 部分 SDK 依赖注解,注解不能被裁剪 -keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod

判断是否需要加 keep 的方法很直接:打包后跑一遍完整流程,凡是出现ClassNotFoundException、NoSuchMethodException、Field xxx not found的,基本都是被混淆掉了,把对应类名加进规则再试。

有个小技巧:先关掉混淆打一个包,跑通完整流程,确认功能没问题;再开混淆打包,对比两次的崩溃日志。这样能把"功能问题"和"混淆问题"彻底隔离开,比一上来就开着混淆调要快得多。

6. 打包、真机验证与问题排查实录

6.1 真机日志怎么抓才有效

Android Studio 的 Logcat 默认过滤太宽,刷屏刷到看不见自己的日志。我习惯给所有桥接相关的日志加统一 tag,然后在 Logcat 里按 tag 过滤:

private static final String TAG = "BridgeTrace"; Log.d(TAG, "recv from js: " + eventName + " payload=" + payload);

JS 侧的日志在真机上不会出现在 Logcat 里,需要另外抓。常用做法是用引擎的调试模式连真机,或者把关键日志通过 bridge 转发到 Java 侧打出来。后者虽然土,但在排查"到底 JS 有没有收到回调"这类问题时极其有效——你只需要在两端的收发点各打一条日志,一眼就能看出断在哪一环。

排查链路问题的标准顺序是:JS 发出去了吗 → Java 收到了吗 → SDK 调用成功了吗 → SDK 回调回来了吗 → Java 转发给 JS 了吗 → JS 收到了吗。六个点各打一条日志,任何问题都能在五分钟内定位。

6.2 常见问题速查表

下面这张表是那段时间攒下来的,覆盖了绝大多数高频故障。

现象最可能的原因排查动作
编译报找不到 SDK 类没配 flatDir,或 aar 没进 libs检查 repositories 和依赖声明
编译报 manifest merger failed属性冲突加 tools:replace 或调整主 Manifest
运行崩溃 dlopen failedso 架构不匹配配 abiFilters,检查 SDK 是否提供对应架构
登录回调收不到回调注册晚于事件触发加 ready 握手,确认注册顺序
回 JS 后操作节点崩溃线程不对用 runOnGameThread 包一层
打包后一进游戏就崩混淆裁掉了反射类加 keep 规则
切后台回来功能异常生命周期没透传补 onResume/onPause
偶现 BadTokenException持有了旧 Activity 引用现取或用 WeakReference
构建后改动丢失直接改了 build 输出目录迁移到 build-templates
支付成功但游戏没发货回调在子线程,数据没同步主线程落库后再通知 JS

6.3 经验:把桥接层当成一个独立模块来写

踩了几轮坑之后我改了一个习惯:不再把桥接代码散落在AppActivity里,而是单独建一个BridgeModule类,所有事件名的注册、线程切换、异常捕获都收敛在这一个文件里,AppActivity只负责在onCreate里调一次BridgeModule.init()。

这么做的收益在项目后期特别明显。接了四五个 SDK 之后,AppActivity往往膨胀到几百行,谁都说不清哪个生命周期该调谁。收拢成模块之后,生命周期透传就是一次遍历调用,加新 SDK 只需要在模块里加一行注册,不用再动 Activity。同时,所有 JS 和 Java 之间的事件名也集中在一处,改名字的时候不会漏掉某一端。

还有个小习惯值得推广:给每个事件名加模块前缀,比如login_、pay_、ad_,并且 Java 侧在addNativeEventListener之前先removeAllListenersForEvent一次,防止页面反复进出导致同一事件被注册多次、回调被触发多次。这个"防重注册"的动作在单页面测试时完全看不出来,只有反复进出界面才会暴露,而那时候排查成本已经很高了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进