查看原文
其他

房产RN页面启动速度优化

杜光中 58技术 2022-03-15

导语

自58引入React-Native技术栈后,RN在房产业务线被广泛使用,业务覆盖租房、二手房、商业地产各线需求共30余个。随着房产对app体验要求的不断提高,RN页面体验优化亟待处理。


RN页面因其特有的处理逻辑,相对其他原生页面在页面启动耗时方面差距尤为明显,所以启动速度优化是RN页面体验优化的第一步。


加载流程分析

首先看下58RN架构图:
以上过程可概括为以下几个阶段:
1)初始化RN环境
2)加载框架JS
3)下载业务JS
4)运行业务JS
5)渲染页面
下文会按照以上流程探讨启动过程中耗时问题产生原因以及解决方案,这里先同步几个概念性的问题:
热更新:
虽然RN使用Js编写,但release状态下,每次访问RN页面并不是访问线上页面,而是访问已经存储在本地的bundle文件。如果没有热更新,那么更新RN页面只能通过发包解决。我们可以通过更新bundle文件来避免发包更新RN页面。
增量更新:
RN编译的bundle体积“比较恐怖”,哪怕仅仅是一个hello world的bundle体积也有1.2M。那么如果我们RN上线,只要更新一个bundle就会耗费用户大量流量。不仅如此,因为bundle文件会内置到apk中,而且每一个RN页面对应一个bundle文件。那么apk就会增加(1.2± x N)M。这显然是我们不能接受的。
我们在ReactNative0.44.0版本、0.57.8版本分别采用了不同的解决方案进行bundle的拆分,但思路都是将bundle拆分为框架JS代码、业务JS代码两本分,下文中会以coreBundle代表框架JS代码部分,bizBundle代表业务JS代码部分。

问题排查

启动过程详细步骤如下图所示:
上图根据Android平台下58App启动流程、方法命名所画,其中节点选择主要依据启动流程中必要且逻辑相对闭环的子模块划分。
根据上图节点选取,打印一组实测日志数据:
2019-12-1218:32:03.094 onCreateView---------------------------载体页生命周期2019-12-1218:32:03.106 after inflater-----------------------------载体页 inflater2019-12-1218:32:03.107 initData----------------------------------初始化数据2019-12-1218:32:03.107 initRN-----------------------------------初始化RN2019-12-1218:32:03.109 createWubaRNImmediately--------------创建WBRN核心类2019-12-1218:32:03.109 buildReactInstanceManager--------------创建RN核心类2019-12-1218:32:03.111 buildReactInstanceManager finish--------完成创建RN核心类2019-12-1218:32:03.114 onViewCreated--------------------------载体页生命周期2019-12-1218:32:03.114 loadRelease-----------------------------release模式加载2019-12-1218:32:03.115 doHotUpdate----------------------------开始热更新2019-12-1218:32:03.129 createReactContextInBackground--------预创建ReactContext2019-12-1218:32:03.211 ReactContext initialized------------------预创建完成2019-12-1218:32:03.246 showContentAndLoadBundle------------加载业务bundle2019-12-1218:32:03.250 after loadBuzBundle---------------------业务bundle加载完成2019-12-1218:32:03.250 startReactApplication[1]-------------------调用js启动入口2019-12-1218:32:03.094 onCreateView---------------------------载体页生命周期
以上数据测试环境:58App、Android系统、本地缓存最新bundle、小米Mix3,环境变化可能会导致实测数据产生部分差异。
接下来我们对这部分数据按照同步、异步线程拆分得到以下两部分并标记差量时间:
进入RN页面同步执行部分:
2019-12-12 18:32:03.094onCreateView---------------------------------0ms2019-12-12 18:32:03.106 afterinflater-----------------------------------12ms2019-12-12 18:32:03.107initData---------------------------------------1ms2019-12-12 18:32:03.107initRN-----------------------------------------0ms2019-12-12 18:32:03.109createWubaRNImmediately--------------------2ms2019-12-12 18:32:03.109 buildReactInstanceManager-------------------0ms2019-12-12 18:32:03.111buildReactInstanceManager finish-------------2ms2019-12-12 18:32:03.114onViewCreated-------------------------------3ms2019-12-12 18:32:03.114 loadRelease----------------------------------0ms2019-12-12 18:32:03.115doHotUpdate---------------------------------1ms2019-12-12 18:32:03.246showContentAndLoadBundle------------------131ms2019-12-12 18:32:03.250 afterloadBuzBundle-------------------------4ms2019-12-12 18:32:03.250startReactApplication[2]-----------------------0ms
预加载部分
2019-12-12 18:32:03.129createReactContextInBackground------------0ms
2019-12-12 18:32:03.211 ReactContextinitialized----------------------82ms
对于以上过程中相对耗时较多部分,已进行标红标记,下文中会详细分析。
1. 初始化RN环境
初始RN环境可以简单分为创建RN载体页,预加载两部分。
1)创建RN载体页
由上文测试数据可知:
2019-12-1218:32:03.094 onCreateView2019-12-1218:32:03.106 after inflater
此过程耗时12ms。
耗时原因为:通过 IO 读取 xml 文件、通过反射来创建对应的ViewNative页面也存在同样情况,且耗时较短,影响较小,暂不处理。
2)预加载:
增量更新降低了bundle体积的同时为预加载提供了可能,由上文测试数据可知:
2019-12-1218:32:03.129 createReactContextInBackground2019-12-1218:32:03.211 ReactContext initialized
此过程耗时80+ms。
包含RN核心类创建和加载coreBundle,由于App启动时进行预加载,每次进入RN页面时会使用上次创建好的环境,所以不会对页面启动速度产生影响。
 
