android 多次反复关闭 service_用安卓虚拟机运行程序时程序停止


2020 年 3 月,暨支持编译时方案之后,Rax 小程序发布了支持运行时方案的版本。截至目前,Rax 仍是业界唯一一个同时支持编译时和运行时方案的小程序开发框架。本文将向大家介绍 Rax 小程序运行时方案的原理以及我们的思考。

回顾编译时方案

介绍运行时方案之前,我们再回顾下什么是编译时方案。顾名思义,编译时方案侧重于编译,这其中的代表框架是 Taro v2.x。其通过静态编译的方式,将 JSX 转换为小程序的模板语言(即 WXML/AXML 等),再辅以轻量级的运行时 JS 代码,抹平小程序生命周期和 React 生命周期的差异,使用户能够以熟悉的 React DSL 进行小程序开发。Rax 的编译时方案原理与 Taro v2.x 类似,关于实现细节,可以参考之前的文章Rax 转小程序链路原理解析(一)及Rax 小程序编译时方案原理解析。区别于编译时方案,运行时方案侧重在运行时实现渲染能力,不依赖静态编译,因此几乎没有语法限制,这也是其最大的特点。下面就来看一下运行时方案实现的原理。

诞生基础

小程序的底层实现实际上也是基于 Web 技术,但是反映至开发者层面,与 Web 却又大相径庭。在小程序中,逻辑层和视图层隔离,逻辑层通过唯一的 setData 方法将数据传递至视图层触发渲染,视图层则通过事件的方式触发逻辑层代码,其架构如下图所示。相比 Web 开发时开发者可以通过 JS 调用浏览器提供的 DOM/BOM API 随心所欲操作渲染内容,小程序的架构更加封闭也更安全,但也意味着 Web 代码无法直接在小程序上运行。


android 多次反复关闭 service_程序运行_02


对于现代的前端框架(React/Vue)来说,底层基本都是通过调用 DOM API 来创建视图。而小程序的视图层模板是需要开发者事先写好的,这意味着动态创建 DOM 的方式在小程序中不被允许。但是,小程序的自定义组件具有的『自引用』特性为动态创建 DOM 打开了突破口。所谓自引用,就是自定义组件支持使用自己作为子节点,也就意味着通过递归引用的方式,我们能够构造任意层级和数量的 DOM 树。

举例来说,假设一个小程序自定义组件 element 的 WXML 模板如下所示:


<view
  wx:if="{{r.tagName === 'view'}}"
  id="{{r.nodeId}}"
>
  <block  
    wx:for=“{{r.children}}”   
    wx:key="nodeId"
  >
      <element data="{{r: item}}" />
  </block>
</view>

<text 
  wx:elif="{{r.tagName === 'text'}}"
>
  {{r.content}}
</text>


注意到,element 在模板中递归引用了自身,并通过条件判断终止递归。那么,当逻辑层通过 setData 传递了以下一份数据过来时:


{
  "nodeId": "1",
  "tagName": "view",
  "children": [
    {
      "nodeId": "2",
      "tagName": "text",
      "content": "我是?"
    },
    {
      "nodeId": "3",
      "tagName": "text",
      "content": "rax"
    }
  ]
}


最终呈现出来的视图便成了:


<view>
  <text>我是</text>
  <text>rax</text>
</view>


通过这种方式,我们巧妙地实现了在 WXML 模板固定的情况下,根据传入的 setData 数据来动态渲染视图的能力。而这,也正是运行时方案能够诞生的基础。

基本原理

Rax 的运行时方案脱胎自 kbone——微信官方推出的小程序与 web 端同构解决方案。kbone 的设计原理可以参考其官网介绍,简单总结就是通过在逻辑层模拟 DOM/BOM API,将这些创建视图的方法转换为维护一棵 VDOM 树,再将其转换成对应 setData 的数据,最后通过预置好的模板递归渲染出实际视图。从 DOM API 到维护 VDOM 树的过程基本原理并不复杂,createElement/appendChild/insertBefore/removeChild 等对应着基本的数据结构的操作。

