React Native 新架构迁移实录:实测启动快了 0.46s,包体积涨了 3.6MB,还有 6 个坑

🔑 关键词:React Native,新架构,Fabric,TurboModules,FlatList性能优化

📖 摘要:一份基于三个真实项目迁移的 React Native 新架构复盘:冷启动与包体积的实测数据、codegen 配置步骤、6 个高频报错对照表,以及为什么我说调 FlatList 参数比换架构更管用。

先说结论:新架构解决的不是你以为的那个问题

图片

我从 2023 年底开始跟着 RN 新架构(Fabric + TurboModules)折腾,那会儿它还叫 "New Architecture"、要手动开 flag,一堆第三方库跑不起来,社区的 issue 区基本是哀嚎一片。到 0.76 官方把它设成默认,再到 0.79,我在三个项目上做完了迁移。踩完之后最大的感受是:很多人对"那座桥"的怨恨,用错了地方。

老架构的 Bridge 本质上是一条异步的、要经过 JSON 序列化的消息队列。JS 侧调一个 NativeModule 方法,参数先 JSON.stringify,塞进队列,等 native 侧被唤醒后批量读取,再 JSON.parse。一次调用的往返,我在 iPhone 13 上测到的量级大概是 0.2ms ~ 1.5ms,取决于 payload 大小。单看不多,但你一帧只有 16.67ms,如果是在 onScroll 或者手势回调里高频调 Native,几十次就能把一帧吃干净。

新架构换上 JSI 之后是另一回事:JS 侧直接拿到一个 C++ HostObject 的引用,方法调用是同步的,没有序列化这一步。这是实打实的改变,但它改变的是"每次调用的固定开销",不是"你的业务代码本身慢"。我见过太多把 App 卡顿全赖在桥上的人,profiler 一开,大头是滚动时反复 decode 的 <Image>、没给 getItemLayout 的 FlatList、以及在 useEffect 里做同步重计算的组件。桥换成 JSI,这些一个都不会变好。这半年我最想说的一句话就是这个。

实测:我怎么量的,量到了什么

测试环境先说清楚,免得被当成通用结论:iOS 是 iPhone 13 / iOS 18.2 / Release 构建,Android 是 Pixel 6a / Android 14,另外还借了一台骁龙 665 的老机器跑低端场景。冷启动的测量方式是 JS 侧 performance.now() 打点,再和 iOS 原生的 RCTPerformanceLogger 交叉对一下,每个版本 10 次取中位数,去掉第一次(系统缓存没热)。

图片

结果:老架构 2.42s,新架构 1.96s,省了大概 0.46s。这个提升我判断主要来自 TurboModule 的懒加载——老架构在启动时会把注册过的 NativeModule 全部 init 一遍,不管你这个页面用不用得到;新架构是按需的,首次调用才初始化。对一个塞了 IM、地图、推送、统计四个大 SDK 的项目来说,这笔账不小。

但有两个反例必须说。第一,包体积涨了:同一个项目的 iOS .ipa(未压缩)从 41.2MB 涨到 44.8MB。Codegen 生成的 C++ 胶水层,加上为兼容老库保留的 interop 代码,双份都在包里。小项目感觉不到,依赖树越深涨得越明显。第二,那台骁龙 665 的老机器上,首屏反而慢了大约 200ms。查了两天才定位到,是一个只有老 ViewManager 的库,每次 mount 都要走一遍 Legacy ViewManager Interop 的转换。所以"新架构一定更快"这句话是不成立的,得看你的依赖树里有几个库还在 interop 上跑。

迁移步骤,以及我踩到的 6 个坑

第一步,先摸清依赖树的底。npx react-native doctor 只能看个大概,更靠谱的办法是去 reactnative.directory 上逐个查库的 New Architecture 支持标记,或者直接翻库的 package.json 里有没有 codegenConfig 字段——有的基本就是原生支持,没有的就要打问号。我自己定的线是:核心依赖里如果有超过 2 个库只靠 interop 撑着,这次就先不升。

图片

第二步,开开关。Android 改 android/gradle.properties

newArchEnabled=true

iOS 在 ios/Podfile 顶部加:

ENV['RCT_NEW_ARCH_ENABLED'] = '1'

然后 cd ios && pod install。注意 RN 0.76 之后这两项本来就是默认开的,升级项目的时候反而要确认一下有没有被别的脚本改回去。

图片

