RN容器启动优化实践

0 阅读9分钟

RN 容器启动优化:从秒开到毫开的实践

前言

2021 年,58 在 GMTC 上分享了《58RN 页面秒开方案与实践》,系统地介绍了 58 在 RN 性能优化上的一系列探索。那套方案做了三件重要的事:

  1. 资源预加载 + 静默更新:解决动态更新瓶颈。如果是同步更新,用户要等 2s+ 才能看到页面。改成异步后,用户进入时直接使用本地缓存,更新在后台静默完成。
  2. metro 拆包 + 框架预执行:解决框架初始化瓶颈。将 RN 框架代码和业务代码拆成两个包,App 启动时提前执行框架包。
  3. Native 并行请求业务数据:解决业务请求瓶颈。框架初始化和业务数据请求从串行改为并行。

这些方案落地后,大部分页面实现了「秒开」,整体首屏从 2280ms 降到了 985ms。

但「秒开」就是终点吗?

我们的数据表明,首屏时间每降低 1s,访问流失率降低约 6.9%。在上述方案全部落地后,我们实测(Pixel 3a 设备)发现 RN 页面的冷启动仍需 1.78s,热启动需 1.1s。用户从点击到看到内容,仍有明显的白屏。

于是我们把目光投向了下一个瓶颈:RN 容器本身的启动耗时

本文将分享我们如何在已有方案基础上,通过容器预加载和复用机制,将冷启动降至 0.8s(提升 55%),热启动降至 0.33s(提升 70%) 的完整实践。


一、起点:前人解决了什么,留下了什么

先明确我们的起点。下图展示了 58RN 秒开方案已经覆盖的瓶颈,以及我们要继续攻克的部分:

graph LR
    subgraph solved [已解决 - 58RN 秒开方案]
        A["动态更新瓶颈<br/>资源预加载 + 静默更新"]
        B["框架初始化瓶颈<br/>拆包 + 框架预执行"]
        C["业务请求瓶颈<br/>Native 并行请求"]
    end
    subgraph target [本文聚焦]
        D["容器启动瓶颈<br/>容器预加载 + 复用"]
    end
    A --> D
    B --> D
    C --> D

秒开方案优化的是用户进入之前的准备工作——资源下载、框架初始化、业务数据。但当用户真正点击进入 RN 页面时,容器的创建和启动仍然是实时发生的:

sequenceDiagram
    participant User as 用户
    participant Native as Native 层
    participant RN as RN 容器
    participant JS as JS 引擎

    User->>Native: 点击进入 RN 页面
    Native->>RN: 创建容器实例(~100ms)
    RN->>RN: 创建运行环境(~100ms)
    RN->>JS: 加载业务代码(~10ms)
    JS->>JS: 执行 JS + 首屏渲染(~1200ms)
    JS-->>User: 页面可见

每次打开,这个链路都要从头走一遍。这就是我们要攻克的第四个瓶颈。


二、先看数据:时间都花在哪里?

搭建性能追踪

优化的第一步不是写代码,而是搞清楚时间花在哪里

我们在 RN 启动链路的每个关键节点打点——路由进入、初始化开始/结束、容器创建开始/结束、代码加载开始/结束、UI 可见。得到了冷启动 1787ms 的完整耗时分布:

pie title 冷启动耗时分布(1787ms)
    "路由到初始化的空等" : 379
    "容器实例创建" : 98
    "运行环境创建" : 99
    "代码加载 + 执行" : 12
    "JS 引擎初始化 + 首屏渲染等" : 1199

两个关键发现:

  • 88.3% 的时间消耗在 JS 引擎初始化、代码解析、首屏渲染等 RN 框架内部逻辑上,短期难以从根本上改变。
  • 从路由进入到初始化开始,居然空等了 379ms——将近 400ms 什么都没做。

这两个发现直接指明了优化方向:能提前的提前,能复用的复用,能预加载的预加载。


三、四步走:渐进式优化

阶段 1:提前初始化时机 —— 砍掉 379ms 的空等

问题:为什么路由进入后要空等 379ms?

排查发现,原来的代码是在页面 onResume 生命周期才触发 RN 初始化。在 Compose 架构下,从路由跳转到页面 Resume,中间经历了一系列生命周期流转,白白浪费了近 400ms。

方案:把初始化时机提前到页面创建时立即触发,而不是等到 Resume。

