kaola-fed / blog

kaola blog
723 stars 56 forks source link

weex 活动落地页性能优化实践 #283

Open elcarim5efil opened 5 years ago

elcarim5efil commented 5 years ago

weex 活动落地页性能优化实践

考拉的活动落地页已经应用 weex 一段时间了,从 0 到 1 的应用过程中,由于自身特殊的业务场景,我们碰到了许多包括功能、性能方面的问题,本文着重对性能优化方面进行简单总结,主要讲述优化时的分析思路和方案,不涉及过多代码细节。

滚动渲染

考拉的活动落地页最大的特点是商品多、页面长。考拉大促主会场需要展示超过 600 个商品,考拉的大促主会场需要 60 多屏,甚至高达 80 屏。渲染大数据量页面时,我们遇到的难题:

  1. 数据量大,高达几万个节点,一次性全部渲染会导致页面卡顿甚至无响应
  2. 每个模块的结构、样式、数据都不太相同,所以类似无限列表那种重复利用节点的方式进行渲染并不适用
  3. weex sdk 版本仍然是比较老的版本,不能使用 recycle-list 组件
  4. 存在电梯导航组件,无法做滚动加载

虽然页面总共有几十屏,但同一时刻用户能看到的数据只有一屏高,可以根据用户滚动的位置渲染对应的模块,其他处于不可见的模块就不进行渲染。但页面中就配置电梯导航组件(即点击后跳到对应对的位置),这种特殊的交互行为会带来一些问题:从模块 1 跳到模块 10,然后页面页面往上滚动。由于不可见的模块不进行渲染,页面重新定位后,只有模块 1 和模块 10 两个渲染了,其他模块不可见。当用户下滑,页面往上滚动时,再去渲染模块 9。此时,由于模块 9 的渲染,模块高度会发生变化,引起页面抖动,用户体验很差。解决有两种方案:

  1. 在定位到模块 10 时,将模块 10 以前所有的模块都进行渲染。抖动问题可以解决,但是由于渲染模块数量太多,点击电梯导航后会出现较长时间的延迟,带来了新的用户体验问题
  2. 对于不可见的模块,渲染模块骨架进行占位,在实际模块渲染出来时由于高度不变也不会引发页面抖动,但这要求提供骨架高度准确

我们采用的是第二种方案,虽然会让开发变得麻烦,但是目前来说较为合适的方案。社区里面有模块骨架高度计算的方法,在构建时利用 css 样式进行计算。但是活动落地页的场景特殊,同一个模块的展示形式会依赖其数据决定,因此同一种模块数据不一样,其高度也可能不一样。在开发时,每个模块以组件的形式进行开发,并提供一个计算高度的方法 height(data),获取模块数据后,通过这个方法计算出模块的骨架高度。

活动落地页滚动渲染的方案可以总结为:

  1. 异步获取模块数据并缓存
  2. 立即渲染第一屏
  3. 非可见区域,先通过模块静态方法计算骨架高度,渲染模块骨架
  4. 滚动中渲染可见区域,并预先渲染 1.5 屏左右的高度

容错

Vue 在编译后会生成一个 render() 方法以在运行时生成 virtual dom,如果 render() 执行时抛出异常,由于 weex sdk 的特殊机制,会导致页面渲染会终止,未渲染的节点会停止渲染,出现大面积空白,功能无法正常使用。在业务快速迭代的开发中,很难保证在 render 时不出现异常(常见异常是在模版中引用一个不存在变量的属性),所以前期经常出现这种白屏现象。为了解决这一个问题,需要对 weex-vue-loader 进行改造,在生成的 render 方法中通过 try-catch 捕获异常,避免影响页面后续的渲染。除了容错,还需要在发生错误时将错误信息进行展示或者上报,以便进行问题定位和处理。所以在异常发生时,会调用挂在 weex.config 上的自定义方法,以进行异常处理,可以在方法中返回展示异常信息的 vdom,同时将异常上报。

render() {
  try {
    // 生成 vdom
    return this._c()
  } catch(err) {
    return weex.config.nodeErrorHandler(err)
  }
}

页面内存优化

