微信小程序性能优化:从0.9秒到0.3秒

721 阅读16分钟

背景

快速迭代期,为了达成项目快速上线的目标,并没有进行规范的项目开发,经过多个不同开发者使用了风格迥异的开发方式后,小程序不可避免的出现了以下问题

  • 点开小程序,一直白屏,什么也看不到
  • Loading 转了好几圈,页面还不显示
  • 点击跳转反应慢
  • 长列表越加载越卡,渲染慢
  • 运行一段时间突然闪退

此时项目在性能上遇到了瓶颈,已经严重影响用户体验,而体验是一个小程序能否留下用户的重要原因之一。

根据官方文档建议的优化方案执行后,效果不是很明显,因此经过慎重考虑,我们打算从源码和业务流程入手,找到根源上影响性能的原因,结合业务场景定制优化方案并实施落地。

本文以线上购药微信小程序为例,记录我们如何从小程序启动到页面渲染完成后,用户在与页面交互的流程下去分析源码,找出明显或者隐藏的影响点,并对发现的问题提出针对性的优化建议,设计方案解决性能问题。希望大家在读完这篇文章后,能对小程序原理有个大致的了解,并学习如何找到问题的思路,在日后开发中遇到相似的问题,能快速定位并解决。

微信小程序介绍

该部分主要从小程序架构、小程序机制、性能指标和优化方案这几个方面介绍微信小程序。

小程序架构

微信小程序架构设计与web技术有一定的差别,小程序取其精华,去其糟粕,吸取了一定的优点,并丢弃了其中的一些缺点。

小程序是双线程设计, 即渲染线程和脚本线程是独立运行在不同的线程中,这样的设计是为了解决web技术中的一个痛点:

web技术是单线程,渲染线程和脚本线程是互斥的,这样如果脚本线程的长时间执行会阻塞渲染脚本的执行,导致页面白屏甚至失去响应,造成糟糕的用户体验。

小程序为了更好的体验,将渲染线程和脚本线程分开设计,具体体现在:

  • 视图 view 层运行在 webview 中,一个页面就是一个 webview
  • 业务逻辑 AppService 运行在 JsCore 中(ios 是 JavaScriptCore、android是 X5 JSCore、微信开发者工具是 webview)

这样就解决了逻辑线程长时间执行导致的阻塞问题,但随之而来的问题:

  • 天生的延迟。两个线程是独立的,二者之间通信必定会有通信成本和一定的延迟。
  • 业务层逻辑因为运行在 JsCore 中,没法儿访问到 DOM 和 BOM 的 api。

开发者工具使用 webview 加载业务逻辑层的代码,虽然依赖的环境有 DOM 和 BOM api,为了保持一致;小程序对所有的模块进行了局部化处理使其不能访问这些 api。这样双线程通过 Native,开发者工具通过后台 websocket 服务充当二者消息中转媒介,并且提供一些基础功能。具体可以看如下的官方图:

掘金-setData.png

小程序托管在腾讯云上,一个小程序可以由一个主包和若干分包组成,在下载完成后会离线保存在本地,在发布新版本前都可以重复使用,无需再次下载。主包和分包分别包含了(如下图所示):

  • 主包含了逻辑层、视图层访问任何页面的公共依赖资源和部分主包页面(例如首页、我的页面)。
  • 分包中也包含了逻辑层、视图层访问分包页面所需资源。

启动流程

小程序的启动过程以「用户打开小程序」为起点,到小程序「首页渲染完成」为止

用户打开小程序」可能是由用户点击访问触发,也可能通过扫码、小程序跳小程序或 APP 打开小程序等入口触发。从扫码、APP 等场景打开小程序时,可能会有前置的跳转和校验流程,不包含在小程序启动流程的讨论范围之内。

小程序「首页渲染完成」的标志是首个页面 Page.onReady 事件触发。由于启动流程的差异,小程序定义的「首页渲染完成」不等同于浏览器的 DOMContentLoaded 或 load 事件。

用户通过点击卡片、扫码进入或者直接访问这类冷启动方式进入小程序后,小程序进入启动流程,由于篇幅有限,这里只介绍未命中环境预加载的启动流程:

image.png

