腾讯课堂小程序性能极致优化——综合篇

5,067 阅读18分钟

1. 缘起

事情,要从一个周末惬意的下午开始说起……

那天,手机突然被唤醒,弹出多条微信消息。原来是这周末正在校园推广的活动群发来的,想起之前大家有条不紊的开发进度,和产品沟通的友好过程,应该是活动反响不错。

现实是残酷的:

“我们的小程序打开慢成狗!”

“这个loading加载的过程也太久了!”

“滚动加载有点卡,而且很容易报错……”

看到的是最直接的控诉。

看到用户的录屏,这几个问题确实是有出现,所以我们还是需要对小程序进行一次主流程的性能优化,三句控诉可以总结为3个点:

  1. 小程序启动慢
  2. 小程序请求慢
  3. 小程序交互慢

2.定位

2.1. 启动慢

收到反馈后第一反应是,用户是不是网速太慢了,自己跑一遍,发现自己的手机跑起来都是没问题的,灰常流畅,下意识的可能想录个屏回复过去。

1.gif

不过有用户录屏在那,当然不能这么草率,所以我们查了下管理后台小程序在不同网络下的大盘数据:

网络启动耗时
总体3.6s
Wifi3.5s
4G3.9s
2G-3G4.1s

从统计看,总体3.7s的启动耗时,网络对于启动耗时是会有影响的,但影响没有很大,就算是2G-3G下跟大盘的数据对比也没有慢很多,可见事情并不简单。

于是我们从另外一个维度来看一下大盘数据:

机型启动耗时JS注入初次渲染
总体3.6s0.29s0.16s
高端机2.9s0.19s0.06s
中端机4.8s0.42s0.19s
低端机7.9s0.72s0.43s

从这里就可以看出问题来了,手机的性能对于小程序的启动速度影响非常大,低端机相对高端机有2-3倍的差距,特别是渲染层甚至有5-6倍的差距,而且问题反馈的用户所使用的手机也确实是中低端机,但用户使用什么手机我们也没法控制,那这里有办法去优化吗?

针对这个问题,我们需要了解一下小程序的启动过程,根据官方文档的介绍,小程序的启动可以分为下面几个步骤: 2.png

上图描述了用户点击小程序开始到页面开始请求数据的一个完整的冷启动过程,而小程序初始化的过程(信息准备、环境准备)占用了比较长的时间,但这部分的工作是由微信客户端来完成,开发者无法干预,所以我们只能聚焦于后续的步骤(下载代码包、注入代码包、初次渲染)。

根据官方文档的介绍,这一部分的可优化手段有:

  1. 减小代码包体积
  2. 降低代码复杂度
  3. 减少同步代码接口调用
  4. 降低页面结构复杂度
  5. 减少自定义组件数量

后面4条在技术上没有特别好的限制手段,需要我们在 Code Review 的时候对复杂度和开销大的接口调用进行把关,复杂度这里还可以借助如 CodeCC 这类工具去进行分析,减少自定义组件数量,这个是比较难以抉择的,需要在代码可读性、可复用性之间做个取舍,不是本次优化的重点。

所以我们就重点关注的代码包体积问题,通过我们的 CI 记录可以收集到我们的总包大小:

3.png

可以看到主包体积达到1949.71KB,接近了2M的极限了,在通过依赖分析后,发现除了一些是未使用到的模块和组件外,很大一部分内容是静态资源,同时我们也在官方文档中看到这样一句话:

小程序代码包在下载时会使用 ZSTD 算法进行压缩,这些资源文件会占用大量代码包体积,并且通常难以进一步被压缩,对于下载耗时的影响比代码文件大得多。

所以我们要减小代码包的体积,最直接的方法就是将非必要的资源去除掉:

  • 对静态资源进行优化,将非必要的静态资源文件上传到CDN

  • 对小程序的组件进行依赖分析,过滤掉未使用的组件

同时我们还关注到,有一些分包特别小,但是由于是普通分包,在打开这些页面的时候还需要先下载主包,这里在包下载耗时上其实是有一些浪费的,比较典型的就是 WebView 页面,他们往往只需要对参数进行处理,对于主包的依赖不是很强,所以这里还有一个可以优化的点:

  • 对独立性比较强的页面进行独立分包,尽可能的减少包下载耗时

2.2. 请求慢

