阅读 11907

京喜小程序的高性能打造之路

本文阅读时长约15分钟。京喜小程序开发团队核心成员倾力之作,都是干货,读完一定会收获满满,请大家耐心阅读~

背景

京喜小程序自去年双十一上线微信购物一级入口后,时刻迎接着亿级用户量的挑战,细微的体验细节都有可能被无限放大,为此,“极致的页面性能”、“友好的产品体验” 和 “稳定的系统服务” 成为了我们开发团队的最基本执行原则。

首页作为小程序的门户,其性能表现和用户留存率息息相关。因此,我们对京喜首页进行了一次全方位的升级改造,从加载、渲染和感知体验几大维度深挖小程序的性能可塑性。

除此之外,京喜首页在微信小程序、H5、APP 三端都有落地场景,为了提高研发效率,我们使用了 Taro 框架实现多端统一,因此下文中有部分内容是和 Taro 框架息息相关的。

怎么定义高性能?

提起互联网应用性能这个词,很多人在脑海中的词法解析就是,“是否足够快?”,似乎加载速度成为衡量系统性能的唯一指标。但这其实是不够准确的,试想一下,如果一个小程序加载速度非常快,用户花费很短时间就能看到页面的主体内容,但此时搜索框却无法输入内容,功能无法被流畅使用,用户可能就不会关心页面渲染有多快了。所以,我们不应该单纯考虑速度指标而忽略用户的感知体验,而应该全方位衡量用户在使用过程中能感知到的与应用加载相关的每个节点。

谷歌为 Web 应用定义了以用户为中心的性能指标体系,每个指标都与用户体验节点息息相关:

体验 指标
页面能否正常访问? 首次内容绘制 (First Contentful Paint, FCP)
页面内容是否有用? 首次有效绘制 (First Meaningful Paint, FMP)
页面功能是否可用? 可交互时间 (Time to Interactive, TTI)

其中,“是否有用?” 这个问题是非常主观的,对于不同场景的系统可能会有完全不一样的回答,所以 FMP 是一个比较模糊的概念指标,不存在规范化的数值衡量。

小程序作为一个新的内容载体,衡量指标跟 Web 应用是非常类似的。对于大多数小程序而言,上述指标对应的含义为:

  • FCP:白屏加载结束;
  • FMP:首屏渲染完成;
  • TTI:所有内容加载完成;

综上,我们已基本确定了高性能的概念指标,接下来就是如何利用数值指标来描绘性能表现。

小程序官方性能指标

小程序官方针对小程序性能表现制订了权威的数值指标,主要围绕 渲染表现setData 数据量元素节点数网络请求延时 这几个维度来给予定义(下面只列出部分关键指标):

  • 首屏时间不超过 5 秒;
  • 渲染时间不超过 500ms;
  • 每秒调用 setData 的次数不超过 20 次;
  • setData 的数据在 JSON.stringify 后不超过 256kb;
  • 页面 WXML 节点少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个;
  • 所有网络请求都在 1 秒内返回结果;

详见 小程序性能评分规则

我们应该把这一系列的官方指标作为小程序的性能及格线,不断地打磨和提升小程序的整体体验,降低用户流失率。另外,这些指标会直接作为小程序体验评分工具的性能评分规则(体验评分工具会根据这些规则的权重和求和公式计算出体验得分)。

我们团队内部在官方性能指标的基础上,进一步浓缩优化指标系数,旨在对产品体验更高要求:

  • 首屏时间不超过 2.5 秒;
  • setData 的数据量不超过 100kb;
  • 所有网络请求都在 1 秒内返回结果;
  • 组件滑动、长列表滚动无卡顿感;

体验评分工具

小程序提供了 体验评分工具(Audits 面板) 来测量上述的指标数据,其集成在开发者工具中,在小程序运行时实时检查相关问题点,并为开发者给出优化建议。

体验评分面板

以上截图均来自小程序官方文档

体验评分工具是目前检测小程序性能问题最直接有效的途径,我们团队已经把体验评分作为页面/组件是否能达到精品门槛的重要考量手段之一。

小程序后台性能分析

我们知道,体验评分工具是在本地运行小程序代码时进行分析,但性能数据往往需要在真实环境和大数据量下才更有说服力。恰巧,小程序管理平台小程序助手 为开发者提供了大量的真实数据统计。其中,性能分析面板从 启动性能运行性能网络性能 这三个维度分析数据,开发者可以根据客户端系统、机型、网络环境和访问来源等条件做精细化分析,非常具有考量价值。

小程序助手性能分析

其中,启动总耗时 = 小程序环境初始化 + 代码包加载 + 代码执行 + 渲染耗时

第三方测速系统

很多时候,宏观的耗时统计对于性能瓶颈点分析往往是杯水车薪,作用甚少,我们需要更细致地针对某个页面某些关键节点作测速统计,排查出暴露性能问题的代码区块,才能更有效地针对性优化。京喜小程序使用的是内部自研的测速系统,支持对地区、运营商、网络、客户端系统等多条件筛选,同时也支持数据可视化、同比分析数据等能力。京喜首页主要围绕 页面 onLoadonReady数据加载完成首屏渲染完成各业务组件首次渲染完成 等几个关键节点统计测速上报,旨在全链路监控性能表现。

内部测速系统

另外,微信为开发者提供了 测速系统,也支持针对客户端系统、网络类型、用户地区等维度统计数据,有兴趣的可以尝试。

