如何使用工具优化性能

在正式介绍之前,希望开发者浏览过性能优化的原理与手段,对性能指标、小程序启动流程、常用的优化方法等有一定了解,以便于之后的阅读。

优化的起点与方向

性能优化指标主要是流量占比较大的落地页 FMP

首次打开小程序页面与非首次打开,性能表现会有所区别,主要原因是首次打开时,需要做环境初始化、代码文件加载、执行 App 生命周期函数等逻辑,而非首次打开准备阶段的工作量较少,因此需要分别讨论。我们一般称首次打开进入小程序的页面为“落地页”。本文主要侧重于讨论落地页的情况,但整体优化思路对于非落地页也是一样的。

对于落地页而言,首先需要知道哪些页面是落地页以及对应的流量比例情况。对于一个主要打开渠道是 feed 流的小程序而言,其流量最大的落地页往往是视频或图文详情页,而非该小程序的首页。我们将重点放到流量占比较大的落地页上,优化会更加有效。

需要特别强调的是,本工具收集的线下性能数据是一次运行的结果,其表现受运行时的手机机型、手机状态、网络环境、基础库版本等限制,无法代表线上情况。此外,线上的性能指标是汇聚结果,其性能表现更加偏向于中低端机;而开发者进行测试时,使用的手机和所处网络环境相对较好,因此性能结果往往优于线上表现。

虽然线下结果无法准确反映线上,但整体启动流程是一致的,性能瓶颈往往也一样。线下分析流量最大的落地页性能瓶颈,进行优化并上线,往往能取得收益。

Demo 小程序简介

我们从一个 简单的 Demo 小程序 来介绍分析优化的整体思路。该小程序只有一个页面,分为三个部分

  1. 问题与回答

问答页图

  1. 为你推荐长列表

问答页图

所有显示数据来自 swan.request 请求。

接下来,我们使用性能工具分析和优化这个小程序的性能。

关键路径分析

依据性能工具的使用流程,我们可以获得如下的 FMP 面板:

性能 1

从右侧逻辑层表格中,我们看到 FMP 耗时是 870ms,并不理想;可以从启动流程的角度分析,为什么如此简单的小程序,性能居然会这么差。

注:本工具提供的 FMP 起点是逻辑层或渲染层初始化的起点;线上使用的上屏时间,起点是百度 app 客户端收到用户打开小程序的时间,因此会更长。

我们关注的是,是从起点到 FMP 的关键路径(必经路径);我们希望这条路径越短越好,从图上看,这条路径包含:

  1. 初始化并加载小程序
  2. 执行逻辑代码
  3. App.onLaunch 执行
  4. 逻辑层发送 initData(onInit 执行在发送 initData 之后)
  5. 渲染层完成 DOM 操作完成后,发送回消息
  6. 执行 Page.onLoad-Page.onReady 生命周期
  7. 发送核心 request
  8. 调用核心 setData,将数据发送到渲染层,绘制页面

需要强调的是:

  • 逻辑层与渲染层互相独立,需要分别考虑

  • 渲染层的初始化,包括初始化、加载资源、初始化自定义组件并不是关键路径;即使优化或延长此阶段,对 FMP 也没有任何影响

  • 在实现代码中,是在 Page.onReady 开始准备请求的,如下

    1. async function requestData() {
    2. await dataDependence1();
    3. await dataDependence2();
    4. await dataDependence3();
    5. return await requestData();
    6. }

    在实际的小程序,往往需要完成一些依赖(如获取用户信息、设备信息等),才能开始发出请求;dataDependence1-3 代表(模拟)了这些异步依赖。

  • 核心 request 和核心 setData,代表的是和页面到达 FMP 最相关的 request 和 setData;计算规则可参考性能指标的收集与计算方式

首次优化

首先来看,当前哪些关键路径步骤是非必须的或者可以减小耗时的。

最先考虑的,就是耗时非常夸张的执行逻辑代码。这个 Demo 小程序功能相对简单,不应该有如此耗时的逻辑代码执行。

注:百度小程序启动时,会先将小程序逻辑层代码文件加载入内存,但不会立即执行;之后,会尽可能按需执行启动小程序、打开页面所需要的代码。例如,只有页面 A 依赖自定义组件 C,那么除非打开页面 A,否则 C 自定义组件代码不会执行。因此,在本工具中,加载与执行逻辑代码是分别统计耗时的。

在小程序代码中,引入了 import 'core-js';引入的目的,是希望使用 core-js 去 polyfill 一些比较新的的语法功能;但是,引入这样一个库会造成很大的性能退化,包括加载时间(增加了包/小程序逻辑代码体积)、代码执行时间。我们建议开发者,尽量避免引入非必需的代码库;一些简单的功能,可以自行实现;若需引用,请检查引入新库对小程序性能的影响是否符合预期。