启动流程的几个阶段如下:

  • 环境准备
    • 小程序运行进程及运行环境的准备
    • 代码包下载、校验及初始化
    • 视图层系统组件、Webview容器和原生组件初始化
    • 逻辑层JS引擎初始化及域的创建
  • 代码注入
    • 框架及第三方基础代码的初始化
    • 开发者代码注入
  • 首屏渲染
    • 逻辑层页面初始化,这个时间点是initDataSendTime,会有Page.onLoad事件的派发
    • 视图层时间点走到viewLayerReaderStartTime,会有Page.onShow事件的一个派发
    • 开发者代码从后端拉取数据,准备data数据(非必须)
    • 页面渲染
    • 视图层时间点走到viewLayerReaderEndTime,会有Page.onReady事件派发标志首屏渲染完成

有兴趣了解命中环境预加载启动流程 点击此处,这里篇幅有限不再赘述。

启动性能:启动耗时越长,白屏时间越久,用户越可能因为失去耐心而退出小程序,打开率也会越低

注:小程序启动的各流程不是串行的,会尽可能的并行。计算总启动耗时不能简单的分阶段加和。

性能指标

  • 代码包下载耗时:主包下载以及载入时间与包体积成正比,全局自定义组件和插件会在小程序启动时随主包一起下载和注入 JS 代码,影响启动耗时
  • 代码注入耗时:视图层和逻辑层的代码注入时间,在小程序启动时,启动页面依赖的所有代码包(主包、分包、插件包、扩展库等)的所有 JS 代码会全部合并注入,包括其他未访问的页面以及未用到自定义组件,同时所有页面和自定义组件的 JS 代码会被立刻执行
  • 首次渲染耗时:页面首次渲染耗时,到onReady的总体时间

小程序官方统计以we分析冷启动耗时为主要指标

运行时机制

运行时机制主要围绕页面切换流程和 setData 介绍。

页面切换流程

页面切换流程如下:

image.png

页面切换的流程从用户触发页面切换开始:

  • 触发页面切换:触发时间对应 PerformanceEntry(route) 中的 startTime。
  • 加载分包(若有)
  • 视图层页面初始化
    • 创建 WebView
    • 注入视图层的小程序基础库
    • 注入主包的公共代码(独立分包除外)
    • (若页面位于分包中)注入分包的公共代码
    • 注入页面代码
  • 逻辑层页面初始化
    • 完成分包加载和 WebView 创建后,客户端会向基础库派发路由事件
  • 目标页面渲染
  • 页面切换动画

setData 原理

小程序的视图层目前使用 WebView 作为渲染载体,而逻辑层是由独立的 JavascriptCore 作为运行环境。在架构上,WebView 和 JavascriptCore 都是独立的模块,并不具备数据直接共享的通道。当前,视图层和逻辑层的数据传输,实际上通过两边提供的 evaluateJavascript 所实现。即用户传输的数据,需要将其转换为字符串形式传递,同时把转换后的数据内容拼接成一份 JS 脚本,再通过执行 JS 脚本的形式传递到两边独立环境。而 evaluateJavascript 的执行会受很多方面的影响,数据到达视图层并不是实时的,因此需要避免在单位时间频繁进行setData,减少通信次数,避免通信任务阻塞。

image.png

性能指标

  • 页面切换平均耗时:页面切换平均耗时
  • 接口平均耗时:接口请求平均耗时
  • setData时间:运行时 setData 更新耗时
  • 运行异常错误率:静态资源异常、接口异常、js执行错误率等

小程序官方统计以we分析页面切换耗时为主要指标

定位性能问题

通过工具定位启动性能和运行时性能问题

启动性能分析

  1. 利用开发者工具的代码依赖分析与代码质量来共同查看包体积和定位性能瓶颈,在优化前发现文件存放杂乱无章,而且主包存在很多主包未用到的代码,即只有分包用到的公共代码和组件

包体积.png

代码质量.png

掘金-代码包分析.png

  1. 通过we分析可以看出某些页面大部分场景是跨分包跳转导致首次进入分包页面时的延迟较大,尤其是首页进入各个分包的入口尤为明显,可用分包预加载解决。
  2. 直播插件体积过大,放在任意一个分包都会影响该分包的大部分页面启动耗时性能。