了解小程序底层架构

为了更好地为小程序制订性能优化措施,我们有必要先了解小程序的底层架构,以及与 web 浏览器的差异性。

微信小程序是大前端跨平台技术的其中一种产物,与当下其他热门的技术 React Native、Weex、Flutter 等不同,小程序的最终渲染载体依然是浏览器内核,而不是原生客户端。

而对于传统的网页来说,UI 渲染和 JS 脚本是在同一个线程中执行,所以经常会出现 “阻塞” 行为。微信小程序基于性能的考虑,启用了双线程模型

  • 视图层:也就是 webview 线程,负责启用不同的 webview 来渲染不同的小程序页面;
  • 逻辑层:一个单独的线程执行 JS 代码,可以控制视图层的逻辑;

双线程模型图

上图来自小程序官方开发指南

然而,任何线程间的数据传输都是有延时的,这意味着逻辑层和视图层间通信是异步行为。除此之外,微信为小程序提供了很多客户端原生能力,在调用客户端原生能力的过程中,微信主线程和小程序双线程之间也会发生通信,这也是一种异步行为。这种异步延时的特性会使运行环境复杂化,稍不注意,就会产出效率低下的编码。

作为小程序开发者,我们常常会被下面几个问题所困扰:

  • 小程序启动慢;
  • 白屏时间长;
  • 页面渲染慢;
  • 运行内存不足;

接下来,我们会结合小程序的底层架构分析出这些问题的根本原因,并针对性地给出解决方案。

小程序启动太慢?

小程序启动阶段,也就是如下图所示的展示加载界面的阶段。

小程序加载界面

在这个阶段中(包括启动前后的时机),微信会默默完成下面几项工作:

1. 准备运行环境:

在小程序启动前,微信会先启动双线程环境,并在线程中完成小程序基础库的初始化和预执行。

小程序基础库包括 WebView 基础库和 AppService 基础库,前者注入到视图层中,后者注入到逻辑层中,分别为所在层级提供其运行所需的基础框架能力。

2. 下载小程序代码包:

在小程序初次启动时,需要下载编译后的代码包到本地。如果启动了小程序分包,则只有主包的内容会被下载。另外,代码包会保留在缓存中,后续启动会优先读取缓存。

3. 加载小程序代码包:

小程序代码包下载好之后,会被加载到适当的线程中执行,基础库会完成所有页面的注册。

在此阶段,主包内的所有页面 JS 文件及其依赖文件都会被自动执行。

在页面注册过程中,基础库会调用页面 JS 文件的 Page 构造器方法,来记录页面的基础信息(包括初始数据、方法等)。

4. 初始化小程序首页:

在小程序代码包加载完之后,基础库会根据启动路径找到首页,根据首页的基础信息初始化一个页面实例,并把信息传递给视图层,视图层会结合 WXML 结构、WXSS 样式和初始数据来渲染界面。

综合考虑,为了节省小程序的“点点点”时间(小程序的启动动画是三个圆点循环跑马灯),除了给每位用户发一台高配 5G 手机并顺带提供千兆宽带网络之外,还可以尽量 控制代码包大小,缩小代码包的下载时间。

无用文件、函数、样式剔除

经过多次业务迭代,无可避免的会存在一些弃用的组件/页面,以及不被调用的函数、样式规则,这些冗余代码会白白占据宝贵的代码包空间。而且,目前小程序的打包会将工程下所有文件都打入代码包内,并没有做依赖分析。

因此,我们需要及时地剔除不再使用的模块,以保证代码包空间利用率保持在较高水平。通过一些工具化手段可以有效地辅助完成这一工作。

  • 文件依赖分析

在小程序中,所有页面的路径都需要在小程序代码根目录 app.json 中被声明,类似地,自定义组件也需要在页面配置文件 page.json 中被声明。另外,WXML、WXSS 和 JS 的模块化都需要特定的关键字来声明依赖引用关系。

WXML 中的 importinclude

<!-- A.wxml -->
<template name='A'>
  <text>{{text}}</text>
</template>

<!-- B.wxml -->
<import src="A.wxml"/>
<template is="A" data="{{text: 'B'}}"/>
复制代码
<!-- A.wxml -->
<text> A </text>

<!-- B.wxml -->
<include src="A.wxml"/>
<text> B </text>
复制代码

WXSS 中的 @import

@import './A.wxss'
复制代码

JS 中的 require/import

const A = require('./A')
复制代码

所以,可以说小程序里的所有依赖模块都是有迹可循的,我们只需要利用这些关键字信息递归查找,遍历出文件依赖树,然后把没用的模块剔除掉。

  • JS、CSS Tree-Shaking

JS Tree-Shaking 的原理就是借助 Babel 把代码编译成抽象语法树(AST),通过 AST 获取到函数的调用关系,从而把未被调用的函数方法剔除掉。不过这需要依赖 ES module,而小程序最开始是遵循 CommonJS 规范的,这意味着是时候来一波“痛并快乐着”的改造了。

而 CSS 的 Tree-Shaking 可以利用 PurifyCSS 插件来完成。关于这两项技术,有兴趣的可以“谷歌一下”,这里就不铺开细讲了。

题外,京东的小程序团队已经把这一系列工程化能力集成在一套 CLI 工具中,有兴趣的可以看看这篇分享:小程序工程化探索

