聊一聊微信小程序性能优化

一、如何衡量小程序性能

微信小程序的开发除了完成必要的产品功能外,性能也是非常重要的。
我们应该如何衡量小程序的性能呢?怎样的小程序才算是一个高性能小程序呢?

个人觉得,所谓的高性能无非就两个点:打开速度够快、交互够流畅。
只要打开够快就能让用户最快的看见,只要交互够流畅,就能让用户没有”反悔“的时间。
那么如何才能做到速度快并且交互流畅呢?
接下来咱们就一起来探讨一下。

关于小程序的性能优化,微信小程序官方文档的《性能与体验》模块其实说的很详细了,甚至还有官方评分规则。

小程序得分
《性能与体验》

所以,一般情况下按照这些规则指标去优化,并做到了100分,那么小程序性能也不会太差了。
并且,这些指标的绝大多数问题都能在小程序开发者工具上在本地检测出来,只需要在开启检测并发现问题之后按照上面的指标一一处理掉,拿到评测高分即可。

tiyankaishi
tiyanjieguo

难道做到小程序的高性能就这样?这也太容易了吧?
当然不止如此,上述官方指标只是给小程序的性能优化指个大致的方向,让其在平台上运行的小程序不至于太差,应该算是一个指导性文件,作为开发者我们尽可能的按照官方指导去优化小程序理论上不会把路子走歪。

除此之外,有一些跟用户息息相关的一些比较复杂的指标才是我们本文会重点介绍的。
比如:脚本执行时间、首屏时间、渲染时间、请求耗时等

这些指标在跟不同的机型、版本与网络环境等多种多样复杂的内外部因素有关,本地检测是很难正确的反应出实际情况的。
除此之外,还有其它的一些性能指标,比如 setData 耗时、各个生命周期执行耗时等很难在本地或者单个检测目标获得准确的评估值。

对于如何收集这些数据,以前 web 端已经玩的很熟练了,这种情况最好的解决方案就是埋点+数据收集上报了。
制定指标、然后埋点上报获得性能指标相关的数据,用数据说话,通过数据分析找出相关性能瓶颈,最后解决问题。

因此,要做性能优化,我们首先要做的就是制定一些能够量化的指标,然后收集衡量对应指标所需要的相关数据。

二、小程序运行原理与过程

我们知道,在 web 端,一个网页的简单生命周期

1
2
3
4
5
6
- 拉取html页面数据 
- 拉取css数据
- 页面初次渲染
- 拉取js数据
- 用户交互
- 最后关闭。

根据这些过程我们制定了相关的性能指标:首屏加载时长、白屏时长、页面加载完成时长、可交互时长等等指标。

对于小程序呢?
小程序以微信为宿主,实现了自己的渲染逻辑,和web端的网页生命周期是有差异的,要想做小程序的性能优化,小程序的运行原理和生命周期是绕不开的。
因此,接下来咱们一起来对小程序的运行原理和过程和生命周期做一个基本介绍。

1.小程序启动过程

xcxqidong
上图就是小程序的启动过程,这个启动过程步骤比较多,看起来耗时也不会短。
微信方为了缩短这个过程多了很多工作,比如各个流程能并行的都做了并行,能缓存的尽可能都做了缓存。

上小程序的启动过程官方给分为了冷启动、热启动两种。
但是实际上冷启动还应该分为“初次冷启动、再次冷启动”。

小程序官方文档有一句话

1
代码包下载到客户端后会被缓存起来,以便于下次离线打开。代码包清理的时机由算法动态计算,但可以认为在手机存储空间足够的情况下,代码包都不会被主动清理。当用户主动在历史列表删除小游戏(下拉任务栏删除不算)时,代码包会被清理。

这也就意味着,非首次打开小程序,即使是冷启动,只要本地代码包还存在且代码不是太旧,就可以复用原来的代码。
这相对于初次启动,打开速度会快很多。

2.小程序生命周期

想要做指标分析和数据埋点,除了理解小程序的启动过程,对于小程序的生命周期是必须要理解的。
我们从官方文档可以知道,小程序的逻辑层和渲染层是分两个线程来管理的,因此其页面的生命周期也是两个线程共同协作完成的。

小程序官方文档有一份完整的生命周期图
page-lifecycle

