原生模块与平台代码
这一章解决三个问题:什么时候你真的需要写原生代码、在新架构(TurboModule / Fabric / JSI)下怎么写、以及大多数"跨平台差异"其实只靠 JS 层的平台代码就能抹平。基线是 RN 0.88 + 新架构,Expo 项目会单独讲 Expo Modules API 这条更省事的路。
注意 本手册全篇假设你运行在新架构上(RN 0.82 已移除旧架构)。网上 2023 年以前的原生模块教程(
RCTBridgeModule手写注册、桥接消息队列那一套)现在只用于读懂存量库,不要照着写新代码。
先说清楚:多数时候你不该写原生代码
写原生代码的真实成本不是"实现那 50 行 Kotlin",而是之后每一件事:RN 每次大版本升级你要重验、iOS/Android 两套代码两份心智、崩溃不再被 JS 错误边界兜住、发版要过原生编译矩阵。所以先过一遍止损清单:
- 自动链接 + 成熟社区库覆盖了约 80% 的需求。相机、定位、推送、蓝牙、支付、健康数据、传感器、文件系统……社区都有多年维护的标准方案,先搜再写。
- Expo 生态自带一大块。Expo SDK 把常见系统能力做成了带自动配置的模块(见
00-安装与快速开始.md),很多"要写原生"的需求在 Expo 里只是加一个包。
真正需要动原生代码的场景,通常只有四类:
- 私有 SDK:公司内部或甲方指定的 SDK(银联、车载、IoT 厂商),社区不可能有。
- 硬件与系统底层:定制外设、BLE 特殊 profile、系统级服务,社区库还没出现或不合用。
- 极限性能:图像处理、音频帧处理这类每毫秒都要抠的路径,JS 往返都是浪费,需要在原生侧一次做完再回传结果。
- 平台新 API 抢首发:iOS/Android 刚发布的新能力(当年 Dynamic Island、实时通知那类),没有库,产品就是要上。
四类之外的"我想自己封装一下",先问一遍自己:封装的库真的存在吗?答案多半是存在。
代际与时间线:从桥到新架构
读懂演进才能读懂存量库的 README 和 changelog。一条线走下来:
RCTBridgeModule 时代(桥):所有 JS 与原生通信都要把参数序列化成 JSON 消息,经消息队列过桥,原生侧再反序列化。异步、有延迟、类型全靠约定,getName/RCT_EXPORT_METHOD 这套手写注册是那个时代的标志。
JSI(JavaScript Interface):C++ 层接口,JS 引擎里的值可以被原生代码直接引用,不再需要序列化中间人。Hermes 引擎配合 JSI 是后来一切的基础——这一点在 01-核心概念与架构.md 里讲过。
TurboModule / Fabric / Bridgeless:新架构的三根支柱。TurboModule 用一份 TypeScript spec 加 Codegen 自动生成三端胶水代码,取代手写注册,还支持按需加载模块;Fabric 给原生视图组件走同一条 codegen 之路;Bridgeless 模式彻底拿掉了 RCTBridge 常驻消息循环。
已核实的时间线锚点:
- 0.76:新架构成为默认。
- 0.82:旧架构(Legacy Architecture)被移除。
- 0.88:桥兼容层仍在,但存在
RCT_REMOVE_LEGACY_MODULE_INTEROP这类编译开关(当前默认关),官方信号是未来默认移除。
对读者的行动结论只有一句话:存量老库尽快升级或替换,新写的直接按新架构(TurboModule spec 风格)写。没有"先按旧架构写、以后再迁"这个中间地带了。
顺带一提 iOS 工程:RN 核心自 0.84 起预编译(省每次全量编译时间),0.87 起有实验性的 Swift Package Manager 支持,但 0.88 模板的 iOS 路径仍是 CocoaPods——新项目别把宝押在 SPM 上。
平台代码:不碰原生也能跨平台
真正的原生差异之前,先把 JS 层的跨平台三板斧用透。
Platform.select 与 Platform.OS
运行期分叉,适合差异小、不值得拆文件的情况:
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
header: {
paddingTop: Platform.select({ ios: 44, android: 24, default: 0 }),
...Platform.select({
ios: { shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 6 },
android: { elevation: 4 },
default: {},
}),
},
});Metro 的文件扩展名解析
Metro 解析 import ... from './Button' 时按平台优先级找文件:Button.ios.tsx(或 Button.android.tsx)优先,其次 Button.native.tsx(RN 平台通用,Web 不会命中),最后才是不带平台后缀的 Button.tsx。差异大时直接把实现拆成两个文件,类型签名保持一份:
components/
Haptics.ios.ts — iOS 实现
Haptics.android.ts — 安卓实现
Haptics.ts — 兜底/类型定义(Web 或漏平台时报错更友好)Platform.constants:按事实分叉,别猜机型
Platform.OS 之外,还有一组机器可读的系统事实,适合做"版本门槛"类分叉:
import { Platform } from 'react-native';
// 例:Android 12(API 31)起才有某行为差异
if (Platform.OS === 'android' && Platform.constants.Version >= 31) {
// 走新分支
}
// isTesting 可用于在仪器化测试里关掉高成本逻辑,避免性能采样被干扰
判断系统版本用 Platform.constants.Version(安卓对应 API level 数字),而不是解析机型字符串——后者在灰度和换机上必翻车。
实战:震动/触觉反馈的跨平台 shim
一个很典型的小例子:RN 内置的 Vibration 模块在 iOS 上不生效(iOS 没有开放任意振动 API,只有系统触觉反馈,这是公开稳定的行为差异)。用平台文件把它封成统一接口:
// haptics/Haptics.android.ts
import { Vibration } from 'react-native';
export function tapFeedback(): void {
// 安卓用短震动近似一次触觉反馈;具体时长依设备马达而定
Vibration.vibrate(8);
}
export function successFeedback(): void {
Vibration.vibrate([0, 20, 40, 20]); // pattern: 静-振-静-振
}// haptics/Haptics.ios.ts
import * as Haptics from 'expo-haptics';
export function tapFeedback(): void {
// iOS 走系统触觉引擎,观感优于震动;非 Expo 项目可自行封装 UIImpactFeedbackGenerator
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
export function successFeedback(): void {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}// haptics/index.ts —— 调用方只 import 这里,Metro 自动挑平台实现
export { tapFeedback, successFeedback } from './Haptics';调用点完全平台无关:
import { tapFeedback } from './haptics';
<Pressable onPress={tapFeedback}>...</Pressable>注意
expo-haptics与Vibration在触感强度、静音开关、系统设置联动上的具体行为各有差异,以各自文档与真机体验为准;这里要示范的是"接口一份、实现两份"的组织方式。
TurboModule 全链路:写一个电池信息模块
这是本章核心。选"读电量/充电状态"是因为它够小、两端都有公开 API、又完整覆盖 spec 到注册的全流程。全链路五步:JS spec 定义接口,Codegen 生成胶水,两端实现逻辑,注册进应用,JS 调用。
Warning 下面各端实现是结构骨架。codegen 生成物的类名、模板里注册的确切挂法会随 RN 小版本微调,动手时以你项目
npx react-native init模板与官方新架构文档为准;但"spec 驱动生成 + 两端按生成物实现"的结构是稳定的。
第一步:JS spec
spec 文件放在项目里约定目录(下文 src/native/),命名必须是 Native<名字>.ts,codegen 按这个模式扫描:
// src/native/NativeBattery.ts
import { TurboModuleRegistry, type TurboModule } from 'react-native';
export interface BatteryInfo {
level: number; // 0.0 到 1.0;-1 表示不可读
plugged: boolean; // 是否在充电/插电
health: string; // 'good' | 'unknown' 等
}
export interface Spec extends TurboModule {
getBatteryInfo(): Promise<BatteryInfo>;
}
// 'NativeBattery' 这个模块名字符串必须与原生侧注册名完全一致
export default TurboModuleRegistry.getEnforcing<Spec>('NativeBattery');getEnforcing 找不到模块直接抛异常;对"可能没装原生实现"的场景(比如 Web 调试),可用 TurboModuleRegistry.get<Spec>(...) 返回 null 再兜底。
第二步:codegenConfig
在库(或应用)的 package.json 里声明,告诉 codegen 去哪扫 spec、生成物前缀叫什么:
{
"codegenConfig": {
"name": "RNBatterySpec",
"type": "modules",
"jsSrcsDir": "src/native"
}
}type 取 modules(TurboModule)或 components(Fabric 组件)。之后每次构建,codegen 会从 TS spec 生成 C++ 与 Kotlin/Swift 的抽象基类/协议——这是"类型即接口契约"在 RN 里的落地:spec 写错类型,原生侧编译直接报错,而不是运行时对不上。
主要类型在 codegen 两端的映射(常用面,确切命名以构建产物为准):
| TS spec 写法 | Android 生成(Kotlin) | iOS 生成(ObjC 桥接) |
|---|---|---|
number |
Double |
double |
boolean |
Boolean |
BOOL |
string |
String |
NSString * |
| 对象字面量类型 | ReadableMap |
NSDictionary * |
数组 T[] |
ReadableArray |
NSArray * |
Promise<T> |
尾参 Promise |
尾参 RCTPromiseResolveBlock / RCTPromiseRejectBlock |
void(同步无返回) |
Unit |
void |
字符串字面量联合(枚举形态)还有 string/int 两种 codegen 变体可选,用到时查当期文档,不要凭旧记忆写。
第三步:Android 实现(Kotlin)
codegen 会生成一个 Kotlin 抽象类(通常以 spec 文件名加 Spec 后缀,本项目语境下叫 NativeBatterySpec,确切生成名以构建产物为准),实现它:
// android/src/main/java/com/example/battery/BatteryModule.kt
package com.example.battery
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
@ReactModule(name = BatteryModule.NAME)
class BatteryModule(reactContext: ReactApplicationContext) :
NativeBatterySpec(reactContext) {
companion object {
const val NAME = "NativeBattery" // 与 spec 的模块名一致
}
override fun getName(): String = NAME
// codegen 把 TS 的 Promise<BatteryInfo> 生成为尾参 Promise 的方法
override fun getBatteryInfo(promise: Promise) {
// ACTION_BATTERY_CHANGED 是 sticky 广播:传 null receiver 只取当前值,无需注册
val info = reactApplicationContext.registerReceiver(
null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)
) ?: return promise.reject("UNAVAILABLE", "battery info unavailable")
val level = info.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = info.getIntExtra(BatteryManager.EXTRA_SCALE, 100)
val status = info.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
val health = info.getIntExtra(
BatteryManager.EXTRA_HEALTH, BatteryManager.BATTERY_HEALTH_UNKNOWN
)
val map = Arguments.createMap().apply {
putDouble("level", if (level >= 0 && scale > 0) level.toDouble() / scale else -1.0)
putBoolean("plugged", status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL)
putString("health", if (health == BatteryManager.BATTERY_HEALTH_GOOD) "good" else "unknown")
}
promise.resolve(map)
}
}注册进应用——本地模块要手写进 MainApplication,三方库由自动链接处理:
// MainApplication.kt 中(以官方模板为准)
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(BatteryPackage()) // PackageList 是自动链接生成的清单
}// BatteryPackage.kt —— 新架构用 TurboReactPackage 按需创建模块
package com.example.battery
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class BatteryPackage : TurboReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
if (name == BatteryModule.NAME) BatteryModule(reactContext) else null
override fun getReactPartyType(): com.facebook.react.bridge.ReactNative =
com.facebook.react.bridge.ReactNative.NATIVE
}TurboReactPackage 按模块名惰性创建,这正是 TurboModule “用到才加载"的落点。
第四步:iOS 实现(Swift)
Swift 类实现 codegen 生成的协议(同样以 NativeBatterySpec 称呼生成物,确切名称以构建产物为准),再用一个 Objective-C 桥接文件把 OC 可见的模块名和方法签名导出去——RCT_EXTERN_MODULE / RCT_EXTERN_METHOD 这套桥接模式是当前 iOS 原生模块公开稳定的写法:
// ios/Battery/NativeBattery.swift
import Foundation
import React
import UIKit
@objc(NativeBattery)
class NativeBattery: NSObject, NativeBatterySpec {
@objc
func getBatteryInfo(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
DispatchQueue.main.async {
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel // 0.0–1.0;模拟器返回 -1
resolve([
"level": level,
"plugged": UIDevice.current.batteryState != .unplugged,
"health": "unknown", // iOS 不暴露健康度,固定兜底
])
}
}
}// ios/Battery/NativeBattery.m —— 桥接文件,导出模块名与方法签名
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(NativeBattery, NSObject)
RCT_EXTERN_METHOD(getBatteryInfo:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end注意 方法签名要和 spec 逐参数对齐:TS 里返回 Promise 的方法,原生侧就是"尾参收 resolve/reject"的形态;参数类型按 codegen 的类型表映射(
number在 OC 桥接里对应double/NSNumber,对象用NSDictionary)。新架构下应用级本地模块的注册由构建时的 codegen 产物(如RCTAppDependencyProvider)自动接线,具体挂法以官方模板为准,不要手写桥接时代的注册代码。
持续型数据(比如电量变化订阅)走事件通道,还是同一套 spec 驱动的路子。spec 里把事件契约声明出来:
export interface Spec extends TurboModule {
getBatteryInfo(): Promise<BatteryInfo>;
addListener(eventType: string): void; // 三个方法名是事件模块的约定契约
removeListeners(count: number): void;
}JS 侧用 NativeEventEmitter 接:
import { NativeEventEmitter, TurboModuleRegistry } from 'react-native';
const emitter = new NativeEventEmitter(
TurboModuleRegistry.getEnforcing<Spec>('NativeBattery'),
);
const subscription = emitter.addListener('batteryChanged', (info) => setInfo(info));
// 组件卸载时务必 subscription.remove(),否则监听器泄漏
原生侧经 codegen 生成的 emitter 基建发送事件(Android 走生成的 DeviceEventEmitter 访问器、iOS 走 sendEventNamed 形态,确切方法名以官方模板为准)。写之前把官方文档的事件一节读完整——事件名拼错没有任何编译期保护,只会静默失灵。
第五步:调用点
import { useEffect, useState } from 'react';
import { Text } from 'react-native';
import NativeBattery, { type BatteryInfo } from './src/native/NativeBattery';
export function BatteryText() {
const [info, setInfo] = useState<BatteryInfo | null>(null);
useEffect(() => {
NativeBattery.getBatteryInfo().then(setInfo).catch(() => setInfo(null));
}, []);
if (!info) return <Text>电量不可用</Text>;
return <Text>{Math.round(info.level * 100)}%,{info.plugged ? '充电中' : '电池供电'}</Text>;
}注意调用方拿到的类型完全来自 spec——把 spec 接口维护好,文档和类型提示就白送。
getEnforcing 之外,再包一层业务可容错的调用层是好实践:原生缺席(Expo Web、单测环境)或模块报错时降级而不是崩溃:
// battery/index.ts —— 调用层的兜底封装
import { TurboModuleRegistry } from 'react-native';
import type { Spec, BatteryInfo } from '../native/NativeBattery';
const Native = TurboModuleRegistry.get<Spec>('NativeBattery'); // get 不抛异常
export async function readBattery(): Promise<BatteryInfo | null> {
if (!Native) return null;
try {
return await Native.getBatteryInfo();
} catch {
return null;
}
}业务组件只依赖 readBattery(),将来这个模块被换实现(比如迁到 Expo Modules),改动被封在这一层。
Fabric 原生组件概览(高级,点到为止)
选路先想清楚:暴露"函数/能力"用 TurboModule;暴露一个"可渲染的视图"才用 Fabric 组件。绝大多数需求是前者。
Fabric 组件同样是 spec 驱动。JS 侧声明 props schema:
// src/specs/NativeCoolView.tsx
import { codegenNativeComponent, type ViewProps } from 'react-native';
interface Props extends ViewProps {
uri: string;
fadeDuration?: number;
}
export default codegenNativeComponent<Props>('CoolView');配合 codegenConfig 的 type: "components",构建时生成两端的 ViewConfig 与抽象基类:Android 侧实现 ViewManager(普通视图继承 SimpleViewManager,复杂场景走 FabricView),iOS 侧继承 RCTViewComponentView;props 经由 C++ 共享层下发,省掉了桥时代的序列化开销,主线程以外也能算布局。
但成本也要认账:原生视图组件涉及生命周期、事件冒泡(bubbling config)、measure 等一整套约定,坑比 TurboModule 多一个数量级。能用 JS 组合 + Reanimated 实现的效果不要上原生视图;真要写,动手前把官方 “Fabric components” 文档读完整。
Expo Modules API:Expo 项目里的更优解
如果你在做 Expo 项目,写原生扩展优先用 Expo Modules API,而不是手搓 TurboModule——它用一个 Swift DSL(安卓侧也有对应 Kotlin DSL)把"模块声明 + 参数类型转换 + promise 包装"全部吃掉,样板代码少得肉眼可见:
// ios/Battery/BatteryModule.swift —— 结构示意,具体宏名与参数包装以 create-expo-module 生成模板为准
Module("Battery") {
Function("ping") { () -> String in
"pong"
}
AsyncFunction("getInfoAsync") { promise in
DispatchQueue.main.async {
UIDevice.current.isBatteryMonitoringEnabled = true
promise.resolve([
"level": Double(UIDevice.current.batteryLevel),
"plugged": UIDevice.current.batteryState != .unplugged,
])
}
}
}DSL 的稳定形状就是这几个构件:Module(模块)、Function / AsyncFunction(同步/异步函数)、Object(原生对象句柄传回 JS)、View(原生视图组件,对标 Fabric)。JS 侧 import 脚手架生成的带类型导出即可。
工程路径上:
npx create-expo-module@latest起新模块,本地模块用--local,脚手架会生成 Swift/Kotlin 两侧骨架和 podspec;具体产物以工具版本为准。- SDK 56 起
expo-modules-core完成 Swift 化重写(官方说法 “Swift all the way down”);SDK 58 的 changelog 里已出现 Expo Modules 2.0 beta——跟进前读对应 release notes。
选择规则很简单:Expo 项目(含 prebuild/managed)写原生扩展,选 Modules API;bare 项目、或库要发布给全 RN 社区用,选 TurboModule spec。跨 Expo 边界发的库也有"用 TurboModule 写、内部可选适配"的路子,别混用两套心智。
自动链接机制:原生库是怎么被"发现"的
装好一个带原生代码的 npm 包后,链接是构建时自动发生的,原理值得知道,因为排查问题全靠它:
- iOS:
ios/Podfile里的use_native_modules!扫描node_modules,凡是带合法.podspec的依赖自动进 Pods。 - Android:RN 的 Gradle 插件在配置阶段扫描依赖的
android/目录与react-native.config.js声明,生成PackageList注册清单。 react-native.config.js:项目/库级的覆盖点。典型用法是关掉某库在某平台的链接(老平台实现损坏时)、声明非标准目录、或指定库的原生工程参数:
// react-native.config.js —— 项目级覆盖示例:禁用某库的 iOS 自动链接
module.exports = {
dependencies: {
'some-broken-lib': {
platforms: { ios: null }, // 该平台改由自己封装或暂时不接入
},
},
};库作者的 podspec 长这样(发布 iOS 原生库必需):
# RNBattery.podspec
Pod::Spec.new do |s|
s.name = 'RNBattery'
s.version = '1.0.0'
s.summary = 'Battery info TurboModule'
s.author = 'You'
s.homepage = 'https://github.com/you/rn-battery'
s.license = 'MIT'
s.source = { git: 'https://github.com/you/rn-battery', tag: s.version.to_s }
s.platforms = { :ios => '15.1' } # 最低 iOS 版本跟紧你支持的 RN 模板要求
s.source_files = 'ios/**/*.{h,m,mm,swift}'
s.dependency 'React-Core'
end仍然要手动链接的遗留场景:monorepo 里依赖路径被提升得让扫描器找不到、库没写 podspec/gradle 配置、特殊 flavor/variant 构建、纯 C++ 库需要手写编译单元。
诊断第一步永远是:
npx react-native config输出的 JSON 就是链接器眼中的依赖清单——库不在里面,问题就出在包结构或 react-native.config.js;在里面但没装上,再去看 Gradle / CocoaPods 日志。
调试原生代码
- iOS:用 Xcode 打开
ios/*.xcworkspace,直接在 Swift/OC 源码打断点;真机 attach 也走 Xcode(设备选好再 Run)。 - Android:Android Studio 打开
android/目录,Run/Debug 选择 app 进程 attach;配合 logcat 看原生日志。 - 改完原生代码必须重新构建原生工程(
npx expo run:ios/run:android,或 bare 的 gradle/xcodebuild);Fast Refresh 只刷 JS,原生改动不会热进去——这是新手"我明明改了代码"的头号原因。 - 先验证"模块到底注册上没有”,再怀疑逻辑。JS 侧一行就能探测:
import { TurboModuleRegistry } from 'react-native';
console.log('NativeBattery 可见性:', !!TurboModuleRegistry.get('NativeBattery'));打出 false 说明链接/注册环节断了(回到上节用 npx react-native config 查),打出 true 才轮到断点进原生。
- JS 断点(Hermes debugger)和原生断点可以同时挂,两边命中互不干扰;混合栈的 trace 怎么读,回指
10-调试测试与性能.md。 - 线上崩溃的符号化:iOS 靠 dSYM 上传映射、安卓靠 mapping 文件还原混淆栈,完整流程在
10-调试测试与性能.md的崩溃分析一节;发版侧配置见11-构建与发布.md。
升级存量项目的现实路径
旧架构已经从 0.82 移除,所以"要不要升新架构"不再是问题,“存量依赖能不能陪你在 0.88 上活下来"才是。按这个顺序体检:
- 拉依赖表:
npx react-native config加package.json里所有带原生代码的包,逐个对照 New Architecture Directory(reactnative.directory) 查兼容状态与最低支持版本。 - 分类处理:已兼容的锁到安全小版本;停止维护的老库,要么找同功能替代品,要么评估自己 fork 改造(桥模块改 TurboModule spec 通常是"包一层"的工作量,但回归测试是整个原生面)。
- 认清兼容开关的定位:0.88 仍保留桥兼容层(
RCT_REMOVE_LEGACY_MODULE_INTEROP默认关闭,官方信号是未来默认移除)。它是过渡期的减震器,不是可以一直依赖的"旧架构续命按钮”——排期要按"没有它"来做。 - 成本评估口径:一个中等项目、3~5 个自研原生模块、十几个三方原生库,迁移加回归通常按周计而不是按天计;预算时把两端真机回归(
12-常见陷阱与最佳实践.md里的发布检查清单走一遍)算进去。 - Expo 项目:SDK 与 RN 版本是锁配的(SDK 58 对应本手册基线),升级时以 Expo changelog 为主线走,见
00-安装与快速开始.md。
实战建议
- 先搜后写:动原生之前花 20 分钟搜社区方案;真要封装,封在"接口层 + 平台实现"里,将来换底层不动业务代码(本章的 Haptics shim 就是这个思路的最小形态)。
- 新写一律新架构:TurboModule spec 驱动、Expo 项目走 Modules API;不要给桥接时代语法写新代码,那是在给未来埋债。
- 原生版本与 JS 版本锁配套:RN 主版本、原生模板、每个原生三方库的版本矩阵要一起定,CI 里固定住;升级时整矩阵一起动,不要"只升 RN 不动库"。
- 发库前跑编译矩阵:至少最新两个 RN 稳定模板,iOS 与 Android 双端各过一遍编译与最小冒烟——原生库的兼容承诺只有编译验证过才算数。
- spec 即文档:把 TurboModule 的 TS spec 注释写好、类型收窄,JS 侧调用方拿到的是编译期契约,这是新架构送给工程效率的礼物。
- 别把性能问题错交给原生:先确认瓶颈在 JS 逻辑还是桥通信(见
10-调试测试与性能.md),多数"想写原生提性能"的案子最后发现是渲染浪费或状态风暴。