硬核实践经验 - 企鹅辅导 RN 迁移及优化总结

2022-06-29 16:09:05 浏览数 (3)

本文由 IMWeb 团队成员 JaxJiang 首发于腾讯内部 KM 论坛。点击阅读原文查看 IMWeb 社区更多精彩文章。

导语

本文阅读时间大约需要 8 分钟,主要内容如下:

1、ReactNative 在腾讯企鹅辅导中的实践

2、ReactNative 的首屏性能优化方案

3、ReactNative 轮播图、动画实践方案

4、ReactNative 不完全避坑记录

背景

随着业务需求复杂度的不断变更,原有的 Plato(类 RN 框架)已经无法满足业务的诉求,故年初之际就九死一生地开启了 PlatoRN 迁移的路程。

腾讯企鹅辅导 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 通过 JSBridgeNative 层通信,这就决定了其不能像传统 Web 应用的 Localstorage 那样快(基本毫秒级)。那怎么办呢?能不能快速的拿到缓存的数据呢?

答案是肯定有的,我们知道 RN 模块的注册其实是执行一段 JS 代码来注册的:

代码语言:javascript复制
/** * 通过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。

渲染相关

我们从上面的第二张图可以看出,首屏的速度是有了优化的效果,但是离完美还是有点差距,其中还包含了两个问题:

  1. 新用户进来没有缓存的情况怎么办?
  2. Banner 区域的白屏问题?

首先从第一个问题开始思考,没有缓存的情况下要提升首屏速度,我们是不是能从 React 渲染层面出发,降低 React 渲染的复杂度呢?

所以这里我们做了 React 的分段渲染,如果是通过 CGI 的数据回来,最开始我们只渲染用户能看的见的部分:

  • 年级选择列表
  • Banner
  • 新人区域
  • 课程卡片的前3张

这几个部分的高度加起来超过了现有市面上的智能设备的高度,然后第二次在将其他的数据吐回来进行第二次渲染

同时,我们的缓存数据其实也不用缓存全部的首屏数据,也仅仅只需要缓存用户能看的见的几个部分就好;再就是 Banner 区域的缓存也仅仅只需要缓存一张图片即可,这样就可以得到一个更快的首屏。

这里我们抽象了一个数据处理模块,来专门负责首屏的相关数据处理,从而更方便后期的统一维护。

除了从数据源的角度之外,还需要尽可能的减少 React 的组件层级,利用 React16 的 Fragment 组件来减少没必要的包裹。

这波优化之后的效果对比(左之前、右拆分数据)如下:

感觉首屏的速度还是有了,但是 Banner 区域的白屏问题还存在,就算只渲染一张图片,还是有点拖节奏。

Banner 优化

在开始讲这块内容之前,要特别的赞一下研究这块内容的 @charryhuang 同学,在 banner 问题的突破过程中充分体现了他的工匠精神。

Banner 问题

banner 在产品上是一个无限轮播的滑动组件,这块的问题除了上面说到的渲染慢之外,还有一些问题,先总结如下:

  1. 上屏慢(本质为 Android的 Image组件上屏慢)
  2. 如果连续滑动可能会滑动到边界,需等动画停止才可以重新设置位置 (表现为可能会出现终点,但是产品逻辑是需要可以无限的滑动)
  3. 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函数处理事件,可以用在滚动事件上:
代码语言:javascript复制
onScroll={Animated.event([    { nativeEvent: { x: this.scrollX }},    { listener: this.handleScroll }])}    

还提供了 interpolate函数用于线性插值,可以这样使用:

代码语言:javascript复制
this.scrollX.interpolate(…)

这个函数接受一个 object,包含两个key: inputRangeoutputRangeinterpolate会根据输入的值输出对应的 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的 inputRangeoutputRange拼接起来!ok,让我们来试一下5对应的item:

代码语言:javascript复制
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*2itemWidth*5)之间缩放都是

inactiveScale,而无论当前offset在哪一个item范围内,另一个都会和当前屏幕内的item大小完全同步,这样的话在切换时就可以保证切换前后的两个item大小相同

  • 上屏慢的问题

上屏慢的问题本质就是 AndroidImage 上屏渲染慢的问题,虽然我们已经在首屏的时候只渲染一张图片,但是我们还是可以发现首屏的时候,除了 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)加上 onLayoutprops;如果你在 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、知乎可搜索 IMWebIMWeb团队 关注我们。

0 人点赞