运行时性能分析

利用开发者工具体验评分和开发者工具自带插件「云测」定位性能瓶颈,开发者工具和云测分析后会提供最佳实践建议,云测则按用户机型比例设置相应机型(可根据we分析提供的用户机型及比例数据设置)跑测试可最接近上线后we分析的性能数据,优化前后可通过云测的性能数据验证优化方案是否有效

TIP: 云测长期使用需氪金

小程序首页最为复杂,这里以小程序首页为例分析

image.png

image.png

image.png

image.png

从工具和代码分析:

  • 页面渲染 dom 节点过多
  • 引入动态组件过多,接近 20 种组件,代码注入时间过长
  • 元素曝光埋点统计,onScroll 事件监听和无用监听函数
  • 自定义 tabbar,无法利用自带 tabbar 缓存
  • setData 频率

从业务逻辑分析:

  • 接口依赖路径过长:首屏时间过长,经过梳理页面业务逻辑发现,每次进首页自动切换门店定位需要走完整的定位流程,静默登录->获取用户信息->获取用户定位和附近门店->请求门店对应的动态组件->请求动态组件数据
  • 旧物料新旧路径跳转:小程序优化几次后换了几次路径,对于已发出去的旧物料的页面路径仍需兼容,需要跳转几层路由,导致页面切换耗时增加
  • 业务特殊性两个首页:会员业务、线上购药业务各有一个首页,大部分启动场景跳两个首页,增加页面切换耗时

启动性能优化

通过以上分析,可以从几个方面优化启动耗时

  • 代码包体积优化
  • 代码注入优化
  • 首次渲染优化

代码包体积优化

根据官方建议使用了以下几种方式优化代码包:

主包体积优化

可从以下几个方面优化主包体积:

  • 文件目录重新规范化,主包和跨分包用到的组件和公共代码放主包,其余放入各自分包
  • 按业务模块重新规划主包、分包内容,分包跟主包目录一致
  • 为了减少主流程页面之间切换耗时,主流程页面应在一个分包中
  • 删除无用的插件和代码功能:可通过eslint校验工具规范代码,如无用的变量,方法等
  • 把基础组件和业务组件分离:降低组件过多导致查找困难的成本
  • 整合相似组件:如商品卡片、导航栏等

image.png

image.png

分包预加载

预下载分包行为在进入某个页面时触发,通过在 app.json 增加 preloadRule 配置来控制:

// 进入首页时加载主流程分包和处方分包
preloadRule: {
  "pages/index": {
    network: "all",
    packages: ["main", "rx"],
  }
}

独立分包

针对直播插件问题可用独立分包解决,在 app.json 配置

subpackages: [...root, ...member, ...o2o, {
  root: "apps/plugins",
  pages: [],
  plugins: {
    "live-player-plugin": {
      version: "1.3.5",
      provider: "xxxx",
    },
  },
  independent: true,
}],

代码注入耗时优化

  • 按需加载组件:第一次冷启动首页无需注入所有主包的组件和 js
  • 减少微信同步api调用,在小程序生命周期中调用使用 Sync 结尾的同步 api 会造成长时间的等待(getSystemInfo 也是同步api),小程序异步 api 底层也是同步实现

在 app.json 中开启按需加载

    {
      "lazyCodeLoading": "requiredComponents"
    }

占位组件

在使用分包预加载或按需注入等特性时,自定义组件所引用的其他自定义组件,在刚开始进行渲染时可能处于不可用的状态。此时,为了使渲染过程不被阻塞,不可用的自定义组件需要一个 「占位组件」(Component placeholder) 。基础库会用占位组件替代不可用组件进行渲染,在该组件可用后再将占位组件替换回该组件。 代码注入阶段可用占位组件优化

在页面 json 文件中配置占位组件,占位组件可以是某个空标签,也可以是自定义组件

    {
      "usingComponents": {
        "comp-a": "../comp/compA",
        "comp-b": "../comp/compB",
        "comp-c": "../comp/compC"
      },
      "componentPlaceholder": {
        "comp-a": "view",
        "comp-b": "comp-c"
      }
    }