另一个值得注意的点是,发请求的时机太晚了。发请求是在 onLoad 之后,而 onLoad 依赖于渲染层首次渲染。我们提供了更早的生命周期函数 onInit将请求数据逻辑移到 onInit 中进行,request 发起的时机会提前不少。

在代码中,可以看到,请求依赖的三个 dataDependence 是串行的,导致 swan.request 必须在这三个依赖依次结束后才可以调用;可以优化为并行的,如使用 await Promise.all([dataDependence1(), dataDependence2(), dataDependence3()]);

我们将这两项优化,应用到小程序,来看下优化结果吧!

性能 2

FMP 时间从 870 降到了 427,优化了 443 ms,性能提升了近 50%,是一次非常有效的优化。但我们并不满足,继续来探索性能优化的空间。

再次优化

从图上看,关键路径依旧很耗时;再次检查关键路径,除了那些耗时极少的,可以优化点的有:

  • app.onLaunch 的执行时间非常耗时
  • 从 onInit 执行到 request 太久了
  • 优化 swan.request 本身的耗时
  • 优化核心 setData 渲染的耗时

我们逐一来分析。

app.onLaunch 优化

优化方向:尽量缩短执行逻辑代码和 app.onLaunch 的耗时,避免调用较多同步的 swan API 与长耗时的同步逻辑。

  1. function whenAppLaunch() {
  2. mockLongTask();
  3. callSwanAPISyncPatch();
  4. mockLongTask();
  5. callSwanAPISyncPatch();
  6. mockLongTask();
  7. callSwanAPISyncPatch();
  8. }
  9. function mockLongTask () {
  10. for (let i = 0; i < 1e6; ++i) {}
  11. }
  12. function callSwanAPISyncPatch () {
  13. swan.isLoginSync();
  14. for (let i = 0; i < 3; ++i) {
  15. const value = swan.getStorageSync('key-' + i);
  16. swan.setStorageSync('key-' + i, value ? value + 1 : 1);
  17. }
  18. swan.getSystemInfo();
  19. }

在 onLaunch 中,三次调用了 mock 长耗时的 js 代码与一些 swan API。你也许觉得正常情况下,逻辑不会这样写,例如 swan.isLogicSync 应该只调用一次并缓存结果。但实际上,这种情况是可能发生的。例如,假设一个小程序有多个页面、自定义组件、一些公共代码文件,每个文件头部有些初始化逻辑。如果这些文件代码、逻辑结构相似,就可能会不约而同地调用相同的 swan API。如果多人维护一份小程序代码,那么出现这种情况的可能性更大。此外,从线上数据来看,我们发现有些小程序的初始化逻辑会非常耗时,严重阻塞了之后的代码逻辑。

从工具上,也可以看到 swan API 的耗时统计:

如何使用工具优化性能 - 图5

从表中可以清晰地看到,swan API 会比 js 代码执行耗时多不少;一些同步 API 比较耗时,会阻塞后续代码执行;此外,即使是异步 API,由于与客户端的相互通信,因此耗时有时也不是可以忽略不计的。

优化的思路,是减少 swan 同步 API 和长逻辑的调用。假设 callSwanAPISyncPatch 是启动逻辑必要的一部分,我们可以只调用一次,不需要多次;对于 mockLongTask 而言,假设在启动时也只需要一次,其他的调用均可以想办法移到首屏显示后,而避免阻塞逻辑代码执行。

request 优化

优化方向:1. 尽可能提前核心 request 的发起时间;2. 减小 request 的耗时

我们已经将请求从 onReady 移到 onInit 了,但是由于有 dataDependence1-3,导致 request 发起时间还是很晚。我们需要仔细地考察一下每个 dataDependence 是否是必要条件。假设 dataDependence1 是必要条件,而 dataDependence2-3 是非必须的:如 dataDependence2-3 只和页面的部分显示逻辑有关,而那部分不是很核心;或者,我们希望最快渲染出首屏或 FMP,这部分数据可以以不依赖 dataDependence2-3 的方式去获取等。

总之,为了尽可能提前发起 request,需要去精简非必要条件,思考必要条件是否是真的必要,或者有没有办法不做那些长耗时的必要条件等。

对于 dataDependence1,目前是在 onInit 发起的;如果其被多个页面依赖,且是一次性的(即不需要每次发请求都获取,只获取一次即可),那么我们提前到 app.onLaunch 去发起,期望其能在执行 onInit 中已经就绪。

综上,优化后的 app.onLaunch 与 onInit 代码为:

  1. function whenAppLaunch() {
  2. // mockShortTask 代表着 dataDependence1 的一些准备工作
  3. mockShortTask();
  4. this._dataDependence1 = dataDependence1();
  5. callSwanAPISyncPatch();
  6. mockLongTask();
  7. }
  8. async function requestData() {
  9. await getApp()._dataDependence1;
  10. const dataPromise = requestData();
  11. dataDependence2();
  12. dataDependence3();
  13. return dataPromise;
  14. }