2. 加载框架JS
由于预加载机制的存在,coreBundle被提前加载,所以进入RN页面此过程无明显感知,但是值得一提的是,由于coreBundle是基于比较或过滤的思想得到的产物,其中并非所有内容都存在依赖关系,所以导致部分对象加载后置,后文会对此详细说明。

3. 下载业务JS
由上文测试数据可知:
2019-12-12 18:32:03.115 doHotUpdate2019-12-12 18:32:03.246showContentAndLoadBundle
此过程为本地缓存bizBundle为最新的情况,耗时130+ms,且受到网络情况影响,在本地没有最新bundle需要下载时耗时增长会更加明显,由于部分高频使用业务的更新频率远远小于用户使用频率,所以我们采取静默更新策略,优先使用缓存bundle来优化请求热更新接口耗时,方案需要同时兼顾缓存命中率、版本覆盖速度两项关键指标。
静默更新处理逻辑
以上为本地缓存bizBundle可用情况,当有更新时首次进入需要下载bundle。
实测数据:WiFi:1.27s(charles 300+kb/s),256kbps:10s以上(charles 16+kb/s)
环境:58APP、Android、小米Mix3、Bundle ID 158
解决方案:
a.   对于本地有缓存但是缓存不可用的情况,可以考虑diff包增量更新。
b.   对于本地没有缓存的情况,优先考虑降低业务包的大小。

4. 运行业务JS
React-native系统框架中包含Java、C++、JS三层结构,启动过程中Java层通过C++层调用JS层,其中C++层主要包含:JSCore、bridge、JSLoader、JSCExecutor,该层对于业务开发者不可见,本文也不对此处展开分析,而是采用Java层中的最后调用时机作为起始,JS层被调用入口作为截止时机,计算差值,从而判断该过程是否有优化必要。
时机选择:
Java层中的最后调用时机:
((AppRegistry)catalystInstance.getJSModule(AppRegistry.class)).runApplication()。
JS层被调用入口:
AppRegistry.runApplication。
另外,通过systrace测试
reactRootView.startReactApplication
((AppRegistry)catalystInstance.getJSModule(AppRegistry.class)).runApplication()
执行时机相差0.415ms,故使用reactRootView.startReactApplication  代替,便于测试。
实测数据:环境 58APP、Android、小米Mix3,单位ms。
数据分析:
1:NativeInvokeStart=>JSRunning耗时80ms
1&2:引入WBAPP耗时增加30ms
3&4&5&6:业务越复杂,JS文件越多,耗时越长
问题归纳:
.  Java层中的最后调用时机到JS方法被调用入口莫名多出80ms
.  引用中间层WBAPP导致加载耗时
.  业务代码增加导致加载耗时
产生原因与解决方案:
1)Java层中的最后调用时机到JS方法被调用入口莫名多出80ms
产生原因:
分析得到耗时根本原因为AppRegistry.js类的加载,其中:
constReactNative=require(‘ReactNative’);
耗时约60ms;
constrenderApplication =require(‘renderApplication’);
耗时约15ms。
本类会拆分到coreBundle中,但coreBundle并不是正常打包的结果,里面存在未被引用的类定义,其中包含AppRegistry.js,只有buzBundle引用这个类,所以耗时被后置。
解决方案:
前置这两个类的引用时机到coreBundle,即在最后添加
__r(‘node_modules/react-native/Libraries/Renderer/shims/ReactNative.js’);__r(‘node_modules/reactnative/Libraries/ReactNative/renderApplication.js’);
由于没有coreBundle发布权限,故直接在启动时读取修改过的bundle,并将其写入
data/data/com.wuba/files/opt_rn/test.core.bundle
并将coreBundle的路径指向新文件路径,从而绕过npm发布权限、MD5文件名和内容长度校验,完成结果验证。
2)引用中间层WBAPP导致加载耗时
数据对比:
内容
加载耗时
引用WBAPP
25ms
引用WBAPP+house-middleware-sdk
62ms
引用WBAPP+house-middleware-sdk、house-middleware-components
62ms


