29

携程RN渲染性能优化实践

 3 years ago
source link: https://tech.ctrip.com/articles/a_frontend/4564/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

一、背景

随着 React Native 在前端业界规模性的应用越来越多,各大厂也对其渲染性能越来越看重。

渲染性能的主要评判指标是FMP与TTI,在 React Native 以跨平台前端框架身份逐步替代 Native 原生界面的同时,两者的渲染性能对比也逐渐浮出水面。

同时,渲染性能调优在业内已存在许多可借鉴的经验,而在项目实践的过程中,往往能体验到现实与理想的巨大差距。

参考业内先行者的经验,针对线上项目做渲染性能优化时,往往会出现事倍功半或不尽人意的情况。

概括主要原因是以下几个方面存在问题:

1)缺少可量化的渲染性能评判标准

2)缺少可量化、可视化的优化工具

3)简单堆砌式的使用多种优化方式,容易相互抵消优化效果

4)优化方式仅局限于前端,忽略了 Native 或 Service 优化方向

本文将从理论方案、实操经验以及实用工具三个方面介绍携程在 React Native 渲染优化方面的经验,希望能给业内的前端伙伴提供实际的帮助与启发。

二、评判标准

评判标准是基于用户交互体感实践出的量化数据,数据达标的界面可做到近似Native 界面交互体感,其中FMP和TTI是渲染优化的两个重要指标。

2.1 FMP

用户交互中,“白屏”体感的特征指标。

“白屏”的原因简单概括如下:

  • Bundle 包热更新
  • 启动 React Native 容器
  • 业务代码加载耗时
  • 服务请求耗时

但FMP耗时并非越短越好,若业务逻辑较为复杂,一味地缩短FMP容易造成TTI耗时过久,在整体性能优化上适得其反。

2.2 TTI

用户交互中,“直出”体感的特征指标。

该指标同样经过实践,得出1.6s和1.2s两条符合良好用户体感的数据线。

  • 1.6s是正常发送服务请求界面的基准
  • 1.2s是采用提前发送服务请求界面的基准

与FMP不同的是,TTI耗时越短,用户“直出”的体感越好,但也需注意界面功能的完整性(如功能阉割、功能操作延迟较多等情况)。

三、优化方向

有了可量化的评判标准后,可以针对性地确认优化方向。

以携程国际机票列表页为例,如图所示从开始调整至界面渲染结束,主要分为FMP与TTI两个阶段。

JBr2UbI.jpg!web

理论上,减少这两个阶段的耗时,就可提升渲染性能。

下面将从客户端(Native)、前端(React Native)、服务端(Service)三个方向来作详细讲解。

3.1 客户端(Native)

3.1.1 React Native 容器

Hermes 渲染引擎切换

Facebook 发布的 Hermes 引擎,在实践中的效果优异,iOS与Android端的TTI指标有明显降低。

有兴趣的同学可看下这篇文章: Hermes引擎分析

React Native 容器热启动

当 Native 打开一个崭新的 React Native 界面时,需要经过如下步骤:

6ZnAJbU.jpg!web

其中启动 React Native 容器至加载业务代码所消耗的时长是FMP指标的关键因素。

而容器热启动的意义在于将界面加载过程中的必经流程提前运行,加快界面渲染的速度。

通常,当有多个界面采用流式加载的方式时,再前一个界面调用 Native API 提前启动下一个界面所需的 React Native 容器。

React Native 容器复用

当多个界面采用流式加载,往往会存在ABAB 式的用户流水。

n2aAJjR.png!web

由于A界面在打开B界面的时候,是作为一个容器被B界面遮罩,并没有被关闭,所以A界面只被打开了一次;而B界面在这用户流水过程中实际被打开了两次,即返回A界面时,B界面的容器就被销毁了,同时其中的 React Native 容器也被销毁。

基于上述场景,可以发现优化点在于容器及其中的 React Native 容器内容可以被缓存,便于下一次进入时可以被复用。

这里需要注意两个风险点:

1)过多的容器及其中的 React Native 容器内容被缓存时,容易造成内存溢出,从而引起 App Crash;

2)复用 React Native 容器内容时,会保持上一次会话的全局变量,容易造成业务逻辑错误。

3.1.2 Bundle

Bundle 预下载

在非业务型界面中,提前下载之后可能会被打开的业务界面 Bundle(若有更新增量存在)。

该方案仅对 Bundle 提前进行下载操作,并不会进行增量文件加压及更新。

