搬运补充自《蒋宏伟-58RN页面秒开方案与实践》—2021全球大前端技术大会小册子

(似乎是内部资料了,网上搜不到买不到)

前篇:RN页面秒开方案与实践(一)


目录


性能优化方案

性能结构与瓶颈

在做任何性能优化钱,都需要先分析性能的结构,然后找到性能的瓶颈。

一个RN的性能结构,整体分为两个部分: Native和JavaScript

再细分,可以分为 6个耗时结构和3个瓶颈 :

动态更新 瓶颈:

  1. 版本请求
  2. 资源下载

框架初始化 瓶颈:

  1. Native初始化
  2. JS初始化

业务耗时 瓶颈:

  1. 业务请求
  2. 业务渲染

动态更新

互联网产品的一个特征就是 快速试错 ,这就要求业务能够 快速迭代 , 应用能够动态更新 。

动态更新需要 发送请求 ,发送请求就会拖慢性能,比如web。如果像native一样进行资源内置,性能会好很多,但又没办法动态更新了。

所以, 动态更新和性能似乎是一对矛盾体 ,有什么权衡之策呢?

第一版方案 是: 资源内置+静默更新。

资源内置用来提高页面性能。 用户首次进来时,已经有内置资源了,不会有请求,页面可以直接渲染出来。

与此同时,native线程会 并行进行静默更新 , 询问服务端 是否有最近版本,如果有就会下载bundle,更新cache。用户下次进来,就可以使用上次缓存的资源,直接渲染页面,同时在后台静默更新下下次的资源……

所以, 每次用户使用的都是上次缓存的资源,而不是线上最新的资源 。如果一个有严重bug的版本被用户缓存下来且无法得到更新,就完了。所以团队又设计了 强制更新功能 ,在静默更新成功后,由 native线程通知js线程是否强制更新 到最新的版本。

这个方案也有一些 缺点 :

  1. APP体积大。超级APP的体积已经很大了,再增加就很困难了
  2. 新版本覆盖率低。相比起web来说,可能只有60%的24小时新版本覆盖率。
  3. 版本碎片化严重。多个内置版本和多次动态更新,会导致线上同时存在多个活跃版本,不利于维护。

所以出现了 第二版方案 ,用资源预加载替代资源内置。

资源预加载+静默更新

  • 资源内置 是 传统的 App更新模式 。即将所有的 JS业务代码、图片、字体等资源 全部打包 进 .apk或 .ipa 文件中。用户只能通过应用商店(App Store / GooglePlay)下载新版本来获取更新。
  • 资源预加载 则是 热更新(Hot Update /OTA)模式 。App 初始安装时只包含一个基础包。当用户打开 App时,App 会在后台 静默地 检查服务器是否有新的 JS包或资源(这就是“预加载”),下载它们,并在下次启动时应用。
  • 资源预加载可以 绕过应用商店的审核,并在下次打开APP时强制推送给100%的活跃用户,而且将绝大部分的资源从安装包转移至安装后下载。

这里,作者提供了一个新视角: 权利的角度。

谁应该拥有预加载的权利 ?是RN框架,还是具体业务?

把权利给 框架 ,可以把页面的资源都预加载了,但是效率很低,还会造成浪费;

把权利给 业务 ,让具体业务一个个加载,又非常麻烦。

所以正确的做法应该是: 谁拥有信息,权利就给谁。

最开始,框架没有任何有用信息,但是业务可以根据业务数据, 预估预加载资源的使用频率 ,因此预加载的权利应该给业务。

当用户已经使用过某个RN应用后,就有 偏好信息 了。框架知道偏好信息后,权利就应该给框架,在APP启动后,进行版本与请求。


框架初始化

框架初始化为什么很慢呢?

因为 JS线程和Native线程是异步通讯的 ,每次通讯都要通过 Bridge 进行序列化和反序列化。

在通讯前,他们不在同一个Context中,是不知道彼此存在,native不知道JS需要使用哪些NativeModule,无法按需初始化,只能全部初始化。

我们先来了解 RN打包生成的产物 。

bundle包 (Bundle Pack)

  • 这就是你的 所有 JavaScript 代码 。
  • 详解 :RN 是用 JS 写的。但在打包 App时, Metro(RN的打包工具)会把你所有的 JS文件(React库、RN库、你写的业务代码、第三方npm包)“打包”成一个巨大的、单独的.js 文件 。这个文件就叫bundle。
  • 问题 :这个 bundle 包会非常大(10MB,20MB 甚至更多)。RN页面启动慢, 90%的性能都消耗在加载、解析、执行这个巨大的 JS文件上 。