产生原因:
sdk、组件库中包含的能力均由各自的index向外提供,这种方式的好处是开发者可以使用形如WBAPP.XXX来调用或引入中间层中的全部API,而不需要关心提供者是谁,这样的问题在于引入WBAPP的同时就引入了其中提供的全部能力,即使本业务场景不需要也会无条件引入,从而导致bizBundle体积增大,下载成本提高,加载成本提高。
影响较轻,优先级不高,且house-middleware中组件库部分已经实现按需引入。
解决方案:
基于babel-plugin-import,对于中间层做按需引入,保证业务开发者使用与改造成本足够低的同时,实现非必须类文件,不参与打包、加载。
但是这样有个缺陷,不能自定义路径,必须在lib文件夹下的文件或文件夹,这样不能很好的分组,我们的需求是希望能自定义组来区分业务组件和通用组件。那么如何通过按需引入的方式实现自定义路径,
具体可以参考:https://www.showdoc.cc/Dugz?page_id=3748379513328446,本文不符赘述。
3)业务代码增加导致加载耗时
测试数据:
内容
增加耗时
基于react-navigation构造多页面bundle
70+ms
处理高度问题、获取初始化参数
60+ms
命中到某个具体业务场景(eg:找室友列表页)
90+ms
使用react-navigation并在入口处建立路由关系,依赖显示引用
110+ms


产生原因:
a.  引用增加导致
b.  兼容高度、获取初始化参数等必要操作
解决方案:
a.  对于非多页面工程,可以优先考虑去掉本层封装,即不引用react-natigation,对于一个bundle中包含多个页面的情况目前没有找到比较好的替代、解决方案,但可以在注册页面时选择跳转协议命中页面或默认页面,其他页面动态引用,就可以将引用和加载后置。
b.  采用全局注入的方式,注入跳转协议、httpHeader等必要字段,取缔等待异步任务——
CatalystInstance.setGlobalVariable
高度问题同理。

5. 渲染页面
为了实现页面“秒开”——用户最快的开到有效视图,在业务层我们相继上线了业务数据缓存、接口拆分、分步渲染策略,对于首页和非首页业务也有不同的处理方式。
1)  首页打开速度优化:
a. 业务数据缓存:
缓存降低了网络情况对于页面打开速度的影响,这种影响在弱网情况下尤为明显。
b. 接口拆分:
接口返回数据时,有些数据可以较快得到,有些数据相对耗时,一个接口返回时,较快部分数据也要等耗时数据一起返回,所以把一个大而全的接口,拆分成几个小的相对业务内容完整的接口。
接口间请求依赖、响应依赖、响应顺序之间描述是一个相对复杂且通用的底层业务逻辑,为了更高效、更可靠的、一致性的实现此功能,我们在中间层提供简洁的封装。
形如:
其中:
. reqDep用于建立请求发起之间依赖关系
. handleDep用于建立响应之间依赖关系
. handleAfter用于描述响应之间处理顺序
注意处理:
. 依赖关系中有环的情况
. 依赖是否可达
c. 分步渲染:
按组件:
数据的分步返回且响应数据的返回顺序可控,为分步渲染提供了一个良好的前提,我们可以把页面内更靠上,返回速度更快的视图优先渲染、可见,来制造更快的体验,如进入列表页立即展示title、筛选。
按可视范围:
实测得到创建一个list类型数据observable对象是一个相对较慢的过程,
所以当一个list接口返回数据长度较大,且list的每一行对应视图较高,页面展示item个数有限时,限制list长度是一个很好的优化方式——先渲染一部分后再渲染一部分。
2) 非首页打开速度优化:
非首页页面优化方式在数据缓存、接口拆分、分步渲染可见视图上是基本一致的,最主要的区别为部分二级页面的数据可以由前置页面带入。
当缓存渲染时间踏进300ms内时,从宏观角度看整个过程,就已经可以明显的感     觉到页面在过场动画切换过程中已经渲染完成,不过从数据来看,似乎还有压缩空    间,按照上述策略优化代码后,经分析可得到过程较长的有:
· 缓存数据读取:读取头部缓存用了25ms、list缓存80ms。
· 网络请求:请求耗时主要受网络情况影响,可控性较低,请求已经进行耗  时接口拆分,业务数据缓存,可以从一定程度上弥补不足。
·多次渲染:多次渲染导致JS线层占用,从而产生耗时。
针对以上问题,给出以下编码建议,对于可以由底层封装解决掉的问题,我们会逐渐向中间层完善。
· 缓存读取耗时问题:降低缓存数据的大小,list类型可在存储前做截取,只存首屏可见内容。
· 长列表赋值:实测结果,10条数据20+ms,50条数据100+ms,普通list数据类型,转observable对象时会遍历list中的子结构把每一层级的每个节点变成observable对象,经调研,这本身没有什么好的优化方案,所以我们采取可见部分与不可见部分的分步处理,
 虽然会触发两次渲染,但是很大程度上的控制了这部分耗时,两次渲染会经虚拟dom做对比转为局部更新,所以用户无感。
