基于小程序技术栈的微信客户端跨平台实践

2019-07-02 13:10:27 浏览数 (1)

本文主要内容整理自 GMTC 2019 分享《基于小程序技术栈的微信客户端跨平台实践》 

https://gmtc2019.geekbang.org/presentation/1711

一、前言


小程序自诞生以来,经过两年多的发展,成为了微信开发者生态中最具有生命力的一环,为外部开发者开辟了全新的想象空间。然而,小程序带来的改变绝不仅限于微信之外,小程序技术栈的确立,又对微信客户端的研发产生了怎样的影响?

二、微信客户端的跨平台实践


微信客户端团队,早在 2012 年的时候就已经开始使用跨平台技术进行研发,从最初为了应对多平台客户端代码逻辑不统一的问题,到后续面向业务和 UI 开发,一直在尝试研发跨平台的解决方案。

最早的跨平台组件是基于 C99 开发的 mmnet,在 2012 年 10 月份的时候为了解决多平台客户端出现的一系列不一致问题而打造的基础网络组件,后续经过不断的迭代优化,尤其是在应对弱网络做了深度的优化,并且加入了安全、容灾等各种网络策略。mmnet 的通用部分逻辑代码于 2016 年以 mars 的名字在 github 开源,在业界获得了广泛的认可,完成了一个内部实验的跨平台组件到最终升华为所有人可用的开源项目。同样在 github 受到欢迎的还有相似思路完成的 wcdb、mmkv 等跨平台组件。

在完成基础组件的跨平台之后,随之而来的是面向业务和 UI 开发的跨平台尝试。为了面对内部快速变化的创新业务,微信客户端团队不得不去寻求在多端上快速迭代的开发模式。在业务开发的过程中,能否可以做到像使用基础跨平台组件那样,只写一次代码就能在多端上得到体验一致的 UI 功能界面呢?

在尝试了不同的方案之后,我们将目光放到了小程序上。在微信小程序快速发展的两年内,各内部业务团队开始基于小程序去做创新业务的开发。借助微信小程序框架,这些业务可以获得相比于纯原生客户端开发周期短、上线快的优势,同时可以满足较强的运营需求。这种基于微信小程序的业务开发模式在内部逐渐的受到认可。

我们认为好的跨平台开发模式必须要达到以下的四个目标

  • 减少平台差异性:应该最大限度减少不同平台上开发的差异性,尽可能减少各平台特有的开发负担;
  • 提高研发效率:从研发效率的角度看,在提高人效比的同时,应该尽可能提升开发人员在开发过程中的效率,包括编码、调试、运行、测试等多个环节;
  • 原生的性能和体验:从最终的研发产物看,应该有和分平台原生技术开发一样的性能表现和用户体验,让用户无法感知出差距;
  • 易学的可控技术栈:跨平台的技术栈应该具有较好的学习曲线,能够让更多原生开发的同学快速学习并掌握,且无论从技术还是商业角度看,都应该是可控、安全的技术栈。

那么,小程序技术栈是否能够满足这些要求呢?

三、小程序与微信客户端


微信小程序采用了以前端技术栈为主的方案,框架上面抹平了许多平台差异性,同时业务也可以随时动态部署更新,而体验和性能也比较接近原生。随着小程序生态的发展,还出现了更多丰富的插件扩展机制、自定义组件机制和第三方开发框架。同时,小程序作为微信团队内部自主研发的框架,小程序已经是一个非常优秀的跨平台框架,满足一般的业务开发是没有问题的。

然而,当我们以“小程序技术栈作为客户端跨平台开发技术”这一命题展开,关注其中的一些细节时,也发现了问题。

附近的餐厅就是微信团队内部基于小程序开发的一个类似原生体验的业务。通过小程序实现了一次开发运行在 iOS、Android 两个客户端上的功能。整个开发的过程都主要以微信小程序的开发工具和开发标准为主,配合客户端实现部分额外增补能力,在基本功能完成之后我们也发现了一些在 Android 平台上出现的问题,这里举两个比较典型的例子。

