《Cocos 游戏开发高级编程》8.1 原生平台与 JSB

从原生构建产物入手,讲清 Cocos Creator 3.8 在 Android/iOS 上如何把 JS 引擎嵌进 C++ 宿主,JSB 绑定怎样把 cc 的类暴露给脚本层;用 cc.d.ts 实测划出「平台无关 API」与「原生运行时注入对象」的边界,讲对 sys.isNative 与 sys.platform 的用法,量化反射式原生调用的成本,给出下沉原生的工程判断。

8.1 原生平台与 JSB

前七章我们一直在「引擎内部」打转:管线、合批、物理、动画、内存。这一章换一个视角——同一份 TypeScript,为什么在 H5 里跑得好好的,一到 Android/iOS 上行为就变了。答案在「原生平台」这四个字里:H5 上你的脚本跑在浏览器的 JS 引擎里,而原生平台上,Cocos 把一个 JS 引擎嵌进了它自己的 C++ 进程,中间隔着一层绑定。

这一节先讲机制:JS 与 C++/Objective-C/Java 到底怎么互相调用(JSB),cc.d.ts 里哪些是跨平台保证、哪些是原生才有的运行时对象,以及每次跨语言调用要付多少代价。8.2 再讲怎么把三方 SDK 接进来。

与站内分工:game/ 顶层有大量「某引擎怎么做」的概念文,graphics/ 讲图形 API 原理;本节不做引擎综述,只对着 @cocos/creator-types@3.8.8 的 cc.d.ts 和原生工程结构,讲 Cocos 自己的实现口径。

8.1.1 原生构建产物里到底有什么

H5 构建产物是 index.html + assets/ + src/,丢给浏览器就完事。原生构建产物是另一回事:Cocos 生成一个完整的可编译工程,里面既有你的脚本,也有引擎的 C++ 源码和一套平台工程文件。

下面这段是构建后 native/ 目录的骨架。

需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

native/
├── engine/
│   ├── common/              # 跨平台 C++:脚本引擎适配、绑定注册
│   │   ├── Classes/
│   │   │   ├── Game.h / Game.cpp
│   │   │   └── jsb_module_register.cpp
│   │   └── ...
│   ├── android/             # Android Studio / Gradle 工程
│   │   ├── app/
│   │   │   ├── src/com/cocos/game/
│   │   │   │   ├── AppActivity.java
│   │   │   │   └── GameActivity.java
│   │   │   └── AndroidManifest.xml
│   │   ├── build.gradle
│   │   └── settings.gradle
│   └── ios/                 # Xcode 工程
│       ├── AppController.h / AppController.mm
│       ├── main.m
│       └── Info.plist
└── ...

需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。

三个目录各自对应一种语言:common/ 是 C++,android/ 是 Java,ios/ 是 Objective-C。它们和你的 TypeScript 处在同一个进程里——这正是理解 JSB 的前提。

8.1.2 一个进程,两个世界

原生平台上没有浏览器。引擎自己带一个 JS 引擎(Android/Windows/macOS 上通常是 V8,iOS 上通常用系统自带的 JavaScriptCore),把 JS 引擎的虚拟机嵌进 C++ 宿主进程。你的每个 TypeScript 类、每次 new Node(),最终都由 C++ 侧的引擎对象来承载。

于是就有了「两个世界」:

  • JS 世界:你的脚本、cc 暴露出来的 JS 对象。
  • 原生世界:C++ 引擎、Java/Objective-C 的 SDK。

两个世界之间不能直接互相访问,必须经过一层绑定(binding)。Cocos 把这层绑定叫 JSB(JavaScript Binding)。它的作用是把 C++ 的类、方法、属性注册成 JS 世界里可用的对象——你在脚本里写 node.setPosition(1, 2, 3),实际执行的可能是 C++ 里某个 Node::setPosition,中间的参数编解码由绑定层完成。

这里有个关键区别要记住:不是所有 C++ 能力都会暴露给 JS。绑定是「白名单式」的——引擎显式注册了哪些类,JS 才能看到哪些。这个「注册了哪些」的清单,恰好就体现在 cc.d.ts 里。

8.1.3 cc.d.ts 只声明了跨平台的那一半

cc.d.ts 是引擎的类型声明文件。它有个容易被忽视的性质:它声明的是「跨平台可用的 API」,而不是「所有存在的 API」。原生运行时还会往全局注入一批对象,这些对象根本不在 cc.d.ts 里。

先看 native 命名空间在 cc.d.ts(3.8.8)里究竟声明了什么。用本机的反查工具核对(命令输出见下):

grep -nE '^\s+export (class|interface|function|namespace|const) ' \
  node_modules/@cocos/creator-types/engine/cc.d.ts | sed -n '1,40p'