减少代码包中的静态资源文件

小程序代码包最终会经过 GZIP 压缩放在 CDN 上,但 GZIP 压缩对于图片资源来说效果非常低。如 JPGPNG 等格式文件,本身已经被压缩过了,再使用 GZIP 压缩有可能体积更大,得不偿失。所以,除了部分用于容错的图片必须放在代码包(譬如网络异常提示)之外,建议开发者把图片、视频等静态资源都放在 CDN 上。

需要注意,Base64 格式本质上是长字符串,和 CDN 地址比起来也会更占空间。

逻辑后移,精简业务逻辑

这是一个 “痛并快乐着” 的优化措施。“痛” 是因为需要给后台同学提改造需求,分分钟被打;“快乐” 则是因为享受删代码的过程,而且万一出 Bug 也不用背锅了...(开个玩笑)

通过让后台承担更多的业务逻辑,可以节省小程序前端代码量,同时线上问题还支持紧急修复,不需要经历小程序的提审、发布上线等繁琐过程。

总结得出,一般不涉及前端计算的展示类逻辑,都可以适当做后移。譬如京喜首页中的幕帘弹窗(如下图)逻辑,这里共有 10+ 种弹窗类型,以前的做法是前端从接口拉取 10+ 个不同字段,根据优先级和 “是否已展示”(该状态存储在本地缓存) 来决定展示哪一种,最后代码大概是这样的:

// 检查每种弹窗类型是否已展示
Promise.all([
  check(popup_1),
  check(popup_2),
  // ...
  check(popup_n)
]).then(result => {
  // 优先级排序
  const queue = [{
    show: result.popup_1
    data: data.popup_1
  }, {
    show: result.popup_2
    data: data.popup_2
  }, 
  // ...
  {
    show: result.popup_n
    data: data.popup_n
  }]
})
复制代码

逻辑后移之后,前端只需负责拿幕帘字段做展示就可以了,代码变成这样:

this.setData({
  popup: data.popup
})
复制代码

首页幕帘弹窗

复用模板插件

京喜首页作为电商系统的门户,需要应对各类频繁的营销活动、升级改版等,同时也要满足不同用户属性的界面个性化需求(俗称 “千人千面”)。如何既能减少为应对多样化场景而产生的代码量,又可以提升研发效率,成为燃眉之急。

类似于组件复用的理念,我们需要提供更丰富的可配置能力,实现更高的代码复用度。参考小时候很喜欢玩的 “乐高” 积木玩具,我们把首页模块的模板元素作颗粒度更细的划分,根据样式和功能抽象出一块块“积木”原料(称为插件元素)。当首页模块在处理接口数据时,会启动插件引擎逐个装载插件,最终输出个性化的模板样式,整个流程就好比堆积木。当后续产品/运营需要新增模板时,只要在插件库中挑选插件排列组合即可,不需要额外新增/修改组件内容,也更不会产生难以维护的 if / else 逻辑,so easy~

当然,要完成这样的插件化改造免不了几个先决条件:

  • 用户体验设计的统一。如果设计风格总是天差地别的,强行插件化只会成为累赘。
  • 服务端接口的统一。同上,如果得浪费大量的精力来兼容不同模块间的接口字段差异,将会非常蛋疼。

下面为大家提供部分例程来辅助理解。其中,use 方法会接受各类处理钩子最终拼接出一个 Function,在对应模块处理数据时会被调用。

// bi.helper.js

/**
 * 插件引擎
 * @param {function} options.formatName 标题处理钩子
 * @param {function} options.validList 数据校验器钩子
 */ 
const use = options => data => format(data)

/**
 * 预置插件库
 */ 
nameHelpers = {
  text: data => data.text,
  icon: data => data.icon
}
listHelpers = {
  single: list => list.slice(0, 1),
  double: list => list.slice(0, 2)
}

/**
 * “堆积木”
 */
export default {
  1000: use({
    formatName: nameHelpers.text,
    validList: listHelpers.single
  }),

  1001: use({
    formatName: nameHelpers.icon,
    validList: listHelpers.double
  })
}
复制代码
<!-- bi.wxml -->
<!-- 各模板节点实现 -->
<template name="renderName">
  <view wx:if="{{type === 'text'}}"> text </view>
  <view wx:elif="{{type === 'icon'}}"> icon </view>
</template>

<view class="bi__name">
  <template is="renderName" data="{{...data.name}"/>
</view>
复制代码
// bi.js
Component({
  ready() {
    // 根据 tpl 值选择解析函数
    const formatData = helper[data.tpl]
    this.setData({
      data: formatData(data)
    })
  }
})
复制代码

分包加载

小程序启动时只会下载主包/独立分包,启用分包可以有效减少下载时间。(独立)分包需要遵循一些原则,详细的可以看官方文档:

部分页面 h5 化

小程序提供了 web-view 组件,支持在小程序环境内访问网页。当实在无法在小程序代码包中腾出多余空间时,可以考虑降级方案 —— 把部分页面 h5 化。

小程序和 h5 的通信可以通过 JSSDK 或 postMessage 通道来实现,详见 小程序开发文档

白屏时间过长?

白屏阶段,是指小程序代码包下载完(也就是启动界面结束)之后,页面完成首屏渲染的这一阶段,也就是 FMP (首次有效绘制)。