我们通过日志查到这个用户的首页数据请求返回会到3-4s,请求慢在正常情况下会有这么两种情况:

  • 并发量突增导致服务器响应慢
  • 用户网速较慢导致发送请求和接收请求变慢

我们通过日志统计发现用户的访问时间端,请求量跟平时保持一致,看大盘请求耗时的统计,也没有产生大的波动:

4.png

所以基本可以排除是后台的问题,虽然大盘的数据在500ms左右,但是当用户网络不好的情况下,这一块要怎么保证呢?

答案当然是做提前拉取,当用户冷启动的时候,我们可以使用小程序官方提供的 数据预拉取 能力提前拉取,从小程序的启动耗时看,完全可以 cover 掉我们的接口请求耗时,可以让小程序启动成功后就直接渲染页面。

在热启动的情况下,请求慢主要体现在用户交互时发生的请求和页面切换时发生的请求,交互的情况我们下一节在分析,这里主要看页面切换,从我们的统计数据来看,页面切换的耗时大概在400ms左右,而其中能够利用的时间大概是50ms-100ms

5.png

利用页面切换的这个时间提前对页面的数据进行加载,可以减少用户感观上的数据请求时间,同时在第一次请求之后可以根据一定的策略对页面的数据进行缓存,从而可以达到二次进入页面秒开的效果。

总结来看,请求慢的优化手段有下面几个,而且理论上效果都会很显著:

  • 冷启动开启数据预拉取
  • 页面路由切换时提前拉取数据
  • 对数据进行缓存

2.3. 交互慢

先说一下这里的交互慢具体指什么,我们收到用户反馈的现象是:用户首屏顺利加载出来之后,后续滚动加载和一些按钮点击的响应非常慢并且很容易报错。收到这个反馈后定位了很久,讲道理如果是因为用户网络问题导致的请求很慢,应该所有的请求都会很慢,但是用户表现出来的现象是后续的加载以及交互很吗,反而首屏还算正常。

通过日志查询,我们发现这个用户的请求报错都是请求超时,为什么超时会集中在交互加载这里呢?定位了一段时间后我们发现一个用户的报错都集中在首屏加载之后就立马下滑或者点击,如果过了一段时间再点击又不会报错。

发现这个现象后,我们想到了官方文档关于 网络使用说明 的一个限制:

wx.request、wx.uploadFile、wx.downloadFile的最大并发限制是 10 个

再结合我们对于 wx.request 的封装,请求超时的计时器是从调用 wx.reqeust 的时候就开始了,如果请求并发超过了限制,那么就很容易出现请求超时,而当我们从第一个业务接口请求到数据后就会进行一系列的数据上报,包括 pv、组件曝光、monitor 等等,所以我们利用 Whistle 的 resDelay 方法,将我们的上报请求都延迟5000ms返回,果然就复现了用户反馈的那种情况。

6.png

找到问题之后也就明确了需要优化的方向:

  • 保障与用户体验相关的业务请求正常发送

交互慢还有别的原因吗?在继续挖掘性能瓶颈的过程中,发现我们的课程详情页内容非常多,有5-6屏的高度,而用户只会关心首屏是不是更快的呈现出来,但是我们原本的处理方式时比较粗暴的,拿到详情页的数据之后对数据进行处理,格式化成整个页面所需要的数据之后一次性调用 this.setData 来更新页面,所以如果要提升首屏速度,这里需要做的就是:

  • 页面分步渲染

2.4. 优化点归总

再归总一下需要优化的点和方向:

  1. 启动慢主要从优化代码包上下手:
    • 对静态资源进行优化,将非必要的静态资源文件上传到CDN
    • 对小程序的组件进行依赖分析,过滤掉未使用的组件
    • 对独立性比较强的页面进行独立分包,减少主包下载耗时
  2. 请求慢主要从预加载和缓存下手:
    • 冷启动开启数据预拉取
    • 页面路由切换时提前拉取数据
    • 对数据进行缓存
  3. 交互慢需要从发起请求和页面渲染下手:
    • 保障与用户体验相关的业务请求正常发送
    • 页面分步渲染

3. 优化 🔧

3.1. 启动优化

3.1.1. 独立分包

由于用户反馈主要是因为校园推广活动来的,而活动页面我们是通过web-view内嵌h5来承载的,而web-view页面的启动过程和小程序原生页面还不太一样:

7.png

实际上web-view页面只需要完善分及登录态传递的功能,对于主包的依赖不是很大,而且这部分页面更大的性能问题需要在h5那边来优化,所以我们第一时间对其进行了独立分包处理。

