小程序前端分析(GMTC全球大前端技术大会)

序言:由极客邦科技旗下InfoQ中国主办的 GMTC 全球大前端技术大会(2019 · 深圳站)于12 月 20 日成功开幕,DCloud CTO 崔红保出席大会,并做了《小程序的未来方向》的专题演讲,会上崔红保对小程序架构进行了深度剖析,并分析了由此架构引发的性能坑点及对应的优化方案,本文是对应的文字版,分享给大家,Enjoy~

分享大纲

了解引擎架构,才能对性能优化有更多的了解。本议题将深度剖析小程序架构,阐述这种架构的优点以及必然伴随带来的缺陷,提供了突破性能瓶颈的方案。

小程序前端分析(GMTC全球大前端技术大会)(1)


小程序架构

这是一个比较通用的小程序架构,目前几家小程序架构设计大致都是这样的(快应用的区别是视图层只有原生渲染)。

小程序前端分析(GMTC全球大前端技术大会)(2)


大家知道小程序是一个逻辑、视图层分离的架构。

逻辑层就是上图左上角这块,小程序中开发的所有页面JS代码,最后都会打包合并到逻辑层,逻辑层除了执行开发者的业务JS代码外,还需处理小程序框架的内置逻辑,比如App生命周期管理。

视图层就是上图右上角这块,用户可见的UI效果、可触发的交互事件在视图层完成,视图层包含web组件、原生组件两种,也就是小程序是原生 web混合渲染的模式,这块后面会详细讲。

逻辑层最后运行在JS CORE或V8环境中;JS CORE既不是浏览器环境,也不是node环境,你是无法使用JS中的window、DOM对象,你能调用的仅仅是ECMAScript标准规范中所给出的方法。

那如果你要发送网络请求怎么办?window.XMLHttpRequest 是无法使用的(当然即使可以调用,在iOS的WKWebView中也存在更严格的跨域限制,会有问题)。这时候,网络请求就需要通过原生的网络模块来发送,JS CORE和原生之间呢,就需要这个JS Bridge来通讯。

小程序前端分析(GMTC全球大前端技术大会)(3)


架构引发的性能坑点

小程序这个架构最大的好处是新页面加载可以并行,让页面加载更快,且不卡转场动画。当然有的小程序引擎没有用好,导致新页面加载经常白屏,微信小程序还是足够快和稳定的。

但这样的架构设计,其实也引发了不少性能坑点,今天主要分享3点:

小程序前端分析(GMTC全球大前端技术大会)(4)


逻辑层/视图层通讯阻塞

我们从swipeAction这个例子讲起,需求是用户在列表项上向左滑动,右侧隐藏的菜单跟随用户手势平滑移动

小程序前端分析(GMTC全球大前端技术大会)(5)


若想在小程序架构上实现流畅的跟手滑动,是很困难的,为什么?

我们再回顾一下上面的小程序架构,小程序的运行环境分为逻辑层和视图层,分别由2个线程管理,小程序在视图层与逻辑层两个线程间提供了数据传输和事件系统。这样的分离设计,带来了显而易见的好处:

环境隔离,既保证了安全性,同时也是一种性能提升的手段,逻辑和视图分离,即使业务逻辑计算非常繁忙,也不会阻塞渲染和用户在视图层上的交互

但同时也带来了明显的坏处:

· 视图层(webview)中不能运行开发者编写的JS,而逻辑层JS又无法直接修改页面DOM,数据更新及事件系统只能靠线程间通讯,但跨线程通信的成本极高,特别是需要频繁通信的场景

基于这样的架构设计,我们回到swipeAction,分析一次touchmove的操作,小程序内部的响应过程:

· 用户拖动列表项,视图层触发touchmove 事件,经Native层中转通知逻辑层(逻辑层、视图层不是直接通讯的,需Native中转),即下图中的⓵、⓶两步

· 逻辑层计算需移动的位置,然后再通过 setData 传递位置数据到视图层,中间同样会由微信客户端(Native)做中转,即下图中的⓷、⓸两步

小程序前端分析(GMTC全球大前端技术大会)(6)


实际上,用户滑动过程中,touchmove的回调触发是非常频繁的,每次回调都需要4个步骤的通讯过程,高频率回调导致通讯成本大幅增加,极有可能导致页面卡顿或抖动。为什么会卡顿,因为通讯太过频繁,视图层无法在16毫秒内完成UI更新。

