做海外渠道的那段时间,最头疼的不是玩法逻辑,而是每次对接渠道方给的 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% 的"莫名其妙的编译失败",根子都在版本不对齐。我整理了一张按经验总结的对齐表,具体数字要按你项目实际使用的引擎版本文档核对,下面是常见组合,供参考。
| 项目 | 建议取值 | 说明 |
|---|---|---|
| JDK | 17(引擎较新版本)/ 11(较老版本) | JDK 版本和 AGP 强绑定,对不上直接报 unsupported class file |
| Gradle | 与 AGP 匹配的版本 | Gradle 版本由 wrapper 决定,改之前先看 wrapper 配置 |
| AGP(Android Gradle Plugin) | 7.x 及以上 | 老版本 AGP 不支持 namespace 写法 |
| compileSdk | 34 或以上 | 低于 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 一直返回未初始化"。
我实际项目里的初始顺序是这样的:
- 引擎 Application 起来 → 只做最基础的环境准备,不碰任何会采集信息的 SDK
- 合规弹窗确认(JS 层完成,用户点了同意之后才继续)
- JS 发
sdk_init_all给 Java - Java 按顺序初始化:统计 → 登录 → 广告 → 支付
- 每个 SDK 初始化完成回调 Java,Java 记一个计数,全部完成后发
sdk_all_ready给 JS - 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 failed | so 架构不匹配 | 配 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一次,防止页面反复进出导致同一事件被注册多次、回调被触发多次。这个"防重注册"的动作在单页面测试时完全看不出来,只有反复进出界面才会暴露,而那时候排查成本已经很高了。