核对结果是:native 命名空间里有类型声明的成员只有这些——

成员形态作用
native.copyTextToClipboard函数拷贝文本到系统剪贴板
native.garbageCollect函数触发脚本引擎(V8/JSC)的 GC
native.saveImageData函数(返回 Promise<void>)把像素数据存成图片文件
native.AssetsManager类原生资源热更管理器
native.Downloader类原生下载器(配合热更)
native.Manifest类热更清单

而文档与社区里常被提到的 native.reflection、native.fileUtils 这类名字,在 cc.d.ts 里查不到。它们是原生运行时动态注入的全局对象,不是 cc 的导出成员。这一条非常重要,因为它直接决定了写法:

  • 凡是 cc.d.ts 里声明了的,import { native } from 'cc' 后可以直接用,tsc 会帮你查错。
  • 凡是没声明的,TypeScript 不知道它存在。你必须自己补类型声明,或者走 globalThis 转型,否则 tsc --noEmit 直接报 TS2339。

下面这段是「没声明也要用」时的标准处理——不要用 as any 一撸到底,而是补一个最小接口:

// 原生运行时注入的全局对象,未在 cc.d.ts 中声明,需要自行补类型
interface RuntimeReflection {
  callStaticMethod(
    className: string,
    methodName: string,
    signature: string,
    ...args: unknown[]
  ): unknown;
}

const runtimeNative = (globalThis as Record<string, unknown>).native as
  | { reflection?: RuntimeReflection }
  | undefined;

export function hasNativeReflection(): boolean {
  return runtimeNative?.reflection !== undefined;
}

这样写的好处是:在 H5 上 runtimeNative.reflection 是 undefined,hasNativeReflection() 返回 false,调用方必须先判空——编译期和运行期各有一道防线。

8.1.4 判断运行环境:sys 是唯一的正门

「现在是原生还是浏览器」这件事,cc 给了一个明确入口:sys。它是 cc.d.ts 里 export const sys(3.8.8)声明的一组环境变量。常用的几个:

成员类型含义
sys.isNativeboolean是否运行在原生平台
sys.isBrowserboolean是否运行在浏览器
sys.isMobileboolean是否移动端
sys.platformsys.Platform当前平台枚举
sys.os / sys.osVersion枚举 / 字符串操作系统与版本
sys.isLittleEndianboolean字节序(写二进制协议时要用)

已知坑:很多人凭直觉写 sys.Platform.NATIVE 来判断原生——这个成员不存在。sys.Platform 是一个具体平台的枚举,不是「原生/非原生」的二分。核对 3.8.8 的声明,它的成员是这些:

UNKNOWN, EDITOR_PAGE, EDITOR_CORE, MOBILE_BROWSER, DESKTOP_BROWSER,
WIN32, ANDROID, IOS, MACOS, OHOS, OPENHARMONY,
WECHAT_GAME, WECHAT_MINI_PROGRAM, XIAOMI_QUICK_GAME, ALIPAY_MINI_GAME,
TAOBAO_CREATIVE_APP, TAOBAO_MINI_GAME, BYTEDANCE_MINI_GAME,
OPPO_MINI_GAME, VIVO_MINI_GAME, HUAWEI_QUICK_GAME, MIGU_MINI_GAME, HONOR_MINI_GAME

没有 NATIVE。正确写法是:用 sys.isNative 判原生,用 sys.platform 只在需要区分具体平台时使用:

import { sys } from 'cc';

export function platformTag(): string {
  if (!sys.isNative) {
    // 小游戏平台(微信/字节等)不是原生,也不是普通浏览器
    return sys.platform === sys.Platform.WECHAT_GAME ? 'wechat' : 'web';
  }
  if (sys.platform === sys.Platform.ANDROID) return 'android';
  if (sys.platform === sys.Platform.IOS) return 'ios';
  if (sys.platform === sys.Platform.MACOS) return 'macos';
  if (sys.platform === sys.Platform.WIN32) return 'win32';
  return 'native-other';
}

注意 sys.Platform.WECHAT_GAME 这类具体平台是存在的(上面清单里有),所以「微信小游戏」要单独判——它既不是原生也不是桌面浏览器。这一段是《方块冲刺》平台层 assets/scripts/platform/ 里真实会用到的东西。

8.1.5 跨语言调用的成本:为什么不能每帧调原生

JSB 调用不是免费的。它至少要付三笔账:

  1. 参数编解码:JS 的 number、string、对象要转成 C++ 能理解的形式,返回时再转回来。传一个对象,就可能触发一次深拷贝。
  2. 反射式查找:如果走 native.reflection.callStaticMethod 这类按字符串方法名调用的路径,每次调用都要用类名 + 方法签名去查一遍目标方法,名字越长、调用越频繁,开销越大。它适合低频、一次性的调用(比如初始化时把 SDK 初始化一次),绝不适合放在 update 里。
  3. 线程切换:如果原生侧在别的线程算完再回调 JS,还要切回 JS 线程(见 8.2.6)。

