文中 metro-code-split 工具已开源,支持 RN 拆包和 dynamic import,欢迎大家 start。
https://github.com/wuba/metro...
以下是正文。
今天和大家分享的主题是《 58RN 页面秒开方案与实践》。先自我介绍一下,我叫蒋宏伟。我在 2015 年入职的 58,在 2016 年开始在 RN 方向上开始探索,这几年来,也推进不少 RN 性能方案的落地。在落地的过程中,一个被经常到的问题是:
做性能优化,耗时是降低了,但对业务来说有什么收益呢?
第一次,我确实被问住了。我就带着这个疑问,做了一个实验,去统计首屏时间和访问流失率的关系。我发现了一个有意思的规律。
首屏时间每降低 1 s,访问流失率降低 6.9%。
预测回头看,实际效果真有 6.9% 这么好吗?
我们拿着 6.9% 的收益数据,推动了几个业务进行了落地。整体来讲,预测的流失率收益跟实际的流失率收益其实是差不多的,但有分化。具体来讲,性能好的页面比预期收益差,性能差的页面比预期收益好。这也很好理解,性能好的页面,流失率已经很低了,进一步优化空间已经很小了。
当我们知道性能优化的收益会分化后,自然把更多的关注重点,落在了那些还没有实现秒开的页面上。我们就设计了几个指标,包括流失率、首屏时间、秒开收益。
流失率、首屏时间是事后指标。而秒开收益指标,是一个事前指标,它会告诉你,如果你的页面实现了秒开,你的流失率会降低多少?我们希望用这一系列的指标,来驱动业务进行性能优化。同时,我们也会给业务提供一些低成本、甚至无成本的优化方案,帮助业务的节约优化成本。
指标驱动业务,业务选择方案,方案提高收益,这就是我们设想的一个收益驱动的模型。
首屏时间的采集方案
本次的分享也会围绕着方案和指标这两块进行具体的展开。先说下指标这一块,最重要的指标是首屏时间,首屏时间算出来了,流失率和秒开收益其实就算出来了。因此,本次分享分为以下三个部分。
- 第一部分讲的是,首屏时间的采集方案
- 第二部分再具体讲讲,性能优化的方案
- 最后跟大家总结和展望一下。
我们先来看一个页面的加载流程,它大概有 5 个阶段:
- 0 ms:用户进入
- 410 ms:首次内容绘制 FCP
- 668 ms:业务组件 DidUpdate
- 784 ms:最大内容绘制 LCP
- 928 ms:可视区加载完成
第 2、3、4、5 个时间点,都可以定义为首屏时间。首屏时间定义不一样,耗时也不一样,而且差距非常大。因此,我们需要先选择一个指标,作为首屏时间的定义。
首屏时间我们选择的是 LCP 这个指标。为什么呢?
- 第一,是因为 LCP 是最大内容绘制,这个时候页面中的主要元素其实已经展示出来。
- 第二,是因为 LCP 可以实现非侵入式的采集,不需要业务手动的去埋点。
- 第三,是因为 LCP 是 W3C 的草案,这是一个重要的原因。你告诉别人,你的首屏指标是 LCP,别人就懂了,不用过多解释。
为了能让大家更好的理解 LCP 算法的实现,先给大家铺垫一下。
简单来讲,LCP 就是你看得到的最大元素,它渲染出来的时间。但是这里存在一个问题,比如我们第 2 张图片的最大元素,和第 5 张图片的最大元素,不是同一个元素。不同的元素,渲染出来的时间是不一样的,LCP 也不一样。也就是说,一个页面有多个 LCP,上报哪个 LCP 呢?应该上报最终收敛的 LCP 值,也就是可视区加载完成时的 LCP 值。
LCP 是 Web 的标准,在 RN 中并没有实现,应该怎么实现呢?
整体上讲,实现上大致分为 5 个步骤:
- 在用户进入时,由 Native 线程记录 Start 时间戳。
- 由 Native 线程将 Start 时间戳注入到 JS Context 中。
- 由 JS 线程,监听页面中渲染元素的布局事件。
- 由 JS 线程,在页面渲染过程中进行计算,并不断更新 LCP 值。
- 由 JS 线程,计算得到 End 时间戳,并上报最终的 LCP 值。
此时,最终上报的 LCP = End Time - Start Time。
其中难点是怎么收敛 LCP,也就是如何判断可视区完全加载。我们采用的规则是,当所有元素都加载完成,且底部的元素也已经加载完成时,可视区加载完成。元素有一个调用周期,先调用 render,再调用 layout。只调用了 render 的元素,是没有加载完成的元素。调用了 render 且调用了 layout 的元素,是加载完成的元素。能够判断一个元素是否加载完成了,也就能够判断可视区是否加载完成了。
性能优化方案
讲具体方案之前,先来讲一下,我们性能优化的整体思路。
做任何性能优化之前,我们要先分析性能的结构是什么,然后找到性能瓶颈,根据瓶颈来出具体的优化方案。
一个 RN 应用的性能结构,整体上看,分为 2 个部分, Native 部分和 JS 部分。再具体一点,又可以分为 6 个部分。以下是一个未优化的、比较复杂的、动态更新的 RN 应用的耗时结构:
- 版本请求 200 ms
- 资源下载 470 ms
- Native 初始化 350 ms
- JS 初始化 380 ms
- 业务请求 420 ms
- 业务渲染 460 ms
从大体上讲,上述 6 个结构,可以分为 3 个瓶颈。
- 动态更新瓶颈,占比为 29%。
- 初始化瓶颈,占比为 32%。
- 业务耗时瓶颈,占比 39%。
瓶颈一:动态更新
互联网产品有一个特征就是快速试错,这就要求业务能够快速迭代。为了支持业务快速迭代,这就要求应用能够动态更新。动态更新,肯定要发请求,要发请求就会拖慢性能,比如 Web。如果和 Native 一样,进行资源内置,性能会好很多,但又如何动态更新呢?
动态更新和性能似乎是一对矛盾体,有什么权衡之策吗?
我们开始想到的方案是,通过资源内置来提高页面性能,通过静默更新来动态更新。
当用户首次进来时,因为已经有内置资源了,所以不会有请求,页面可以直接渲染出来。与此同时,Native 线程会并行静默更新,询问服务端是否有最新版本,如果有就会下载 bundle,更新 cache。这样当用户下次进来的时候,可以使用到上次缓存的资源,直接渲染页面,同时并行静默更新。依此类推,用户每次进入时,都没有请求,可以直接渲染页面。
设计静默更新时有一个小细节需要注意。用户每次都使用的是上次缓存的资源,而不是线上最新的资源。因此存在一种风险,一个有严重 BUG 的版本被用户缓存下来了,且不能得到更新。为此,我们又设计了强制更新功能。在静默更新成功后,由 Native 线程通知 JS 线程,由业务根据具体的情况决定是否强制更新到最新版本。
资源内置 + 静默更新方案也有一些缺点:
- 增加 App 体积。对于超级 App 而言,体积已经非常大了,要增加体积很难。
- 新版本覆盖率低。72 小时新版本覆盖率为 60% 左右,相对于 Web 方案来说比较低。
- 版本碎片化严重。多个内置版本和多次的动态更新,会导致版本碎片化的问题,推高了维护成本。
因此,我们进行了一些改良。
用资源预加载,替代了资源内置。这就很大程度避免了包体积、覆盖率和碎片化的问题。静默更新依旧保留了,来更新可能出现的 BUG 版本。
资源预加载的话题其实已经讲烂了,我这里只从“权利”的角度,帮大家分析一下。
谁应该有预加载的权利?是 RN 框架,还是具体业务?把权限给框架,框架是可以把所有页面的资源都预加载了,但这样做明显效率很低,对于平台级 App 而言,一个 App 有几十个甚至上百个 RN 应用,大部分预加载的资源用户用不上,这就造成了浪费。把权限给业务,让具体业务一个个加载,又非常麻烦。
信息即权利,谁拥有信息,权利就给谁。最开始,框架没有任何有用信息,但业务可以根据业务数据,知道跳转到具体页面的比例,因此调用预加载的权利应该给业务。当用户已经使用过某个 RN 应用后,框架时知道这个信息的,这时权利应该给框架。框架可以在 App 启动后,进行版本预请求。
针对动态更新瓶颈,我们使用了资源预加载和静默更新的方案。耗时从未优化的 2280 ms,降低到了 1610 ms,降幅 29%。
瓶颈二:框架初始化瓶颈
首先,我们分析一下为什么框架初始化很慢。
JS 线程和 Native 线程是异步的通讯的,每次通讯都是通过 Bridge 进行序列化和反序列化完成的。在通讯之前,因为不在一个 Context 中,JS 线程和 Native 线程是相互不知道彼此存在的。因为 Native 不知道 JS 会使用哪个 NativeModule,所以 Native 需要初始化所有的 NativeModule,而不是按需初始化,这就是初始化性能慢的原因。
在 RN 新架构中,有计划把异步 Bridge 通讯替换成同步 JSI 通讯,从而实现按需初始化。但现在按需初始化功能还没有实现,因此,框架初始化的优化我们还是要做的。
我们给出的思路是,拆包内置和框架预执行。
我们的 App 是混合应用,首页用的不是 RN。因此,能在 App 启动后,先执行 RN 内置包,初始化所有的 NativeModules。在用户真正进入到 RN 页面时,性能自然会快上很多。
该方案最大的难点是拆包。如何把一个完整的 bundle 包,正确的拆成内置包和动态更新包呢?
刚开始我们踩了一个坑,希望能帮大家避免。
原来我们用的是 google 的 diff-match-patch 算法,该算法会对比新旧文本的区别,生成一个 patch 文件。同理,可以使用 diff-match-patch 算法,对比业务包和内置包的区别,生成一个 patch 动态更新包。
但是,patch 实际是一个“文本补丁”,“文本补丁” 是不能单独执行的。不能满足先执行内置包,再执行动态更新包的要求。
后来,我们改造了 metro 实现了正确拆包,从而实现了框架预加载。
一个完整的 bundle,由若干 module 组成,怎么区分某个 module 是属于内置包,还是动态更新包呢?内置 module 的路径或者说 ID,有一个特征,它是在 node_modules/react/xxx 或 node_modules/react-native/xxx 路径下的。可以先提前记录所有的内置 module 的 IDs,在打包时,将属于内置 module 都过滤掉,生成只包含业务 module 的动态更新包。
metro 拆包的动态更新包是“代码补丁”,可以直接执行,能够满足先执行内置包,再执行动态更新包的要求。
其中有一个细节是,内置包中要增加一行 require(InitializeCore) 的代码,来调用内置包中 defined 的 modules。增加这一行代码,首屏耗时大概可以多减少 90 ms。
针对框架初始化瓶颈,我们使用了拆包内置和框架预执行的方案。耗时从未优化的 1610 ms,降低到了 1300ms,整体降幅 43%。
瓶颈三:业务请求瓶颈
动态更新瓶颈、框架耗时瓶颈优化完后,再来看一下业务瓶颈。业务瓶颈主要由业务请求和业务渲染两部分组成,请求是比较好优化的,所以我们先针对业务请求瓶颈做优化。
业务请求的优化,其实有很多常用方案。
- 业务数据缓存
- 在上一个页面预加载下一个页面的业务数据
但是,不是每个应用它都适合做缓存,不是每个应用它的数据都适合在上个页面预加载。因此,我们需要一种更加通用的方案。仔细观察一下,Init 部分和业务请求部分是串行的,是不是可以改为并行?
我们的思路是,由 Native 代替 JS,在用户进入页面时直接并行的请求业务数据。
具体方案如下。
- 在 Native 下载的资源文件中,会同时包含 Biz 业务包和原始的业务请求的 URL。
- 原始 URL 中会包含动态的业务参数,该变量会根据事先约定的规则进行转换。例如,
58.com/api?user=${user}
将会转换为58.com/api?user=GTMC
。 - Native 并行执行 Biz 包渲染页面,和发起 URL 请求获取业务数据。
- JS 侧直接调用 PreFetch(cb),即可获得 Native 侧请求的数据。
针对业务请求瓶颈,我们使用了业务数据并行加载的方案。耗时从未优化的 1300 ms,降低到了 985 ms,整体降幅 57%。
应用上述方案,大部分页面都可以实现秒开。那还有性能优化的空间吗?
代码执行瓶颈
RN 页面渲染的慢的另外一个原因是,RN 需要执行完整的 JS 文件,即使 JS 中有不需要执行的代码。
我们来看一个案例。一个页面包含 3 个 tab,用户进来时只会看到 1 个 tab。理论上,只需要执行 1 个 tab 的代码即可。但实际上,另外 2 个看不见的 tab 的代码也会下载和执行,拖慢了性能。
RN 代码懒加载和懒执行的能力来提高性能,类似 Web 中的 Dynamic Import。
RN 官方并没有提供 dynamic import ,于是我们决定自己做。
目前,dynamic import demo 已经在 RN 0.64 版本中跑通了。业务初始化时,可以只执行 Biz 业务包,在跳转到 Foo、Bar 两个 dynamic 页面时,才会动态的下载对应的 chunk 动态包。退出已进入的 dynamic 页面再次进入,不会再下载,会利用原有的缓存直接渲染 dynamic 页面。
RN 的 dynamic import 实现,我们参考的是 TC39 的规范。
业务只需要写一行代码 import("./Foo")
,就可以实现代码懒加载和懒执行。剩下的所有工作,都在框架层和平台层做了。
在 runtime 运行时,业务执行 import("./Foo")
之后,框架层会去判断 ./Foo
路径对应的 module 是否已经 install。如果没有 install,就会通过 ./Foo
路径找到对应 chunk 包的 URL 地址,接着下载和执行 chunk,最后渲染 Foo Component。
Chunk 包的 URL 是一个 CDN 地址,显然上传 CDN 和记录 Path 和 URL 关系的工作,不是在 runtime 运行时做的,而是在 compile time 编译时做的。
在平台层的编译过程中,会将 Path 和 URL 的关系表存在 Biz 包中,这样 Runtime 才能通过 Path 找到对应的 URL。
完成这个过程,大致分为 5 个部分。
- Project:一个项目由若干个文件组成,文件之间会有相互依赖关系。
- Graph:每个文件会生成一个对应的 module,所有 module 及其依赖关系组成了一个 graph。
- Modules:给 dynamic module 的集合进行“着色”,进行区分。
- Bundles:将多个 module 的集合都打包成多个 bundle。
- CND:将 bundle 上传至 CDN。
其中最为关键的步骤是,给 dynamic module 的集合进行着色。
- 分解着色:一个 Graph 的着色的情况可以分解为若干个基础的 case,这些基础 case 的着色方案是已经确定下来的。
- dynamic map:着色完成后,会将“绿色”“蓝色”这些 dynamic module 的根路径 Path 记录下来,并和其 bundle 的 CDN URL 地址组成一个 dynamic map。
- Path to URL:Dynamic map 会打包到“白色”的 Biz 业务包中,因此在 runtime 调用
import()
时,可以通过 Path 找到对应的 URL。
上述很多细节没有展开讲,关注实现细节实现的同学可以关注一下我们的开源工具 metro-code-split。
metro-code-split:https://github.com/wuba/metro...
- 基于 metro
- 支持 DLL 拆包
- 支持 RN Dynamic Import
总结与展望
我们通过分析性能结构,找到了 3 类性能瓶颈,并产出了不同的优化方案。下图就是我们秒开方案的集合,图中列举(预期)收益、生效范围和生效场景,希望对大家的技术选型有所帮助。
在最新版本中, RN 新架构的很多功能已经成熟,我们也在进行积极探索。其中最让人惊喜的是 Hermes 引擎,已经可以同时在 iOS 和 Android 中使用了。Hermes 引擎相对于原来的 JSCore 引擎最大的区别是,Hermes 会进行预编译,在编译时将 JS 文件编译成 bytecode 文件,这样在运行时就能直接使用 bytecode 文件进行执行了,能够大幅减少 JS 执行耗时。经过测试我们发现,一个耗时 140 ms 的页面,能够降到 40 ms,降幅 80%。
在我们为业务提供性能优化方案的同时,我们也需要关注业务的落地情况。为了能让更多业务实现秒开,我们通过非侵入式收集的方式,收集了流失率、首屏时间、秒开收益等指标。在我们的实践中,这种将技术优化需求与业务收益挂钩的方式,更容易被业务接受,推动起来也更容易。
最后,希望我们的秒开方案和收益驱动的实践,能给大家带来启发,谢谢大家。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。