FMP 没法用标准化的指标定义,但对于大部分小程序来说,页面首屏展示的内容都需要依赖服务端的接口数据,那么影响白屏加载时间的主要由这两个元素构成:

  • 网络资源加载时间
  • 渲染时间

启用本地缓存

小程序提供了读写本地缓存的接口,数据存储在设备硬盘上。由于本地 I/O 读写(毫秒级)会比网络请求(秒级)要快很多,所以在用户访问页面时,可以优先从缓存中取上一次接口调用成功的数据来渲染视图,待网络请求成功后再覆盖最新数据重新渲染。除此之外,缓存数据还可以作为兜底数据,避免出现接口请求失败时页面空窗,一石二鸟。

但并非所有场景都适合缓存策略,譬如对数据即时性要求非常高的场景(如抢购入口)来说,展示老数据可能会引发一些问题。

小程序默认会按照 不同小程序不同微信用户 这两个维度对缓存空间进行隔离。诸如京喜小程序首页也采用了缓存策略,会进一步按照 数据版本号用户属性 来对缓存进行再隔离,避免信息误展示。

数据预拉取

小程序官方为开发者提供了一个在小程序冷启动时提前拉取第三方接口的能力:数据预拉取

关于冷启动和热启动的定义可以看 这里

数据预拉取的原理其实很简单,就是在小程序启动时,微信服务器代理小程序客户端发起一个 HTTP 请求到第三方服务器来获取数据,并且把响应数据存储在本地客户端供小程序前端调取。当小程序加载完成后,只需调用微信提供的 API wx.getBackgroundFetchData 从本地缓存获取数据即可。这种做法可以充分利用小程序启动和初始化阶段的等待时间,使更快地完成页面渲染。

京喜小程序首页已经在生产环境实践过这个能力,从每日千万级的数据分析得出,预拉取使冷启动时获取到接口数据的时间节点从 2.5s 加速到 1s(提速了 60%)。虽然提升效果非常明显,但这个能力依然存在一些不成熟的地方:

  • 预拉取的数据会被强缓存

    由于预拉取的请求最终是由微信的服务器发起的,也许是出于服务器资源限制的考虑,预拉取的数据会缓存在微信本地一段时间,缓存失效后才会重新发起请求。经过真机实测,在微信购物入口冷启动京喜小程序的场景下,预拉取缓存存活了 30 分钟以上,这对于数据实时性要求比较高的系统来说是非常致命的。

  • 请求体和响应体都无法被拦截

    由于请求第三方服务器是从微信的服务器发起的,而不是从小程序客户端发起的,所以本地代理无法拦截到这一次真实请求,这会导致开发者无法通过拦截请求的方式来区分获取线上环境和开发环境的数据,给开发调试带来麻烦。

    小程序内部接口的响应体类型都是 application/octet-stream,即数据格式未知,使本地代理无法正确解析。

  • 微信服务器发起的请求没有提供区分线上版和开发版的参数,且没有提供用户 IP 等信息

如果这几个问题点都不会影响到你的场景,那么可以尝试开启预拉取能力,这对于小程序首屏渲染速度是质的提升。

跳转时预拉取

为了尽快获取到服务端数据,比较常见的做法是在页面 onLoad 钩子被触发时发起网络请求,但其实这并不是最快的方式。从发起页面跳转,到下一个页面 onLoad 的过程中,小程序需要完成一些环境初始化及页面实例化的工作,耗时大概为 300 ~ 400 毫秒。

实际上,我们可以在发起跳转前(如 wx.navigateTo 调用前),提前请求下一个页面的主接口并存储在全局 Promise 对象中,待下个页面加载完成后从 Promise 对象中读取数据即可。

这也是双线程模型所带来的优势之一,不同于多页面 web 应用在页面跳转/刷新时就销毁掉 window 对象。

分包预下载

如果开启了分包加载能力,在用户访问到分包内某个页面时,小程序才会开始下载对应的分包。当处于分包下载阶段时,页面会维持在 “白屏” 的启动态,这用户体验是比较糟糕的。

幸好,小程序提供了 分包预下载 能力,开发者可以配置进入某个页面时预下载可能会用到的分包,避免在页面切换时僵持在 “白屏” 态。

非关键渲染数据延迟请求

这是关键渲染路径优化的其中一个思路,从缩短网络请求时延的角度加快首屏渲染完成时间。

关键渲染路径(Critical Rendering Path) 是指在完成首屏渲染的过程中必须发生的事件。

以京喜小程序如此庞大的小程序项目为例,每个模块背后都可能有着海量的后台服务作支撑,而这些后台服务间的通信和数据交互都会存在一定的时延。我们根据京喜首页的页面结构,把所有模块划分成两类:主体模块(导航、商品轮播、商品豆腐块等)和 非主体模块(幕帘弹窗、右侧挂件等)。

在初始化首页时,小程序会发起一个聚合接口请求来获取主体模块的数据,而非主体模块的数据则从另一个接口获取,通过拆分的手段来降低主接口的调用时延,同时减少响应体的数据量,缩减网络传输时间。

京喜首页浮层模块

分屏渲染

这也是关键渲染路径优化思路之一,通过延迟非关键元素的渲染时机,为关键渲染路径腾出资源。