通常情况是不太需要注意页面内存占用问题,但由于考拉的活动落地页所要渲染的节点过太多,在 iOS 端,尤其是内存只有 1G 的老机型中,会出现由 oom 引发的 app 崩溃现象。一个高度由 60 屏的活动页,在所有模块渲染完成以后,整个 App 的内存占用高达 400MB,纯 weex 的页面内存占用有 300MB,当同时打开多个活动页时,内存占用巨大,系统会强行把 App 杀死。所以,活动页刚刚上线时,iOS 端的 App 崩溃率骤然升高,我们不得不对页面的内存占用进行优化。

优化之前,首先需要知道页面中什么占用了内存以及占了多少内存,因为「你无法优化你无法测量的事物」。weex 页面在打开的时候会创建一个 js context,因此可以利用 safari 的 devtool 对 weex 的 js 部分进行分析。另外,可以通过 Xcode 自带的工具对 native 渲染的节点进行分析。

减少节点

weex 官方文档建议控制节点层级,首先就来尝试对模版的部分进行优化。通过 Xcode 的分析工具可以对页面元素节点进行分析,可以发现,有许多宽高一直的节点。例如需要展示一张图片,代码里用 div 包裹 image,但是实际上这两个元素的标签的宽高时完全一样的,即使把 div 去掉,所展示的布局效果不变。这里的 div 就是一个冗余的节点。

<div>
  <image></image>
</div>

通过 Xcode 的分析工具可以看到,一个一排二的商品列表模块,一行里面就可能有 4 ~ 5 个冗余节点,如果一个页面里面有 600 个商品,300 行,那这里就有上千个节点,说明这里有优化空间。

节点-前

经过优化以后,冗余节点被移除,保证布局效果的前提下,节点数和层级都减少了。

节点-后

模版的优化要求对业务模块代码进行修改,对于上百个业务模块,一个个去优化会有巨大的工作量。在这里,我们可以采用「二八定律」,先对使用量达到 80% 的那几个业务模块进行优化,以求最高的性价比。这里展示的一排二商品模块就是被大量使用的模块之一。

经过测试,虽然总的节点数下降了,渲染性能略微有提升,但页面总占用内存数并没有明显变化。这里减少的节点,对于 native 而言,是可见区域内的节点数,由于 weex 的 list 组件会对 cell 中不可见区域的节点进行回收,所以这部分优化对于内存占用而言没有明显效果。而对于 js 部分而言,减少的时 virtual dom 的节点数据,由于单个 vnode 占用的内存并不是太多,所以减少 1000 个 vnode 也不会有质的改变。

减少 watcher

通过 xcode 可以看到,全页面渲染完成后,整个 app 内存占用 439MB,页面占用约 300MB。

总内存

而通过 safari 的 devtool 可以看到 js 部分单单是 Object 对象的内存占用却高达 200MB。可以说,js 部分的内存占用是大头,有很大的优化空间。

js context

进一步分析可以看到,其中大量的对象都带有 __ob__ 属性,显然是 Vue 创建的 watcher。虽然每个对象看着不大,但由于数量非常大,所以总体占用会比较多。

watcher-object

活动落地页的大部分模块都是偏展示,即展示的数据很多都不会因为交互而发生即时的变化,因此,这部分数据实际上是不需要创建 watcher 的。Vue 的官方文档上有说明,通过 Object.freeze 可以阻止 Vue 为对象的数据创建 watcher。

针对这部分模块,在获取了数据以后,通过调用 Object.freeze 方法对这些不需要修改的数据进行冻结,阻止的 watcher 的创建(要注意的是,Object.freeze 只能冻结一级属性,需要自行实现 deepFreeze)。在这里同样使用「二八定律」,只修改高频模块。

优化后,对象的数据大大降低,从 433k 下降至 354k,保留内存从 203MB 降低至 170 MB。

freeze 对象

而 App 的内存占用也从 439MB 下降至 385MB,下降 12%,效果明显。

freeze 内存

滚动回收

但由于页面数量太多,页面中需要创建的组件实例也非常多,要从根源上解决,就要减少页面的数据量。然而我们却无法限制运营所配置的页面数据量,那是不是就没有办法了呢?

我们再仔细分析一下,weex 的 list 组件会回收不可见区域内的 cell 中所有节点的内存。但这部分内存包括哪些部分呢?是只回收 native 的视图部分,还是包括了 js context 部分呢?通过 devtool 我们发现,当一个模块处于不可见区域内时,它的对应的组件实例仍然保留,包括它的 vnode 和子组件。weex 所回收的仅仅是 native 的视图部分。