使用该方案后,会面临如下的问题:

1)App中的界面数量往往比较多,全部使用 Bundle 预下载可能会造成网络下载列队阻塞,影响正常使用。

2)Bundle 存在下载失败的概率,会丧失预下载的想要达到的效果。

针对上述可能出现的问题,需要进行深度优化:

1)预下载的时机需要符合如下几个条件:

  • 利用底包优势,以 Native 实现的界面
  • 业务改动频率较低
  • 具备一定停留度的界面

2)采取优先级异步多线程下载策略,按不同维度设定优先级,如 Bundle 使用率。

3)重试机制,类似 setInterval 轮询增量更新列表

Bundle预加载

在 React Native 容器热启动之前,解压 Bundle 文件并更新。

通常配合 React Native 容器热启动和 Bundle 预下载使用。

Bundle 的加载完成了下述3件工作:

1)更新Bundle文件

2)编译JS代码

3)执行JS代码

随着 React Native 容器采用 Hermes 引擎,Bundle 被打包为单个文件,相比使用 JSCore 被打包成多个文件来看:

1)更新 Bundle 文件阶段,单文件的更新速率优于多文件

2)编译JS代码阶段,单文件减少了多个文件加载耗时

3.1.3 Native To React Native API

Sync 同步

React Native 与 Native 之间采用异步通信机制,当线程繁忙时,会产生阻塞和等待。

另,在首屏渲染过程中,内存获取数据比较慢的场景也会出现,耗时可能高达200ms。

解决上述问题,主要有以下几个方向:

  • 对内存读写数据类 API
  • Sync API 耗时可控在毫秒级
  • Chrome Dev 不支持 Sync,需特殊处理
  • 有利于解决阻塞依赖 Native 异步接口调用的场景

此时,使用 Sync 同步方案显得可行,可解决如下场景:

  • 获取 ABTesting 实验号
  • 获取本地 Storage 内容
  • 获取功能开关列表
  • 获取屏幕 Size
  • SOTPCookie

3.2 前端(React Native)

3.2.1 Bundle 瘦身

Bundle 中存在几种文件类型,针对不同类型选择不同的优化方案:

  • 代码字符串
  • Iconfont 字符串
  • 图片文件

代码字符串

冗余代码是代码 Size 的主要问题,而冗余代码的产生主要源自于四个方面:

  • 已下线的需求代码
  • 已结项的实验代码
  • NPM 冗余调用
  • 缺乏抽象的重复代码

解决方案:

  • 整理已下线需求,删除相应代码及库文件
  • 使用组件库及方法库,减少重复代码
  • 抽象可复用的组件,使用高阶组件

图片文件

在打包压缩过程中,图片文件的压缩比极低,越大的图片占用的 Bundle Size 越大。

解决方案:

  • 较大的图片在保证清晰度的前提下压缩后打包
  • 视业务场景使用网络图片替代
  • 较小图片可以使用 IconFont 替代

3.2.2 模块

LazyRequire

在编译过程中,import会被编译成 require,require 所完成的功能是读取JavaScript 模块并执行。

而大模块的执行会耗费较多时间,使得界面加载速度变慢。

因此,优化的方向是当模块被需要才加载。但 React Native 提供的标准 require 目前并不支持动态加载。

需要修改 React Native 源码的打包功能,使其支持动态加载功能,并提供出对应的 API 来供业务方实现。

使用示例如下:

import {lazyRequire} from 'react';
let moduleA = lazyRequire('../src/ModuleA');

动态加载

使用 import 语句导入模块时,会自动执行所加载的模块。而当使用组件库或公共方法库的时候,往往并不希望如此。

假设 Common.js 文件为公共方法库

import A from './A';
import B from './B';
import C from './C';
export {
    A,
    B,
    C
}

此时若希望只引用 Common.js 中的A模块,即

import {A} from './Common.js';

但实际B和C模块代码也被执行了。

为了使程序能如你所愿的仅执行A模块,需要使用属性 getter 动态 require 的方式来修改 Common.js 文件。

const Common = {
    get A(){
        const module = require('./A');
        return (module && module.__esModule)? module.default:module;
    }
    get B(){
        const module = require('./B');
        return (module && module.__esModule)? module.default:module;
    }
    get C(){
        const module = require('./C');
        return (module && module.__esModule)? module.default:module;
    }
}
module.exports = Common;