上图中的 view Thread 是用来渲染view组件的,是内置的 webkit 内核的浏览器,支持前进、后退、浏览历史、放大缩小等功能,无地址栏和导航栏,单纯的展示网页界面。
上图中的Appservice Thread 是一个javascript解释器(javascriptCore|jsCore)专门处理业务逻辑。
二者通过 native 中转进行事件和数据传递。

下面是对于官网的生命周期图自己的理解,并截取的页面首次渲染的生命周期图
xcxpagelife

如上两图可以看出:

1
2
3
4
5
6
小程序开始运行时,view的渲染和appServer逻辑层是各自开始的
view初始化完成之后会通知逻辑层,同时会等待逻辑层的初始数据的传递。
逻辑层初始化完成之后会等待view的“初始化完成通知”,接到通知之后会将初始数据发送给view层。
view层接收到初始数据之后就开始执行第一次页面渲染,初次渲染完成即表明用户已经看到小程序的页面了。
待页面初次渲染完成之后,view就会通知逻辑层,此时的逻辑层就会执行 onReady 事件。
onReady即代表小程序首次渲染结束。

根据上面的生命周期图,小程序启动、到 Page.onReady 执行的顺序如下

1
2
3
4
App:App.onLaunch -> App.onShow
组件:Component.created -> Component.attached
页面:Page.onLoad -> Page.onShow
Ready:执行 Component.ready -> Page.onReady

组件是页面的一部分,页面要想 onLoad 肯定需要组件预先初始化完成
上图中,页面 create 阶段,就包含了组件的初始化,组件attached事件表示组件已解析完成并合并入了页面的节点树。
组件初始化完成之后,才会执行页面的 onLoad,onLoad表示整个页面的节点树创建并加载完成。
最后就是页面的UI绘制了,组件UI是页面UI的一部分,因此,页面的 ready 事件一定是在组件 ready之后才触发
PS:(这里的组件是指页面首次渲染需要的组件,并不包括后期setData触发的组件显示和更新)。

了解了小程序的生命周期之后,接下来就是制定指标了。
下面总结的7个指标,这些指标跟上面的生命周期息息相关,理解了页面和组件的生命周期才能够更好的理解下面的性能指标。

3.指标定义

指标 描述
js注入时间 从代码包中读取配置和代码,注入到js引擎中耗时 App.onLaunch
小程序启动时间 从用户点击访问小程序到小程序首屏渲染完成 Page.onReady
页面首次渲染时间 开发者代码注入完成后,结合逻辑层的数据和渲染层的页面结构、样式,小程序框架进行小程序首页渲染,展示首屏,并触发首页的 Page.onReady 时间。
页面可用耗时 页面用户可见,所需的基本数据下载并渲染完成,可交互
路由切换时间 路由切换耗费的时间
接口请求耗时 request 异步请求所耗费的时间
setData耗时 setData 方法触发到页面渲染耗费的时间

4.收集指标

前面定义了性能指标,那么接下来就是想办法来获取和收集这些指标了。
做过web端性能优化的同学应该知道,对于H5页面,Chrome 浏览器提供了一个性能Api(performance API)。
这个api提供了源自浏览器自己收集的丰富的网页性能数据。

而我们知道,微信小程序底层的界面实现也是webview,因此理论上也会有类似的接口。
从官方文档可以找到,这个接口就是 wx.getPerformance();

获取性能接口
getperformance 能获取到三个指标类型的数据:路由、渲染、脚本。

性能数据详情

小程序创建过程中,每个阶段包含了相应的执行耗时。
其中AppLaunch 的 startTime 为点击小程序图标的时间。
navigationStart 表示路由真正响应开始时间。

在页面生命周期中通过调用 wx.getPerformance() 可以获取当前阶段之前的小程序的相关性能情况。
但是在埋点过程中发现,若是将该方法放在 Page.onLoad 的时候会获取不到当前页面的性能数据

1
2
3
4
5
6
Page({
onLoad() {
// 可能获取不到当前页的数据
console.log(wx.getPerformance())
}
})

wx.getPerformance() 获取到的性能数据是小程序自己收集的,不管我们用不用,什么时候用它都在那里默默地收集。
但是,getPerformance() 性能收集是一个异步过程,如果直接在onLoad事件调用,此时该页面的数据很可能还未收集完成。
因此,在实际使用中推荐使用事件监听的方式获取性能指标数据。
事件监听的绑定需要放在被监听页面打开之前(比如App.onLaunch()的时候 或者 App()方法执行之前)。