“ 分包 ”策略就是为了解决这个“巨型bundle”问题而诞生的。它把一个大 bundle拆成下面三个部分:

  1. react内置包 (React built-in pack)

    • 也叫 : 基础包 (Base Bundle) 或 框架包(Framework Pack) 。
    • 答案 :它包含了 永远不变 或 极少变化 的代码。
    • 详解 :具体就是React、React Native框架本身的代码,以及其他一些非常稳定的第三方库(比如react-navigation)。
    • 为什么叫“内置” :因为它非常稳定,所以它被 “内置”在App 的安装包 (.apk / .ipa) 里 ,伴随 App一起发布。
  2. biz动态包 (biz dynamic pack)

    • 也叫 : 业务包 (BusinessBundle) 。
    • 答案 :它包含了你 经常修改 的业务逻辑代码。
    • 详解 :你写的所有的页面、组件、业务逻辑都在这里。
    • 为什么叫“动态” :因为这部分代码经常变动,所以它最适合用来做 热更新(OTA) 。它从服务器上 动态下载 ,而不是内置在 App安装包里。
  3. patch文件 (Patch File)

    • 答案 : 增量更新包 ,让“动态”变得更轻更快。
    • 详解 :假设你的 biz动态包 有5MB。你现在改了一个 bug,只改了 10 行代码。
    • 传统热更新 :用户需要重新下载一个 完整的、新的5MB biz动态包。
    • Patch 方式 :服务器会对比新旧两个biz动态包,生成一个 只包含差异 的patch文件(可能只有 2KB)。App 只需要下载这个 2KB 的 patch文件,然后在手机本地,通过算法将它和旧的 biz动态包“合并”,就能合成出最新的 biz动态包。
    • 优势 :极大地减小了热更新的体积,下载更快、更省流量。

所以,框架初始化的解决核心就是 拆包 。将 bundle 拆成 框架包(react内置包)和业务包(biz动态包) ,然后执行 拆包内置 和 框架预执行 。

在APP启动后,先执行RN内置包,初始化所有的nativemodules,之后进入RN页面再执行RN业务包。

一个完整的bundle,由若干module组成,如何 区分它属于那个包 呢?

内置module 的 路径/ID 有一个特征,是在node_modules/react/XXX或node_modules/react-native/XXX下的。

可以 提前记录 所有内置模块的ID,在 打包时用metro过滤掉 他们, 只生成包含业务module 的 动态更新包 。

注意,这个动态更新包 不是patch 。 patch是文本布丁,无法单独执行 ,而这个是代码补丁,由打包器metro直接生成,是 可以直接执行的。


业务请求瓶颈

= 业务请求 + 业务渲染

业务请求相对好优化,又很多常用方案:

  • 业务数据缓存
  • 在上一个页面预加载下一个页面的恶业务数据

不过,项目中用了更加通用的方案:Init和业务请求 目前是穿行的 , 可否改为并行?

简单来说,在传统的 RN/Web页面里,加载一个有数据的页面,是 串行 的两大步:

  1. [等待A]:加载和执行 JS

    • Native(App 外壳)启动 RN/Webview。
    • 开始下载、解析、执行你的 biz业务包(JSBundle)。
    • React 开始初始化,render 组件,最后在useEffect 或 componentDidMount里触发下一步。
  2. [等待B]:JS 发起业务请求

    • 直到 useEffect 执行时,JS才 第一次 有机会发起网络请求(比如fetch(...))去获取业务数据。
    • 等待网络数据返回。
    • 数据返回后,调用 setState,React 再次render,页面才最终显示完整。

改进思路:用Native代替JS, 在用户进入页面时直接并行地请求业务数据 。即等待A等待B一起做。

  1. 在Native下载的资源文件中,会同时包含biz业务包和原始的业务请求的URL
  2. 原始URL中会包含动态的业务参数,该变量会按事先约定的规则进行转换,如将58.con/api?user=${user}转换为58.com/api?user=GTMC
  3. Native并行执行Biz包渲染页面,和发起URL请求获取业务数据
  4. JS侧直接调用PreFetch(cb),即可获取Native侧的请求

翻译:

