本文由 IMWeb 团队成员 JaxJiang 首发于腾讯内部 KM 论坛。点击阅读原文查看 IMWeb 社区更多精彩文章。
导语
本文阅读时间大约需要 8 分钟,主要内容如下:
1、ReactNative 在腾讯企鹅辅导中的实践
2、ReactNative 的首屏性能优化方案
3、ReactNative 轮播图、动画实践方案
4、ReactNative 不完全避坑记录
背景
随着业务需求复杂度的不断变更,原有的 Plato
(类 RN
框架)已经无法满足业务的诉求,故年初之际就九死一生地开启了 Plato
往 RN
迁移的路程。
腾讯企鹅辅导 App 中,一共有7个页面是由前端来编写的,其中比较重要的两个:首页 & 列表页都是使用 Plato
编写,具体业务分布图如下:
故这次 Plato
迁移 RN
的工作主要体现在首页、列表页的重构、优化以及 RN
升级 0.58
所带来的其他页面的兼容问题。
首页模块剖析
APP首页作为此次重构工作最核心的页面,主要顶部功能区、科目列表、Banner、新人信息、课程卡片List 这5个区域组成。
其中 ViewPager 为第三方组件,底层实现在 IOS 环境下为 ScrollView
,Android 环境下则为 AndroidViewPager
,首页主要的渲染逻辑如下
优化之路
随着整体的架构的确定,业务的代码开发就变得非常的清晰与快速。但是随着初代的产品成型的时候,我们也发现了很多问题。
为了保持环境和变量的统一,后续优化中使用的手机统一为 vivo X9(4GB RAM),2016年底上市。 选择这款手机的原因是因为辅导75%的用户为 Android,Android 下的RN性能是明显弱于 IOS。
首屏相关
缓存问题
从上面的图片可以看到,虽然我们做了缓存,但是Loading 的时间其实还是有点长的,随之我们对和首屏相关的每个阶段耗时做了个仔细的分析( Android
),APP 启动到 RN
模块注册的这段时间前端无法掌控,所以这个因素先忽略。
阶段名称 | 过程耗时 |
---|---|
JS 业务代码加载 | 400ms |
AsyncStorage 缓存加载 | 300ms |
React 渲染 | 730ms |
渲染上屏 | 820ms |
我们可以看到我们所做的缓存优化好像没什么太多的作用, RN
中的持久化存储 AsyncStorage
的本质是 JavaScript
通过 JSBridge
与 Native
层通信,这就决定了其不能像传统 Web 应用的 Localstorage
那样快(基本毫秒级)。那怎么办呢?能不能快速的拿到缓存的数据呢?
答案是肯定有的,我们知道 RN
模块的注册其实是执行一段 JS
代码来注册的:
/** * 通过AppRegistry.registerComponent来注册 Native 的模块。 */AppRegistry.registerComponent('rn', () => DiscoverCourse); // ios模拟器展示页面AppRegistry.registerComponent('order', () => Orders); // 我的-我的订单AppRegistry.registerComponent('message', () => Messages); // 我的-消息AppRegistry.registerComponent('coursebreak', () => CourseBreak); // 课间AppRegistry.registerComponent('coursebreakall', () => Articles); // 课间-更多AppRegistry.registerComponent('index', () => DiscoverCourse); // 发现页AppRegistry.registerComponent('packdetail', () => CoursePackDetail); // 列表页AppRegistry.registerComponent('review', () => Review); // 上课 - 评价AppRegistry.registerComponent('search', () => Search) // 搜索
当时脑子灵光一现,竟然这里已经可以执行 JS 代码了,那可否在这个阶段就去与 Native 通信拉取首屏需要的数据呢?那么流程就变成下面这样:
经过这么一个小改动,奇迹出现了,APP 在第二次打开的时候速度提升非常明显,肉眼即可明显观察出性能的提升。肉眼就可以观察到首屏速度提升至少两倍以上。这里就可以节省了 JSBridge
的通信时间,效果相当可观。
这里有一个小点 ,为了减少
JSBridge
的通信时间,我们可以尽可能多的将数据放到一个 key 中,比如首屏的数据其实可以拆成多个 key 存放在 Asyncstorage 中,也可以存放在一个 key 中,这个时候我们就应该选择一个 key。
渲染相关
我们从上面的第二张图可以看出,首屏的速度是有了优化的效果,但是离完美还是有点差距,其中还包含了两个问题:
- 新用户进来没有缓存的情况怎么办?
- Banner 区域的白屏问题?
首先从第一个问题开始思考,没有缓存的情况下要提升首屏速度,我们是不是能从 React
渲染层面出发,降低 React
渲染的复杂度呢?
所以这里我们做了 React
的分段渲染,如果是通过 CGI 的数据回来,最开始我们只渲染用户能看的见的部分:
- 年级选择列表
- Banner
- 新人区域
- 课程卡片的前3张
这几个部分的高度加起来超过了现有市面上的智能设备的高度,然后第二次在将其他的数据吐回来进行第二次渲染。
同时,我们的缓存数据其实也不用缓存全部的首屏数据,也仅仅只需要缓存用户能看的见的几个部分就好;再就是 Banner 区域的缓存也仅仅只需要缓存一张图片即可,这样就可以得到一个更快的首屏。
这里我们抽象了一个数据处理模块,来专门负责首屏的相关数据处理,从而更方便后期的统一维护。
除了从数据源的角度之外,还需要尽可能的减少 React 的组件层级,利用 React16 的 Fragment 组件来减少没必要的包裹。
这波优化之后的效果对比(左之前、右拆分数据)如下:
感觉首屏的速度还是有了,但是 Banner 区域的白屏问题还存在,就算只渲染一张图片,还是有点拖节奏。
Banner 优化
在开始讲这块内容之前,要特别的赞一下研究这块内容的 @charryhuang 同学,在 banner 问题的突破过程中充分体现了他的工匠精神。
Banner 问题
banner 在产品上是一个无限轮播的滑动组件,这块的问题除了上面说到的渲染慢之外,还有一些问题,先总结如下:
- 上屏慢(本质为
Android
的Image
组件上屏慢) - 如果连续滑动可能会滑动到边界,需等动画停止才可以重新设置位置 (表现为可能会出现终点,但是产品逻辑是需要可以无限的滑动)
- banner中选中的item大小为100%,两侧item大小为94%,因为切换瞬间item大小不同,在
Android
上重定位时会出现闪动
我们可以看到最后一次滑动,直接触发了 ViewPager 的滑动,就是因为无限滚动后面图片还没有生成,动画停止事件回调慢。
经过对 GitHub 开源组件的调研,发现这类 carousel 组件都是通过监听动画事件结束来做无限轮播,故这里我们决定基于 react-native-snap-carousel
重写一套轮播组件。
解决方案
- 滚动终止的问题
原理:无限滑动banner本质是一个 FaltList,当滑动到最左或最右时会重新定位,为了做到无缝切换,需要在左或右增加几个额外的item。如45[12345]12,12345是原items,左右两侧额外增加了2个items,无限滑动时,当滑动到原5右侧的1处,则重定位到原item 1处,当滑动到原1左侧的5处,则重定位到原5位置。
原组件通过监听动画结束事件对banner进行重设 offset
,所以会出现滑动到边界的现象,所以这里可以更改为监听 offset
变化来触发重定位。
监听 offset
,当 offset
超过左阈值或右阈值时触发重定位函数。此间需要考虑用户手势操作是否停止,所以判断阈值的操作应放在手势结束上。如果超出阈值,则重定位到当前 offset±originWidth
(左加右减)的位置,如下图:
第一行表示 items,第二行表示 items 对应的下标。蓝色框为原数据,其他为额外增加的数据。
矩形的左右两边分别表示重定位后和前 offset
的位置,矩形的宽度即 originWidth
,假设蓝色矩形的左右边对应左右阈值,当banner为红色矩形所示状态时,超过右阈值,即下标为8的时候,应该重定位到下标3的地方。由于是直接设置 offset
,不需要考虑是否在基准点上。
这套方案在ios上实现起来没有任何问题,然而 Android
上会发生抖动。原因是安卓的banner具有惯性,重定位后速度变化导致“脱节“,就会出现抖动,滑动速度越快抖动越明显。
这里经过各种 Google 大法之后,我们在 Android
下面用 ViewPagerAndroid
代替 FaltList
组件,这个组件有一个好处,用户一次最多只能滑动一页,没有惯性。这就完美的解决了”脱节“的问题。我们在滑动位置监听函数中也判断了组件当前offset,当其距离基准点小于某个值的时候就可以触发重定位,用肉眼看不出来的抖动的代价,解决滑动到边界才触发重定位的问题。
-
Banner 缩放动画问题
居中的(选中的)item大小为
100%
,两侧的为94%
,当滑动时,实时改变items
的大小:从中间到两边(100%
->94%
)、从两边到中间(94%
->100%
)。最初我们采用setState
的方式来更新,刷新发现有问题,非常非常的不流畅,尤其在 Android 系统下。 经过资料的查阅之后,我们发现Animated
提供了event
函数处理事件,可以用在滚动事件上:
onScroll={Animated.event([ { nativeEvent: { x: this.scrollX }}, { listener: this.handleScroll }])}
还提供了 interpolate
函数用于线性插值,可以这样使用:
this.scrollX.interpolate(…)
这个函数接受一个 object
,包含两个key: inputRange
、 outputRange
, interpolate
会根据输入的值输出对应的 outputRange
,如 {inputRange:[0,1],outputRange:[0.94,1]}
,当 this.scrollX
为1时输出1,为0.5时输出0.97。
所以我们根据每个item的index设置其 inputRange:[itemWidth*(index-1),itemWidth*index,itemWidth*(index 1)],outputRange:[0,1,0]
,根据banner的offset对每个item的大小缩放进行控制。但这样在重定位的时候也会遇到闪的问题,原因就是重定位前和重定位后的item大小缩放是不一样的。
最后我们想到了一个办法,将所有内容相同的item共享缩放,如item序列45[12345]12中的所有相同数字对应的item同时缩放。如何做到?把同数字的item的 inputRange
和 outputRange
拼接起来!ok,让我们来试一下5对应的item:
animatedValue: {inputRange: [0, itemWidth * 1, itemWidth * 2, itemWidth * 5, itemWidth * 6, itemWidth * 7],outputRange: [0, 1, 0, 0, 1, 0]}
transform: [{ scale: animatedValue.interpolate({ inputRange: [0, 1], outputRange: [inactiveScale, 1] }),}],
可以预见,两个item的间隔( itemWidth*2
到 itemWidth*5
)之间缩放都是
inactiveScale
,而无论当前offset在哪一个item范围内,另一个都会和当前屏幕内的item大小完全同步,这样的话在切换时就可以保证切换前后的两个item大小相同。
- 上屏慢的问题
上屏慢的问题本质就是 Android
的 Image
上屏渲染慢的问题,虽然我们已经在首屏的时候只渲染一张图片,但是我们还是可以发现首屏的时候,除了 Image
其他的组件其实是已经渲染完的。
这里我们尝试使用了
FastImage
组件来代替Image
,但是效果改观并不大。
这就可以尝试换一种思路,不用 Image
组件来做 Banner
的首帧,而是改用一个和默认图片差不多的灰色 View
来作为 Banner
的首帧,这样就可以解决 Image
组件上屏慢的问题。
最终效果图如下所示:
优化后的 Carousel 组件后面我们会整理完之后,在 tnpm 上开源。
其他优化
除了这些特定优化之外,这边还可以参考一些 RN 的常规优化项目,总结如下:
图片来自文章:彻底弄懂 React Native 性能优化的来龙去脉
踩坑总结
在这次 RN 实践之中,我们也踩了不少 RN 的坑,其中很多基本都是 RN 的 Bug,有些问题在 RN 的 Github 仓库已有反馈。
Image 组件圆角问题
Github Issue: https://github.com/facebook/react-native/issues/6556
问题描述
RN 的 Image
组件在 Android
5.0及以下,如果给 Image
组件设置 borderWidth
或者 borderRadius
属性,就会导致图片显示为黑色,并且几秒后 APP 就会 Crash。
解决方案
如果是有圆角的样式需求,可以使用 View
来包裹 Image
组件,然后给 View
设置 overflow:'hidden'
和 borderRadius
来达到同样的效果。
Android 下measure函数问题
Github issue: https://github.com/facebook/react-native/issues/3282
问题描述
在 Android 系统下,我们使用一个元素的measure方法来获取其位置,从回调函数拿到的值返回是空值。
代码语言:javascript复制<View collapsable={false} ref={(component) => { this.myview = component; }} {...this.props}> {this.props.children}</View>
代码语言:javascript复制this.myview.measure((x, y, width, height, pageX, pageY) => { this.setState({ rectTop: pageY, // pageY为空 rectBottom: pageY height, rectWidth: pageX width, // pageX也为空 }); });
解决方案
经过各种 Google 大法之后,这里需要对 Android
系统下做特殊处理,必须要当前的元素(例子中为 View)加上 onLayout
props;如果你在 View
组件上使用 onLayout
,那将不会有问题;如果没有你可以加一个空的 onLayout
: onLayout=()=>{}
。
ViewPagerAndroid 白屏问题
Github issue: https://github.com/facebook/react-native/issues/4775
问题描述
ViewPagerAndroid
组件在不销毁重新渲染(React 组件不 unmount
)的情况下,会出现白屏(其实内容是有的)。
解决方案
给 ViewPagerAndroid
设置不同的 key
,这样每次 Render
的时候都会对 ViewPagerAndroid
进行重新渲染。
关注我们
IMWeb 团队隶属腾讯公司,是国内最专业的前端团队之一。
我们专注前端领域多年,负责过 QQ 资料、QQ 注册、QQ 群等亿级业务。目前聚焦于在线教育领域,精心打磨 腾讯课堂、企鹅辅导 及 ABCMouse 三大产品。
社区官网:
http://imweb.io/
加入我们:
https://hr.tencent.com/position_detail.php?id=45616
扫码关注 IMWeb前端社区 公众号,获取最新前端好文
微博、掘金、Github、知乎可搜索 IMWeb 或 IMWeb团队 关注我们。