为解决这种通讯阻塞的问题,各家小程序都在逐步提供对应的解决方案,比如微信的WXS、支付宝的SJS、百度的Filter,但每家小程序支持情况不同,详细见下表。

小程序前端分析(GMTC全球大前端技术大会)(7)


另外,微信的关键帧动画、百度的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中完成绘制,卡顿也就产生了。

小程序前端分析(GMTC全球大前端技术大会)(8)


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绘制

小程序前端分析(GMTC全球大前端技术大会)(9)


Tips:大家需要注意,并不是所有场景都是原生性能更好,小程序架构下,如上多球同时移动的动画,原生canvas并不如在wxs(renderjs)中直接调用web canvas

下表总结了跨端框架在通讯阻塞方面的解决方案。

小程序前端分析(GMTC全球大前端技术大会)(10)


数据/组件差量更新

小程序开发非常需要注意的就是setData的调用,因为每次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传输量的极简化

组件差量更新

下图是一个微博列表截图:

小程序前端分析(GMTC全球大前端技术大会)(11)


假设当前有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控件的原生化,可以稍微发散一下。

小程序前端分析(GMTC全球大前端技术大会)(12)


小程序中原生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等组件中使用,就是无法在前端的区域滚动组件中进行区域滚动

小程序前端分析(GMTC全球大前端技术大会)(13)

2.通讯问题:比如一个长列表中内嵌视频组件,页面滚动时,需通知原生的视频组件一起滚动,通讯阻塞,可能导致组件抖动或拖影

3.字体问题:在Android手机上,调整系统主题字体,所有原生渲染的控件的字体都会变化,而web渲染的字体则不会变化。如下图,系统rom字体为一款"你的名字"的三方字体,设置后,小程序顶部标题字体变了,底部选项卡字体也变了,但小程序中间内容区字体不变,这就是比较尴尬的一种情况,一个页面,两种字体。

小程序前端分析(GMTC全球大前端技术大会)(14)


当然,字体问题并非无解。各家小程序基本都是自带一个webview内核,而不是使用系统webview,通过定制修改webview也可以使用rom主题字体,比如微信、qq、支付宝;其他小程序(百度、头条),webview仍然无法渲染为rom主题字体。

混合渲染改进方案

既然混合渲染有这些问题,对应就会有解决方案,目前已有的方案如下。

方案1:创造层级更高的组件

既然其它组件无法覆盖到原生组件上,那就创造出一种新的组件,让这个新组件可以覆盖到video或map上。cover-view/cover-image就是基于这种需求创造出来的新组件;其实它们也是原生组件,只不过层级略高,可以覆盖在 map、video、canvas、camera等原生组件上。

小程序前端分析(GMTC全球大前端技术大会)(15)


目前除了字节跳动外,其它几家小程序均已支持cover-view/cover-image。

cover-view/cover-image 在一定程度上缓解了分层覆盖的问题,但也有部分限制,比如严格的嵌套顺序。

方案2:消除分层,同层渲染

既然分层有问题,那就消除分层,从2层变成1层,所有组件都在一个层中,z-index岂不就可生效了?

小程序前端分析(GMTC全球大前端技术大会)(16)


这个小目标说起来简单,具体实现还是很复杂的,下个章节具体介绍。

同层渲染

抛开小程序当前架构实现,解决混合渲染最直接的方案,应该更换渲染引擎,全部基于原生渲染,video/map和image/view均为原生控件,层级相同,层级遮盖问题自然消失。这正是uni-app在App端的推荐方案。

uni-app在App端支持weex原生渲染,至于uni-app如何抹平weex和小程序的各项差异,这是另外一个话题,后续可单独分享。

回归到当前web渲染为主、原生渲染为辅的主流小程序现状,如何实现同层渲染?

基于我们的分析研究,这里简单讲解一下同层渲染实现的方案,和微信真实实现可能会有出入(目前仅微信一家实现了同层渲染)。

iOS平台

小程序在 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平台

小程序在 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插件市场目前已有上千款成熟插件

小程序前端分析(GMTC全球大前端技术大会)(17)


OK,我的分享到此结束,若有错误,欢迎交流指正。

,

免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com

    分享
    投诉
    首页