当用户点击进入页面时,Native(原生线程)会 “兵分两路”, 同时开始做两件事 :

  • 任务A (Native Thread -> JS Thread) :
    • Biz -> Execute(Biz)
    • 翻译 :Native 线程开始加载并执行biz业务包(那个黄色的 Biz 框)。
    • 这会启动 JS Thread,React 开始初始化,跑你的 JS逻辑。
  • 任务B (Native Thread) :
    • 58.con/api?user=${user} ->58.com/api?user=GTMC
    • 翻译 :Native线程 在同一时刻 ,不等 JS启动, 自己先去发起业务数据请求 。
    • 它从资源文件里读取了那个原始 URL 模板(...${user})。
    • 它用 原生代码 的能力,把动态参数(${user}) 替换成一个真实的值。
    • (这里的 GTMC 可能是一个 Native 已经知道的通用值,或者是从原生缓存里读取的用户 ID 等。总之,它 不需要等 JS 启动 就能拿到这个值。)
    • 这个数据请求现在已经在网络上“飞”了。

最后,两路任务汇合:

  1. 数据先到了 (Native Thread) :

    • cb? -> Invoke cb
    • 翻译 :Native的网络请求先完成了!它拿到了数据(cb?),并把这个数据先“攥在手里”(Invoke cb)。
  2. JS 终于启动好了 (JS Thread) :

    • Registry -> PreFetch(cb)
    • 翻译 :你的 JS代码(Biz)终于运行起来了。它不再调用fetch(),而是调用一个你们约定好的“桥接”函数PreFetch(cb)。
    • 这个函数的意思是:“嘿,Native!你是不是已经帮我把我 预先(Pre) 抓取 (Fetch) 好的数据 拿到了?拿到了就通过这个 回调 (cb) 传给我。”
  3. 瞬间交付 (Native -> JS) :

    • Native 通过Invoke cb,把刚才“攥在手里”的数据,瞬间传递给了 JS 的PreFetch 回调。
  4. 渲染 :

    • JS 拿到了数据,调用 setState,页面瞬间渲染完成。

代码执行瓶颈

RN页面渲染慢的另一个原因是,它 需要执行完整的JS文件 ,而这其中有不需要执行的代码。(原因就是native和JS不相通)

比如,一个页面包含3个tab,用户进来时只会看到1个tab,其实执行这一个的代码就行。但是实际上,另外两个看不到的代码也会下载和执行。

所以这对这方面的优化就是: 将代码拆成两部分 。

首屏代码先执行,非首屏代码懒加载+懒执行 ,类似web中的DynamicImport,就可以提高首屏。(当然,RN官方没做动态导入功能,所以得自己做)

RN dynamicimport的实现,团队参考的是TC39的规范。业务只需要写一句import (”/.foo”),其他由框架层和平台层完成。

在runtime运行时,业务执行import (”/.foo”)后,框架层会去判断./foo路径对应的module是否已经install。如果没有,就会通过这个路径找到对应chunk包的URL地址,接着下载和执行chunk,最后渲染foo Component.

Chunk包的URL时一个CDN地址,而上传CDN和记录Path, URL关系的工作,不是在runtime运行时做的,而是complie time 编译时 做的。

在平台层的编译过程中,会将path和URL之间的关系表存在Biz包中,这样Runtime才能通过Path找到对应的URL。

完成这个部分,大致需要5个部分:

  1. Project: 一个项目由若干文件组成,文件间有相互依赖关系
  2. Graph: 每个文件会生成一个对应的module,所有module及其之间的依赖组成了一个graph
  3. Modules: 给 dynamic module 的集合着色,进行区分
  4. Bundles: 将多个module的集合打包成多个bundle
  5. CDN: 将bundle上传到CDN

其中最关键的关节,是 给 dynamic module 的集合着色。

  1. 分解着色:一个Graph的着色可以分解为多个基础的case,这些case的着色方案是已经确定的。
  2. dynamic map:着色完成后,会将不同颜色的模块的根路径记录下来,并和其bundle的CDNURL地址组成一个dynamic map
  3. Path to URL: dynamic map会打包到“白色”的Biz业务包中,因此在runtime调用import()时,可以通过Path找到对应的URL

其他细节,作者团队放到了开源工具metro-code-split中。

RN 页面秒开耗时结构图(原文图片补录)

RN 页面秒开并行请求优化图(原文图片补录)

RN 页面秒开资源路径图(原文图片补录)


原文存档:Notion 原文