gantt
    title 初始化时机对比
    dateFormat X
    axisFormat %s

    section 优化前
    路由跳转           :done, 0, 50
    空等 379ms         :crit, 50, 429
    RN 初始化 + 渲染    :active, 429, 1780

    section 优化后
    路由跳转           :done, 0, 50
    空等 62ms          :crit, 50, 112
    RN 初始化 + 渲染    :active, 112, 1530
指标优化前优化后提升
冷启动1.78s1.53s14%
热启动1.10s0.63s43%

改动很小,但热启动直接砍掉将近一半的耗时。消除无意义的等待,往往是性价比最高的优化。


阶段 2:预加载基础资源 —— 提前备好弹药

问题:RN 启动时需要加载底层 Native 库和框架代码,每次都要做初始化和解压,累计约 50ms。

方案:在 App 启动时,后台异步完成这两步。用户打开 RN 页面时直接使用,跳过初始化和解压。

flowchart TD
    A[App 启动]

    P((后台预热任务))
    B[加载 Native 库]
    C[解压框架代码到缓存]

    R[资源准备完成]

    U((用户进入 RN 页面))
    E[直接使用\n跳过 50ms 初始化]

    A --> P
    P --> B
    P --> C
    B --> R
    C --> R
    R --> U
    U --> E
指标优化前优化后提升
冷启动1.53s1.43s6.5%
热启动0.63s0.63s

提升不大,但这是下一步的前置条件——基础资源就绪了,才能预创建完整容器。


阶段 3:容器复用 —— 创建一次,用无数次

问题:每次打开 RN 页面,即使是同一个页面的第二次打开,仍要走一遍完整的容器创建流程(~200ms)。同样的容器,为什么要反复创建?

方案:引入容器缓存机制。首次打开正常创建,后续打开直接复用已有容器,跳过创建步骤。

我们设计了两种策略:

  • 复用模式:同一个业务包共享同一个容器,性能优先
  • 隔离模式:每次创建独立容器,兼容性优先

缓存按页面维度管理,页面销毁时自动清理。

flowchart TD

    subgraph 冷启动
        A1[用户进入页面]
        B1[初始化 React 容器 约200ms]
        C1[执行 JS Bundle]
        D1[完成首屏渲染]
        E1[缓存容器实例]
        A1 --> B1 --> C1 --> D1 --> E1
    end

    subgraph 热启动
        A2[用户再次进入]
        B2[复用已缓存容器 约0ms]
        C2[执行 JS 逻辑]
        D2[完成渲染]
        A2 --> B2 --> C2 --> D2
    end
指标优化前优化后提升
冷启动1.43s1.43s
热启动0.63s0.33s47.6%

热启动从 0.63s 降到 0.33s,已经非常接近原生体验。但冷启动呢?首次打开仍然要创建容器


阶段 4:容器预加载 —— 用户还没点,容器已就绪

问题:容器复用解决了「二次打开」的问题,但首次打开仍是瓶颈。核心矛盾:容器创建发生在用户点击之后,而创建本身需要 200ms

方案:在 App 启动时,后台异步预创建完整的 RN 容器(包括容器实例、运行环境、业务代码),存入预热缓存。用户首次打开时直接消费。

sequenceDiagram
    participant App as App 启动
    participant BG as 后台线程
    participant Cache as 预热缓存
    participant User as 用户
    participant RN as RN 页面

    App->>BG: 异步启动预加载
    BG->>BG: 预加载 Native 库
    BG->>BG: 预解压框架代码
    BG->>BG: 预创建容器实例
    BG->>BG: 预创建运行环境
    BG->>BG: 预加载业务代码
    BG->>Cache: 存入预热缓存
    Note over App,Cache: 以上在后台完成,不阻塞用户

    User->>RN: 首次打开 RN 页面
    RN->>Cache: 取出预热容器
    Cache-->>RN: 容器已就绪
    RN->>RN: 渲染页面
    RN-->>User: 页面可见(0.8s)

核心思路:把容器创建的 200ms 从用户的关键路径移到 App 启动后的后台异步线程,用户无感知。

gantt
    title 冷启动时间线对比
    dateFormat X
    axisFormat %s

    section 优化前
    用户点击        :milestone, 0, 0
    创建容器 200ms  :crit, 0, 200
    渲染 1200ms     :active, 200, 1400
    页面可见        :milestone, 1400, 1400

    section 优化后
    用户点击            :milestone, 0, 0
    取出预热容器 0ms    :done, 0, 1
    渲染 800ms          :active, 1, 801
    页面可见            :milestone, 801, 801
指标优化前优化后提升
冷启动1.43s0.8s44%(累计 55%)
热启动0.33s0.33s