1
2
3
4
5
6
7
8
9
10
11
12
function logPagePerformance() {
const performance = wx.getPerformance();
const observer = performance.createObserver((entryList) => {
const entries = entryList.getEntries();
const result = {}
entries.forEach(entry => {
result[entry.name] = entry
});
console.log(result)
});
observer.observe({ entryTypes: ['navigation', 'render', 'script'] });
}

通过上述方法我们可以获得页面的启动、脚本注入、初次渲染、路由切换的消耗时间。
而页面可用耗时因为跟可用的定义有关,比如有的是需要等待图片渲染完成,有的是初次 setData() 之后页面初次渲染完成。
因此,这个点只需要用户在相应的位置取一个 Date.now(),然后减去小程序启动开始时间 appLaunch 的 startTime 即可。

接口请求耗时的收集页很简单。
直接包装 wx.request() 方法即可(重写方法见后文)

5.setData耗时

setData是小程序中一个非常重要的方法,小程序页面更新的核心方法
页面的更新就是通过setData将数据从逻辑层通过native发送至webview层触发更新的。
因此 setData 的性能直接影响到页面的流畅性。
而小程序官方除了给了很多 setData 的优化建议之外,也提供了一个性能监听方法 setUpdatePerformanceListener 来获取更新性能统计信息。它将返回每次更新中主要更新步骤发生的时间戳

1
2
3
4
5
6
7
8
// setData 性能监听
this.setUpdatePerformanceListener({ withDataPaths: true }, (res: any) => {
// 更新运算结束时的时间戳,更新运算开始时的时间戳,此次更新进入等待队列时的时间戳
const { updateStartTimestamp, updateEndTimestamp, pendingStartTimestamp, dataPaths = [] } = res;
const cost = updateEndTimestamp - updateStartTimestamp;
const waiting = updateStartTimestamp - pendingStartTimestamp;
console.log({ duration: cost, waiting, from: this.is, dataPaths });
});

官方文档说明了,此方法需要放在Page.onReady 或者 Component.attached 生命周期之中执行。
而且 setUpdatePerformanceListener 只会激活当前组件或页面的统计。

1
2
Page({ onReady() { /** 在页面里面将监听方法放在 onReady生命周期触发之后 **/ } })
Component({ attached() { /** 在组件里面将监听方法放在 attached生命周期触发之后 **/ } })

为什么需要放在这两个生命周期方法里面呢?
其实很容易理解,因为setUpdatePerformanceListener统计的是更新性能统计信息。
而所谓更新,一定是需要页面或者组件被渲染之后才会发生。
从前面的生命周期图可以看出:
对于页面,onReady 事件发生就代表着页面首次渲染完成,既然首次渲染完成,再触发 setData 那就是页面更新了。
对于组件,attached 事件发生的时机为 组件实例进入页面节点树时。

1
2
3
4
5
listeners 返回携带一个 res 对象,表示一次由 setData 引发的 更新过程 
根据 setData 调用时机的不同,更新过程大体可以分为三类
基本更新 ,它有一个唯一的 updateProcessId ;
子更新 ,它是另一个基本更新的一个子步骤,也有唯一的 updateProcessId ,但还有一个 parentUpdateProcessId;
被合并更新 ,它被合并到了另一个基本更新或子更新过程中,无法被独立统计。

上面是官方原话,介绍了三种更新形式。

1
2
3
const { updateStartTimestamp, updateEndTimestamp,pendingStartTimestamp, dataPaths } = res;
const cost = updateEndTimestamp - updateStartTimestamp;
const waiting = updateStartTimestamp - pendingStartTimestamp;

其中dataPaths为当次setData传入的数据。
计算结果 cost 的值为页面更新花费的时间。

每次成功的 setData 调用都会产生一个更新过程,使得 listener 回调一次。
不过 setData 究竟触发了哪类更新过程很难判断,更新性能好坏与其具体是哪类更新也没有必然联系,只是它们的返回值参数有所不同。