骨架屏之反向优化

本次优化实践中发现使用骨架屏反而让耗时增加,查找相关资料发现官网有关于骨架屏的以下说明:

从渲染效率上来讲,骨架屏它并不能使首屏渲染加快。由于骨架屏的一些使用又向用户渲染了额外的一些内容,这些内容是额外添加的、本来是不需要渲染的,它反而从整体上加长了首屏渲染的一个时长。但是骨架屏在这个页面白屏的时候,它给了用户及时的反馈,减缓了用户焦急等待的一个情绪,这是它的意义所在。

首次渲染耗时优化

  • 精简首屏数据和请求数,网络请求并发数最大是 10,分批请求数据
  • 页面 json 文件或全局 app.json 启用初始渲染缓存
    {
      "initialRenderingCache": "static/capture/dynamic"
    }
    
    注:
    • this.setInitialRenderingCache 调用时机不能早于 Page 的 onReady 或 Component 的 ready 生命周期,否则可能对性能有负面影响。
    • 如果想禁用初始渲染缓存,调用 this.setInitialRenderingCache(null) 。

合理规划版本发布

小程序发版频率会影响启动耗时,应合理规划版本发布

运行时性能优化

根据微信we分析统计,首页页面切换耗时和所有页面平均切换耗时优化前分别为 1816ms938ms

image.png

image.png

首页优化方案

通过以上分析,首页可以从几个方面优化

  • 技术层面优化
    • 渲染耗时优化
      • 改造成小程序自带 tabbar,利用自带 tabbar 缓存降低耗时
      • 去除不必要的监听,减少监听频次,如元素曝光等
    • 分批请求:请求数过多,分两批请求执行,优先执行首屏请求,剩下的请求在第二批执行
    • 减少 setData 频率
    • 图片优化: 压缩图片体积、利用 cdn 缓存、base 64图片换成图片文件外链形式
  • 业务层面优化
    • 代码注入耗时优化:跟产品讨论减少动态组件种类,把没用到的组件删除,从 20 种动态组件减少到 10 种,减少约 50% 组件量
    • 定位流程优化:缓存定位信息,当有定位缓存信息时拿定位信息,通过用户手动切换定位,不再自动切换门店定位
    • 页面切换耗时优化:
      • 拉通产品和业务,访问量连续两个月小于 10 就不再兼容旧物料路径,逐步替换旧路径
      • 和会员组产品达成共识,两个首页改成一个首页
    • 精简 dom 节点数:拉通产品和业务,精简首页组件配置

TIP: WXML节点越少、嵌套层次越浅、渲染效率越高,按照这个小程序的性能评判标准,每页总节点数要小于1000个,层次要低于30层,每个节点的子节点不能多于60个,针对JS脚本解析执行这样一个效率低下这样的一个特点。

合理使用setData

控制 setData 次数及单次数据量大小,setData每次传递的数据大小不能超过 256KB,超过这个限制 页面就容易卡顿,并不是低于256KB它便不会产生性能问题。在这个页面或者是列表组件scroll事件里面,如果我们频繁地调用setData视图层它来不及渲染也会出现明显的一个卡顿现象。

更多 setData 优化可查看官方文档

渲染性能优化

适当监听 scroll 事件、使用 IntersectionObserver 监听元素曝光、控制dom节点层级和数量

资源加载优化

  • 控制图片大小、避免滥用widthFix动态改变图片宽度或高度等
  • 使用 cdn 图片
  • base64 图片转换成图片文件存储在cdn,在此次优化中还做了一个 base64 图片转图片文件的脚本,后期有时间再分享

内存优化

  • 闭包、定时器、事件监听等引起的内存泄漏问题
  • 减少不必要的 storage

优化成果

微信整体评价从一般升级为良好

主包体积和启动性能

优化后主包体积从 1.8M 降低到 1.2M,体积减少三分之一

启动耗时降低 35%,从 3496ms 降低到 2268ms

image.png

运行时性能

首页优化后性能提升 82%,切换耗时从 1816ms 降低到 315ms

首页优化后切换耗时.png

整体优化后平均页面切换耗时为 338ms,整体性能提升 64%

image.png