最终的优化效果还不错,因为启动过程中不需要下载主包,启动性能提升了30%。

3.1.2. 静态资源上CDN

我们小程序构成主要是由原生页面 + kbone 页面组成的,kbone 是采用的官方的方案,通过 webpack 构建,有很多单独打包静态资源的方案。而我们的原生页面是使用 gulp 进行构建的,原来主要的功能是将源码中的 ts 转成 js,同时对 css 文件通过 postcss 转成 wxss,由于 wxss 不支持引用相对路径,所以在 wxss 中引用的图片和字体都是转成 base64 的,然后对其余的文件如 json、wxml 文件则直接复制到产物中去。

这样的处理方式比较粗暴,通过 postcss 将 background-image 所引用的本地图片都转成 base64,还会导致很多图片在项目中占用了2倍的体积。

8.png

所以我们首先需要将源码下的静态资源匹配到并单独构建出来,并且为了规避同名文件的问题,需要对资源打个 hashtag,我们这里需要用到一个 gulp 插件gulp-rev,这个插件可以对基于资源的内容进行 hash。

9.png

将图片上到 CDN 后,把 css、js、json、wxml 中的引用路径替换成 CDN 地址,具体的替换逻辑如下。

10.png

3.1.3. 过滤未使用组件

随着业务的迭代,不可避免的会有一些组件被废弃了但是难以察觉,通过我们团队开发的小程序脚手架 imweb-miniprogram-cli 对页面所使用到的组件进行分析,可以把项目中未使用到的组件给过滤出来,不会打包到最终的产物中,大致的思路如下:

11.png

app.json开始,拿到小程序所配置的所有页面和分包,通过检查 app、页面、分包中所使用的自定义组件来进行收集,并且递归检查自定义组件所使用的组件,如果检测到有未使用的组件,也会给到提示,非常友好:

12.png

可以看到在我们的项目中就发现了好几个未使用到的组件。

3.2. 请求优化

3.2.1. 数据预拉取

数据预拉取需要在小程序的管理后台开启,数据来源可以选择开发者服务器或者云开发,选择开发者服务器的话会有一些限制,如果是直接填写 CGI 地址,就只能拉取一种数据,不够灵活,而如果再搭建一个服务去做预拉取涉及到的工作量又会很大,所以我们选择的是云开发的方式,大致流程如下图:

13.png

当小程序启动的时候,微信客户端会根据配置去拉取指定的云函数,在云函数中通过 cl5 调用业务后台的服务拉取到需要的数据,拉取到后客户端会将数据缓存在本地,当小程序启动成功后,在业务代码中调用wx.getBackgroundFetchData就可以拿到预拉取的数据,如果缓存数据拉到的是所需要的数据则可以直接渲染,如果不是则降级到业务中再拉一次接口。

在云函数中可以拿到本次小程序启动的pathquery参数,所以我们可以根据这两个参数来判断本次预拉取需要调用业务后台的哪个服务,从而达到从不同的页面启动小程序都可以通过一个云函数预拉取到所需要的数据。

const preFetchMap = {
  'pages/index/index': fetchIndex,
  'pages/course/course': fetchCourse,
}

// 云函数入口函数
exports.main = async (event) => {
  const { path, query = '' } = event;
  const fetchFn = preFetchMap[path];

  if (fetchFn) {
    const res = await fetchFn(query);
    return res;
  }

  return {
    error: {
      event,
      retcode: -1002,
      msg: `${path}页面未设置预拉取逻辑`
    }
  };
};

不过要注意的是,因为小程序自身做了很多初始化的优化,有可能在小程序启动后,预拉取的数据还没有返回,所以我们做了进一步的优化,在业务拉取的过程中通过 wx.onBackgroundFetchData监听预拉取的返回,收到返回就直接渲染 ,尽可能的使用预拉取的数据来渲染首屏。

14.png

3.2.2. 提前拉取 & 数据缓存

前面已经提到过,提前拉取就是要利用小程序切换页面的空隙开始拉取数据,从而在感官上较少数据请求的时间,整体的逻辑是通过封装的跳转逻辑,对应的页面添加不同的数据拉取逻辑,并将拉取的 promise 挂载在 app 上,当页面切换完成后优先使用 app 上的 promise 来获取数据。