四、踩过的坑:容器复用没有想象中简单

容器复用和预加载的方案设计不难,但落地过程中遇到了一系列工程问题。这些坑如果提前知道,可以少走很多弯路。

mindmap
  root((容器复用的坑))
    生命周期问题
      返回键失效
      全局状态清理
    数据一致性问题
      路由参数错误
      多语言不刷新
    版本管理问题
      热更新不生效
    依赖完整性问题
      Native Module 缺失

坑 1:返回键失效

现象:使用预加载容器后,用户按返回键没反应。

根因:容器创建时需要绑定「返回键回调」。预加载时没有页面上下文,传入的是空回调。而容器创建后,这个回调不可修改。

解法:利用容器的页面恢复机制,每次页面可见时动态重新注入返回键回调。

教训:预加载场景下,所有需要页面上下文的回调都需要延迟绑定


坑 2:路由参数错误

现象:从页面 A 跳到 RN 页面 B,传了 userId=123,但 RN 侧拿到的是上次的 userId=456

根因:容器复用时,参数是第一次创建时注入的。复用容器不会重新注入。

解法:引入全局参数管理器,每次页面可见时刷新当前参数。RN 侧通过 Bridge 获取最新参数,而不是依赖容器创建时的注入。

关键:在代码加载之前刷新参数,确保 RN 侧拿到的一定是最新值。


坑 3:热更新后代码不生效

现象:发了新版 Bundle,用户打开仍然是旧代码。

根因:容器复用时,代码是首次加载时注入的,复用容器不会重新加载。

flowchart LR

    subgraph 首次运行
        A[首次打开]
        B[加载代码 v1.0]
        C[容器运行中]
        A --> B --> C
    end

    subgraph 热更新阶段
        D[触发热更新]
        E[下载代码 v2.0]
        F[存入本地缓存]
        D --> E --> F
    end

    subgraph 再次进入
        G[再次打开页面]
        H[复用已有容器]
        I[仍运行 v1.0]
        G --> H --> I
    end

    C --> G

解法:引入热更新标记机制。热更新完成后打标记,下次打开时检查标记,如果有则重建运行环境并加载新代码

flowchart LR

    A[热更新完成]
    B[设置重建标记]

    C[下次打开页面]
    D[检查重建标记]

    Y((存在标记))
    N((不存在标记))

    E[重建运行环境]
    F[加载新代码]

    G[复用现有容器]

    A --> B
    C --> D
    B --> D

    D --> Y
    D --> N

    Y --> E
    E --> F

    N --> G

坑 4:预加载时 Native Module 缺失

现象:预加载的容器缺少某些 Native 能力,RN 侧调用报错。

根因:部分 Native Module 需要运行时回调(如关闭页面、弹窗),预加载时没有这些回调,所以没注册。

解法:引入统一的能力工厂。预加载时全量注册所有 Module,回调先用空占位。运行时复用同一份配置,将空回调替换为真实回调

graph TD
    subgraph preload [预加载阶段]
        A[创建能力配置] --> B["注册所有 Module<br/>回调 = 空占位"]
        B --> C[容器创建完成]
    end

    subgraph runtime [运行时]
        D[页面打开] --> E["复用同一份配置<br/>补齐真实回调"]
        E --> F[功能完整可用]
    end

    C --> D

坑 5:全局状态清理问题

现象:退出 RN 页面再进入,某些数据丢失。

根因:容器复用后,退出页面不会销毁容器。但部分 Native 组件在视图卸载时清理了全局变量,再次进入时不会重新赋值。

解法:将状态从全局下沉到视图实例中。每个视图创建时初始化自己的状态,卸载时清理自己的状态,互不影响。

原则:视图创建和视图卸载必须成对出现,状态跟随视图生命周期。


坑 6:多语言不刷新

现象:用户切换国家/语言后,RN 页面的文案没有跟着切换。

根因:容器复用后,国际化模块不会主动刷新语言。

解法:与路由参数类似,将语言信息也注入到当前容器。RN 侧每次页面可见时通过 Bridge 获取最新语言,并重置国际化模块。


规律总结

容器复用的本质是把「一次性初始化」变成「多次复用」。所有依赖「一次性初始化」的逻辑,都需要改造为「每次使用时刷新」。

graph LR
    A["一次性初始化思维"] -->|容器复用后| B["每次使用时刷新"]
    B --> C[返回键回调]
    B --> D[路由参数]
    B --> E[热更新代码]
    B --> F[Native Module 回调]
    B --> G[视图状态]
    B --> H[多语言设置]

