其中,“是否有用?”这个问题是非常主观的,对于不同场景的系统可能会有完全不一样的回答,所以FMP是一个比较模糊的概念指标,不存在规范化的数值衡量。
小程序作为一个新的内容载体,衡量指标跟Web应用是非常类似的。对于大多数小程序而言,上述指标对应的含义为:
综上,我们已基本确定了高性能的概念指标,接下来就是如何利用数值指标来描绘性能表现。
小程序官方针对小程序性能表现制订了权威的数值指标,主要围绕渲染表现、setData数据量、元素节点数和网络请求延时这几个维度来给予定义(下面只列出部分关键指标):
我们应该把这一系列的官方指标作为小程序的性能及格线,不断地打磨和提升小程序的整体体验,降低用户流失率。另外,这些指标会直接作为小程序体验评分工具的性能评分规则(体验评分工具会根据这些规则的权重和求和公式计算出体验得分)。
我们团队内部在官方性能指标的基础上,进一步浓缩优化指标系数,旨在对产品体验更高要求:
体验评分工具是目前检测小程序性能问题最直接有效的途径,我们团队已经把体验评分作为页面/组件是否能达到精品门槛的重要考量手段之一。
很多时候,宏观的耗时统计对于性能瓶颈点分析往往是杯水车薪,作用甚少,我们需要更细致地针对某个页面某些关键节点作测速统计,排查出暴露性能问题的代码区块,才能更有效地针对性优化。京喜小程序使用的是内部自研的测速系统,支持对地区、运营商、网络、客户端系统等多条件筛选,同时也支持数据可视化、同比分析数据等能力。京喜首页主要围绕页面onLoad、onReady、数据加载完成、首屏渲染完成、各业务组件首次渲染完成等几个关键节点统计测速上报,旨在全链路监控性能表现。
为了更好地为小程序制订性能优化措施,我们有必要先了解小程序的底层架构,以及与web浏览器的差异性。
作为小程序开发者,我们常常会被下面几个问题所困扰:
接下来,我们会结合小程序的底层架构分析出这些问题的根本原因,并针对性地给出解决方案。
小程序启动阶段,也就是如下图所示的展示加载界面的阶段。
1.准备运行环境:
2.下载小程序代码包:
在小程序初次启动时,需要下载编译后的代码包到本地。如果启动了小程序分包,则只有主包的内容会被下载。另外,代码包会保留在缓存中,后续启动会优先读取缓存。
3.加载小程序代码包:
小程序代码包下载好之后,会被加载到适当的线程中执行,基础库会完成所有页面的注册。
在页面注册过程中,基础库会调用页面JS文件的Page构造器方法,来记录页面的基础信息(包括初始数据、方法等)。
4.初始化小程序首页:
在小程序代码包加载完之后,基础库会根据启动路径找到首页,根据首页的基础信息初始化一个页面实例,并把信息传递给视图层,视图层会结合WXML结构、WXSS样式和初始数据来渲染界面。
经过多次业务迭代,无可避免的会存在一些弃用的组件/页面,以及不被调用的函数、样式规则,这些冗余代码会白白占据宝贵的代码包空间。而且,目前小程序的打包会将工程下所有文件都打入代码包内,并没有做依赖分析。
因此,我们需要及时地剔除不再使用的模块,以保证代码包空间利用率保持在较高水平。通过一些工具化手段可以有效地辅助完成这一工作。
WXML中的import和include:
@import'./A.wxss'JS中的require/import:
constA=require('./A')所以,可以说小程序里的所有依赖模块都是有迹可循的,我们只需要利用这些关键字信息递归查找,遍历出文件依赖树,然后把没用的模块剔除掉。
小程序代码包最终会经过GZIP压缩放在CDN上,但GZIP压缩对于图片资源来说效果非常低。如JPG、PNG等格式文件,本身已经被压缩过了,再使用GZIP压缩有可能体积更大,得不偿失。所以,除了部分用于容错的图片必须放在代码包(譬如网络异常提示)之外,建议开发者把图片、视频等静态资源都放在CDN上。
这是一个“痛并快乐着”的优化措施。“痛”是因为需要给后台同学提改造需求,分分钟被打;“快乐”则是因为享受删代码的过程,而且万一出Bug也不用背锅了...(开个玩笑)
通过让后台承担更多的业务逻辑,可以节省小程序前端代码量,同时线上问题还支持紧急修复,不需要经历小程序的提审、发布上线等繁琐过程。
总结得出,一般不涉及前端计算的展示类逻辑,都可以适当做后移。譬如京喜首页中的幕帘弹窗(如下图)逻辑,这里共有10+种弹窗类型,以前的做法是前端从接口拉取10+个不同字段,根据优先级和“是否已展示”(该状态存储在本地缓存)来决定展示哪一种,最后代码大概是这样的:
//检查每种弹窗类型是否已展示Promise.all([check(popup_1),check(popup_2),//...check(popup_n)]).then(result=>{//优先级排序constqueue=[{show:result.popup_1data:data.popup_1},{show:result.popup_2data:data.popup_2},//...{show:result.popup_ndata:data.popup_n}]})逻辑后移之后,前端只需负责拿幕帘字段做展示就可以了,代码变成这样:
this.setData({popup:data.popup})
京喜首页作为电商系统的门户,需要应对各类频繁的营销活动、升级改版等,同时也要满足不同用户属性的界面个性化需求(俗称“千人千面”)。如何既能减少为应对多样化场景而产生的代码量,又可以提升研发效率,成为燃眉之急。
类似于组件复用的理念,我们需要提供更丰富的可配置能力,实现更高的代码复用度。参考小时候很喜欢玩的“乐高”积木玩具,我们把首页模块的模板元素作颗粒度更细的划分,根据样式和功能抽象出一块块“积木”原料(称为插件元素)。当首页模块在处理接口数据时,会启动插件引擎逐个装载插件,最终输出个性化的模板样式,整个流程就好比堆积木。当后续产品/运营需要新增模板时,只要在插件库中挑选插件排列组合即可,不需要额外新增/修改组件内容,也更不会产生难以维护的if/else逻辑,soeasy~
当然,要完成这样的插件化改造免不了几个先决条件:
下面为大家提供部分例程来辅助理解。其中,use方法会接受各类处理钩子最终拼接出一个Function,在对应模块处理数据时会被调用。
白屏阶段,是指小程序代码包下载完(也就是启动界面结束)之后,页面完成首屏渲染的这一阶段,也就是FMP(首次有效绘制)。
小程序提供了读写本地缓存的接口,数据存储在设备硬盘上。由于本地I/O读写(毫秒级)会比网络请求(秒级)要快很多,所以在用户访问页面时,可以优先从缓存中取上一次接口调用成功的数据来渲染视图,待网络请求成功后再覆盖最新数据重新渲染。除此之外,缓存数据还可以作为兜底数据,避免出现接口请求失败时页面空窗,一石二鸟。
但并非所有场景都适合缓存策略,譬如对数据即时性要求非常高的场景(如抢购入口)来说,展示老数据可能会引发一些问题。
小程序内部接口的响应体类型都是application/octet-stream,即数据格式未知,使本地代理无法正确解析。
如果这几个问题点都不会影响到你的场景,那么可以尝试开启预拉取能力,这对于小程序首屏渲染速度是质的提升。
为了尽快获取到服务端数据,比较常见的做法是在页面onLoad钩子被触发时发起网络请求,但其实这并不是最快的方式。从发起页面跳转,到下一个页面onLoad的过程中,小程序需要完成一些环境初始化及页面实例化的工作,耗时大概为300~400毫秒。
实际上,我们可以在发起跳转前(如wx.navigateTo调用前),提前请求下一个页面的主接口并存储在全局Promise对象中,待下个页面加载完成后从Promise对象中读取数据即可。
这也是双线程模型所带来的优势之一,不同于多页面web应用在页面跳转/刷新时就销毁掉window对象。
如果开启了分包加载能力,在用户访问到分包内某个页面时,小程序才会开始下载对应的分包。当处于分包下载阶段时,页面会维持在“白屏”的启动态,这用户体验是比较糟糕的。
以京喜小程序如此庞大的小程序项目为例,每个模块背后都可能有着海量的后台服务作支撑,而这些后台服务间的通信和数据交互都会存在一定的时延。我们根据京喜首页的页面结构,把所有模块划分成两类:主体模块(导航、商品轮播、商品豆腐块等)和非主体模块(幕帘弹窗、右侧挂件等)。
这也是关键渲染路径优化思路之一,通过延迟非关键元素的渲染时机,为关键渲染路径腾出资源。
类似上一条措施,继续以京喜小程序首页为例,我们在主体模块的基础上再度划分出首屏模块(商品豆腐块以上部分)和非首屏模块(商品豆腐块及以下部分)。当小程序获取到主体模块的数据后,会优先渲染首屏模块,在所有首屏模块都渲染完成后才会渲染非首屏模块和非主体模块,以此确保首屏内容以最快速度呈现。
鉴于移动端设备的分辨率是有上限的,很多图片的尺寸常常远大于页面元素尺寸,这非常浪费网络资源(一般图片尺寸2倍于页面元素真实尺寸比较合适)。得益于京东内部强大的图片处理服务,我们可以通过资源的命名规则和请求参数来获取服务端优化后的图片:
这两者都是比较老生常谈的图片优化技术,这里就不打算细讲了。
在不得不使用大图资源的场景下,我们可以适当使用“体验换速度”的措施来提升渲染性能。
下面为大家提供部分例程:
“白屏”的加载体验对于首次访问的用户来说是难以接受的,我们可以使用尺寸稳定的骨架屏,来辅助实现真实模块占位和瞬间加载。
有趣的是,京喜首页的骨架屏方案经历了“统一管理”和“(组件)独立管理”两个阶段。出于避免对组件的侵入性考虑,最初的骨架屏是由一个完整的骨架屏组件统一管理的:
当调用wx.navigateTo打开一个新的小程序页面时,小程序框架会完成这几步工作:
1.准备新的webview线程环境,包括基础库的初始化;
2.从逻辑层到视图层的初始数据通信;
3.视图层根据逻辑层的数据,结合WXML片段构建出节点树(包括节点属性、事件绑定等信息),最终与WXSS结合完成页面渲染;
尽可能地把多次setData调用合并成一次。
在Taro框架下,调用setState时提供的对象会被加入到一个数组中,当下一次事件循环执行的时候再把这些对象合并一起,通过setData传递给原生小程序。
所以,与视图层渲染无关的数据尽量不要放在data中,可以放在页面(组件)类的其他字段下。
每当调用setData更新数据时,会引起视图层的重新渲染,小程序会结合新的data数据和WXML片段构建出新的节点树,并与当前节点树进行比较得出最终需要更新的节点(属性)。
即使小程序在底层框架层面已经对节点树更新进行了diff,但我们依旧可以优化这次diff的性能。譬如,在调用setData时,提前确保传递的所有新数据都是有变化的,也就是针对data提前做一次diff。
Taro框架内部做了这一层优化。在每次调用原生小程序的setData之前,Taro会把最新的state和当前页面实例的data做一次diff,筛选出有必要更新的数据再执行setData。
当用户事件(如Click、Touch事件等)被触发时,视图层会把事件信息反馈给逻辑层,这也是一个线程间通信的过程。但,如果没有在逻辑层中绑定事件的回调函数,通信将不会被触发。
所以,尽量减少不必要的事件绑定,尤其是像onPageScroll这种会被频繁触发的用户事件,会使通信过程频繁发生。
组件节点支持附加自定义数据dataset(见下面例子),当用户事件被触发时,视图层会把事件target和dataset数据传输给逻辑层。那么,当自定义数据量越大,事件通信的耗时就会越长,所以应该避免在自定义数据中设置太多数据。
不难得出,如果自定义组件的颗粒度太粗,组件逻辑过重,会影响节点树构建和新/旧节点树diff的效率,从而影响到组件内setData的性能。另外,如果组件内使用了createSelectorQuery来查找节点,过于庞大的节点树结构也会影响查找效率。
WXML数据绑定是小程序中父组件向子组件传递动态数据的较为常见的方式,如下面例程所示:ComponentA组件中的变量a、b通过组件属性传递给ComponentB组件。在此过程中,不可避免地需要经历一次ComponentA组件的setData调用方可完成任务,这就会产生线程间的通信。“合情合理”,但,如果传递给子组件的数据只有一部分是与视图渲染有关呢?
一个全局的事件调度中心
classEventBus{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(){}}constevent=newEventBus()事件订阅者
//子组件Component({created(){event.on('data-ready',(data)=>{this.setData({data})})}})事件发布者
//ParentComponent({ready(){event.trigger('data-ready',data)}})子组件被创建时事先监听数据下发事件,当父组件获取到数据后触发事件把数据传递给子组件,这整个过程都是在小程序的逻辑层里同步执行,比数据绑定的方式速度更快。
但并非所有场景都适合这种做法。像京喜首页这种具有“数据单向传递”、“展示型交互”特性、且一级子组件数量庞大的场景,使用事件总线的效益将会非常高;但若是频繁“双向数据流“的场景,用这种方式会导致事件交错难以维护。
我们可能会遇到这样的需求,多个组件之间位置不固定,支持随时随地灵活配置,京喜首页也存在类似的诉求。
京喜首页主体可被划分为若干个业务组件(如搜索框、导航栏、商品轮播等),这些业务组件的顺序是不固定的,今天是搜索框在最顶部,明天有可能变成导航栏在顶部了(夸张了...)。我们不可能针对多种顺序可能性提供多套实现,这就需要用到小程序的自定义模板。
实现一个支持调度所有业务组件的模板,根据后台下发的模块数组按序循环渲染模板,如下面例程所示。
假设,上一次渲染的组件顺序是['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更影响用户体验了。
不过内存告警的信息收集倒是有意义的,我们可以把内存告警信息(包括页面路径、客户端版本、终端手机型号等)上报到日志系统,分析出哪些页面Crash率比较高,从而针对性地做优化,降低页面复杂度等等。
根据双线程模型,小程序每一个页面都会独立一个webview线程,但逻辑层是单线程的,也就是所有的webview线程共享一个JS线程。以至于当页面切换到后台态时,仍然有可能抢占到逻辑层的资源,譬如没有销毁的setInterval、setTimeout定时器:
//PageAPage({onLoad(){leti=0setInterval(()=>{i++},100)}})即使如小程序的
对于大图片资源(譬如满屏的gif图)来说,我们只能尽可能对图片进行降质或裁剪,当然不使用是最好的。
然而无可避免地,当用户快速滚动长列表时,被销毁的组件可能来不及加载完,视觉上就会出现短暂的白屏。我们可以适当地调整销毁阈值,或者优化骨架图的样式来尽可能提升体验感。
结合上述的种种方法论,京喜小程序首页进行全方位升级改造之后给出了答卷:
1.Audits审计工具的性能得分86;
3.优化前后的测速数据对比:
凹凸实验室(Aotu.io,英文简称O2)始建于2015年10月,是一个年轻基情的技术团队。