序言:由极客邦科技旗下InfoQ中国主办的 GMTC 全球大前端技术大会(2019 · 深圳站)于12 月 20 日成功开幕,DCloud CTO 崔红保出席大会,并作了《小程序的将来方向》的专题演讲,会上崔红保对小程序架构进行了深度剖析,并分析了由此架构引起的性能坑点及对应的优化方案,本文是对应的文字版,分享给你们,Enjoy~前端
了解引擎架构,才能对性能优化有更多的了解。本议题将深度剖析小程序架构,阐述这种架构的优势以及必然伴随带来的缺陷,提供了突破性能瓶颈的方案。node
这是一个比较通用的小程序架构,目前几家小程序架构设计大体都是这样的(快应用的区别是视图层只有原生渲染)。react
你们知道小程序是一个逻辑、视图层分离的架构。web
逻辑层就是上图左上角这块,小程序中开发的全部页面JS代码,最后都会打包合并到逻辑层,逻辑层除了执行开发者的业务JS代码外,还需处理小程序框架的内置逻辑,好比App生命周期管理。express
视图层就是上图右上角这块,用户可见的UI效果、可触发的交互事件在视图层完成,视图层包含web组件、原生组件两种,也就是小程序是原生+web混合渲染的模式,这块后面会详细讲。canvas
逻辑层最后运行在JS CORE或V8环境中;JS CORE既不是浏览器环境,也不是node环境,你是没法使用JS中的window、DOM对象,你能调用的仅仅是ECMAScript标准规范中所给出的方法。小程序
那若是你要发送网络请求怎么办?window.XMLHttpRequest 是没法使用的(固然即便能够调用,在iOS的WKWebView中也存在更严格的跨域限制,会有问题)。这时候,网络请求就须要经过原生的网络模块来发送,JS CORE和原生之间呢,就须要这个JS Bridge来通信。微信小程序
小程序这个架构最大的好处是新页面加载能够并行,让页面加载更快,且不卡转场动画。固然有的小程序引擎没有用好,致使新页面加载常常白屏,微信小程序仍是足够快和稳定的。跨域
但这样的架构设计,其实也引起了很多性能坑点,今天主要分享3点:浏览器
咱们从swipeAction这个例子讲起,需求是用户在列表项上向左滑动,右侧隐藏的菜单跟随用户手势平滑移动
若想在小程序架构上实现流畅的跟手滑动,是很困难的,为何?
咱们再回顾一下上面的小程序架构,小程序的运行环境分为逻辑层和视图层,分别由2个线程管理,小程序在视图层与逻辑层两个线程间提供了数据传输和事件系统。这样的分离设计,带来了显而易见的好处:
环境隔离,既保证了安全性,同时也是一种性能提高的手段,逻辑和视图分离,即便业务逻辑计算很是繁忙,也不会阻塞渲染和用户在视图层上的交互
但同时也带来了明显的坏处:
· 视图层(webview)中不能运行开发者编写的JS,而逻辑层JS又没法直接修改页面DOM,数据更新及事件系统只能靠线程间通信,但跨线程通讯的成本极高,特别是须要频繁通讯的场景
基于这样的架构设计,咱们回到swipeAction,分析一次touchmove的操做,小程序内部的响应过程:
· 用户拖动列表项,视图层触发touchmove 事件,经Native层中转通知逻辑层(逻辑层、视图层不是直接通信的,需Native中转),即下图中的⓵、⓶两步
· 逻辑层计算需移动的位置,而后再经过 setData 传递位置数据到视图层,中间一样会由微信客户端(Native)作中转,即下图中的⓷、⓸两步
实际上,用户滑动过程当中,touchmove的回调触发是很是频繁的,每次回调都须要4个步骤的通信过程,高频率回调致使通信成本大幅增长,极有可能致使页面卡顿或抖动。为何会卡顿,由于通信太过频繁,视图层没法在16毫秒内完成UI更新。
为解决这种通信阻塞的问题,各家小程序都在逐步提供对应的解决方案,好比微信的WXS、支付宝的SJS、百度的Filter,但每家小程序支持状况不一样,详细见下表。
另外,微信的关键帧动画、百度的animation-view Lottie动画,也是为减小频繁通信的一种变动方式。
其实,通信阻塞是业界广泛存在的一个问题,不止小程序,react native、weex等一样存在通信阻塞的问题。只不过react native、weex的视图层是原生渲染,而小程序是web渲染。咱们下面以weex为例来讲明。
你们知道,weex底层使用的 JS-Native Bridge,这个 Bridge 使得 JS 和 Native 之间的通讯会有固定的性能损耗。
继续以上述swipeaction为例,要实现列表项菜单的跟手滑动,大体需经以下流程:
· 在UI视图上绑定 touch 事件(或 pan 事件)
· 当手势触发时, Native UI层将手势事件经过 Bridge 传递给 JS逻辑层 , 这产生了一次 Native UI到 JS 逻辑的通讯,即下图中的⓵、⓶两步
· JS 逻辑在接收到事件后,根据手指移动的偏移量驱动界面变化,这又会产生一次 JS 到 Native UI的通讯,即下图中的⓷、⓸两步
一样,手势回调事件触发的频率是很是高的,频繁的的通讯带来的时间成本极可能致使界面没法在16ms中完成绘制,卡顿也就产生了。
weex为解决通信阻塞,提供了BindingX解决方案,这是一种称之为Expression Binding的机制,简要介绍一下:
· 接收手势事件的视图,在移动过程当中的偏移量以x,y两个变量表示
· 指望改变(跟随移动)的视图,变化的属性为translateX和translateY,对应变化的偏移量以f(x),f(y)表达式表示
· 将"交互行为"以表达式的方式描述,并提早预置到Native UI层
· 交互触发时,Native UI根据其内置的表达式解析引擎,去执行表达式,并根据表达式执行的结果驱动视图变换,这个过程无需和JS逻辑通信
伪代码 - 摘录自weex官网
{
anchor: foo_view.ref // ----> 这是"产生手势的视图"的引用
props:
[
{
element: foo_view.ref, // ----> 这是"指望改变的视图"的引用
expression: f(x) = x, // ----> 这是具体的表达式
property: translateX // ----> 这是指望改变的属性
},
{
element: foo_view.ref,
expression: f(y) = y, // ----> y 属性
property: translateY
}
]
}
React Native 一样存在相似问题,为避免频繁的通讯,React Native 生态也有对应方案,好比Animated组件及Lottie动画支持。以 Animated 组件为例,为实现流畅的动画效果,该组件采用了声明式的API,在 JS 端仅定义了输入与输出以及具体的 transform 行为,而真正的动画是经过 Native Driver 在 Native 层执行,这样就避免了频繁的通讯。然而,声明式的方式可以定义的行为有限,没法胜任交互场景。
uni-app在App端一样面临通信阻塞的问题,咱们目前的方案是采用相似微信wxs的机制(内部叫renderjs),但放开了wxs中没法获取页面DOM元素的限制,好比下图中多个小球同时移动的canvas动画,uni-app在App端的实现方案是:
· renderjs 中获取canvas对象
· 基于web的canvas绘制动画,而非原生canvas绘制
Tips:你们须要注意,并非全部场景都是原生性能更好,小程序架构下,如上多球同时移动的动画,原生canvas并不如在wxs(renderjs)中直接调用web canvas
下表总结了跨端框架在通信阻塞方面的解决方案。
小程序开发很是须要注意的就是setData的调用,由于每次setData,都是一次逻辑层向视图层通讯的过程。开发者应尽量的:
· 减小调用setData的次数
· 每次调用setData,传递尽量少的数据量,即数据差量更新
假设咱们有更改多个变量值的需求,示例以下:
change:function(){
this.setData({a:1});
... //其它业务逻辑
this.setData({b:2});
... //其它业务逻辑
this.setData({c:3});
... //其它业务逻辑
this.setData({d:4});
}
如上4次调用setData,会引起4次逻辑层、视图层数据通信。这种场景,开发者需意识到setData有极高的调用代价,本身需手动调整代码,合并数据,减小数据通信次数。
部分小程序三方框架已内置数据自动合并的能力(好比uni-app),开发者无需关心setData的调用代价,专一业务逻辑实现便可,建议你们使用。
减小setData调用次数,还有个注意点:后台页面(用户不可见的页面)应避免调用setData。
假设咱们有一个 "列表页 + 上拉加载" 的场景,初始化列表项为 "item1 ~ item4",用户上拉后要向列表追加4条新记录 "item5 ~ item8",小程序代码以下:
page({
data:{
list:['item1','item2','item3','item4']
},
change:function(){
let newData = ['item5','item6','item7','item8'];
this.data.list.push(...newData); //列表项新增记录
this.setData({
list:this.data.list
})
}
})
如上代码,change方法执行时,会将list中的 "item1 ~ item8" 8个列表项经过setData所有传输过去,而实际上变化的数据只有"item5 ~ item8"。
开发者在这种场景下,应经过差量计算,仅经过setData传递变化的数据,以下是一个示例代码:
page({
data:{
list:['item1','item2','item3','item4']
},
change:function(){
// 经过长度获取下一次渲染的索引
let index = this.data.list.length;
let newData = ['item5','item6','item7','item8'];
let newDataObj = {};//变化的数据
newData.forEach((item) => {
newDataObj['list[' + (index++) + ']'] = item;//经过list下标精确控制变动内容
});
this.setData(newDataObj) //设置差量数据
}
})
每次都手动计算差量变动数据是繁琐的,新手不理解小程序原理的话,也容易忽略这些性能点,给App埋下性能坑点。
此处建议开发者选择成熟的小程序三方框架,这些框架已经自动封装差量数据计算,对开发者更友好。好比uni-app借鉴了 westore JSON Diff库,在调用setData以前,会先比对历史数据,精确高效计算出有变化的差量数据,而后再调用setData,仅传输变化的数据,这样可实现传递数据量的最小化,提高通信性能。以下是一个示例代码:
export default{
data(){
return {
list:['item1','item2','item3','item4']
}
},
methods:{
change:function(){
let newData = ['item5','item6','item7','item8'];
this.list.push(...newData) // 直接赋值,框架会自动计算差量数据
}
}
}
Tips:如上change方法执行时,仅会将list中的"item5 ~ item8"4个新增列表项传输过去,实现了setData传输量的极简化
下图是一个微博列表截图:
假设当前有200条微博,用户对某条微博点赞,需实时变动其点赞数据(状态);在传统模式下,一条微博的点赞状态变动,会将整个页面(Page)的数据所有经过setData传递过去,这个消耗是很是高的。你就会发现那个点赞按钮点下去后,要等一会才能变为已赞的状态。 而经过以前介绍,经过差量计算的方式获取变动数据,这个 Diff 遍历范围也很大,计算效率极低。
如何实现更高性能的微博点赞?这其实就是组件更新的典型场景。
合适的方式应该是,将每一个点赞按钮封装成一个组件,用户点赞后,仅在当前组件范围内计算差量数据(可理解为Diff范围缩小为原来的1/200),这样效率才是最高的。
提醒你们注意,并非全部小程序三方框架都已实现自定义组件,只有在基于自定义组件模式封装的框架,性能才会大幅提高;若是三方框架是基于老的template模板封装的组件开发,则性能并不会有明显改善,其 Diff 对比范围依然是Page页面级的。
你们知道,小程序当中有一类特殊的内置组件——原生组件,这类组件有别于 WebView 渲染的内置组件,他们是由原生客户端渲染的。
小程序中的原生组件,从使用方式上来讲,主要分为三类:
· 经过配置项建立的:选项卡、导航栏,还有下拉刷新
· 经过组件名称建立的,好比:camera、canvas、input、live-player、live-pusher、map、textarea、video
· 经过API接口建立的,好比:showModal、showActionSheet等
除了上面提到的这些以外,其它基本都是web渲染。因此说,小程序是混合渲染模式,web渲染为主,原生渲染为辅。
接下来的问题,为何要引入原生渲染?以及为何仅针对这几个组件提供了原生加强?其余组件为何没有作原生实现?
每一个解决方案的诞生,要了解它为了解决什么问题而出现:
· tabbar/navigationbar:避免切换页面白屏,提高新窗口进入时的用户体验。虽然不使用原生的tabbar和导航栏,能够作出更灵活的界面,但在切换页面那短短300毫秒内,想保证页面不白屏,仍是须要使用渲染更快的原生tabbar和导航栏。
· video:全屏后的滑动控制(声音、进度、亮度等),更丰富的视频格式
· map:更流畅的双指缩放、位置拖动
· input:web端的input,键盘弹出时,只有完成按钮,没法让键盘右下角显示发送、下一个这样的按键
提到input控件的原生化,能够稍微发散一下。
小程序中原生input控件的一般作法是,未获取焦点时以web控件显示,但在获取焦点时,绘制一个原生input,盖在web input上方,此时用户看见的键盘即为原生input所对应的键盘,原生弹出键盘是可自定义按钮(如上图中下一步、send按钮)的。这种作法存在一个缺陷:web和原生,毕竟不一样渲染引擎,在键盘弹出和关闭时,对应input的placeholder会闪烁。
在Android平台,还有一种作法是基于webkit改造,定制弹出键盘样式;这种方案,在键盘弹出和关闭时,input控件都是web实现的,故不存在placeholder闪烁的问题。
原生组件虽然带来了更丰富的特性及更好的性能,但同时也引入了一些新的问题,好比:
1.层级问题:原生永远在最高层,没法经过z-index设置不一样元素的层级,没法与 view、image 等内置组件相互覆盖,不支持在picker-view、scroll-view、swiper等组件中使用,就是没法在前端的区域滚动组件中进行区域滚动
2.通信问题:好比一个长列表中内嵌视频组件,页面滚动时,需通知原生的视频组件一块儿滚动,通信阻塞,可能致使组件抖动或拖影
3.字体问题:在Android手机上,调整系统主题字体,全部原生渲染的控件的字体都会变化,而web渲染的字体则不会变化。以下图,系统rom字体为一款"你的名字"的三方字体,设置后,小程序顶部标题字体变了,底部选项卡字体也变了,但小程序中间内容区字体不变,这就是比较尴尬的一种状况,一个页面,两种字体。
固然,字体问题并不是无解。各家小程序基本都是自带一个webview内核,而不是使用系统webview,经过定制修改webview也可使用rom主题字体,好比微信、qq、支付宝;其余小程序(百度、头条),webview仍然没法渲染为rom主题字体。
既然混合渲染有这些问题,对应就会有解决方案,目前已有的方案以下。
方案1:创造层级更高的组件
既然其它组件没法覆盖到原生组件上,那就创造出一种新的组件,让这个新组件能够覆盖到video或map上。cover-view/cover-image就是基于这种需求创造出来的新组件;其实它们也是原生组件,只不过层级略高,能够覆盖在 map、video、canvas、camera等原生组件上。
目前除了字节跳动外,其它几家小程序均已支持cover-view/cover-image。
cover-view/cover-image 在必定程度上缓解了分层覆盖的问题,但也有部分限制,好比严格的嵌套顺序。
方案2:消除分层,同层渲染
既然分层有问题,那就消除分层,从2层变成1层,全部组件都在一个层中,z-index岂不就可生效了?
这个小目标提及来简单,具体实现仍是很复杂的,下个章节具体介绍。
抛开小程序当前架构实现,解决混合渲染最直接的方案,应该更换渲染引擎,所有基于原生渲染,video/map和image/view均为原生控件,层级相同,层级遮盖问题天然消失。这正是uni-app在App端的推荐方案。
uni-app在App端支持weex原生渲染,至于uni-app如何抹平weex和小程序的各项差别,这是另一个话题,后续可单独分享。
回归到当前web渲染为主、原生渲染为辅的主流小程序现状,如何实现同层渲染?
基于咱们的分析研究,这里简单讲解一下同层渲染实现的方案,和微信真实实现可能会有出入(目前仅微信一家实现了同层渲染)。
小程序在 iOS 端使用 WKWebView 进行渲染,WKWebView 在内部采用的是分层的方式进行渲染,通常会将多个DOM节点,合并到一个层上进行渲染,所以DOM节点和层之间不存在一一对应关系。可是,一旦将一个 DOM 节点的 CSS 属性设置为 overflow: scroll 后,WKWebView 便会为其生成一个 WKChildScrollView,且WebKit 内核已经处理了WKChildScrollView与其余 DOM 节点之间的层级关系,这时DOM节点就和层之间有一一对应关系了。
小程序 iOS 端的同层渲染可基于 WKChildScrollView 实现,主要流程以下:
· 建立一个 DOM 节点并设置其 CSS 属性为 overflow: scroll
· 通知原生层查找到该 DOM 节点对应的原生 WKChildScrollView 组件
· 将原生组件挂载到该 WKChildScrollView 节点上做为其子 View
小程序在 Android 端采用 chromium 做为 WebView 渲染层,和iOS的WKWebView不一样,是统一渲染的,不会分层渲染。但chromium 支持 WebPlugin 机制,WebPlugin 是浏览器内核的一个插件机制,可用来解析<embed>。Android 端的同层渲染可基于 <embed> 加 chromium 内核扩展来实现,大体流程以下:
· 原生层建立一个原生组件(如video)
· WebView 建立一个 <embed> 节点并指定其类型为video
· chromium 内核建立一个 WebPlugin 实例,并生成一个 RenderLayer
· 原生层将原生组件的画面绘制到 RenderLayer 所绑定的 SurfaceTexture 上
· chromium 渲染该 RenderLayer
这个流程至关于给 WebView 添加了一个外置插件,且<embed>节点是真正的 DOM 节点,可将更多的样式做用于该节点上。
若是要探讨小程序接下来的技术升级方向,咱们认为应该在用户体验、开发效率两个方向上努力。
先说用户体验的问题,主要也是两个方面:
· 解决现有的性能坑点,好比前面分析的这几项,通信阻塞、分层限制等,这里再也不赘述
· 支持更多App的体验,更自由灵活的配置,好比高斯模糊
若是你也想快速搭建的本身的小程序引擎,并更优的解决如上体验问题,该怎么办?
这里放一个福利。
uni-app发行到App端,其实是一个功能更丰富的小程序引擎,DCloud会在近期将这个小程序SDK完整开源,欢迎你们基于uni-app小程序SDK快速打造本身的小程序平台。
uni-app小程序SDK具有以下几个特征:
· 更接近App的性能:支持纯native view和webview双引擎渲染,扩展的wxs,更高的通信性能
· 更接近App的功能:提供丰富的原生API
· 开放性更强:更灵活的配置,支持更多小程序难以实现的丰富交互效果
· 开源不受限:无需签定任何协议,拿走就用
· 生态丰富:支持微信小程序自定义组件,支持全部uni-app插件,uni-app插件市场目前已有上千款成熟插件
OK,个人分享到此结束,如有错误,欢迎交流指正。