这些成本无法用纯逻辑精确复现,但「反射查找 vs 直接调用」的量级差异可以用一段与引擎无关的纯 TS 逻辑示意。这段代码可以直接在 Node 里跑:

// logic/reflect-cost.ts —— 与引擎无关,可直接单测
type Fn = (...args: unknown[]) => unknown;

// 模拟「反射式」调用:每次都按字符串签名查表
function makeReflective(table: Map<string, Fn>) {
  let lookups = 0;
  return {
    call(sig: string, ...args: unknown[]): unknown {
      lookups += 1;
      const fn = table.get(sig);
      if (!fn) throw new Error(`no method: ${sig}`);
      return fn(...args);
    },
    get lookups() { return lookups; },
  };
}

// 模拟「直接绑定」:调用点持有函数引用,零查找
function makeDirect(fn: Fn) {
  let calls = 0;
  return {
    call(...args: unknown[]): unknown { calls += 1; return fn(...args); },
    get calls() { return calls; },
  };
}

const table = new Map<string, Fn>([['add(II)I', (a, b) => (a as number) + (b as number)]]);
const refl = makeReflective(table);
const direct = makeDirect((a, b) => (a as number) + (b as number));

const N = 100000;
const t0 = performance.now();
for (let i = 0; i < N; i += 1) refl.call('add(II)I', i, 1);
const t1 = performance.now();
for (let i = 0; i < N; i += 1) direct.call(i, 1);
const t2 = performance.now();

console.log(`reflective ${(t1 - t0).toFixed(2)}ms, direct ${(t2 - t1).toFixed(2)}ms`);

在 JS 世界里,一次字符串查表的差异在纳秒级;但跨到 C++ 之后,这个差异会被编解码放大到微秒级。结论不是「反射一定慢到不能用」,而是:把原生调用收敛到低频路径——初始化、结算、存档、上报,而不是逐帧渲染或逐帧物理。

8.1.6 原生才有的 sys / native 能力

有几件事只有原生做得到,H5 上要么没有、要么实现不同。它们是「为什么值得为原生单独写代码」的实际理由:

能力APIH5 上
强制脚本 GCsys.garbageCollect() / native.garbageCollect()无效或不可用
校验原生对象有效性sys.isObjectValid(obj)仅判空
写剪贴板native.copyTextToClipboard(text)需用户手势 + 安全上下文
存图片到文件native.saveImageData(...)走下载
原生热更native.AssetsManager / Downloader走 Service Worker 或自研

sys.garbageCollect() 值得单独说一句:H5 上你无法主动触发 GC,只能靠分代回收自己找时机;原生上可以显式触发。这在第 7 章讲的「内存峰值」场景里有用——比如在关卡切换的加载屏上主动回收一次,避免游戏中途卡顿。但注意:显式 GC 本身会停顿,不要在游戏过程中调用。

import { sys, native } from 'cc';

export function collectOnLoadingScreen(): void {
  if (!sys.isNative) return; // H5 上没有可用的强制 GC
  // 加载屏是唯一适合付这笔停顿成本的地方
  sys.garbageCollect();
  native.garbageCollect();
}

8.1.7 工程决策:什么时候该下沉到原生

这一节讲完,回到真实项目里的判断。默认不要碰原生——原生代码一写,就多了一套需要维护、需要跟着引擎升级、需要在 Android 和 iOS 各测一遍的东西。只有当下面几条同时成立时,才值得下沉:

  • 能力在 H5 上根本没有(比如系统级推送、内购、原生广告、特定硬件);
  • 或性能确实卡在 JS 侧,且已经排除了算法与内存问题(回到第 7 章的定位方法);
  • 且这条路径是低频的——能收敛到「初始化一次 / 每次结算一次」,而不是逐帧。

反过来,如果只是「觉得原生快」,或者只是「不想改算法」,下沉原生通常是负收益:它把可移植的 TypeScript 变成了平台相关的胶水代码,把一次构建变成三次构建。

这一节的边界划在这里:cc.d.ts 能证明的跨平台 API 用 TypeScript 写;cc.d.ts 证明不了的原生能力,才交给 8.2 的桥接。 下一节就把《方块冲刺》接一个三方 SDK 的完整链路走一遍:原生工程怎么改、桥接类怎么写、结果怎么安全地回传给 JS。

阅读导航:上一节:目录 · 下一节:8.2 原生插件与三方 SDK 接入 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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