8.2 原生插件与三方 SDK 接入
8.1 把机制讲完了:原生平台上你的 TS 跑在嵌进 C++ 进程的 JS 引擎里,cc.d.ts 只声明跨平台的那一半。这一节把它用起来——给《方块冲刺》接一个三方 SDK。
选一个真实场景:登录与支付走某个厂商 SDK,它只提供 Android 的 .aar 和 iOS 的 .framework,没有 H5 版本。于是你必须在原生侧写桥接代码,把 SDK 的 Java/Objective-C 接口翻译成 JS 能调的东西。这一节的目标不是教你某家 SDK,而是给你一套任何三方 SDK 都能套进去的接入骨架。
8.2.1 为什么三方 SDK 必须走原生
先明确边界。一个 SDK「必须走原生」通常是因为它依赖了 JS 世界够不着的能力:
| 依赖 | 为什么 JS 够不着 |
|---|---|
| 系统 API(推送、内购、权限、剪贴板) | 浏览器沙箱不暴露,小游戏平台也只给有限封装 |
厂商 native 库(.aar / .framework / .so) | 编译产物只能被同语言宿主链接 |
| 硬件与系统服务(指纹、蓝牙、传感器) | 需要平台权限与原生线程 |
| 需要在后台/锁屏继续运行 | 浏览器标签页会被挂起 |
反过来,如果一个 SDK 提供纯 HTTP/WebSocket 接口,就别碰原生——直接用 cc 的网络能力或 fetch 就够了。8.1 那句「默认不要碰原生」在这里同样成立。
8.2.2 接入点:三个目录,三种语言
原生工程的结构在 8.1.1 看过。接一个插件,就是往三个目录里各放一点东西。下面这份结构,是《方块冲刺》接登录 SDK 后的样子。
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
native/engine/
├── common/Classes/ # C++:跨平台注册与胶水
│ └── blockdash_bridge.cpp # 把 JS 侧回调名注册进脚本引擎
├── android/app/src/com/example/blockdash/
│ ├── AppActivity.java # 引擎入口,挂载桥接
│ ├── BlockDashBridge.java # 桥接类:暴露给 JS 的静态方法
│ └── SdkCallback.java # SDK 回调的 Java 侧实现
└── ios/
├── AppController.mm # 引擎入口
└── BlockDashBridge.h/.mm # 桥接类:暴露给 JS 的 Objective-C 方法
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
注意一个现实问题:native/ 目录是构建生成的。如果你直接改这里,下次构建可能被覆盖。所以要么在构建模板里改(编辑器里可配置原生工程模板),要么用编辑器提供的「原生工程插件」机制,把文件以插件形式挂进去。这一节假设你已经用模板方式托管了这些改动——具体怎么托管见你所用 3.8.8 版本的构建文档,本书不展开编辑器操作。
8.2.3 Android 侧:写一个桥接类
Android 侧的桥接类是普通 Java。原则只有两条:暴露给 JS 的方法用 public static,回调用注册的 JS 函数名。
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
// native/engine/android/app/src/com/example/blockdash/BlockDashBridge.java
package com.example.blockdash;
import android.app.Activity;
import android.util.Log;
public final class BlockDashBridge {
private static final String TAG = "BlockDashBridge";
// 只存一个 Activity 引用,避免静态持有导致泄漏
private static Activity sActivity;
// JS 侧注册的回调名,如 "BlockDashNative.onLoginResult"
private static String sLoginCallback = "";
private BlockDashBridge() {}
public static void init(Activity activity) {
sActivity = activity;
}
// 由 JS 调用:注册结果回调
public static void setLoginCallback(String jsCallback) {
sLoginCallback = jsCallback == null ? "" : jsCallback;
}
// 由 JS 调用:发起登录,SDK 结果异步回来
public static void login(String channel) {
if (sActivity == null) {
Log.w(TAG, "login called before init");
return;
}
Log.i(TAG, "login with channel=" + channel);
// 真实项目里这里调用厂商 SDK,并在其回调里派发到 JS 线程
SdkCallback.dispatchLoginResult(sActivity, sLoginCallback, "code=0;token=demo");
}
}
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
三个设计点值得注意:
- 静态方法 + 静态状态。JSB 的反射式调用天然适合静态方法(不需要 JS 侧持有 Java 对象)。但静态字段有生命周期——
sActivity必须能置空,否则 Activity 泄漏。 - 回调名以字符串传入。Java 侧不知道 JS 函数长什么样,只知道一个名字;真正的派发由
SdkCallback完成。 - 参数用字符串。跨语言边界上,字符串是最不容易出编解码问题的类型(8.1.5 说的编解码成本,用字符串 + 约定格式能大幅降低歧义)。
8.2.4 iOS 侧:Objective-C 桥接
iOS 侧是对称的,只是语言与选择器语法不同。方法签名要按 Objective-C 的选择器规则拼。
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
// native/engine/ios/BlockDashBridge.mm
#import <Foundation/Foundation.h>
#import "AppController.h"
@implementation BlockDashBridge
+ (void)setLoginCallback:(NSString *)jsCallback {
// 把回调名存到单例或静态变量里
[BlockDashBridge storeCallback:jsCallback];
}
+ (void)login:(NSString *)channel {
NSLog(@"[BlockDashBridge] login channel=%@", channel);
// 真实项目里调用厂商 framework,并在其回调里派发回 JS 线程
[BlockDashBridge dispatchLoginResult:@"code=0;token=demo"];
}
@end
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。
对照 Android 版可以看出,两边要暴露的能力必须一一对齐:setLoginCallback 对应 setLoginCallback,login 对应 login。这是接三方 SDK 最容易出错的环节——两个平台的方法名、参数个数、返回值不一致,JS 侧就会在某一个平台上静默失败。工程上的做法是:先定 JS 侧的接口,再让两端去实现它,而不是反过来。
8.2.5 JS 侧:门面 + 平台适配
现在到 TypeScript 侧。核心原则和 8.1.6 一脉相承:业务代码不应该出现 if (sys.isNative)。所以先定义一个接口,再用一个门面把它实现出来。
// assets/scripts/platform/native-bridge.ts
export interface LoginResult {
ok: boolean;
token: string;
}
export interface ILoginService {
login(channel: string): Promise<LoginResult>;
}
// 运行时注入的原生对象,未在 cc.d.ts 中声明,自行补最小类型
interface RuntimeReflection {
callStaticMethod(
className: string,
methodName: string,
signature: string,
...args: unknown[]
): unknown;
}
function reflection(): RuntimeReflection | undefined {
const g = globalThis as Record<string, unknown>;
const n = g.native as { reflection?: RuntimeReflection } | undefined;
return n?.reflection;
}
上面的 ILoginService 是业务唯一需要知道的东西。接着给出两个实现:原生走反射,其余平台走一个「不支持」的兜底。
// assets/scripts/platform/login-service.ts
import { sys } from 'cc';
import { ILoginService, LoginResult } from './native-bridge';
const ANDROID_CLASS = 'com/example/blockdash/BlockDashBridge';
const IOS_CLASS = 'BlockDashBridge';
export class NativeLoginService implements ILoginService {
login(channel: string): Promise<LoginResult> {
const refl = reflection();
if (!sys.isNative || !refl) {
return Promise.resolve({ ok: false, token: '' });
}
return new Promise<LoginResult>((resolve) => {
// 把 resolve 注册到一张表里,等原生回调时取出
const cbName = `BlockDashNative_onLogin_${Date.now()}`;
registerCallback(cbName, resolve);
if (sys.platform === sys.Platform.ANDROID) {
refl.callStaticMethod(ANDROID_CLASS, 'setLoginCallback', '(Ljava/lang/String;)V', cbName);
refl.callStaticMethod(ANDROID_CLASS, 'login', '(Ljava/lang/String;)V', channel);
} else {
refl.callStaticMethod(IOS_CLASS, 'setLoginCallback:', '(NSString*)', cbName);
refl.callStaticMethod(IOS_CLASS, 'login:', '(NSString*)', channel);
}
});
}
}
// 回调注册表:原生回来时按名字取回 Promise 的 resolve
type Resolver = (r: LoginResult) => void;
const callbacks = new Map<string, Resolver>();
function registerCallback(name: string, r: Resolver): void {
callbacks.set(name, r);
}
// 引擎会调用这个全局函数(原生侧按名字派发到这里)
export function BlockDashNative_onResult(name: string, payload: string): void {
const r = callbacks.get(name);
if (!r) return;
callbacks.delete(name);
r(parseResult(payload));
}
function parseResult(payload: string): LoginResult {
const ok = payload.includes('code=0');
const token = payload.split(';').find((p) => p.startsWith('token='))?.slice(6) ?? '';
return { ok, token };
}
注意 Android 与 iOS 的签名写法不同:Android 是 JNI 风格((Ljava/lang/String;)V),iOS 是 Objective-C 选择器风格(setLoginCallback:)。这正是 8.2.4 强调「两端必须对齐」的地方——名字对齐了,签名格式却各自不同,写错任何一处,调用都会静默失败。
8.2.6 回传给 JS:线程与生命周期
这一节是接入三方 SDK 真正的难点,也是绝大多数「线上偶发崩溃」的来源。
第一,原生回调往往不在 JS 线程上。 SDK 的完成回调可能在后台线程、UI 线程、甚至网络线程上触发。JS 引擎(V8/JSC)不是线程安全的——在非 JS 线程上碰任何 JS 对象,轻则数据错乱,重则崩溃。所以桥接层必须做一件事:把结果先存成纯数据,再切回 JS 线程派发。
第二,切回 JS 线程之后才能碰回调注册表。 上面 BlockDashNative_onResult 只能在 JS 线程上被调用。原生侧的正确姿势是:在后台线程完成 SDK 逻辑 → 把结果编码成字符串 → 派发到 JS 线程 → 在 JS 线程上按回调名调用 JS 函数。
第三,生命周期要兜住。 玩家可能在等登录结果时切后台、甚至退出游戏。这时:
- Promise 可能永远不会 resolve——所以要有超时兜底,别让 UI 一直转圈;
- 回调注册表里的条目要及时清理,否则内存只增不减(回到第 7 章的「泄漏」主题);
- Activity / ViewController 被销毁后,桥接类里的静态引用必须能失效,否则就是经典的泄漏与空指针。
下面是一段与引擎无关的纯 TS 逻辑,把「带超时与清理的回调注册表」单独拎出来测——这正是这套机制里最容易写错、又最好单测的部分:
// logic/callback-registry.ts —— 与引擎无关,可直接单测
type Resolver<T> = (value: T) => void;
export class CallbackRegistry<T> {
private readonly map = new Map<string, { resolve: Resolver<T>; timer: unknown }>();
add(name: string, resolve: Resolver<T>, timeoutMs: number, onTimeout: () => void): void {
const timer = setTimeout(() => {
this.map.delete(name);
onTimeout();
}, timeoutMs);
this.map.set(name, { resolve, timer });
}
resolve(name: string, value: T): boolean {
const entry = this.map.get(name);
if (!entry) return false; // 超时后到达的迟到结果,直接丢弃
clearTimeout(entry.timer as number);
this.map.delete(name);
entry.resolve(value);
return true;
}
get size(): number {
return this.map.size;
}
}
resolve 返回 boolean 而不是 void,是为了让调用方能区分「正常返回」与「超时后的迟到结果」——后者必须被静默丢弃,否则会出现「登录早就超时失败了,过两秒又弹出一个成功」的诡异现象。
8.2.7 工程决策:插件是负债,不是资产
接入一个原生 SDK,你付出的不只是这一节的代码。算一笔账,一个插件长期要维护这些:
| 成本项 | 说明 |
|---|---|
| 平台差异 | Android 与 iOS 各一套桥接,方法名、签名、返回值都要对齐 |
| 引擎升级 | 引擎升级(见 9.3)可能改动原生工程模板与绑定,插件要跟着改 |
| 构建集成 | .aar / .framework 要进构建流水线,体积、混淆、签名都要管 |
| 生命周期 | 后台切换、权限、Activity/VC 销毁,每个平台都有各自的坑 |
| 测试面 | 真机矩阵 × 系统版本,无法在 CI 里跑 |
| 回滚 | 原生代码一旦发版,改起来比热更慢得多 |
所以工程判断很清楚:能用 Web/HTTP 解决的,绝不下沉原生;必须下沉的,把所有原生调用收敛到 8.2.5 那一个门面后面,让业务代码永远只面对 ILoginService 这样的接口。这样将来换 SDK、加平台、或者某个平台临时降级,改动都被锁在一个文件里。
下一节换个方向: 前面讲的原生调用都是「低频、跨边界」的;但如果瓶颈是主线程本身——一个纯计算的重活把每帧 16.6 ms 的预算吃光了——跨语言调用救不了你,你需要的是真正的并行。8.3 讲多线程与 Worker。
阅读导航:上一节:8.1 原生平台与 JSB · 下一节:8.3 多线程与 Worker 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。