五、最终效果

性能数据

指标优化前优化后提升幅度
冷启动1.78s0.8s55%
热启动1.10s0.33s70%

优化路径

graph TD
    A["基线<br/>冷启动 1.78s / 热启动 1.10s"] --> B
    B["阶段1:提前初始化时机<br/>冷启动 1.53s(↓14%)/ 热启动 0.63s(↓43%)"] --> C
    C["阶段2:预加载基础资源<br/>冷启动 1.43s(↓6.5%)/ 热启动 0.63s"] --> D
    D["阶段3:容器复用<br/>冷启动 1.43s / 热启动 0.33s(↓47.6%)"] --> E
    E["阶段4:容器预加载<br/>冷启动 0.80s(↓44%)/ 热启动 0.33s"]
    E --> F["累计:冷启动 ↓55% / 热启动 ↓70%"]

耗时分布变化

graph LR
    subgraph before ["优化前(冷启动 1787ms)"]
        A1["路由 → 初始化<br/>379ms"] --> A2["容器创建<br/>197ms"]
        A2 --> A3["代码加载<br/>12ms"]
        A3 --> A4["JS 渲染<br/>1199ms"]
    end

    subgraph after ["优化后(冷启动 812ms)"]
        B1["路由 → 初始化<br/>62ms"] --> B2["容器创建(预加载)<br/>0ms"]
        B2 --> B3["代码加载<br/>17ms"]
        B3 --> B4["JS 渲染<br/>733ms"]
    end

六、架构总览

分层架构

graph TD
    L1["业务配置层<br/>配置哪些页面需要预加载"] --> L2
    L2["预加载引擎层<br/>预加载 Native 库 / 框架代码 / 完整容器"] --> L3
    L3["容器缓存管理层<br/>预热缓存池 + 页面绑定缓存池"] --> L4
    L4["运行时选择层<br/>复用模式 / 隔离模式"]

缓存生命周期

stateDiagram-v2
    [*] --> AppStarted: App 启动
    AppStarted --> PreloadDone: 后台异步预加载
    PreloadDone --> WarmCache: 存入预热缓存
    WarmCache --> Consumed: 用户首次打开(消费)
    Consumed --> BoundCache: 绑定到页面缓存
    BoundCache --> Reused: 用户再次打开(复用)
    Reused --> BoundCache: 继续复用
    BoundCache --> Cleaned: 页面销毁
    Cleaned --> [*]: 自动清理

七、总结与展望

核心经验

1. 数据先行。 搭建性能追踪工具后,才发现 379ms 的空等和 88% 的框架内部耗时。没有数据,优化就是盲人摸象。

2. 渐进式优化。 4 个阶段各有侧重,每步都有可量化的收益。不要试图一步到位,逐步迭代更靠谱。

3. 预加载的本质是「空间换时间」。 在用户不需要的时候(App 启动后的空闲期),提前做用户未来需要的事(创建容器),利用空闲时间为关键路径减负。

4. 复用的代价是复杂度。 容器复用带来了返回键、参数、热更新、全局状态、多语言等一系列问题。每一个都需要专门机制来应对。简单的方案背后是复杂的工程。

与 58RN 秒开方案的关系

graph TD
    subgraph phase1 ["58RN 秒开方案(2021)"]
        P1[解决动态更新瓶颈]
        P2[解决框架初始化瓶颈]
        P3[解决业务请求瓶颈]
    end

    subgraph phase2 ["本文:容器启动优化"]
        P4[解决容器启动瓶颈]
        P5[解决容器复用工程问题]
    end

    phase1 -->|在此基础上| phase2

    P1 -->|异步更新保证资源就绪| P4
    P2 -->|框架预执行减少初始化| P4
    P3 -->|数据并行减少等待| P4

两套方案是互补关系:秒开方案确保资源就绪,容器优化确保启动极速。叠加使用,才能实现从「秒开」到接近「毫开」的体验。

展望

  • Hermes 引擎:预编译 JS 为 bytecode,大幅减少 JS 执行耗时。测试中 140ms 的页面降到 40ms,降幅 80%。
  • 新架构(Fabric + TurboModules):同步通信替代异步 Bridge,实现 Native Module 按需初始化。
  • 智能预加载策略:基于用户行为预测,动态决定预加载哪些页面的容器,提高命中率。

性能优化没有终点,但每一步都在让用户的体验更好一点。


参考:58RN 页面秒开方案与实践 - 蒋宏伟