小程序渲染架构设计

One 什么是小程序 Ⅰ 小程序概念 微信小程序算是小程序的鼻祖了,2017年1月9日微信正式上线了小程序。在探究小程序技术架构之前,我们先看看小程序究竟是什么,微信官网对微信小程序的产品定位及功能介绍是: “微信小程序是一种全新的连接用户与服务的方式,它可以在微信内被便捷地获取和传播,同时具有出色的使用体验。”
这个介绍有种看了跟没看一样的感觉。网上对于微信小程序是什么还有一个介绍的版本: “小程序是一种不需要下载安装即可使用的应用,它实现了应用「触手可及」的梦想,用户扫一扫或搜一下即可打开应用。也体现了「用完即走」的理念,用户不用关心是否安装太多应用的问题。应用将无处不在,随时可用,但又无需安装卸载。”
这个概念就更清晰一些,可以看出小程序是众多实例运行在一个宿主应用中,小程序本身也是一种可插拔的外接应用。
Ⅱ 用户角度的小程序 下面从用户使用交互角度来看一下小程序:

(图1)
iOS:从小程序独立性角度(小程序与小程序之间,小程序与宿主应用之间切换)来说,BATT 的小程序与招商银行的小程序基本交互相似。 Android:从交互上来看BATT 的小程序都可以看做是独立的应用程序,独立存在于后台(多进程),可以在小程序与小程序之间,小程序与宿主应用之间切换。可以直观的理解为这类小程序为小程序应用。招商银行的的小程序是与宿主应用共存的,也就是在一个进程中,不能在小程序与小程序之间,小程序与宿主应用之间切换。这类小程序可以直观的理解为小程序页面。

BATT: 微信,支付宝,头条,百度小程序。由于交互相似,所以并称。
所以,从用户使用角度来看,BATT的交互体验更有优势。从对小程序概念的理解来看,各app理解有所差异,但这并不影响功能层面的使用。
Ⅲ 平台角度的小程序 最早应用小程序的微信为什么会创造出小程序这个东西呢?它到底有什么作用?在我看来,主要目的还是在于管控为目的,使用了多个手段来实现,主要管控在于两个方面:
UI管控:以微信为例,微信自己定义了一套DSL,而不是用HTML来开发页面。这样就不能让开发者随意开发,而是在微信的DSL框架中开发。开发者写的DSL具体转换成什么,是通过什么渲染,都是微信平台来决定。基于自定义的这套DSL,可以更好的做代码管控方面的工作,比如:请求白名单,代码扫描等。
服务管控:还是以微信为例,微信中的宿主平台提供的服务(比如:支付,微信运动,卡券,发票,用户账号信息等)对于无论是二方使用者还是三方使用者,都有权限管控的需求。目的也是不能让接入的小程序,在没有授权机制的前提下,随意调用微信基础服务。
Two 小程序技术架构 基于从用户角度的体验需求,以及平台角度的管控需求,我们来看看BATT系小程序在技术上做了哪些达到了这些目的。
Ⅰ 渲染流程 下图是小程序的渲染流程,里面包含了部分技术选型,后面的部分会提到:

(图2)
Ⅱ 主要技术点
  1. DSL(Domain-specific language):