这样在使用到A模块的时候才会执行 require(‘./A’).default,并不会加载B和C。

至此,使用该方式导出模块可以减少引用模块时的无效加载数量,达到优化渲染速度的目的。

3.2.3 渲染方式

骨架屏/呼吸态

骨架屏是有效减少用户体感“白屏”的有效措施,通常在骨架屏完成耗时较长的关键性任务,如核心服务请求、重要异步回调等。

同时,骨架屏也是缩短 FMP 标准的重要方法,主要方式:

  • 减少加载骨架屏之前的非必要模块引用
  • 核心服务请求参数的拼接可放在骨架屏渲染之前完成
  • 骨架屏自身的渲染结构足够简单

分批次渲染

分批次的概念主要运用在列表型界面或内容型界面。

顾名思义,是将界面需展示的内容,分成不同阶段/批次进行渲染,阶段/批次的数量根据业务自身情况而定,往往以覆盖满屏幕的主要区域为宜。

该方案对提升TTI有较大作用,可数量级的减少渲染内容,从而降低渲染耗时。

渐进式渲染

React Native 渲染的本质是将 JSX 构建的虚拟 DOM 树通过 Native Render 的方式绘制界面内容。

虚拟 DOM 树结构越复杂,Native Render 所需绘制的时间也越长。

从这个特性出发,可以通过降低虚拟 DOM 树结构的复杂度来减少渲染耗时,用尽可能短的时间到达 TTI 阶段。

降低虚拟 DOM 树结构复杂度的底线是最低程度得保证业务功能的完整性,而在其渲染完成后(达到TTI阶段),通过 setState 去更新渲染完整的虚拟 DOM 树结构即可。

下面两幅图在渲染过程中采用了渐进式渲染,可观察航空公司部分:

ve2ummb.jpg!web

延迟渲染

界面在相对复杂的情况下,渲染的模块会比较多,渲染的耗时也会随着需要渲染的模块数水涨船高。

对待渲染的模块区分核心和非核心,或者区分模块需渲染的轻重缓急,优先渲染核心/重要的模块,符合界面基本交互功能(达到TTI阶段),再渲染非核心/次要模块来完成整个界面的渲染工作。

按需渲染

界面中不可避免的会存在一些浮层或者二级界面,下面统称为次级界面。

这次次级界面在TTI阶段前,大部分是不需要进行渲染的,可以配合 LazyRequire 的方式完成。

预渲染

空间换时间的经典方案。

假设存在流式界面A -> B,若能在A界面时能够提前渲染B界面的话,理论上可以做到在打开B界面时做到“直出”效果。

若要做到上述方案,需要结合多个优化方案,这里只分析“预”的实现方式。

在A界面时,通过 Native API 热启动一个新的 React Native 容器,同时在新容器内预加载B界面的 Bundle 并执行。

当从A界面进入B界面时,由于B界面已经完成/正在渲染,B界面可达到“直出”效果。

优化结构

虚拟 DOM 树的结构越复杂,所需消耗的渲染时长也就越久,也就越晚到达 TTI 阶段。

首先,通过工具去观察虚拟 DOM 树结构的深度和广度,使用渐进式渲染方案减少深度,同时也使用分批次渲染方案减少广度。

其次,由于研发过程属于 TeamWork,一个结构合理的 UI 组件库可以大幅减少优化结构所需的工作量。

3.3 Service

优先发送服务请求

从进入界面到渲染界面完成(即TTI阶段)需要经过许多代码逻辑阶段,研发人员需要理清这些逻辑阶段的依赖关系,并做出优先级的策略。

为了更快的将服务请求发送出去,利用等待服务返回数据的时间差去运行其他渲染所需的逻辑,待服务数据返回后再去渲染界面。

但需要注意的是,若服务返回时间较长,可能会子执行完其他逻辑时进入 render阶段,当服务返回数据后再次 render,造成 TTI 阶段耗时有所延长。

解决方案是采用服务预搜索后,使用同步请求服务数据的方式来避免重复/无效 render。

按需异步获取数据

类似按需渲染的场景,同一个界面需要请求的服务个数往往不止一个,除了渲染界面主要模块所必须的核心服务外,其他次要模块的服务请求可以放在 TTI 阶段后请求。

jEFbY3q.jpg!web

图中红色部分的模块,在渲染的界面中并不属于核心模块,可以采取延迟按需请求的方式获取数据后再进行渲染。

服务预搜索

