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.isNative | boolean | 是否运行在原生平台 |
sys.isBrowser | boolean | 是否运行在浏览器 |
sys.isMobile | boolean | 是否移动端 |
sys.platform | sys.Platform | 当前平台枚举 |
sys.os / sys.osVersion | 枚举 / 字符串 | 操作系统与版本 |
sys.isLittleEndian | boolean | 字节序(写二进制协议时要用) |
已知坑:很多人凭直觉写 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 调用不是免费的。它至少要付三笔账:
- 参数编解码:JS 的
number、string、对象要转成 C++ 能理解的形式,返回时再转回来。传一个对象,就可能触发一次深拷贝。 - 反射式查找:如果走
native.reflection.callStaticMethod这类按字符串方法名调用的路径,每次调用都要用类名 + 方法签名去查一遍目标方法,名字越长、调用越频繁,开销越大。它适合低频、一次性的调用(比如初始化时把 SDK 初始化一次),绝不适合放在update里。 - 线程切换:如果原生侧在别的线程算完再回调 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 上要么没有、要么实现不同。它们是「为什么值得为原生单独写代码」的实际理由:
| 能力 | API | H5 上 |
|---|---|---|
| 强制脚本 GC | sys.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 接入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。