类似上一条措施,继续以京喜小程序首页为例,我们在 主体模块 的基础上再度划分出 首屏模块(商品豆腐块以上部分) 和 非首屏模块(商品豆腐块及以下部分)。当小程序获取到主体模块的数据后,会优先渲染首屏模块,在所有首屏模块都渲染完成后才会渲染非首屏模块和非主体模块,以此确保首屏内容以最快速度呈现。

京喜首页分屏渲染

为了更好地呈现效果,上面 gif 做了降速处理

接口聚合,请求合并

在小程序中,发起网络请求是通过 wx.request 这个 API。我们知道,在 web 浏览器中,针对同一域名的 HTTP 并发请求数是有限制的;在小程序中也有类似的限制,但区别在于不是针对域名限制,而是针对 API 调用:

  • wx.request (HTTP 连接)的最大并发限制是 10 个;
  • wx.connectSocket (WebSocket 连接)的最大并发限制是 5 个;

超出并发限制数目的 HTTP 请求将会被阻塞,需要在队列中等待前面的请求完成,从而一定程度上增加了请求时延。因此,对于职责类似的网络请求,最好采用节流的方式,先在一定时间间隔内收集数据,再合并到一个请求体中发送给服务端。

图片资源优化

图片资源一直是移动端系统中抢占大流量的部分,尤其是对于电商系统。优化图片资源的加载可以有效地加快页面响应时间,提升首屏渲染速度。

  • 使用 WebP 格式

WebP 是 Google 推出的一种支持有损/无损压缩的图片文件格式,得益于更优的图像数据压缩算法,其与 JPG、PNG 等格式相比,在肉眼无差别的图片质量前提下具有更小的图片体积(据官方说明,WebP 无损压缩体积比 PNG 小 26%,有损压缩体积比 JPEG 小 25-34%)。

小程序的 image 组件 支持 JPG、PNG、SVG、WEBP、GIF 等格式。

  • 图片裁剪&降质

鉴于移动端设备的分辨率是有上限的,很多图片的尺寸常常远大于页面元素尺寸,这非常浪费网络资源(一般图片尺寸 2 倍于页面元素真实尺寸比较合适)。得益于京东内部强大的图片处理服务,我们可以通过资源的命名规则和请求参数来获取服务端优化后的图片:

裁剪成 100x100 的图片:https://{host}/s100x100_jfs/{file_path}

降质 70%:https://{href}!q70

  • 图片懒加载、雪碧图(CSS Sprite)优化

这两者都是比较老生常谈的图片优化技术,这里就不打算细讲了。

小程序的 image 组件 自带 lazy-load 懒加载支持。雪碧图技术(CSS Sprite)可以参考 w3schools 的教程。

  • 降级加载大图资源

在不得不使用大图资源的场景下,我们可以适当使用 “体验换速度” 的措施来提升渲染性能。

小程序会把已加载的静态资源缓存在本地,当短时间内再次发起请求时会直接从缓存中取资源(与浏览器行为一致)。因此,对于大图资源,我们可以先呈现高度压缩的模糊图片,同时利用一个隐藏的 <image> 节点来加载原图,待原图加载完成后再转移到真实节点上渲染。整个流程,从视觉上会感知到图片从模糊到高清的过程,但与对首屏渲染的提升效果相比,这点体验落差是可以接受的。

下面为大家提供部分例程:

<!-- banner.wxml -->
<image src="{{url}}" />

<!-- 图片加载器 -->
<image
  style="width:0;height:0;display:none"
  src="{{preloadUrl}}"
  bindload="onImgLoad"
  binderror="onErrorLoad"
/>
复制代码
// banner.js
Component({
  ready() {
    this.originUrl = 'https://path/to/picture'  // 图片源地址
    this.setData({
      url: compress(this.originUrl)             // 加载压缩降质的图片
      preloadUrl: this.originUrl                // 预加载原图
    })
  },
  methods: {
    onImgLoad() {
      this.setData({
        url: this.originUrl                       // 加载原图
      })
    }
  }
})
复制代码

注意,具有 display: none 样式的 <image> 标签只会加载图片资源,但不渲染。

京喜首页的商品轮播模块也采用了这种降级加载方案,在首屏渲染时只会加载第一帧降质图片。以每帧原图 20~50kb 的大小计算,这一措施可以在初始化阶段节省掉几百 kb 的网络资源请求。

Banner 大图降级加载

为了更好地呈现效果,上面 gif 做了降速处理

骨架屏

一方面,我们可以从降低网络请求时延、减少关键渲染的节点数这两个角度出发,缩短完成 FMP(首次有效绘制)的时间。另一方面,我们也需要从用户感知的角度优化加载体验。

“白屏” 的加载体验对于首次访问的用户来说是难以接受的,我们可以使用尺寸稳定的骨架屏,来辅助实现真实模块占位和瞬间加载。

骨架屏目前在业界被广泛应用,京喜首页选择使用灰色豆腐块作为骨架屏的主元素,大致勾勒出各模块主体内容的样式布局。由于微信小程序不支持 SSR(服务端渲染),使动态渲染骨架屏的方案难以实现,因此京喜首页的骨架屏是通过 WXSS 样式静态渲染的。

有趣的是,京喜首页的骨架屏方案经历了 “统一管理”“(组件)独立管理” 两个阶段。出于避免对组件的侵入性考虑,最初的骨架屏是由一个完整的骨架屏组件统一管理的:

<!-- index.wxml -->
<skeleton wx:if="{{isLoading}}"></skeleton>
<block wx:else>
  页面主体
