《Cocos 游戏开发高级编程》8.2 原生插件与三方 SDK 接入

把《方块冲刺》接一个三方 SDK 的完整链路走一遍:先看 Android/iOS 原生工程里插件该落在哪,再分别写 Java 与 Objective-C 桥接类,然后用 TypeScript 门面包一层平台适配,让业务代码只见接口不见平台;重点讲清原生结果回传给 JS 时的线程约束、回调注册与生命周期坑,最后算一笔插件维护成本的账。

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 编辑器打开,本节不可命令行复现。

三个设计点值得注意:

  1. 静态方法 + 静态状态。JSB 的反射式调用天然适合静态方法(不需要 JS 侧持有 Java 对象)。但静态字段有生命周期——sActivity 必须能置空,否则 Activity 泄漏。
  2. 回调名以字符串传入。Java 侧不知道 JS 函数长什么样,只知道一个名字;真正的派发由 SdkCallback 完成。
  3. 参数用字符串。跨语言边界上,字符串是最不容易出编解码问题的类型(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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画