在小程序前端代码中,几乎是无法优化 swan.request 请求耗时的。除了数据服务本身的优化外,我们也提供了 prelink 去提升请求速度。本 Demo 使用了线下临时服务,因此不会介绍如何优化 request 本身的耗时。

渲染优化

优化方向:初次只渲染首屏,避免渲染过多未展示的数据。

从工具的 setData 面板中,可以看到核心数据,包含了:

  • question: 问题文字
  • shownAnswerList:显示的回答,只有三条
  • answerList:全部的回答
  • recommendedList:为你推荐数据

setData 耗时统计

发送 answerList,是为了计算回答数量;虽然,百度小程序经过多次对 setData 优化后,setData 对体积已经不是特别敏感,但还是希望开发者避免去 set 过大的 data,或一些无用数据。因此,这里我们避免使用 answerList,直接将差值计算出来。

观察首屏,肉眼直接看到的内容主要有问题框、回答部分和为你推荐的极少一部分,没必要首次渲染全部的为你推荐数据。recommendedList 的长度是 9,全部渲染会极大地影响首屏时间,其实可以根据屏幕高度计算出首次需要渲染的数据,其余数据延后渲染。代码实现:

  1. Page({
  2. async renderPage() {
  3. if (this.__rendered) {
  4. return;
  5. }
  6. this.__rendered = true;
  7. const data = await requestData();
  8. // 大致根据各项高度计算最少需要加载的数量
  9. const firstShowRecommendedLen = Math.ceil(
  10. (swan.getSystemInfoSync().screenHeight - 80 - 3 * 160 - 60 - 40) / 300
  11. );
  12. const showData = {
  13. question: data.question,
  14. shownAnswerList: data.answerList.slice(0, 3),
  15. moreAnswers: data.answerList.length - 3,
  16. recommendedList: data.recommendedList.slice(0, firstShowRecommendedLen)
  17. };
  18. this.setData(showData, () => {
  19. setTimeout(() => {
  20. this.setData({
  21. recommendedList: data.recommendedList
  22. });
  23. }, 500);
  24. });
  25. },
  26. onInit() {
  27. this.renderPage();
  28. },
  29. onLoad() {
  30. this.renderPage();
  31. }
  32. });

使用分屏渲染(或按需渲染)的思想,就可以在一定程度上,缩减首屏渲染时间,优化 FMP,更加详细的介绍可以参考按需渲染优化

优化结果

最终优化结果

我们依次来看三项优化的结果:

App.onLaunch 优化:可以发现收益并不明显;一定程度上是因为我们几次 callSwanAPISyncPatch 调用,都是相同的 swan API,而首次是最耗时的;因此收益并不明显。在这里,需要特别强调:必须实际测量优化对小程序性能的改进。性能问题的很多方面是反直觉的,你的优化并不一定有(预期的)成效。

request 提前:收益很大,目前几乎是在 Page.onInit 之后立即调用;从图上看到,即使 swan.request 耗时比之前增加了不少,但由于极大提前了发起时间,使得数据达到时间提早了很多。

setData 优化:可以看到优化确实有效果,大概有 15 ms 左右。打开 setData 面板,可以看到我们成功地将数据分为两次:首次 setData 触发了 FMP,因此认为是核心 setData;虽然两次 setData 加起来比单独一次渲染更加耗时,但我们保证了首次渲染速度更快;两次 setData 间隔并不长,因而对于用户体验也没有影响,达到了我们想要的效果。

最终优化结果

需要额外说明的是,不同手机间性能差距很大,在高端机的一些优化结果在低端机可能会成倍放大,也可能因不同阶段耗时差距较大而导致优化措施并不奏效。因此,线下优化方法必须在线上检验。

总结

  • 根据小程序流量与入口情况确定需要重点优化的页面
  • 分析关键路径以确定性能瓶颈
  • 使用多种方式优化小程序性能
  • 必须实际地测量程序的性能,并对其优化。然后再次测量,检查究竟有多少改进;反复此过程,已达到最优
  • 线下测量以辅助分析,优化结果以线上为主

补充说明

  • 在小程序 Demo 代码片段 中,三次优化逻辑主要对应于 LOGIC_CODE 为 1/2/3 三种情况。
  • 本文基于安卓机测试;IOS 与 安卓的区别在于,目前性能工具无法获取到 IOS 下有效的 FMP,因此部分数据统计、计算会有所差异。
  • 更详细的性能指标数据的的定义与数据的收集方式介绍,可以参考性能指标的收集与计算方式