第三步,自己写 TurboModule 要配 codegen,在 package.json 里加:

"codegenConfig": {
  "name": "AppSpecs",
  "type": "modules",
  "jsSrcsDir": "src/specs",
  "android": {
    "javaPackageName": "com.yourapp.specs"
  }
}

坑一:name 不能和任何依赖重名。我第一次顺手写了 AppSpec,跟一个三方库撞了,Android 编译直接报 duplicate symbol,看了半天才发现是自己起的名字太大众。坑二:spec 文件必须是 NativeXxx.ts 这种命名,导出 TurboModuleRegistry.getEnforcing<Spec>('Xxx')。继续用老的 NativeModules.Xxx 虽然还能跑,但那是走 interop,等于白折腾。坑三:jsSrcsDir 指错目录的话,构建不会报错,只会在运行时抛找不到模块,特别难查;Android 上可以单独跑 ./gradlew generateCodegenArtifactsFromSchema 看有没有产物落下来确认。

坑四到坑六是高频报错:Unimplemented component: <RCTViewManager>,说明某个原生组件没写 Fabric 版本,interop 也没兜住,去查这个库的 ViewManager 有没有实现 RCTViewComponentViewTurboModuleRegistry.getEnforcing(...): 'Xxx' could not be found,八成是 codegen 没跑或者命名对不上;iOS 白屏且无报错,通常是 RCTHost 初始化时某个模块崩了被吞掉,得开原生日志慢慢磨。这三个我平均每个花掉半天。

图片

顺手说说 FlatList,这比换架构有用得多

写了这么多新架构,但还是要说一句:大多数人手上 App 的卡顿,调一下 FlatList 配置就能解决,根本不需要动架构。下面这组参数是我在自己项目里调出来的,长列表、每项固定高度 72:

<FlatList
  data={items}
  keyExtractor={(i) => i.id}
  getItemLayout={(_, index) => ({ length: 72, offset: 72 * index, index })}
  initialNumToRender={8}
  maxToRenderPerBatch={8}
  updateCellsBatchingPeriod={40}
  windowSize={7}
  removeClippedSubviews={Platform.OS === 'android'}
  renderItem={renderItem}
/>

几个参数为什么这么给:getItemLayout 给了之后 FlatList 不用逐项测量高度,滚动条不跳,scrollToIndex 才准;windowSize 的默认值是 21,意思是视口上下各留 10 屏内容,对内存很不友好,我改成 7 之后 1000 条带图列表的常驻内存大约省了 30MB;initialNumToRender 默认 10,首屏实际只显示 6 个的话就改成 6 到 8,首帧能快几十毫秒;removeClippedSubviews 在 Android 上默认就是 true,iOS 默认 false,别手贱去 iOS 上开,我踩过内容整块闪没的坑。

如果列表里塞的全是图,@shopify/flash-list 的回收机制确实比 FlatList 强。我拿 2000 条图片列表跑过,FlashList 的掉帧采样数大概只有 FlatList 的三分之一。但它有自己的脾气,estimatedItemSize 给错的话滚动会抖得很难看,而且它默认的 drawDistance 偏保守,图多的时候得手动往上调。

图片

最后:什么情况下我会建议你别升

标题是标题党,我自己其实没关回去。但有三类项目我会建议先按兵不动。第一,依赖树里有超过 3 个只支持 interop 的重量级原生库(地图、播放器、IM SDK 这类),升上去大概率性能不升反降,我那台骁龙 665 上慢的 200ms 就是活生生的例子。第二,团队里没人看得懂 C++ 或者原生构建日志——新架构的报错栈比老架构深得多,经常甩给你一屏 C++ 模板报错,会看到怀疑人生。第三,项目还停在 RN 0.72 以下且不急的,没必要为了一个自己都不知道收益在哪的升级折腾两周。

新架构真正的价值,我现在的判断是:不是"更快",而是 React 18 的 concurrent 能力终于有了施展空间。startTransitionuseDeferredValue 这些在 Fabric 上才有意义,因为渲染终于可以被中断和重排了;老架构那套同步的 UIManager 操作,你根本没法中断它。对搜索联想、多 tab 切换这类复杂交互来说,这个能力的价值远大于启动快 0.4 秒。

不过这个说法我目前只有手感和零散的日志,还没做出能摆出来的 benchmark。等我哪天把测量方案理顺了,再单独写一篇。

🏷️ 标签: