前言:由极客邦科技InfoQ中国主办的GMTC全球大前端技术大会(2019·深圳站)圆满开幕12月20日,DCloudCTO崔洪宝出席会议并发表专题演讲《小程序的未来方向》。会上,崔宏宝对小程序架构进行了深入分析,分析了该架构带来的性能坑及相应的优化方案。本文为对应文字版,分享给大家,Enjoy~分享大纲了解引擎架构,让大家对性能优化有更深入的了解。本课题将深入剖析小程序架构,阐述该架构的优势和不可避免的缺陷,并提供突破性能瓶颈的解决方案。小程序架构这是一个比较通用的小程序架构。目前几个小程序的架构设计大致是这样的(快应用的区别在于视图层只有原生渲染)。大家都知道小程序是逻辑层和视图层分离的结构。逻辑层就是上图左上角的那一层。所有在小程序中开发的页面JS代码,最终都会被打包合并到逻辑层。逻辑层除了执行开发者的业务JS代码外,还需要处理小程序框架内置的逻辑。比如App生命周期管理。视图层就是上图右上角的那个。用户可见的UI效果和可触发的交互事件都在视图层完成。视图层包括web组件和native组件两种,即小程序是native+web混合渲染模式。这块将在后面详细讨论。逻辑层最终运行在JSCORE或V8环境下;JSCORE既不是浏览器环境也不是node环境,你不能使用JS中的window和DOM对象,你可以调用的只是ECMAScript标准规范中给出的方法。那么如果要发送网络请求怎么办呢?window.XMLHttpRequest不能用(当然,即使可以调用,iOS的WKWebView中有比较严格的跨域限制,会出问题)。这时需要通过原生网络模块发送网络请求,需要JSBridge来实现JSCORE与原生之间的通信。这种架构最大的优点是可以并行加载新页面,从而使页面加载速度更快,不会卡在过渡动画中。当然,有些小程序引擎没有用好,导致加载新页面时经常黑屏,但微信小程序还是够快够稳定的。但是这种架构设计其实造成了很多性能上的隐患。今天主要分享3点:逻辑层/视图层通信阻塞。让我们从swipeAction的例子开始。隐藏的菜单随着用户的手势流畅地移动。手在小程序架构上很难实现流畅的滑动。为什么?我们再回顾一下上面的小程序架构。小程序的运行环境分为逻辑层和视图层,分别由两个线程管理。小程序提供视图层和逻辑层两个线程之间的数据传输和事件系统。这样的分离设计带来的好处很明显:环境隔离不仅保证了安全性,也是一种性能提升的手段,逻辑和视图分离,即使业务逻辑计算非常繁忙,也不会阻塞渲染和用户在上的交互视图层也带来了明显的缺点:开发者写的JS不能在视图层(webview)运行,逻辑层JS不能直接修改页面DOM,数据更新和事件系统只能靠线程相互通信,但是跨线程通信的成本极高,尤其是在需要频繁通信的场景下。基于这样的架构设计,我们回到swipeAction,分析一个touchmove操作。view层触发touchmove事件,由Native层中继通知逻辑层(逻辑层和view层不直接通信,需要Native中继),即中的?和?两个步骤下图。逻辑层计算出要移动的位置,然后通过setData将位置数据传递给视图层,微信客户端(Native)也会做中间的传递,即中的?和?两个步骤下图。其实在用户的滑动过程中,touchmove的回调触发是非常频繁的。每个回调都需要一个4步的通信过程。高频回调导致通信成本大幅增加,极有可能造成页面卡顿或抖动。为什么卡住了?由于通信过于频繁,视图层无法在16毫秒内完成UI更新。为了解决这个通信阻塞问题,各种小程序都在逐渐提供相应的解决方案,比如微信的WXS、支付宝的SJS、百度的Filter,但是每个小程序的支持都不一样,详见下表。另外,微信的关键帧动画和百度的animation-viewLottie动画也是一种减少频繁交流的变法。事实上,通信阻塞是业界普遍存在的问题。不仅是小程序,reactnative、weex等也存在通信阻塞问题。只是reactnative和weex的view层是原生渲染,而小程序是web渲染。下面以weex为例进行说明。众所周知,weex底层使用的JS-NativeBridge,这个Bridge使得JS和Native的通信有一个固定的性能损耗。继续以上述的swipeaction为例,要实现列表项菜单的滑动,一般需要经过以下过程:在UI视图上绑定touch事件(或pan事件)当手势被触发时,NativeUI层将手势事件通过TheBridge传递给JS逻辑层,这就产生了一个NativeUI到JS逻辑的通信,也就是下图中的?和?两个步骤。JS逻辑接收到事件后,根据手指移动的偏移量,驱动界面发生变化。从JS到NativeUI还会有一次通信,也就是下图中的?和?两个步骤。同样,手势回调事件的频率非常高,频繁通信带来的时间成本很可能导致接口在16ms内失效。中途绘制完成,会产生卡顿。为了解决通信阻塞,weex提供了BindingX的解决方案,这是一种叫做ExpressionBinding的机制,简单介绍一下:接收手势事件的视图,移动过程中的偏移量用两个变量x和y表示,表示视图期望变化(跟随运动),变化的属性是translateX和translateY,变化对应的offset用f(x),f(y)表达式表示。以表达式的形式描述“交互行为”,并预先预置到NativeUI层。当交互被触发时,NativeUI根据其内置的表达式解析引擎执行表达式,并根据表达式执行的结果驱动视图转换。这个过程不需要和JS逻辑伪代码通信-摘自weex官网{anchor:foo_view.ref//---->这里引用了“生成手势的View”props:[[{element:foo_view。类似的问题,为了避免频繁通信,ReactNative生态也有相应的解决方案,比如Animated组件和Lottie动画支持。以Animated组件为例。为了实现流畅的动画效果,该组件采用声明式API。在JS端,只定义了输入输出和具体的transform行为,而真正的动画是通过NativeDriver在Native层执行的。这样就避免了频繁的交流。但是,声明式方式只能定义有限的行为,不适合交互场景。uni-app同样面临着App端通信阻塞的问题。我们目前的解决方案是采用类似微信wxs的机制(内部称为renderjs),但是它解除了wxs中无法获取页面DOM元素的限制,比如多个小球同时移动的canvas动画,uni-app在App端的实现是:在renderjs中获取canvas对象基于Web的canvas绘制动画,而不是原生的canvas绘制Tips:大家需要注意,并不是所有的场景都是原生的表现更好。在小程序架构下,原生canvas不如上面多个球同时运动的动画直接在wxs(renderjs)中调用webcanvas。下表总结了跨终端框架在通信阻塞方面的解决方案。数据/组件差异更新Applet开发需要注意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(){letnewData=['item5','item6','item7','item8'];this.data.list.push(...newData);//列表项的新记录this.setData({list:this.data.list})}})As上面的代码,执行了change方法,列表中的8个列表项“item1~item8”都会通过setData传递过来,但实际改变的数据只有“item5~item8”。在这种场景下,开发者应该计算差异,只通过setData传递变化的数据。以下是示例代码:page({data:{list:['item1','item2','item3','item4']},change:function(){//获取下一次渲染的indexlengthletindex=this.data.list.length;letnewData=['item5','item6','item7','item8'];letnewDataObj={};//改变数据newData.forEach((item)=>{newDataObj['list['+(index++)+']']=item;//精确控制list下标改变内容});this.setData(newDataObj)//设置差异数据}})是每次手动计算差异变化数据很麻烦。如果新手不了解小程序的原理,很容易忽略这些性能点。埋性能坑。建议开发者选择成熟的小程序三方框架。这些框架已经自动封装了差异数据的计算,对开发者更加友好。比如uni-app就借鉴了westoreJSONDiff库。在调用setData之前,会先对比历史数据,准确高效的计算出变化的差异数据,然后调用setData只传输变化的数据,这样数据才能传输。尽量减少数量并提高通信性能。以下是示例代码:newData=['item5','item6','item7','item8'];this.list.push(...newData)//直接赋值,框架会自动计算差值数据}}}Tips:上面的change方法执行时,只会新增列表项“item5~item8”中的4个list会传过去,实现setData传输量的组件差异更新极其简化。当用户点赞一条微博时,需要实时改变其点赞数据(状态);传统模式下,当微博点赞状态发生变化时,整个页面(Page)的所有数据都会通过setData传递。消费非常高。你会发现点赞按钮后,要过一段时间才能变成点赞状态。根据前面的介绍,变化数据是通过差值计算的方式得到的。Diff遍历范围也很大,计算效率极低。如何实现更高性能的微博点赞?这实际上是组件更新的典型场景。合适的做法应该是将每一个点赞按钮都封装成一个组件。用户点赞后,只计算当前组件范围内的差异数据(可以理解为Diff范围缩小为原来的1/200),效率高。是最高的。提醒大家注意,并不是所有的小程序三方框架都实现了自定义组件。只有基于自定义组件模式封装的框架才会大大提高性能;如果三方框架是基于老模板模板封装的组件开发的,那么性能不会有明显提升,Diff比较范围还是在页面级别。众所周知,小程序中有一种特殊的内置组件——原生组件。这些组件不同于WebView渲染的内置组件。它们由本地客户端呈现。小程序中的原生组件在使用上主要分为三类:通过配置项创建:tabs,navigationbars,下拉刷新通过组件名称创建,如:camera,canvas,input,live-player,live-pusher,map,textarea,video通过API接口创建,比如:showModal,showActionSheet等,除了上面提到的,其他基本都是web渲染。因此,小程序是一种混合渲染模式,以网页渲染为主,原生渲染为辅。为什么要引入混合渲染下一个问题,为什么要引入原生渲染?为什么只有这几个组件有原生增强?为什么其他组件没有原生实现?对于每一个方案的诞生,需要了解它出现是为了解决什么问题:tabbar/navigationbar:避免切换页面白屏,提升新窗口进入时的用户体验。虽然不使用原生的tabbar和navigation也可以打造更灵活的界面,但是300毫秒内切换页面,如果要保证页面不空白,还是需要使用原生的tabbar和navigation渲染速度更快的栏。·视频:全屏后滑动控制(声音、进度、亮度等),视频格式更丰富·地图:双指缩放、位置拖动更流畅·输入:网页端输入,弹出键盘时只有一个finish按钮,不可能让键盘右下角显示send和next按钮提到输入控件的原生化,可以稍微发散一点。小程序中原生输入控件的通常做法是,在没有获得焦点时,将它们显示为网页控件,但当获得焦点时,绘制一个原生输入并覆盖在网页输入之上。这时,用户看到的键盘就是原生输入对应的键盘。键盘,原生弹出式键盘可以自定义按钮(如上图中的下一步和发送按钮)。这种做法有一个缺陷:web和native毕竟是不同的渲染引擎,当键盘弹出和关闭时,输入对应的placeholder会闪烁。在Android平台上,还有一种基于webkit改造自定义弹出键盘样式的方式;在这个方案中,当键盘弹出和关闭时,输入控件都是在web上实现的,所以不存在占位符闪烁的问题。混合渲染带来的问题虽然原生组件带来了更丰富的特性和更好的性能,但是也引入了一些新的问题,例如:1.层级问题:原生组件总是处于最高层级,无法通过z-index设置不同层级elements不能与view、image等内置组件重叠,也不能在picker-view、scroll-view、swiper等组件中使用。前端区域滚动组件无法进行区域滚动。2.沟通问题:比如一个视频组件嵌入了一个长长的列表。当页面滚动时,需要通知原生视频组件一起滚动。通信阻塞可能会导致组件抖动或拖尾。3、字体问题:在安卓手机上,调整系统主题字体,所有原生渲染控件的字体都会改变,但是网页渲染的字体不会改变。如下图所示,系统rom字体是“你的名字”的三方字体。设置后,小程序顶部标题的字体发生变化,底部选项卡的字体也发生变化,但小程序中间内容区的字体保持不变。这是比较尴尬的一种情况,一页,两种字体。当然,字体问题也不是不能解决。每个小程序基本上都自带webview内核,而不是使用系统webview。也可以通过自定义修改webview使用rom主题字体,如微信、QQ、支付宝;其他小程序(百度,今日头条),webview仍然无法渲染rom主题的Font。混合渲染改进方案既然混合渲染存在这些问题,就会有相应的解决方案。目前现有的解决方案如下。方案一:创建一个更高层级的组件由于其他组件无法叠加在原生组件上,那么创建一个新的组件,让这个新的组件可以叠加在视频或地图上。cover-view/cover-image是根据这个需求新建的组件;其实它们也是原生组件,只是级别稍微高一点,可以叠加在地图、视频、画布、相机等原生组件上。目前,除了字节跳动,其他几个小程序已经支持cover-view/cover-image。cover-view/cover-image在一定程度上缓解了分层覆盖的问题,但也有一定的局限性,比如严格的嵌套顺序。方案二:消除分层,同层渲染既然分层有问题,那就消除分层,由2层改为1层,所有组件都在一层。z-index不会生效吗?这个小目标说起来简单,但具体实现起来还是很复杂的,下一章会介绍。同层渲染忽略了小程序目前的架构实现,混合渲染最直接的解决方案就是更换渲染引擎,全部基于原生渲染。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节点和图层之间存在着一一对应的关系。基于WKChildScrollView可以实现小程序iOS端的同层渲染。主要流程如下:创建一个DOM节点,并设置其CSS属性为overflow:scroll通知native层寻找该DOM节点对应的原生WKChildScrollView组件挂载该原生组件在WKChildScrollView节点上作为其子View,Android平台小程序使用chromium作为Android端的WebView渲染层。不同于iOS的WKWebView,它是统一渲染的,不会分层。但是chromium支持WebPlugin机制,它是浏览器内核的一种插件机制,可以用来解析