</block>
复制代码

但这种做法的维护成本比较高,每次页面主体模块更新迭代,都需要在骨架屏组件中的对应节点同步更新(譬如某个模块的尺寸被调整)。除此之外,感官上从骨架屏到真实模块的切换是跳跃式的,这是因为骨架屏组件和页面主体节点之间的关系是整体条件互斥的,只有当页面主体数据 Ready(或渲染完毕)时才会把骨架屏组件销毁,渲染(或展示)主体内容。

为了使用户感知体验更加丝滑,我们把骨架屏元素拆分放到各个业务组件中,骨架屏元素的显示/隐藏逻辑由业务组件内部独立管理,这就可以轻松实现 “谁跑得快,谁先出来” 的并行加载效果。除此之外,骨架屏元素与业务组件共用一套 WXML 节点,且相关样式由公共的 sass 模块集中管理,业务组件只需要在适当的节点挂上 skeletonskeleton__block 样式块即可,极大地降低了维护成本。

<!-- banner.wxml -->
<view class="{{isLoading ? 'banner--skeleton' : ''}}">
  <view class="banner_wrapper"></view>
</view>
复制代码
// banner.scss
.banner--skeleton {
  @include skeleton;
  .banner_wrapper {
    @include skeleton__block;
  }
}
复制代码

京喜首页骨架屏

上面的 gif 在压缩过程有些小问题,大家可以直接访问【京喜】小程序体验骨架屏效果。

如何提升渲染性能?

当调用 wx.navigateTo 打开一个新的小程序页面时,小程序框架会完成这几步工作:

1. 准备新的 webview 线程环境,包括基础库的初始化;

2. 从逻辑层到视图层的初始数据通信;

3. 视图层根据逻辑层的数据,结合 WXML 片段构建出节点树(包括节点属性、事件绑定等信息),最终与 WXSS 结合完成页面渲染;

由于微信会提前开始准备 webview 线程环境,所以小程序的渲染损耗主要在后两者 数据通信节点树创建/更新 的流程中。相对应的,比较有效的渲染性能优化方向就是:

  • 降低线程间通信频次;
  • 减少线程间通信的数据量;
  • 减少 WXML 节点数量;

合并 setData 调用

尽可能地把多次 setData 调用合并成一次。

我们除了要从编码规范上践行这个原则,还可以通过一些技术手段降低 setData 的调用频次。譬如,把同一个时间片(事件循环)内的 setData 调用合并在一起,Taro 框架就使用了这个优化手段。

在 Taro 框架下,调用 setState 时提供的对象会被加入到一个数组中,当下一次事件循环执行的时候再把这些对象合并一起,通过 setData 传递给原生小程序。

// 小程序里的时间片 API
const nextTick = wx.nextTick ? wx.nextTick : setTimeout;
复制代码

只把与界面渲染相关的数据放在 data

不难得出,setData 传输的数据量越多,线程间通信的耗时越长,渲染速度就越慢。根据微信官方测得的数据,传输时间和数据量大体上呈正相关关系:

数据传输时间与数据量关系图

上图来自小程序官方开发指南

所以,与视图层渲染无关的数据尽量不要放在 data 中,可以放在页面(组件)类的其他字段下。

应用层的数据 diff

每当调用 setData 更新数据时,会引起视图层的重新渲染,小程序会结合新的 data 数据和 WXML 片段构建出新的节点树,并与当前节点树进行比较得出最终需要更新的节点(属性)。

即使小程序在底层框架层面已经对节点树更新进行了 diff,但我们依旧可以优化这次 diff 的性能。譬如,在调用 setData 时,提前确保传递的所有新数据都是有变化的,也就是针对 data 提前做一次 diff。

Taro 框架内部做了这一层优化。在每次调用原生小程序的 setData 之前,Taro 会把最新的 state 和当前页面实例的 data 做一次 diff,筛选出有必要更新的数据再执行 setData

附 Taro 框架的 数据 diff 规则

去掉不必要的事件绑定

当用户事件(如 ClickTouch 事件等)被触发时,视图层会把事件信息反馈给逻辑层,这也是一个线程间通信的过程。但,如果没有在逻辑层中绑定事件的回调函数,通信将不会被触发。

所以,尽量减少不必要的事件绑定,尤其是像 onPageScroll 这种会被频繁触发的用户事件,会使通信过程频繁发生。

去掉不必要的节点属性

组件节点支持附加自定义数据 dataset(见下面例子),当用户事件被触发时,视图层会把事件 targetdataset 数据传输给逻辑层。那么,当自定义数据量越大,事件通信的耗时就会越长,所以应该避免在自定义数据中设置太多数据。

<!-- wxml -->
<view
  data-a='A'
  data-b='B'
  bindtap='bindViewTap'
>
  Click Me!
</view>
复制代码
// js
Page({
  bindViewTap(e) {
    console.log(e.currentTarget.dataset)
  }
})
复制代码

适当的组件颗粒度

小程序的组件模型与 Web Components 标准中的 ShadowDOM 非常类似,每个组件都有独立的节点树,拥有各自独立的逻辑空间(包括独立的数据、setData 调用、createSelectorQuery 执行域等)。

不难得出,如果自定义组件的颗粒度太粗,组件逻辑过重,会影响节点树构建和新/旧节点树 diff 的效率,从而影响到组件内 setData 的性能。另外,如果组件内使用了 createSelectorQuery 来查找节点,过于庞大的节点树结构也会影响查找效率。