除静态界面外,几乎所有 CSR 界面都需要在渲染过程中发送服务请求,再根据服务请求返回的内容渲染界面。

等待服务请求响应的时长将直接拖慢到达 TTI 阶段的耗时,而提前发送服务请求是否可行?

前端在发送服务请求前往往需要拼接较多的请求参数,这些参数中存在很多变量,而变量的来源有许多是来自于用户交互。

正因为这样的场景较多,提前发送服务请求的难度也陡然上升。同时,也会给服务端带来请求数量成倍增加的副作用。

处理的方式有以下几种,可根据业务形态的不同进行选择或组合:

  • 区分业务不同场景,针对大部分场景做提前请求服务的操作
  • 需要依据多个用户交互结果作为请求参数的场景,可配合 BI 用户模型做到较精准的提前请求

在提前发送请求服务后,在进入下一界面时,代码逻辑仍然会正常发送服务请求,这里需要做好网络缓存。具体操作方式如下:

  • 请求服务时,根据请求的 url 和参数通过 Hash 生成一个唯一的 Key
  • 请求返回时,将返回的数据存入本地
  • 在一定时间内,发送相同 url 和参数的请求,都会匹配已生成的 Key,从本地拿取数据返回,而不进行真实的网络请求

四、实践工具

每个项目/界面的业务逻辑不同,从而代码逻辑也不相同。显然在优化不同界面时,采用的优化方案也不同。

那么,在优化界面过程中该如何选取适合的优化方案,显得尤为重要,而这个过程中,经验并不能起到决定性的作用。

需要借助线上和线下两方面的工具来完成性能分析工作后,再依据经验选择合适的优化方案。

4.1 Offline

借助 Chrome Devtool Performance,可以分析运行时的性能表现,主要借助以下两种方法采样性能数据。

  • 调试环境:通用的 Web 性能分析方案,打开 React Native 调试功能–>运行项目–>采样数据。
  • 真机环境:在测试环境中修改 React Native 代码,模拟 Profile 数据结构生成埋点数据。

以上两种方法存在部分差异:

  • 调试环境:采样数据来自于模拟器,数据的真实性存在偏差,多用于快速试验优化方案效果。
  • 真机环境:采样数据来自于真实机型,数据的真实性较为可靠,多用于验证优化方案效果,以及针对特殊机型验证优化效果。

两种方式采样到的性能数据,分为 Timing 和 Console 两种。

4.1.1 Timing

作用是分析视图组件渲染顺序与耗时,如下图使用 Timing 火焰图,在视图渲染层面分析性能:

  • 组件渲染顺序与耗时:“火焰模块”的长度标识组件渲染耗时(包括其子组件),至上而下可以分析组件的渲染顺序。
  • 组件间渲染空闲时间:通过两个“火焰模块”之间结构,分析各模块组件之间的渲染顺序,其中空白部分表示组件渲染空闲的耗时。
7fUFvq2.png!web

4.1.2 Console

作用是通过代码执行层面分析性能,如下图使用 Console 时序图分析性能:

  • 代码块执行耗时:单个模块表示该代码块执行的总耗时,可以更直观的分析代码块的执行顺序。
  • 异步与同步代码之间的关系:简单的埋点计时并不能精确计算异步耗时,而模块与模块之间的叠加关系,可以直观分析异步代码的执行耗时。
ZjQJz2i.png!web

4.2 Online

业务代码在上线后,存在许多环境因素,如网络情况、机型覆盖面、OS系统、Bundle 更新及解压等。

性能优化的目的是让用户切实提升使用 App 的感受,线上性能数据采样就成了重要的试金石。

线上性能数据采样主要记录的是界面渲染的 TTI 和 FMP 耗时点,采样的方式主要采用屏幕像素检测,检测用户访问的界面屏幕渲染出像素点的耗时。

采集到 FMP 和 TTI 数据之后,根据 App 版本、OS 系统类型、时间周期等维度进行拆解,绘制出对应的性能波形图和P90覆盖情况。

具体可见文章 《携程无线APM升级实践》

五、小结

渲染性能已然成为大前端工程师必须面对的一道课题。本文通过工具及方案的介绍,将前端优化的视野打开,更加系统得看待渲染性能问题。

叠加使用各种优化方案在优化渲染性能方面具备一定的普适性,部分优化理论同样也适用于 H5 与 Native 平台。

【作者简介】

佳璐,前端开发专家,关注前端框架、性能、质量、效率和新技术。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK