干货 | 携程RN渲染性能优化实践

作者简介

 

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

一、背景

随着 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两个阶段。

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

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

3.1 客户端(Native)

3.1.1 React Native 容器

Hermes 渲染引擎切换

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

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

React Native 容器热启动

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

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

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

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

React Native 容器复用

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

由于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 树结构即可。

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

延迟渲染

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

对待渲染的模块区分核心和非核心,或者区分模块需渲染的轻重缓急,优先渲染核心/重要的模块,符合界面基本交互功能(达到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 阶段后请求。

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

服务预搜索

除静态界面外,几乎所有 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 火焰图,在视图渲染层面分析性能:

  • 组件渲染顺序与耗时:“火焰模块”的长度标识组件渲染耗时(包括其子组件),至上而下可以分析组件的渲染顺序。

  • 组件间渲染空闲时间:通过两个“火焰模块”之间结构,分析各模块组件之间的渲染顺序,其中空白部分表示组件渲染空闲的耗时。

4.1.2 Console

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

  • 代码块执行耗时:单个模块表示该代码块执行的总耗时,可以更直观的分析代码块的执行顺序。

  • 异步与同步代码之间的关系:简单的埋点计时并不能精确计算异步耗时,而模块与模块之间的叠加关系,可以直观分析异步代码的执行耗时。

4.2 Online

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

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

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

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

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

五、小结

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

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

【推荐阅读】

《携程架构实践》

京东

当当

《携程人工智能实践》

京东

当当

 “携程技术”公众号

  分享,交流,成长

展开阅读全文

Windows版YOLOv4目标检测实战:训练自己的数据集

04-26
©️2020 CSDN 皮肤主题: 大白 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值