我们来看一个场景,京喜首页的 “京东秒杀” 模块涉及到一个倒计时特性,是通过 setInterval 每秒调用 setData 来更新表盘时间。我们通过把倒计时抽离出一个基础组件,可以有效降低频繁 setData 时的性能影响。

京东秒杀

适当的组件化,既可以减小数据更新时的影响范围,又能支持复用,何乐而不为?诚然,并非组件颗粒度越细越好,组件数量和小程序代码包大小是正相关的。尤其是对于使用编译型框架(如 Taro)的项目,每个组件编译后都会产生额外的运行时代码和环境 polyfill,so,为了代码包空间,请保持理智...

事件总线,替代组件间数据绑定的通信方式

WXML 数据绑定是小程序中父组件向子组件传递动态数据的较为常见的方式,如下面例程所示:Component A 组件中的变量 ab 通过组件属性传递给 Component B 组件。在此过程中,不可避免地需要经历一次 Component A 组件的 setData 调用方可完成任务,这就会产生线程间的通信。“合情合理”,但,如果传递给子组件的数据只有一部分是与视图渲染有关呢?

<!-- Component A -->
<component-b prop-a="{{a}}" prop-b="{{b}}" />
复制代码
// Component B
Component({
  properties: {
    propA: String,
    propB: String,
  },
  methods: {
    onLoad: function() {
      this.data.propA
      this.data.propB
    }
  }
})
复制代码

推荐一种特定场景下非常便捷的做法:通过事件总线(EventBus),也就是发布/订阅模式,来完成由父向子的数据传递。其构成非常简单(例程只提供关键代码...):

  • 一个全局的事件调度中心

    class EventBus {
      constructor() {
        this.events = {}
      }
    
      on(key, cb) { this.events[key].push(cb) }
    
      trigger(key, args) { 
        this.events[key].forEach(function (cb) {
          cb.call(this, ...args)
        })
      }
      
      remove() {}
    }
    
    const event = new EventBus()
    复制代码
  • 事件订阅者

    // 子组件
    Component({
      created() {
        event.on('data-ready', (data) => { this.setData({ data }) })
      }
    })
    复制代码
  • 事件发布者

    // Parent
    Component({
      ready() {
        event.trigger('data-ready', data)
      }
    })
    复制代码

子组件被创建时事先监听数据下发事件,当父组件获取到数据后触发事件把数据传递给子组件,这整个过程都是在小程序的逻辑层里同步执行,比数据绑定的方式速度更快。

但并非所有场景都适合这种做法。像京喜首页这种具有 “数据单向传递”“展示型交互” 特性、且 一级子组件数量庞大 的场景,使用事件总线的效益将会非常高;但若是频繁 “双向数据流“ 的场景,用这种方式会导致事件交错难以维护。

题外话,Taro 框架在处理父子组件间数据传递时使用的是观察者模式,通过 Object.defineProperty 绑定父子组件关系,当父组件数据发生变化时,会递归通知所有后代组件检查并更新数据。这个通知的过程会同步触发数据 diff 和一些校验逻辑,每个组件跑一遍大概需要 5 ~ 10 ms 的时间。所以,如果组件量级比较大,整个流程下来时间损耗还是不小的,我们依旧可以尝试事件总线的方案。

组件层面的 diff

我们可能会遇到这样的需求,多个组件之间位置不固定,支持随时随地灵活配置,京喜首页也存在类似的诉求。

京喜首页主体可被划分为若干个业务组件(如搜索框、导航栏、商品轮播等),这些业务组件的顺序是不固定的,今天是搜索框在最顶部,明天有可能变成导航栏在顶部了(夸张了...)。我们不可能针对多种顺序可能性提供多套实现,这就需要用到小程序的自定义模板 <template>

实现一个支持调度所有业务组件的模板,根据后台下发的模块数组按序循环渲染模板,如下面例程所示。

<!-- index.wxml -->
<template name="render-component">
  <search-bar wx:if="{{compId === 'SearchBar'}}" floor-id="{{index}}" />
  <nav-bar wx:if="{{compId === 'NavBar'}}" floor-id="{{index}}" />
  <banner wx:if="{{compId === 'Banner'}}" floor-id="{{index}}" />
  <icon-nav wx:if="{{compId === 'IconNav'}}" floor-id="{{index}}" />
</template>

<view
  class="component-wrapper"
  wx:for="{{comps}}"
  wx:for-item="comp"
>
  <template is="render-component" data="{{...comp}}"/>
</view>
复制代码
// search-bar.js
Component({
  properties: {
    floorId: Number,
  },
  created() {
    event.on('data-ready', (comps) => {
      const data = comps[this.data.floorId] // 根据楼层位置取数据
    })
  }
})
复制代码

貌似非常轻松地完成需求,但值得思考的是:如果组件顺序调整了,所有组件的生命周期会发生什么变化?

假设,上一次渲染的组件顺序是 ['search-bar','nav-bar','banner', 'icon-nav'],现在需要把 nav-bar 组件去掉,调整为 ['search-bar','banner', 'icon-nav']。经实验得出,当某个组件节点发生变化时,其前面的组件不受影响,其后面的组件都会被销毁重新挂载。