数据缓存则是在数据拉取成功后,将比较固定的数据通过 wx.setStorage 缓存在本地,当第二次切换到这个页面时,先使用本地缓存的数据进行渲染,后面再通过拉取的数据来进行更新。

15.png

3.3. 交互优化

3.3.1. 业务请求保障

保障业务请求的核心思路是让业务请求优先,我们封装了一个 排队请求模块 ,通过对 wx.request API的拦截,将请求根据配置有个优先级排序,低优先级的会在请求并发数达到一定的阈值之后被推到等待队列 WaitingQueue 中,留出足够的通道给到高优先级的业务请求。

16.png

3.3.2. 分步渲染

这里的方案相信大家也能很好理解,主要是优先处理首屏需要的数据并通过 setData 更新视图,然后在处理其余的数据。但根据官方文档的说明:

setData 函数用于将数据从逻辑层发送到视图层(异步),同时改变对应的 this.data 的值(同步)。

而小程序代码执行顺序也遵循JS的事件循环机制,仅仅是在处理后的数据调用 setData ,然后继续或者通过 Promise 处理下一步的话,并不能达到分步渲染的目的,而直接通过回调的方式在 setTimeout 中使用嵌套渲染,代码的可读性会变差,同时也不是很优雅。我们的解决方式是利用 setTimeout 封装了一个符合Promise标准的方法,从而可以像使用Promise那样继续分步渲染:

17.png

4. 成果

经过这一系列的优化,效果还是比较明显的

4.1. 包大小

在包大小方面

  • 总包从9132.94KB减小到6736.42KB,减少了27%
  • 主包从1949.71KB减小到985.96KB,减少了49.5%

从启动耗时的数据看,下载耗时和JS注入耗时都有明显的下降:

18.png

再看打开耗时分布,可以看到3s内打开的用户比例有明显增加,从56.26%增加到64.25%;

19.png

4.2. 请求耗时

数据预拉取,提前拉取,数据缓存在冷启动和页面切换时都起到了很不错的效果:

首页请求速度从平均400ms下降到50ms,优化了87.5%

课详页的请求速度从平均800ms下降到90ms,优化了88.75%

而数据缓存让二次访问时页面可以秒开:

20.gif

使用排队请求之后,对网络请求顺序的干预效果还比较明显,灰度用户业务请求耗时平均有50-100ms,约15%的优化

同时我们通过分析耗时分别在80分位、50分位、20分位的效果发现,请求耗时越长,优化效果越明显,也就是说在弱网情况下能够更好的发挥作用。

21.png

4.3. 渲染

使用分步渲染后,我们的页面可以在处理完首屏的基础数据之后就立即开始渲染了,由于我们的目录结构比较复杂,处理起来耗时比较长,所以第二部才处理目录,实际的渲染效果如下图:

22.png

首屏可以比原来提前100ms-150ms渲染出来。

5. 总结

我们本次的性能优化对小程序启动、请求、交互、渲染多个方面都进行了性能的挖掘,是在对基础库版本要求不高的情况下能做到的极致了。

以我们的核心页面首页和课程详情页来说:

  • 首页冷启动耗时开发者可干预的部分优化大概是1300下载 + 300注入 + 170首渲 + 430请求 = 2200ms -> 750 + 245 + 170 + 50 = 1215ms优化了45%
  • 课详页冷启动耗时开发者可干预的部分优化大概是1300下载 + 300注入 + 170首渲 + 790请求 = 2560ms -> 750 + 245 + 170 + 100 = 1265ms优化了50.5%
  • 页面切换首次进入详情页耗时从400路由 + 800请求 + 450处理 = 1650ms -> 400 + 720 + 300 = 1420ms优化了14%
  • 二次进入详情页面几乎看不到加载和渲染过程

还有更多的优化手段吗?官方还提供了一些比较高级的功能,对基础库版本要求比较高的,例如:

  • 组件的按需注入和用时注入可以进一步减小代码包下载耗时,但是在我们发布时这个功能还有点问题,会导致首页的自定义组件加载不出来,所以暂时没有使用到。
  • 还可以使用2.11.1开始支持的初始渲染缓存,不必等到逻辑层初始化完毕,可以更早的开始渲染视图。
  • 尚在试验阶段的分包异步化,利用异步加载模块的方式也可以减少代码包的下载耗时和JS的注入耗时。

利用这些能力可以在更多细节上进行优化,我们也将进一步探索和跟进,如果你有更好的方案欢迎讨论。