diff --git "a/source/_posts/weex \346\264\273\345\212\250\350\220\275\345\234\260\351\241\265\346\200\247\350\203\275\344\274\230\345\214\226\345\256\236\350\267\265.md" "b/source/_posts/weex \346\264\273\345\212\250\350\220\275\345\234\260\351\241\265\346\200\247\350\203\275\344\274\230\345\214\226\345\256\236\350\267\265.md" new file mode 100644 index 0000000..47b3950 --- /dev/null +++ "b/source/_posts/weex \346\264\273\345\212\250\350\220\275\345\234\260\351\241\265\346\200\247\350\203\275\344\274\230\345\214\226\345\256\236\350\267\265.md" @@ -0,0 +1,175 @@ +# 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,同时将异常上报。 + +```javascript +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 就是一个冗余的节点。 + +```html +
+ +
+``` + +通过 Xcode 的分析工具可以看到,一个一排二的商品列表模块,一行里面就可能有 4 ~ 5 个冗余节点,如果一个页面里面有 600 个商品,300 行,那这里就有上千个节点,说明这里有优化空间。 + +![节点-前](https://haitao.nos.netease.com/835408b8-5a12-44ab-862b-6d01b4ec270c_2046_1010.jpg) + +经过优化以后,冗余节点被移除,保证布局效果的前提下,节点数和层级都减少了。 + +![节点-后](https://haitao.nos.netease.com/0e922ee7-6369-4be1-800e-20b5a1e437d1_1712_862.jpg) + +模版的优化要求对业务模块代码进行修改,对于上百个业务模块,一个个去优化会有巨大的工作量。在这里,我们可以采用「二八定律」,先对使用量达到 80% 的那几个业务模块进行优化,以求最高的性价比。这里展示的一排二商品模块就是被大量使用的模块之一。 + +经过测试,虽然总的节点数下降了,渲染性能略微有提升,但页面总占用内存数并没有明显变化。这里减少的节点,对于 native 而言,是可见区域内的节点数,由于 weex 的 list 组件会对 cell 中不可见区域的节点进行回收,所以这部分优化对于内存占用而言没有明显效果。而对于 js 部分而言,减少的时 virtual dom 的节点数据,由于单个 vnode 占用的内存并不是太多,所以减少 1000 个 vnode 也不会有质的改变。 + +### 减少 watcher + +通过 xcode 可以看到,全页面渲染完成后,整个 app 内存占用 439MB,页面占用约 300MB。 + +![总内存](https://haitao.nos.netease.com/b93019c3-bea8-42f8-827a-8d91d57bb6ac_528_281.jpg) + +而通过 safari 的 devtool 可以看到 js 部分单单是 Object 对象的内存占用却高达 200MB。可以说,js 部分的内存占用是大头,有很大的优化空间。 + +![js context](https://haitao.nos.netease.com/d8eae61b-d13f-4f35-8382-5b8d0bfc7c35_1262_229.jpg) + +进一步分析可以看到,其中大量的对象都带有 `__ob__` 属性,显然是 Vue 创建的 watcher。虽然每个对象看着不大,但由于数量非常大,所以总体占用会比较多。 + +![watcher-object](https://haitao.nos.netease.com/9d9c69db-9e1e-499e-adb3-1ddf9933ff06_1272_275.jpg) + +活动落地页的大部分模块都是偏展示,即展示的数据很多都不会因为交互而发生即时的变化,因此,这部分数据实际上是不需要创建 watcher 的。Vue 的官方文档上有[说明](https://cn.vuejs.org/v2/guide/instance.html#数据与方法),通过 `Object.freeze` 可以阻止 Vue 为对象的数据创建 watcher。 + +针对这部分模块,在获取了数据以后,通过调用 `Object.freeze` 方法对这些不需要修改的数据进行冻结,阻止的 watcher 的创建(要注意的是,Object.freeze 只能冻结一级属性,需要自行实现 deepFreeze)。在这里同样使用「二八定律」,只修改高频模块。 + +优化后,对象的数据大大降低,从 433k 下降至 354k,保留内存从 203MB 降低至 170 MB。 + +![freeze 对象](https://haitao.nos.netease.com/b7dae9f4-c4f1-48d4-85b9-9fbbd6cdf69f_1266_240.jpg) + +而 App 的内存占用也从 439MB 下降至 385MB,下降 12%,效果明显。 + +![freeze 内存](https://haitao.nos.netease.com/f2436b04-2bca-409a-a79b-f508362221e7_469_225.jpg) + +### 滚动回收 + +但由于页面数量太多,页面中需要创建的组件实例也非常多,要从根源上解决,就要减少页面的数据量。然而我们却无法限制运营所配置的页面数据量,那是不是就没有办法了呢? + +我们再仔细分析一下,weex 的 `list` 组件会回收不可见区域内的 `cell` 中所有节点的内存。但这部分内存包括哪些部分呢?是只回收 native 的视图部分,还是包括了 js context 部分呢?通过 devtool 我们发现,当一个模块处于不可见区域内时,它的对应的组件实例仍然保留,包括它的 vnode 和子组件。weex 所回收的仅仅是 native 的视图部分。 + +```html + + + +
+ +
+
+``` + +由于页面中存在大量的业务模块,每个模块又包含多个组件,因此组件实例的数量也非常多,而因为处于可见区域的模块仅有几个,所以绝大部分模块都是处于不可见区域的。 + +![不可见](https://haitao.nos.netease.com/7c8d0f2c-848f-41e1-b7f9-925f8faf8074_310_912.jpg) + +之前,我们为了解决渲染卡顿而滚动中去渲染模块,同时在这滚动的过程中,对于移动到不可见区域的模块,我们可以对其进行回收,将其恢复到骨架占位的状态,将渲染创建的 vm 和 vdom 全都回收掉,减少 vm 和 vnode 的总量。 + +```html + + + + + + + +``` + +在应用了滚动回收以后,前页面渲染后,对象的数据从 354K 下降至 87k,App 的内存从 385MB 下降至 279MB,效果明显。 + +![回收-内存](https://haitao.nos.netease.com/ad6ce387-ada1-47be-ae8f-db25554644a9_1265_220.jpg) + +经过之前从小粒度(节点、属性)到大粒度(模块、组件)的优化,单个内存有明显下降明显,App 总的内存占用从 439MB 下降至 385MB,除去 App 自身运行是需要的 110MB,页面的内存占用从 329MB 下降至 169MB,下降幅度达 48%,优化效果明显。 + +![内存 trend](https://haitao.nos.netease.com/37e29489-51ac-42a3-8204-c1c61f3585be_1298_575.jpg) + +在优化时,为了控制影响面积,我们一直都是采用二八原则,无论时冻结属性还是滚动回收,都只对几个高频的业务模块生效,因此对于不同的活动页面配置,由于模块比例不同,其效果也不同。对几个不同类型的页面进行测试,内存下降的比例保持在 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 +``` + +2. App 在打开一个 weex 活动页面时会先访问一个接口服务,服务根据页面 url 和 manifest.json 计算出当前页面的所需要的文件列表 +3. App 根据文件列表下载对应 js 文件,下载完成后将多个文件拼接成一个 js 文件,然后加载执行 + +将每个页面所需要的 js 文件总大小限制在几百 KB,且不会随着业务模块增多而变得不可控。 + + +## 总结 + +weex 在 native 端的渲染性能很好,但对于长列表而言,其内存占用仍然有优化空间。虽然最新版的 sdk 已推荐使用 recycle-list,但是考拉由于历史原因和业务场景,无法直接使用。在针对大数据的页面渲染时,无论是滚动渲染、滚动回收还是文件拆分,其实其原则都是「按需使用」,不管总体的数据量有多大,同一场景、同一时刻用户所能接触到的数据量是有限的。得益于 weex 的高性能渲染能力,无论时首次渲染还是二次重新渲染,都能保证较好的用户体验。 + +## 参考 + +- [Vue 官方文档](https://cn.vuejs.org/v2/guide/instance.html#数据与方法) +- [Weex 官方文档](https://weex.apache.org/zh/docs/components/list.html#简介) + +by [elcarim5efil](https://github.com/elcarim5efil) \ No newline at end of file