原理很简单,每个组件都有各自隔离的节点树(ShadowTree),页面 body 也是一个节点树。在调整组件顺序时,小程序框架会遍历比较新/旧节点树的差异,于是发现新节点树的 nav-bar 组件节点不见了,就认为该(树)分支下从 nav-bar 节点起发生了变化,往后节点都需要重渲染。

但实际上,这里的组件顺序是没有变化的,丢失的组件按道理不应该影响到其他组件的正常渲染。所以,我们在 setData 前先进行了新旧组件列表 diff:如果 newList 里面的组件是 oldList 的子集,且相对顺序没有发生变化,则所有组件不重新挂载。除此之外,我们还要在接口数据的相应位置填充上空数据,把该组件隐藏掉,done。

通过组件 diff 的手段,可以有效降低视图层的渲染压力,如果有类似场景的朋友,也可以参考这种方案。

内存占用过高?

想必没有什么会比小程序 Crash 更影响用户体验了。

当小程序占用系统资源过高,就有可能会被系统销毁或被微信客户端主动回收。应对这种尴尬场景,除了提示用户提升硬件性能之外(譬如来京东商城买新手机),还可以通过一系列的优化手段降低小程序的内存损耗。

内存不足弹窗提示

内存预警

小程序提供了监听内存不足告警事件的 API:wx.onMemoryWarning,旨在让开发者收到告警时及时释放内存资源避免小程序 Crash。然而对于小程序开发者来说,内存资源目前是无法直接触碰的,最多就是调用 wx.reLaunch 清理所有页面栈,重载当前页面,来降低内存负荷(此方案过于粗暴,别冲动,想想就好...)。

不过内存告警的信息收集倒是有意义的,我们可以把内存告警信息(包括页面路径、客户端版本、终端手机型号等)上报到日志系统,分析出哪些页面 Crash 率比较高,从而针对性地做优化,降低页面复杂度等等。

回收后台页面计时器

根据双线程模型,小程序每一个页面都会独立一个 webview 线程,但逻辑层是单线程的,也就是所有的 webview 线程共享一个 JS 线程。以至于当页面切换到后台态时,仍然有可能抢占到逻辑层的资源,譬如没有销毁的 setIntervalsetTimeout 定时器:

// Page A
Page({
  onLoad() {
    let i = 0
    setInterval(() => { i++ }, 100)
  }
})
复制代码

即使如小程序的 <swiper> 组件,在页面进入后台态时依然是会持续轮播的。

正确的做法是,在页面 onHide 的时候手动把定时器清理掉,有必要时再在 onShow 阶段恢复定时器。坦白讲,区区一个定时器回调函数的执行,对于系统的影响应该是微不足道的,但不容忽视的是回调函数里的代码逻辑,譬如在定时器回调里持续 setData 大量数据,这就非常难受了...

避免频发事件中的重度内存操作

我们经常会遇到这样的需求:广告曝光、图片懒加载、导航栏吸顶等等,这些都需要我们在页面滚动事件触发时实时监听元素位置或更新视图。在了解小程序的双线程模型之后不难发现,页面滚动时 onPageScroll 被频发触发,会使逻辑层和视图层发生持续通信,若这时候再 “火上浇油” 调用 setData 传输大量数据,会导致内存使用率快速上升,使页面卡顿甚至 “假死”。所以,针对频发事件的监听,我们最好遵循以下原则:

  • onPageScroll 事件回调使用节流;
  • 避免 CPU 密集型操作,譬如复杂的计算;
  • 避免调用 setData,或减小 setData 的数据量;
  • 尽量使用 IntersectionObserver 来替代 SelectorQuery,前者对性能影响更小;

大图、长列表优化

小程序官方文档 描述,大图片和长列表图片在 iOS 中会引起 WKWebView 的回收,导致小程序 Crash。

对于大图片资源(譬如满屏的 gif 图)来说,我们只能尽可能对图片进行降质或裁剪,当然不使用是最好的。

对于长列表,譬如瀑布流,这里提供一种思路:我们可以利用 IntersectionObserver 监听长列表内组件与视窗之间的相交状态,当组件距离视窗大于某个临界点时,销毁该组件释放内存空间,并用等尺寸的骨架图占坑;当距离小于临界点时,再取缓存数据重新加载该组件。

然而无可避免地,当用户快速滚动长列表时,被销毁的组件可能来不及加载完,视觉上就会出现短暂的白屏。我们可以适当地调整销毁阈值,或者优化骨架图的样式来尽可能提升体验感。

小程序官方提供了一个 长列表组件,可以通过 npm 包的方式引入,有兴趣的可以尝试。

总结

结合上述的种种方法论,京喜小程序首页进行全方位升级改造之后给出了答卷:

1. Audits 审计工具的性能得分 86

2. 优化后的首屏渲染完成时间(FMP):

优化后的首屏渲染时间

3. 优化前后的测速数据对比:

优化前后的测速数据对比

然而,业务迭代在持续推进,多样化的用户场景徒增不减,性能优化将成为我们日常开发中挥之不去的原则和主题。本文以微信小程序开发中与性能相关的问题为出发点,基于小程序的底层框架原理,探究小程序性能体验提升的各种可能性,希望能为各位小程序开发者带来参考价值。

参考


欢迎关注凹凸实验室博客:aotu.io

或者关注凹凸实验室公众号(AOTULabs),不定时推送文章:

image

关注下面的标签,发现更多相似文章
评论