上个月把手头这个跑了三年的电商 App 从 RN 0.72.6 升到了 0.76.5,顺手把新架构(Fabric + TurboModules)打开了。升级之前我信心挺足——官方那篇博客把 Fabric 讲得挺明白,同步渲染、原生模块懒加载、启动阶段省掉一堆 UIManager 的跨端调用。结果上线第一天客服群里就开始传截图:商品列表往下滑,图片位置一片白,一两秒之后才 pop 出来。
更邪门的是数据自相矛盾。P90 冷启动从 2.8s 掉到 1.6s,这个是实打实的好看。但同一套埋点里,列表滚动的掉帧率(单帧超过 16.7ms 的占比)从 6% 涨到了 18%。用户最容易感知的地方,反而退化了。
后来我在 GitHub issues 里翻到好几条同款,搜「Fabric FlatList blank」、「new architecture removeClippedSubviews」都能对上。所以这不是我们代码独一份的玄学,是有共性的东西。
排查:从原生侧量到 JS 侧
工具就那三个,Xcode Instruments 的 Time Profiler、Perfetto(老 Systrace)、还有 RN 自带的 Perf Monitor,够用。
第一步看原生侧。Time Profiler 挂上去,UI 线程的时间大量堆在 RCTViewComponentView 的 mount 上。这就解释了两件事:老架构下视图是走 UIManager 一批一批创建的,新架构下每个原生视图是单独 mount 的。好处是首屏同步渲染变快,坏处是滚动时每个 cell 的视图创建是分散的,直接挤占滑动的帧预算。
第二步看 JS 线程。发现我们有个 ProductCard 组件,React.memo 包了,但 props 里塞了个 style={{...}} 字面量,每次 render 都是新对象,memo 等于白包。老架构下 JS 线程算力有富余,多渲染几次没人管,因为结果是被批量异步丢给 UI 线程的。新架构同步了之后,这些徒劳的 re-render 直接反映在帧时间上。
第三步量具体数字。我给 cell 加了个简单计数器,快速下滑一次,单帧最多触发 47 次 onLayout。原因是价格标签做了自适应宽度,用 onLayout 回调再 setState 调整——这个写法在 Paper 渲染器下被合批了,Fabric 下没合。
改法:按影响从大到小排
改完一版测一版,最后就四件事。
第一,干掉 onLayout + setState。 自适应标签宽度换成 maxWidth 加 numberOfLines,让 flex 自己撑,这一条直接把单帧 onLayout 从 47 次降到 0。
// 之前:onLayout 回调里 setState
<View onLayout={(e) => setTagWidth(e.nativeEvent.layout.width)}>
<Text style={{ width: tagWidth }}>{tag}</Text>
</View>
// 之后:交给 flex
<Text numberOfLines={1} style={{ maxWidth: 120, flexShrink: 1 }}>
{tag}
</Text>
第二,FlatList 参数重配。 默认那套在长列表里基本不及格:windowSize 默认 21(视口上下各留 10 屏)、maxToRenderPerBatch 默认 10、initialNumToRender 默认 10。我们 cell 固定高 108,最后配成这样:
<FlatList
data={products}
keyExtractor={(item) => item.id}
renderItem={renderItem}
getItemLayout={(_, index) => ({ length: 108, offset: 108 * index, index })}
initialNumToRender={6}
maxToRenderPerBatch={4}
updateCellsBatchingPeriod={40}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
这里有个反直觉的点:removeClippedSubviews 在 Android 上默认是 true,iOS 上默认 false。我一开始为了「优化」在 iOS 上也开了,白块反而更严重,折腾两天才定位到这个参数。iOS 上最后保持关闭。getItemLayout 一定要给,固定行高不给这个,新版 FlatList 连滚动条位置都算不准,会跳。
第三,图片换 expo-image。 cachePolicy 用 memory-disk,fadeDuration 设 0。滚动过程中出现空白占位的比例(出现白块的 cell 数除以总 cell 数,指标很土但好用)从 22% 降到 1.3%。
第四,清理 memo 依赖。 所有 style 字面量提到组件外,回调用 useCallback 包上并给对依赖。
全部改完再测:掉帧率 18% → 3.2%,冷启动 1.6s 保持住没退,内存峰值涨了约 40MB,主要是图片缓存,我认了。
我现在对这个事的看法
新架构不是「性能优化开关」,它是一次资源重新分配。它拿掉了老架构里 JS 线程和 UI 线程之间那层异步批处理的缓冲。缓冲没有了,你代码里原来被缓冲吃掉的脏东西,就直接砸在屏幕上。
所以「要不要升新架构」这个问题,正确的问法是「我的代码在同步渲染面前扛不扛得住」。分几种情况:
- 列表参数全默认、memo 随手包、有 onLayout 接 setState 的写法,别急着升,先清债,不然升级就是给自己找麻烦。
- 瓶颈在冷启动和原生模块调用频次(地图、蓝牙、相机这类),新架构的收益是实打实的,冲。
- 页面简单、列表短、用户根本感知不到差别的项目,别为了版本号好看去升。
升级前我会跑的检查清单
五条,够用:
- grep 一遍 onLayout,凡是后面跟着 setState 的,列出来一个个改。
- 检查所有 React.memo 的依赖,有没有对象或函数字面量。
- FlatList / SectionList / FlashList 参数有没有显式配过,没配的先按上面那套来。
- 图片组件有没有缓存策略,没有的换 expo-image 或者 react-native-fast-image。
- 第三方库的新架构支持情况——去 reactnative.directory 勾上 New Architecture 筛一遍,或者更土的办法,翻 node_modules 里那个包有没有 codegenConfig 字段。
另外 Hermes 一直开着就行,0.76 里字节码是默认行为,别关。JSC 这几年官方在逐步收口,新版已经不是默认选项了,如果还有库硬依赖 legacy JSC,早点找替代方案。
最后说句实在的,我到现在也不觉得新架构是个「升级即变快」的东西。它更像把你原来藏在异步缝隙里的问题,一件一件摆到台面上。摆出来是好事,就是过程有点疼。