第一个是字体一致性体验问题。微信小程序使用 WebView 渲染,与原生客户端的是两套不同的视图渲染体系,在 Android 平台上出现了无法跟随系统字体保持一致的问题,体验上会有较为明显的割裂感。

第二个在大量的图片和视频混排的场景下,会出现一些掉帧现象,在 Android 中低端机上较为明显。如下图所示,在图片滑动等连续过程中,会偶尔出现 LAG 的情况。并且受目前小程序框架所限,视频、图片的全屏显示效果也不够理想。

正是因为微信小程序框架在面对复杂业务的场景下还会存在一些体验和性能不尽人意的地方,在性能和体验上虽然接近原生,但仍不能达到原生体验效果,我们决定针对这些细节尝试进行一步步的优化。

先来看看小程序目前的系统架构。

四、基于小程序技术栈的跨平台开发


微信小程序的系统架构相信今天大部分的读者都比较熟悉了,总体来讲分为两部分:

  1. View 视图端通过小程序的框架将用户采用 WXML 和 WXSS 描述的UI信息处理成 H5 元素,最终交给 WebView 去渲染;
  2. App Service 端运行用户编写的 JavaScript 逻辑,并且可以调用具有微信开放能力的 JSAPI。逻辑和视图分离,通过事件和数据彼此之间建立联系。

回到我们上面的问题,在中低端机和稍复杂的业务上,受制于 Web 庞大而复杂的体系,要达到原生视图体系这样简单设计的体验,难度很高。那么是否能够使用平台原生的视图渲染体系来解决问题呢?

1. 基于原生渲染优化


原理上我们可以将用户描述的 UI,转换成系统原生的组件,行业里面早有实践,受到 ReactNative 这类框架的启发,我们将小程序的视图端进行了一些改造,在 Android 平台上我们 dump 出小程序框架中 Virtual DOM 的信息和所有的 CSS 样式,在 Java 层逐一的去解析映射成原生的组件。但原生体系并不能完全的表达过于复杂的 CSS 样式,因此前期只支持了部分的 WXSS 特性。

2. LV-CPP


我们初步方案当中有太多的实现一开始是用 Java 去做的,考虑平台兼容问题,为了方便移植到其他平台以及可以更低成本的更换渲染模块,我们就将原来解析 DOM 和 CSS 样式的实现单独抽离了出来,形成一个独立的跨平台模块。最终选择了 C 实现的 LV-CPP 模块,由 LV-CPP 去做跨平台的小程序 UI 体系处理器,完成 DOM 和 CSS 的解析、布局计算,同时执行 JS 的功能由 V8 或者 JSCore 来完成。

当 WXML/WXSS 描述的 UI 发生改变时,小程序前端公共库(WXA Framework)通过内部计算,将 Virtual DOM 树 Diff 的结果以操作指令的形式提交到 LV-CPP。LV-CPP 接收指令后,更新相应的节点,进行 CSS 的匹配、CSS 属性的转换以及布局的计算,计算好之后再调用 Native View 进行界面的渲染。

CSS 匹配上,目前支持了 ID 选择器(#id)、标签选择器(button)、类选择器(.class)、组合选择器(A,B、A B、A>B、A B、A~B)。为了提高性能,其中组合选择器的匹配使用了 WebKit 的逆序解析方案。

之所以在 LV-CPP 中进行 CSS 属性的转换以及布局计算,目的是为了尽量抹平以后即使使用不同的渲染模块所带来的属性和布局上的差异。最典型的是颜色的转换。CSS 中颜色有各种表示方法,最常见的有:

  1. 十六进制颜色,如:#0000ff
  2. RGB 颜色,如:rgb(0,0,255)
  3. RGBA 颜色,如:rgba(255,0,0,0.5)
  4. HSL 颜色,如:hsl(120,65%,75%)
  5. HSLA 颜色,如:hsla(120,65%,75%,0.3)
  6. 颜色名,如:black

这些不同种类的颜色表示方式,经过 LV-CPP 计算后输出的全部是十进制的颜色值,再交由渲染模块进行渲染。


采用原生组件的方案确实在体验和性能方面能够带来不错的提升,在 Pixel 2 XL 的机器上我们测出,帧率方面比 WebView 提升了 27.5%,内存也可以下降 14%~23%。但随着我们要将该方案推广到各平台的时候,我们意识到需要在各个平台去做适配是一个巨大工作量的事情,而且后续的维护成本也将无法预测。

基于 Web 的渲染满足不了性能和体验的要求,基于原生渲染又会带来高维护成本问题,我们需要一个跨平台的渲染方案来解决。在研究各种可能的方案的时候,Flutter 再次走进了我们的视野。

3. Flutter


Flutter 是 Google 为跨平台打造的高性能应用框架,受到了很多同行的关注,但如果按照我们设定的微信跨平台开发的目标来看,Flutter 并不完全符合,使用 Dart 开发会对现有开发同学造成额外的学习成本,所以一开始我们并没有将 Flutter 作为客户端跨平台开发的候选。

当我们的问题重新设定为“寻找一个跨平台的高性能渲染框架”时,Flutter 就逐渐体现出了各项优势。从一些经典的 Benchmarks 案例中看到,Flutter 具有非常不错的性能水平。

这组数据是我们在 ARM 平台测出的 Java,Dart JIT 和 Dart AOT 的对比数据,数值越高表示性能越好。同时另一个有意思的情况是,随着 Flutter 版本的提升,性能表现会越来越好,也说明 Flutter 的开发人员在不断地优化性能表现。

而且从 Benchmarks Game 上能获取到和 JavaScript 的一些对比数据,从中大概能得出一个结论:Dart 的语言性能是超过 JavaScript,和 Java 有得一拼的。

可以看下官方对 Flutter 的介绍:

  • 快速开发:Flutter 的热重载可以快速地进行测试、构建UI、添加功能并更快地修复错误。
  • 富有表现力,漂亮的用户界面:自带的 Material Design 和 Cupertino(iOS风格)widget、丰富的 motion API、平滑而自然的滑动效果。
  • 响应式框架:使用 Flutter 的现代、响应式框架,和一系列基础 widget,轻松构建您的用户界面。
  • 访问本地功能和 SDK:Flutter 可以复用现有的 Java、Swift 或 ObjC代码,访问 iOS 和 Android 上的原生系统功能和系统 SDK。
  • 统一的应用开发体验:Flutter 拥有丰富的工具和库,可以帮助开发者轻松地同时在 iOS 和 Android 系统中实现想法和创意。
  • 原生性能:Flutter 包含了许多核心的 widget,如滚动、导航、图标和字体等,这些都可以在 iOS 和 Android 上达到原生应用一样的性能。

在一系列的评估基础上,我们觉得可以使用 Flutter 去尝试一下。于是我们提出了基于 Flutter 的小程序框架渲染优化方案

4. 基于 Flutter 渲染优化


我们把渲染部分由原来的平台原生组件替换成了 Flutter 的 Widgets,依然只支持精简后的 WXML 和 WXSS。

在这个架构下,我们就将 Layout 层的 LV-CPP 专门的作为小程序的 UI 体系处理器,将 UI 信息布局计算好再提交给抽象的后端去渲染,LV-CPP 作为小程序的框架和渲染器的中间层,集中的在 C 层去处理与 Web 相关的复杂特性。渲染端就可以基于特定的协议和接口专注将元素转化为 UI 组件,最终绘制出来。

通过结合 Flutter 和 LV-CPP,我们把实现代码收敛在 C 和 Dart 上,进一步简化了基于小程序技术栈实现跨平台业务开发的框架维护成本。

然而,真正实现的过程中我们还得做更多的思考和优化。

5. 通信难题


小程序的框架是使用 JavaScript 再加上一些平台注入的接口来实现的,它们是运行在 JS Engine 的环境当中。而 Layout 层是采用 C 来实现,如何去解决 JavaScript 和 C 的互相通信问题呢?LV-CPP 在 C 层计算好布局之后,又如何将这些信息传递给渲染后端 Flutter 的 Dart 环境中呢?要想保障框架的性能,那么我们就必须要去解决两个问题。

a. JS 的通信


基于 Android WebView 的体系下可以在 Java 层通过 WebView 提供的接口注入一个 JavaScriptInterface,JS 就可以得到一个扩展的 API,调用的时候经过 V8 最终反射到 Java 上面。

在 iOS 上面也是类似的实现,这种方式第一是会带来平台相关性的实现;第二是调用路径较长。所以在这个问题上,我们最终使用了 JS Binding 的方案,将原先依赖平台的实现直接下沉到 C ,去实现 JS 对象的扩展,既可以解决跨平台的问题也能带来性能的提升。

b. Flutter 的通信


Flutter 官方提供了一种 Platform Channel 的方案,用于 Dart 和平台之间相互通信。主要的原理就是将传递的数据编码成消息的形式,跨线程发送到平台接口层,处理之后再将返回的数据通过同样的方式原路返回。基于消息和跨线程的处理使得这种方式的通信效率并不高,我们在骁龙845的机器上测了一组数据,一秒内通过 Platform Channel 只能大概完成四千次左右的相互调用。

所以我们对 Flutter Engine 进行了一些改造,增加了一个 dart2cpp 的模块,暴露出部分的 C 接口,使得外部的动态库可以基于这些接口通过 DartVM 调用到 dart 的接口。在 Dart 的运行环境中 C 和 Dart 之间就可以像调用自身的接口一样调用彼此的接口。而且在 AOT 模式下 Dart 会被编译成机器码,所以 C 和 Dart 的调用会非常的高效。不需要将数据编码成消息和跨线程一系列的复杂流程,而是直接在内存栈上操作数据

dart2cpp 相比于 Platform Channel 的方案提升多少呢,同样的测试案例,一秒内通过 dart2cpp 可以完成三十多万次的相互调用,可以说是极大的提升了通信效率。

c. dart2cpp 实现原理


DartVM 提供了一种机制,可以在 Dart 的代码中使用 native 关键字来表示调用的是一个 C/C 的接口。

代码语言:javascript复制
// Dart 示例代码bool systemSrand(int seed) native "SystemSrand";

但这个 C/C 接口必须要先注册到 DartVM 当中,不然就无法查找到符号。

代码语言:javascript复制
DART_EXPORT Dart_HandleDart_SetNativeResolver(Dart_Handle library,                       Dart_NativeEntryResolver resolver,                       Dart_NativeEntrySymbol symbol);

注册可以通过 Dart_SetNativeResolver 来完成,在 Dart 的运行过程中会通过注册的 Dart_NativeEntryResolver 根据函数信息来查找到 C/C 的函数地址。

通过以上的两步就可以在 Dart 直接调用一个扩展的 C/C 函数,但是还没完,Dart 的内存模型和 C/C 的是有区别的,Dart 调到 C/C 的过程中传递的参数和函数返回值都使用了一个 Dart_NativeArguments 来描述,可以通过 Dart_GetNativeArgument/Dart_SetReturnValue 这两个接口来从 Dart_NativeArguments 上获取参数和设置返回值。

代码语言:javascript复制
// C   示例代码void SystemSrand(Dart_NativeArguments arguments) {  Dart_EnterScope();  bool success = false;  Dart_Handle seed_object = HandleError(Dart_GetNativeArgument(arguments, 0));  if (Dart_IsInteger(seed_object)) {    bool fits;    HandleError(Dart_IntegerFitsIntoInt64(seed_object, &fits));    if (fits) {      int64_t seed;      HandleError(Dart_IntegerToInt64(seed_object, &seed));      srand(static_cast<unsigned>(seed));      success = true;    }  }  Dart_SetReturnValue(arguments, HandleError(Dart_NewBoolean(success)));  Dart_ExitScope();}

d. cpp2dart 实现原理


以上介绍了 Dart 调用 C/C 接口的实现原理,那么在 C/C 如何的调用 Dart 的接口呢,别急,在 DartVM 中依然可以找到解决办法。

代码语言:javascript复制
DART_EXPORT DART_WARN_UNUSED_RESULT Dart_HandleDart_Invoke(Dart_Handle target,            Dart_Handle name,            int number_of_arguments,            Dart_Handle* arguments);

可以在 dart_api.h 中找到一系列 API ,这些 API 就可以在 C/C 层操作到 Dart 的接口甚至是变量。

有了这些基础的 API 就基本上可以做到 Dart 和 C/C 之间相互调用,但你可能还需要知道一些 DartVM 的执行机制,才能让你的代码正常的 work。

上面的 C/C 的示例代码中,使用了 Dart_EnterScope/Dart_ExitScope这么两个 API,事实上在 C/C 持有的 Dart 对象都是用 Dart_Handle 句柄来描述的,我们在函数内创建的很多变量都是局部变量,在离开作用域之后应该释放内存,那么 Scope 的概念就相当告诉 DartVM 当前创建的都是局部变量,在 ExitScope 之后应该回收这里用到的内存。

当然还有一个重要的概念是 Isolate,Dart 的代码是运行在一个独立的 Isolate 当中的,在 Flutter 的体系当中,这个主 Isolate 一般是寄生在 UI Runner 的线程中,在 C/C 去调用 Dart 的接口必须要在 Isolate 的环境当中,不然就会出现各种异常。

这里就涉及到非常多细节以及繁琐的 API 调用的问题,对一般的开发者开讲他只是要去调用一个外部的接口而已,可能不了解这些具体的技术细节,因此我们才开发了 dart2cpp 这么一套东西,使得开发者能够正常的写 Dart 和 C/C 的代码,不需要去关注数据如何的传递、Scope 以及 Isolate 这些细节。

而且我们也不希望最终业务的动态库和 Flutter Engine 的动态库是绑定在一起的,它们可以是相互独立的动态库,在需要用到的时候,只需要通过 Dart 的接口去加载这个动态库,然后动态库将自己的信息注册到 Flutter Engine 当中,就可以做到 Dart 和外部动态库之间的 C/C 相互调用。

e. js2dart


这两套解决方案呢,其实它的想象空间绝非仅此,既然 JS 可以和 C 相互调用,C 又可以和 Dart 相互调用,他们结合在一起其实就可以间接的打通 JavaScript 和 Dart。虽然 JavaScript 和 Dart 有各自的执行环境和机制,但通过 C 的桥梁,依然可以构建一个高效的通道,中间可以通过引用和一些转换(类似 JNI)来完成大多数的调用操作和数据传递。

另外,Flutter 在动态部署(Hot Patch)方面虽然没有提供官方的支持,但是在借助于 js2dart 下能够做的事情就很多了,但这并不在本文的讨论范围。

至此不同语言环境中的调用通信问题有了比较高效的解决方案

6. Flutter 渲染优化后的小程序整体架构


来看一下到目前为止小程序的整体架构调整。App Service 端依然保持原有的结构,处理用户编写的 JavaScript 逻辑;而视图端(PageView)则重新划分为四个层级,除了原有的 UI DSL 描述(WXML/WXSS)、小程序前端公共库(WXA Framework)外,还有由 LV-CPP 为主的 UI 布局处理层(Layout )和 Flutter 实现的渲染层(Renderer)。

使用简化的 WXML/WXSS 描述的 UI 信息,经过小程序前端公共库处理成 DOM 描述,通过 JS Binding 接口传递给 LV-CPP去解析 CSS 和 DOM 节点(Layout )。LV-CPP 在完成布局计算之后将元素信息通过 dart2cpp 的接口发送到 Flutter 端,Flutter Framework 层直接将布局计算好的元素描述成渲染节点,交给 Flutter Engine 去绘制。

整体上来讲我们把代码收敛在 JavaScript,C 和 Dart 上,所以在跨平台方面会极大减少额外的负担。对小程序的开发者也不会带来任何的改变,面向开发者的依然是原有的小程序技术体系。

7. 从 RN-like 到 Flutter 渲染


从最初的 RN-like 方案再到基于 Flutter 方案的研究,本质上都只是在不断的解决我们遇到的问题,对比 Web 的方案体验和性能也都有提升,而且在平台维护方面也得到解决。

汇总 Flutter 渲染解决的问题,基本上看是能够满足我们在性能和体验上的诉求的:

  • 字体不一致问题:通过自定义 Flutter Engine 实现跟随系统原生视图字体;
  • 视频、地图等同层渲染:Flutter 官方提供了一种机制,通过 Texture Widgets 的方式将 Native 平台渲染的 Texture 同步到 Flutter 的渲染体系中来,保证同一时刻界面上仅存在一种视图体系;
  • 文本输入框:Flutter 官方提供了较为完整的输入框控件;
  • 性能提升:相比 WebView 在低端机上有可见的性能指标提升;
  • 减少重复资源投入,多平台维护:基本上只需要维护 Dart 和 C 代码,平台相关代码可以最小化。

当然,目前阶段在性能上还存在很大的进步空间,相比 RN-like 方案的各项性能指标并未达到最佳,仍需要充分的发挥 Flutter 的特性,提高这套框架整体的可用性。

注:由于开发阶段方案变化较快,此处对比数据并未在同样的设备下测定,仅以相对 WebView 渲染提升为例做为说明。

五、总结与展望


回顾一下上下文,微信在客户端跨平台开发方案的探索从最早期的打造高质量、开源化的基础组件,到现在尝试探索大前端技术栈的业务跨平台开发方案,始终是从提升研发团队效能和最终产品用户体验两个角度出发,去思考如何能够不断地提高移动研发技术水平如果把我们的视线重新拉回来这一根本出发点,今天我们所分享的渲染方案也并不一定是小程序技术栈作为跨平台开发的唯一优化方案选择。WebView 渲染真的无法有突破性提升?跨平台开发只有大前端技术选择?随着大前端技术不断的发展和深入,相信未来一定会继续出现新的技术方案去解决现有研发流程中的问题,也欢迎大家继续关注我们的最新进展。

Q & A


在 GMTC 2019 大会分享结束后,我们陆续收到了很多同学提出的疑问,这里也统一整理了一些具有代表性的问题统一回答。

Q1. 小程序在产品上是否会有什么改变?是否会放弃 WebView 渲染转向 Flutter 渲染?

A1. 微信小程序是一个独立的生态和产品,使用 WebView 渲染具有极大的灵活性和前端兼容性,不会放弃 WebView 渲染。目前我们的尝试仅限于微信客户端内部部分场景使用,对微信小程序的外部开发者不会有任何影响。

Q2. 使用 Flutter 渲染的这套方案在遇到复杂 CSS 属性的时候表现如何?

A2. 过于复杂的 CSS 属性,我们不会支持。当前在 LV-CPP 上支持的 CSS 是一个比较小的子集(我们内部称之为 “WXSS-LITE”),从性能和复杂度角度去看,也不会支持完整 CSS 属性。针对内部开发的业务来说,会根据性能、支持的复杂度和必要性等方面综合决定是否纳入 WXSS-LITE 支持范围,即也有可能大幅限制内部开发同学能够使用的 CSS 属性。

Q3. js2dart 模块是否支持传递对象和自定义数据,是否考虑开源或者开放出来供大家使用?

A3. JS 和 Dart 都有各自的执行机制和对象模型,所以是无法直接的传递对象的,事实上也不需要,但是可以借助于引用或者其他的数据结构来解决对象映射的问题,以及自定义的数据结构也可以在一定的协议之上来完成,甚至可以基于共享内存的方案来传递大块的数据都没有问题。开放使用方面,我们也在考虑,但具体的方式还在讨论中,希望我们的解决方案能够为广大的开发者带来更广阔的想象空间。

Q4. iOS 端接入会带来包大小的变化,以及无法 Hot Patch,你们对 iOS 的接入是怎么做的?

A4. 我们最初是从 Android 平台去切入的,iOS 的接入会晚一点,根据我们实际的调研情况来看呢,iOS 的同学对开发工具、包大小、动态性等都比较关注,后续我们也准备在这些方面去做一些研究,和 iOS 的同学一起来探讨出一些解决方案,也希望大家积极的拥抱新技术,在社区当中分享自己的解决方案。但无论怎么讲,我们使用新技术的目的是为了解决我们遇到的问题,只要是对我们有益的技术,我们一定会持续的跟进。

0 人点赞