熟悉 Rax 的同学应该知道,为了支持跨端,Rax 有 driver 的设计。实际上,我们完全可以针对小程序端再编写一个 driver,根据上述原理实现其接口 API 即可。但我们最后的选择还是通过更底层的模拟 BOM/DOM API 来完成了整个渲染机制。这么做的考量是,第一,基于 kbone 开发,这是最快的一套方案,小程序端的 driver 只需复用 web 端的 driver-dom 即可,毕竟底层的 documentwindow 变量都已经模拟好;第二,则是因为我们想为开发者提供更贴近 web 的开发体验。这套方案意味着开发者除了使用 JSX 之外,也是支持直接使用 BOM/DOM API 创建视图的,灵活度会更高一点。我们把目光拉长到整个市面上的小程序运行时框架,remax 通过 react-reconciler 直接从 VDOM 层和小程序对接(类似上面说的 Rax 小程序 driver 设计),而 kbone 和 Taro 3.0 都选择通过模拟 Web 环境来实现渲染。这也与框架开发人员的设计意图有关,见仁见智。Rax 小程序运行时方案的基本原理图如下所示:


android 多次反复关闭 service_API_03


事件系统

Rax 小程序运行时中,模拟 DOM/BOM API 的库为 miniapp-render,其支持的 API 如下:


android 多次反复关闭 service_用安卓虚拟机运行程序时程序停止_04


除了处理渲染数据外,另一个比较重要的事情便是事件系统。其通过 EventTarget 基类实现了一套完整的事件派发机制。逻辑层 DOM 节点均继承自 EventTarget,通过唯一的 nodeId 来收集自身绑定事件。视图层模板上的每个内置组件都会绑定 nodeId,并监听所有可触发的事件,比如一个简单的 view 标签,会将 bindtap/bindtouchstart/bindtouchend 等事件都进行绑定。在事件触发时,通过 event.currentTarget.dataset.nodeId 获取到目标节点 id,再触发该节点上用户绑定的对应函数。

工程设计

Rax 小程序运行时的工程主体流程 follow 了 Rax Web 的设计,Web 端 Webpack 打包出的 JS Bundle 可以在小程序运行时中复用。我们通过插件将 miniapp-render 模拟出的 window 和 document 变量注入该 bundle,再生成一个固定的小程序项目骨架,在 app.js 中加载 JS Bundle 即可。其整体工程结构如下图所示:


android 多次反复关闭 service_API_05


MPA or SPA ?

以上架构是逐步演进的结果。最初,我们使用了 webpack 的多 entry 模式打包运行时小程序代码,也就是每个页面都会作为一个 entry 独立打包。这使得从行为上来说小程序更像一个 MPA。这带来的问题就是页面间公共依赖的代码不在同一内存中执行,与原生小程序表现不符。这个差异导致我们最终决定变更工程打包模式。目前版本的 Rax 运行时小程序更符合 SPA 的形式,所有的业务代码都打包到了一个 JS 文件中。

我们将 Rax 工程入口的 rax-app 包在小程序运行时上的链路做了一定改造,其在初始化时会根据路由返回各个页面的 render 函数,该 render 函数创建 root 节点(document.createElement)将对应的 Rax 组件挂载至 其上,并将 root 节点 append 到 body 节点(document.body.appendChild)。小程序每个页面在 onLoad 生命周期中,会创建一个独立的 document 并设置为全局变量,然后调用其对应的 render 函数进行各个页面的独立渲染。

性能调优

从上面的小程序运行时原理来看,其性能相比原生是存在一定差距的,这主要由以下几个方面造成:第一:逻辑层运行完整的 Rax + 通过模拟 DOM/BOM API 处理 VDOM 并生成 setData 数据,需要消耗更多的计算时间;第二,相比原生小程序需要传递更多 setData 数据,如果容器层数据序列化能力较弱,会大大增加数据传输耗时;第三,视图层通过自定义组件递归动态生成视图,而我们知道递归动作本身就是一个性能损耗点。此外,由于无法预先知晓用户需要绑定的属性和事件,自定义组件模板中只能将所有属性和事件预先绑好,这导致小程序运行过程中会触发很多无用的事件,进一步加重负担。经过我们的 benchmark 计算,在支付宝小程序平台上,运行时小程序框架(包括 Rax/Taro/Remax 等)与原生小程序存在约 40% 的性能差距。

Rax 小程序运行时发布后,经测试其性能相比其他运行时框架存在着较为明显的差距,于是我们启动了性能调优的专项计划。通过以下方面的重构,成功将 Rax 小程序运行时小程序的性能拉升至业界领先水平,与 Taro/Remax 基本处于同一水平线。

  • 更新数据精确化。在旧版本中,setData 的数据是全量更新的,虽然有 dom 子树分割批量更新的设计,但是数据传输仍然存在大量冗余。重构版本中,Rax 增加了节点渲染判断,未挂载节点无须触发更新;将所有更新收拢至顶层 root 节点统一批量处理, 并且通过精确计算数据更新的 path,实现局部更新。比如某次更新节点的 class 属性时,setData 的数据可能是:{ "root.children.[0].children.[1].class": "active" }
  • 内置小程序组件无需维护其属性列表,而是根据用户传参直接赋值。旧版本中,我们维护了所有内置组件的属性,在获取属性值的时候均需要调用 domNode.getAttribute,具有一定性能开销。重构版本 Rax 直接根据用户传参给属性赋值,并将默认值设置的操作移至视图层 WXS/SJS 中处理。
  • 更新 miniapp-render 中的数据结构。经过梳理,Rax 移除了冗余的 tree 数据,重写了 getaElementById 等 API;重构了 attribute、classList 等类;使用了更符合场景需要的 Map/Set 等数据结构,提升了整体的数据处理性能。
  • 渲染模板优化。在支付宝小程序中,Rax 使用 template 进行递归调用;在微信中,Rax 使用 template 调用 element 再调用 template 的形式以避免微信端递归调用 template 的层数限制。在模板中,我们尽量使用 template is 语法进行判断,减少 a:if/wx:if 条件判断,提升模板递归时的性能。

混合使用

无论是出于旧有业务的迁移,或者是出于性能考虑,Rax 小程序运行时中都存在着混合使用的需求。目前,Rax 已经打通与小程序内置组件、小程序自定义组件、小程序页面、小程序插件混合使用的能力。这其中,使用小程序自定义组件是最为复杂的。

与小程序自定义组件混用

在 Rax 中使用小程序自定义组件,其引入路径需要与 usingComponents 保持一致(例如 import CustomComp from '../components/CustomComp/index')。 在编译阶段,Rax 工程使用 Babel 插件进行代码扫描,检测到 JSX 中使用的某个组件是小程序自定义组件(根据其引入路径是否存在同名 axml 文件)时,会将其使用到的属性和事件进行缓存,然后通过 webpack 插件动态生成至递归模板中。在运行时中的创建节点阶段,通过查询缓存判断节点是否为自定义组件。若是自定义组件,则其渲染数据中会插入缓存中的属性,并且绑定事件至该自定义组件实例。

与编译时组件混用(双引擎混合)

通过 Rax 小程序编译时方案产出的组件,从使用形态上来说,可以直接视为小程序自定义组件。而 Rax 工程加强了运行时与编译时的联系,当在 Rax 小程序运行时中使用编译时组件 npm 包时,用户无需引入组件的具体路径,只需像使用普通组件时一样引入,Rax 工程将自动根据该组件 package.json 中是否存在 miniappConfig 字段来判断其是否为一个 Rax 多端组件,然后直接使用其编译时的组件实现。

未来优化方向

Rax 作为业界唯一一个同时支持编译时和运行时引擎的小程序开发方案,其双引擎混合使用的能力能够较为完美地实现性能与开发效率的平衡。在未来,Rax 将实现更为灵活的双引擎混合使用方式,如支持在单个项目中指定某个组件以编译时引擎编译,为业务提供更高的灵活度。

总结

以上就是 Rax 小程序运行时方案的原理解析。运行时方案解决了编译时方案天生的语法限制问题,但是也存在着明显的性能掣肘。可以说,在当前 2020 年这个节点,小程序开发仍然没有所谓的银弹,也许 Rax 小程序双引擎的融合会是一个相对范围内的最优解。逆标准而上的小程序究竟能走多远,谁也无法下定论,未来相当一段时间内,开发者仍然要面临种种问题。站在小程序开发框架的角度,只希望所有开发者能够选择对自己最合适的框架,爽快而又高效地完成小程序的开发。