· 多次渲染:这里提到的多次是符合逻辑的多次处理,并非因错误或处理不当导致,设计状态时应该考虑如下几点:
a. 降低渲染次数,考虑数据结构合并,注意过度合并会导致不必要的更新;
b. 强关联的内容建议合并处理,弱关联的内容建议分离;
c. 包含关系赋值顺序先内后外。
3)按需加载补充:
上面提及了很多首页打开速度的优化方式,但是没有采用按需加载的方式,这里有     必要解释一下,打开有缓存的首页主要问题在于bundle文件的体积,降低bundle大小可以有效提高下载效率、加载效率,所以业务拆分是要必要的,按需加载是一个  在现有工程结构下降低包体积的方式。
工程改造为按需加载后实测数据:
.  公寓正式包 364kb
.  公寓去掉独栋包 352kb
.  独栋按需加载包249kb
简单计算一下,假设a为公共依赖,b为无公共依赖部分除去独栋相关业务,c为独栋相关业务,a+b+c= 364 && a+b = 352 && a+c = 249(a = 237,b = 115,c = 12)。
可以看到,公共依赖占了绝大部分体积,如果不减少公共依赖,只拆分业务,收益十分有限,另外直接命中非主包页面的情况等待时间会增加,还会产生两个或三个loading的情况。

优化效果分析

1. 首页打开速度优化上线前后对比
开启静默更新策略优化后,在不同网络情况下,页面启动时间由变量变为常量,且速度有明显提升。
2. 非首页打开速度优化上线前后对比
开启缓存、接口拆分、分步渲染等策略后,二级页面打开速度明显提升,且首屏展示速度不受网络情况影响。
3. 静默更新策略缓存命中率
不同业务场景开启静默更新后,缓存命中率约为60%~90%。
注:产品形态不同导致用户粘度不同,用户使用频率和业务迭代频率的不同会导致     不同产品静默更新的缓存命中率不同。
4. 静默更新策略版本收敛速度

不同业务场景开启静默更新后,版本收敛情况约为首日上线覆盖到85%,7日内超过90%,并在2周内趋近99%,如果开启业务控制强制弃用缓存,则可以全量覆盖(用于处理严重线上问题,上图未包含此情况)

总结

由于ReactNative的实现机制,RN页面不可能达到原生页面的启动速度,但是我们会以原生体验为目标,尽量缩小差距,并尽量多的把复杂优化逻辑,封装在底层或模板化,来保证开发效率。
未来计划:
· 公共依赖瘦身,业务代码拆分
· 首页业务引用后置
· 58APP两个loading合并推动
· 封装复杂的优化流程,使业务开发更独立、更纯粹
· 建立监控体系,开发过程中检验各流程耗时情况是否符合标准

参考文献:
1.react-native 
https://facebook.github.io/react-native/
2.Mobx Issues
https://github.com/mobxjs/mobx/issues
3.react-navigation
https://reactnavigation.org/docs/en/getting-started.html
4.  基于ReactNative的58APP的开发实践
https://blog.csdn.net/byeweiyang/article/details/80125527

作者简介:
杜光中:58同城房产技术部-Android开发工程师。主要负责58和安居客APP租房和商业地产业务的开发和维护工作。

推荐阅读:
分布式锁的实现方式介绍
58App Android新首页改版历程
React Native应用性能瓶颈分析和优化
58同城AI算法大赛开放报名,欢迎参赛

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存