从 Bridge 到 JSI:决定何时迁移到 React Native 的新架构
JSI、Fabric与TurboModules如何取代旧的React Native桥接层,所宣称的性能提升在实践中意味着什么,以及如何安全地审计和迁移现有应用。
来源: React Native’s New Architecture Finally Stopped Being a Compromise
凡是曾使用旧架构发布过较为复杂的 React Native 应用的人,都会遇到这样的情况:当 JavaScript 线程忙于处理其他任务,比如解析网络响应或在庞大的组件树中传播状态变化时,页面滚动就会立刻出现卡顿。这种卡顿并非偶然,而是旧架构工作方式的直接后果。自 React Native 0.76 版本起,取代旧架构的新架构便已成为默认选项,但很长一段时间里许多团队都打算以后再采用它。本文将解释新架构的三个核心变化是什么,如何理解与之相关的性能宣称,以及如何决定是否以及如何迁移现有应用。
旧架构为何会导致卡顿
在旧架构中,JavaScript与原生代码之间的每一次交互都要通过异步桥接机制来完成。调用会被序列化为JSON格式,放入桥接机制的队列中,在原生端反序列化后执行,结果再以相同方式传回。这种设计带来了两个问题:首先,每次数据传输都会产生序列化开销;其次,由于桥接机制是异步且有序的,每当JavaScript线程被占用时,所有要传递给原生代码的操作都必须等待。因此,繁忙的JavaScript线程会导致依赖它的滚动、布局及手势操作停滞。
这种替代方案早已以某种形式存在多年,从0.76版本起便正式成为默认选项。不过,成为默认选项与让一个谨慎的团队将其用于生产环境是两码事。今年上半月发布的React Native 0.85以及Expo SDK 56被广泛视为填补这一差距的节点;具体细节将在后文阐述。
新架构的三个组成部分
暂且不谈营销层面,新架构由三个相互协作的组件构成,每个组件都替代了“桥梁”中的特定部分。
JSI:直接调用而非消息传递
JavaScript接口(JSI)是基础。它是一种轻量级的C++接口,通过该接口,JavaScript可以持有对本地对象的引用,并直接调用本地函数,必要时还可同步调用。对于不需要异步处理的调用,无需进行JSON序列化、桥接队列处理或异步往返操作。原本需要经过序列化、排队、反序列化和调用的流程,最终简化为本质上的一次C++函数调用。
Fabric:共享的C++阴影树
Fabric是基于JSI构建的渲染系统。它将渲染过程分为三层:JavaScript中的React纤维树、阴影树以及原生宿主视图的树结构。关键的区别在于,阴影树现在存在于共享的C++代码中,而非通过桥接机制由JavaScript驱动。使用Yoga进行的布局计算就在该共享层中完成,因此布局信息不再需要通过桥接机制传递。
Fabric也是让React的并发功能,如过渡效果、Suspense以及useTransition,能够在React Native上运行的基础。它的提交阶段可以同步或并发执行,而基于桥接的渲染器无法妥善支持这两种模式。如果您的团队希望使用与网页上相同的并发渲染模型,那么Fabric是必不可少的条件,而非仅仅是优化手段。
TurboModules:延迟加载的类型化原生模块
TurboModules取代了用于调用原生功能的旧版NativeModules系统。这段代码展示了两种方式的差异:旧的导入方式从NativeModules中获取模块,这些模块在启动时就会立即被初始化,且每次调用都会产生序列化开销;而新的导入方式则加载由类型化规范生成的模块,这些模块由JSI提供支持,仅在首次使用时才被初始化,并且在适当的情况下可以同步调用。
// Old NativeModules: loaded eagerly at startup, whether used or not,
// with every call paying the bridge's serialization cost
import { NativeModules } from 'react-native';
const { CameraModule } = NativeModules;
// TurboModules: JSI-backed, lazily loaded only when actually called,
// with type-safe interfaces generated by Codegen from your spec
import CameraModule from './specs/NativeCameraModule';
CameraModule.capturePhoto(); // direct, synchronous-capable call via JSI
懒加载的重要性比最初看上去的要高。在旧系统中,拥有众多原生模块的应用程序在启动时必须为每个模块支付初始化成本,即便某个会话从未使用过其中的大多数模块。而TurboModules则只在实际使用模块时才产生费用。类型化规范则是另一项优势:Codegen会将其转换为原生接口,相关细节可在我们的文章《为何Codegen要求TurboModules必须先有规范》中找到。
仔细阅读性能数据
关于新架构的数值因数据来源和工作负载的不同而有所差异,因此应将其视为大致趋势,而非可向利益相关方承诺的精确数值。报告的结果包括:
- 对于普通应用,UI线程性能提升约10%至30%;而对于严重依赖原生模块的应用,性能提升幅度可达3倍。
- JavaScript调用原生代码的速度提高20%至50%。
- 启动时间缩短20%至40%,具体幅度取决于应用所使用的原生模块数量以及测量的操作类型。
相比任何单一百分比,更有用的指标是哪些类别的应用从“可通过变通方法解决”变成了“可直接实现”。那些依赖相机功能的应用、实时数据可视化工具、复杂的动画界面,以及任何需要真正同步进行布局测量的应用,要么需要在架构上采用复杂的解决方案,要么根本不可行。而在新架构下,这些都属于常规的工程任务。如果你的应用属于这类,那么无论平均数值如何,迁移的必要性都很强;如果是以表单和列表为主的应用,虽然收益确实存在,但幅度会相对较小。决定迁移与否的唯一依据,应该是你在应用迁移前后自行测量的数据。
最近一次发布周期解决了什么问题
今年被视为转折点的原因并非新功能的加入,而是一系列针对那些让谨慎的团队迟迟不愿采用的问题的修复。具体细节请参阅官方发布说明,因为以下总结仅反映撰写时的相关报道。
- 4月发布的React Native 0.85重新构建了共享动画系统,消除了长期存在的帧丢失问题。据报道,在大多数支持的设备上,那些依赖动画的过渡效果仍能保持60帧/秒的流畅度,而不会在负载加重时出现性能下降。
实际迁移流程:先进行依赖审计
这并不意味着所有现有应用都应立即切换。在决定升级之前,首先要确认的是库的兼容性。截至本文撰写时,约有 85% 的热门 React Native npm 包被报告支持新架构,剩下约 15% 的包仅提供桥接功能或仅有部分兼容性。那些进展滞后的迁移项目几乎总是卡在剩余的这些包上:问题不在于框架本身,而在于应用所依赖的少数第三方原生模块。
那些成功完成迁移的团队往往遵循以下模式:
- 在开始之前进行审计。列出所有的原生依赖项,并根据其当前版本确认其对新架构的支持情况,而非依据一两年前的记忆。对于每个不受支持的包,需决定是升级、替换、自行 fork 还是为其编写封装。
- 利用 Codegen 工具。由于 Codegen 能根据 TypeScript 或 JavaScript 规范生成类型安全的原生接口,因此JavaScript 与原生代码之间的各种不匹配问题都可以在构建阶段被发现,而不会在运行时导致崩溃。如果您自行编写模块,我们关于使用 Codegen 实现应用内 Turbo Module 的端到端集成的指南展示了完整流程。
根据具体情况制定方案
启动新项目
几乎没有理由不采用这一方案。新架构已是默认选择,相关的工具链,尤其是Expo SDK 56,都假定用户正在使用它,而且现在的兼容性差距已经很小,除非应用存在特殊的原生依赖,否则不太可能出现问题。
在旧架构上维护应用
在着手其他任何工作之前,先使用真实的依赖列表进行兼容性审计。目前兼容的软件包比例正在稳步上升,因此一年前的评估结果很可能已经过时。应规划分步迁移,并将Codegen发现的错误视为过渡期间的真正保障,而非走形式。
早些时候评估后决定暂缓使用的团队
考虑到当时存在的诸多问题,将实施时间推迟到2023年或2024年是合理的决定。0.85版本中对动画重建功能及内存泄漏问题的修复,正是针对那些促使人们选择暂缓使用的生产稳定性问题,因此现在正是重新考虑这一问题的合适时机。
主要结论
- 旧版框架会将每次调用序列化并排队等待繁忙的 JavaScript 线程处理;正是这一点,而非运气不佳,导致了诸多历史上的性能问题。
- JSI 支持直接调用,Fabric 将阴影树与布局移至共享的 C++ 中并实现 React 的并发处理,而 TurboModules 则能根据类型化规范按需加载。
- 实际提升的速度差异很大;应依据应用程序的类型及自身的测试数据来做出决策。
- 近期变化主要体现在稳定性与工具方面,而非新功能:包括动画的可靠性、长时间运行时的内存表现以及构建速度。
- 迁移风险主要来自第三方原生模块,因此应先审查依赖项,利用 Codegen 工具,一次只迁移一个界面或模块。