在我们聊DSL之前,我们先看看编译代码需要做哪些工作。无论是解释性语言(JS,Ruby,Python)还是编译型语言(Java,C++,C#),都会有一个共同的部分,将源代码解析为AST(抽象语法树)。AST不仅能够以结构化的方式呈现源代码,而且在语义分析中起到关键作用。AST不仅仅应用在解释器和编译器,而且在静态代码分析中也比较常用,比如:我们在重构代码的时候,希望提取出公共模块,以便减少重复代码方便复用。这时我们单纯的用字符串比对的方式会比较片面不能达到效果,这时生成AST就比较有用。另一个应用例子对于我们的DSL设计会比较有借鉴意义:代码转换器。下图是一种语言代码转换为另一种语言代码的主要步骤:

(图3:图片来源于网络)
源代码先解析成AST,解析之前它是遵循语言规则的文本,解析之后成为与输入文本完全相同的树形结构,这个过程是可逆的。然后再对AST遍历以及替换,这个过程对于前端来说类似于DOM树的生成,最后根据修改后的AST生成编译后的代码。我们以JS为例,用acorn生成的AST,同样我们也可以使用其他的解析器,例如:babylon,esprima等,下面是一个简单的例子(限于篇幅,右侧的AST树没有完全展开,读者可以到astexplorer上生成结果):

(图4)
由于小程序的渲染容器有可能是Webview容器,原生Native容器,Flutter容器(虽然Flutter也是Native渲染,为了与原生Native区别,这里把它单独出来,下同),所以我们可以借鉴前面的代码转换器的思路,用AST抹平具体渲染容器的区别,下图是DSL转换的整体思路:

(图5)
有了以上的的设计并不是大功告成,还需要有很多需要做的工作要做。我们可以简单的把DSL的处理分为编译时和运行时,编译时负责把DSL代码预编译为目标代码,目标代码可以在相应的运行时环境执行。生成的目标代码的作用是,可以在具体的运行时通过当前环境的参数来执行出实际的代码,简单的理解就是为了在多渲染环境运行的一个适配器。
对于编译时来说,从零写起肯定是不现实的。首先我们继续上面AST的话题,上面已经提到了几个AST的解析器:acorn,babylon,esprima。当然还有很多其他的例如:cherow,espree,shift等。所以我们不用再造一个轮子,下面用babylon举例,因为babylon在babel中使用,会与最近的JS功能同步,并且API设计良好易于使用。babel是js编译器(并不仅仅是ES6支持的工具包,否则就变成了类似于Android里面的support包了),可以用于代码压缩,语法转换等。对于生成目标代码的过程:解析(babylon),转换(babel-traverse)都有很好的支持。由于不同的渲染容器有不同的组件库和API,同样功能的组件或API的使用也不尽相同,所以需要封装出适配层代码。
对于运行时来说,只需要把编译生成的适配代码生成具体渲染环境的代码执行就可以了。这里比较类似babel把ECMAScript新版代码转换成旧版代码的逻辑比较像。
  1. Native渲染
从性能角度出发,把小程序最终通过Native方式渲染会比用Webview作为渲染容器得到更好的效果。DSL的设计可以很好的屏蔽底层实际渲染的实现,可以用Native,也可以用H5,也可以使用两者结合的方式,底层渲染引擎的切换也不会影响到小程序开发者的外部接入。目前移动端跨平台Native渲染的技术非常流行,比较常用的有Weex/RN/Flutter。市场上有基于Weex和RN进行小程序的案例,Flutter毕竟是后起之秀,目前还没有见到用Flutter作为渲染引擎的案例。
  1. Android多进程:
前面我们在从“用户角度的小程序”部分看到,BATT的方案让小程序真正可以做到像应用一样的体验。由于iOS应用无法开启新进程让小程序本身在独立进程中运行,所以iOS中的小程序只能与宿主应用共享同一进程。对于Android来说就不一样了,小程序占用独立进程,从安全角度来说,二方后者三方的小程序应用与宿主应用进程隔离,小程序出现的问题不会影响宿主应用。而且,从性能角度来说,小程序不会共享宿主应用的内存。从BATT的小程序实际应用操作来看,基本都会控制五个后台开启的进程保活,可以用五个容器Activity各自在自己的进程中渲染小程序,有的还会有后台的保活时间限制。再开启新的小程序,会关闭最早打开的小程序进程,这样达到了高效热启动的目标。
  1. 多线程:
逻辑渲染隔离: 首先,在聊具体的多线程之前,先说一下小程序的逻辑和渲染的问题。小程序的逻辑和渲染是分离的,当然从功能层面,不分离一样可以实现。这里说的逻辑和渲染分离是指小程序的逻辑运行在单独JS环境的线程中,只需要JS引擎就可以,渲染运行在Webview线程中。逻辑和渲染分离到两个线程有几点好处:第一个是可以逻辑和渲染代码分离没有耦合,第二个是可以让逻辑线程和渲染线程并行执行,JS执行不会阻塞UI。第三是补充了前面所说的UI管控的目的,逻辑线程里面JS在JS引擎中运行,而不是在Webview里面,这样就限制了通过注入JS代码来操作dom的可能,任何与UI相关的API都没有办法通过JS来改变,这样就与DSL一起达到了UI管控的目的。第四个好处是多个小程序页面共享同一个JS逻辑运行环境,可以方便高效的共享数据。

(图6)
上图展示了逻辑层与视图层的通信过程,通信通过Bridge中转,利用发布/订阅模式。视图层通过触发UI事件,会把事件通过bridge传递到Native,Native再通过bridge把事件中转给逻辑层,逻辑层处理事件完成后,把数据再通过bridge传递到Native,之后再由bridge返回给视图层做渲染。
优化: 逻辑和渲染分离之后,逻辑线程需要把数据发送给渲染线程,渲染线程需要把事件发送给逻辑线程,这都需要序列化为字符串进行传输。这样会带来一个问题,频繁的数据传输,和单次大数据量传输都会带来性能问题。针对这个问题,支付宝小程序的设计思路比较值得借鉴,支付宝小程序重新设计了V8虚拟机,让逻辑和渲染都有自己的Local Runtime,存放私有的模块和数据。又提供了共享的Global Runtime的Shared Heap来共享数据,这样依然保证了逻辑和渲染的隔离,又减少了序列化和传输成本。
  1. 预加载:
小程序的开发者在小程序应用方面,做了很多优化,比如:数据的预加载。从用户点击页面,到新页面onload(),会延迟100ms-300ms,这个延迟时间,可以做数据的预加载。这里所说的小程序启动预加载,是小程序渲染框架层面的。iOS的优化会预加载比实际渲染小程序数多一个wkwebview放在后台,打开新的小程序会直接把预先加载的wkwebview直接渲染,节省了初始化时间;Android上实现稍微复杂一点,不过依然是空间换时间的思路,从Android宿主应用启动开始,就会启动一个预留进程,当开启新的小程序,会占用这个进程,并再预加载新的进程,直到开启第五个进程的上限。
  1. 离线包(分包):
离线包机制的根本目的在于让小程序打开的时候,可以让页面资源从网络IO替换为本地IO。其实就是在app打开之前从网络拉取或者推拉结合,预置等方式让离线包可以在打开小程序之前就已经在本地了。离线包模块的职责包括:更新,解压,存储,读取和校验等,当然也可以做二进制的差量包以。有了离线包机制,也要考虑把整个小程序整体作为一个离线包会影响效率的问题,所以这里需要增加分包的方案,可以把离线包分为一个主包和多个子包的形式,主包里面主要包括:首屏资源,公共代码,相关子包的信息等;子包可以包含二级页面的页面资源。这样就可以提高首屏打开速度,可以做到按需加载的目的,如下图:

(图7)
Ⅲ 技术选型
  1. IDE
小程序平台都有自己的IDE,对于多系统平台的现状,选取跨平台桌面技术开发小程序IDE,肯定是最好的选择,这里选择了Electron和NW.JS做了一下对比:

(图8)
对比结果简单的说,两者开源协议都比较友好,如果重视代码安全性或者兼容XP需求,就选择NW.JS,也是国内厂商的选择;如果从开发支持角度来比较,就选择Electron。
  1. JS引擎
前面已经说了,逻辑层具有单独的JS环境,也从管控角度说明了这样做也可以防止js修改UI的风险。就技术选型角度来说,iOS可以使用自带的JScore,虽然iOS上wkwebview的JS引擎比JScore多了JIT优化,执行速度快很多,但是比起额外引入js引擎来说,使用自带js引擎具有稳定且不增加包大小的先天优势。这块可能有人会提到Flutter在iOS里面引入了skia渲染引擎的问题,Flutter在iOS引入skia的好处是与Android自带的skia引擎使用相同渲染引擎,这样会在UI兼容性上有更好的提升。而js引擎兼容性问题就小的多。 Android方面,可选择性多一下,以下是一个主流JS引擎对比:

(图9)
微信小程序旧版本用的JScore,新版本用的V8;支付宝小程序用的重新设计的V8;头条小程序也是使用的V8;可以看到V8的中标率还是很高的,而且开源协议也比较友好。
Three 结语 【小程序渲染架构设计】本文算是介绍了一种小程序渲染架构的一种实现方式,就小程序平台本身来说,还有一些其他的功能和优化点,比如:小程序路由,Debug包加载,埋点统计,虚拟Dom的优化等。文章只是介绍了一些主要流程和技术点,真正做一个完善的小程序平台还是需要很多细节需要考虑的,就小程序开发者的角度来说,也是有优化空间的,比如:骨架屏。做一个小程序平台需要多平台多种技术能力的综合应用才能不断完善,随着新技术的涌现,未来会有更多的技术应用到小程序中。


    推荐阅读