值得注意的是:
setData 在逻辑层执行是同步过程,但是当数据传入渲染层之后,渲染层的渲染逻辑是异步执行的。
因此如果需要监测 setData 整个执行耗时,可以在 setData 执行前埋点t1,然后在 setData 回调方法里面埋点t2,t2-t1(二者相减)即为整个 setData 耗时T1。
经过测试 T0 耗费的时间是远大于 setUpdatePerformanceListener监听中算出来的cost的值的。
原因是,cost 表示的是 由 setData 引发的 更新过程,仅仅是更新过程;而 T1 表示的是从 setData 执行开始到页面渲染完成。
整个过程除了UI更新,还有逻辑层的编码处理过程、逻辑层像UI层数据的传递过程、更新进入等待队列时的时间以及异步回调可能遇到的线程阻塞等等。

高性能的使用setData
关于setData的运行时性能优化建议,官方文档总结起来有三句话

1
2
3
不要频繁的去 setData
每次 setData 不要传递大量新数据
不要在后台状态页面中进行 setData

第3点很容易理解,后台状态的页面用户都不可见了也没必要进行 setData,造成资源浪费。

对于1和2,刚开始看到句话我就有点困惑了
不要频繁的触发 setData,意思不就是将数据合并传递吗?
不要一次传递大量数据,意思不就是拆分数据传递吗?
这两句看起来有冲突啊。

其实并不冲突,理解了 setData 原理之后就知道为什么会这样说了。
setData 主要性能开销包括

1
2
3
将逻辑层代码用 jsBridge 方式通过传入 Native,然后 Native 再与 webiew 交互
webview 线程执行脚本,渲染UI
在 webview 里面执行就是一个 evaluateJavascript 的过程,当数据量过大的时候脚本的编译执行会占用 webview 的js线程,导致页面卡顿。

因此,要提高页面更新性能就需要尽可能少的触发更新操作,在不阻塞页面渲染的前提下一次性更新更多的 setData 数据。

三、其它性能收集

其它系统信息、网络信息、错误信息的收集就不过多介绍了,官方提供了强大的收集这些信息的API。
一看便知。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 设备信息收集/系统信息收集、网络信息收集
env.getSystemInfo({
success(res) {
console.log(res)
}
})
wx.getNetworkType({
success (res) {
console.log(res.networkType)
}
})

// 错误事件处理
// 接口请求错误建议在 wx.request 请求的代理上去做
// 小程序有未处理的 Promise 拒绝时触发
App.onUnhandledRejection(() => {})
wx.onUnhandledRejection(() => {})
wx.onError()

四、无侵入式埋点

本文介绍了小程序的启动过程、相关生命周期以及小程序官方提供的性能监控Api及相关使用方法。
知晓了这些,再做小程序的性能监控就容易的多了。

当然性能监控埋点上报最好就是能够做到无侵入式埋点,对于上面提到的性能指标,除了各个业务方定制的页面可用耗时(可能包含异步请求数据的渲染)
其它指标都可以通过重写小程序Api的形式实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 重写 Page
const OriginPage = Page;
Page = (page)=> {
const originalOnReady = page.onReady;
page.onReady = () => {
// onReady 方法里面监听性能
this.setUpdatePerformanceListener({ withDataPaths: true }, (res: any) => {
const { updateStartTimestamp, updateEndTimestamp, dataPaths = [] as string[] } = res;
const cost = updateEndTimestamp - updateStartTimestamp;
console.log(cost);
});
return originalOnReady.call(this);
}
return OriginPage(page);
}

// 重写 Component
const OriginComponent = Component;
Component = (comp) => {
const originalAttached = comp.attached;
comp.attached = function () {
this.setUpdatePerformanceListener({ withDataPaths: true }, (res: any) => {
const { updateStartTimestamp, updateEndTimestamp, dataPaths } = res;
const cost = updateEndTimestamp - updateStartTimestamp;
console.log(cost);
});
return originalAttached.call(this);
};
return OriginComponent(comp);
}

// 重写 wx.request API
Object.defineProperty(wx, 'request', {
get: () => hackHandler.bind(this),
});
function hackHandler() {
return new Promise((resolve, reject) => {
const startTime = Date.now();
wx.request({
...opts,
success: (res) => {
resolve(res);
},
fail: (res) => {
reject(res);
},
complete: (res) => {
console.log('接口请求耗时:', Date.now() - startTime)
},
});
});
}

五、总结

一个优秀的小程序,完备功能是基础,好用易懂的交互是加分项,高性能是提升用户信任度的一个重要指标。
当然,我们还得综合考虑其安全性、稳定性,不能为了性能优化而性能优化。

相关链接

performanceApi
setUpdatePerformanceListenerApi
性能与体验优化官方文档