<list>
  <cell>
    <!-- 当 cell 处于不可见区域时,内部元素会被回收 -->
    <div></div>
    <moduleA></moduleA>
  </cell>
</list>

由于页面中存在大量的业务模块,每个模块又包含多个组件,因此组件实例的数量也非常多,而因为处于可见区域的模块仅有几个,所以绝大部分模块都是处于不可见区域的。

不可见

之前,我们为了解决渲染卡顿而滚动中去渲染模块,同时在这滚动的过程中,对于移动到不可见区域的模块,我们可以对其进行回收,将其恢复到骨架占位的状态,将渲染创建的 vm 和 vdom 全都回收掉,减少 vm 和 vnode 的总量。

<list>
  <cell>
    <!-- 通过标识位控制渲染 -->
    <moduleA v-if="moduleA.inSight" />
    <skeleton v-else />
  </cell>
</list>

在应用了滚动回收以后,前页面渲染后,对象的数据从 354K 下降至 87k,App 的内存从 385MB 下降至 279MB,效果明显。

回收-内存

经过之前从小粒度(节点、属性)到大粒度(模块、组件)的优化,单个内存有明显下降明显,App 总的内存占用从 439MB 下降至 385MB,除去 App 自身运行是需要的 110MB,页面的内存占用从 329MB 下降至 169MB,下降幅度达 48%,优化效果明显。

内存 trend

在优化时,为了控制影响面积,我们一直都是采用二八原则,无论时冻结属性还是滚动回收,都只对几个高频的业务模块生效,因此对于不同的活动页面配置,由于模块比例不同,其效果也不同。对几个不同类型的页面进行测试,内存下降的比例保持在 20% ~ 50% 之间。

限制历史栈

在优化后,即便单个页面内存占用减少了,但如果对同一时间打开的页面数量不作限制,App 的总内存占用也会不断上升,直至崩溃。因此需要对此进行页面的历史栈大小进行限制。

  1. App 限制大小,一般可以限制 5~6,但考拉的活动页面实在太大,针对不同的机型,可能需要一个动态的大小,1G 内存的机子限制为 1~2,内存大一点的机子限制为 3~4
  2. App 提供关闭上一个页面的接口,由前端业务代码控制页面关闭逻辑,对于同一类型(主会场活动页)的大型页面,通过调用这个接口关闭上一个页面,控制此类页面的数量

限制历史栈大小的方法还有很多,具体需要根据实际的业务场景去定制,而且通常需要 App 端提供相关能力。

文件拆分

weex 本身只提供了打包单个文件 bundle 的能力,无法像在 h5 那样进行按需加载。目前活动页的模块种类已经解决两百个,而每个页面实际所需要的模块类型一般只有 10 个左右,因此没每次发布新版本时,App 都需要下载一个将近 2MB 的 js 文件。为了解决这一个问题,我们需要按照当前页面所需要的模块进行代码拆分。

  1. 构建时,会按照配置将每个模块输出到不同的 chunk 中,并将不同 chunk 对应的 module 信息放到 manifest.json 中保存
common.js
module-a.js
module-b.js
  1. App 在打开一个 weex 活动页面时会先访问一个接口服务,服务根据页面 url 和 manifest.json 计算出当前页面的所需要的文件列表
  2. App 根据文件列表下载对应 js 文件,下载完成后将多个文件拼接成一个 js 文件,然后加载执行

将每个页面所需要的 js 文件总大小限制在几百 KB,且不会随着业务模块增多而变得不可控。

总结

weex 在 native 端的渲染性能很好,但对于长列表而言,其内存占用仍然有优化空间。虽然最新版的 sdk 已推荐使用 recycle-list,但是考拉由于历史原因和业务场景,无法直接使用。在针对大数据的页面渲染时,无论是滚动渲染、滚动回收还是文件拆分,其实其原则都是「按需使用」,不管总体的数据量有多大,同一场景、同一时刻用户所能接触到的数据量是有限的。得益于 weex 的高性能渲染能力,无论时首次渲染还是二次重新渲染,都能保证较好的用户体验。

参考

by elcarim5efil

Froguard commented 5 years ago

👍