基于小程序技术栈的微信客户端跨平台实践
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 的情况。并且受目前小程序框架所限,视频、图片的全屏显示效果也不够理想。
正是因为微信小程序框架在面对复杂业务的场景下还会存在一些体验和性能不尽人意的地方,在性能和体验上虽然接近原生,但仍不能达到原生体验效果,我们决定针对这些细节尝试进行一步步的优化。
先来看看小程序目前的系统架构。
四、基于小程序技术栈的跨平台开发
微信小程序的系统架构相信今天大部分的读者都比较熟悉了,总体来讲分为两部分:
View 视图端通过小程序的框架将用户采用 WXML 和 WXSS 描述的UI信息处理成 H5 元素,最终交给 WebView 去渲染;
App Service 端运行用户编写的 JavaScript 逻辑,并且可以调用具有微信开放能力的 JSAPI。逻辑和视图分离,通过事件和数据彼此之间建立联系。
回到我们上面的问题,在中低端机和稍复杂的业务上,受制于 Web 庞大而复杂的体系,要达到原生视图体系这样简单设计的体验,难度很高。那么是否能够使用平台原生的视图渲染体系来解决问题呢?
原理上我们可以将用户描述的 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 中颜色有各种表示方法,最常见的有:
十六进制颜色,如:#0000ff
RGB 颜色,如:rgb(0,0,255)
RGBA 颜色,如:rgba(255,0,0,0.5)
HSL 颜色,如:hsl(120,65%,75%)
HSLA 颜色,如:hsla(120,65%,75%,0.3)
颜色名,如:black
这些不同种类的颜色表示方式,经过 LV-CPP 计算后输出的全部是十进制的颜色值,再交由渲染模块进行渲染。
采用原生组件的方案确实在体验和性能方面能够带来不错的提升,在 Pixel 2 XL 的机器上我们测出,帧率方面比 WebView 提升了 27.5%,内存也可以下降 14%~23%。但随着我们要将该方案推广到各平台的时候,我们意识到需要在各个平台去做适配是一个巨大工作量的事情,而且后续的维护成本也将无法预测。
基于 Web 的渲染满足不了性能和体验的要求,基于原生渲染又会带来高维护成本问题,我们需要一个跨平台的渲染方案来解决。在研究各种可能的方案的时候,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 的小程序框架渲染优化方案。
在这个架构下,我们就将 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 环境中呢?要想保障框架的性能,那么我们就必须要去解决两个问题。
基于 Android WebView 的体系下可以在 Java 层通过 WebView 提供的接口注入一个 JavaScriptInterface,JS 就可以得到一个扩展的 API,调用的时候经过 V8 最终反射到 Java 上面。
Flutter 官方提供了一种 Platform Channel 的方案,用于 Dart 和平台之间相互通信。主要的原理就是将传递的数据编码成消息的形式,跨线程发送到平台接口层,处理之后再将返回的数据通过同样的方式原路返回。基于消息和跨线程的处理使得这种方式的通信效率并不高,我们在骁龙845的机器上测了一组数据,一秒内通过 Platform Channel 只能大概完成四千次左右的相互调用。
c. dart2cpp 实现原理
DartVM 提供了一种机制,可以在 Dart 的代码中使用 native 关键字来表示调用的是一个 C/C++ 的接口。
// Dart 示例代码
bool systemSrand(int seed) native "SystemSrand";
DART_EXPORT Dart_Handle
Dart_SetNativeResolver(Dart_Handle library,
Dart_NativeEntryResolver resolver,
Dart_NativeEntrySymbol symbol);
Dart_SetNativeResolverDart_NativeEntryResolver
Dart_NativeArgumentsDart_GetNativeArgument/Dart_SetReturnValue Dart_NativeArguments
// 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 中依然可以找到解决办法。
DART_EXPORT DART_WARN_UNUSED_RESULT Dart_Handle
Dart_Invoke(Dart_Handle target,
Dart_Handle name,
int number_of_arguments,
Dart_Handle* arguments);
有了这些基础的 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++ 相互调用。
这两套解决方案呢,其实它的想象空间绝非仅此,既然 JS 可以和 C++ 相互调用,C++ 又可以和 Dart 相互调用,他们结合在一起其实就可以间接的打通 JavaScript 和 Dart。虽然 JavaScript 和 Dart 有各自的执行环境和机制,但通过 C++ 的桥梁,依然可以构建一个高效的通道,中间可以通过引用和一些转换(类似 JNI)来完成大多数的调用操作和数据传递。
Script,C++ 和 Dart 上,所以在跨平台方面会极大减少额外的负担。对小程序的开发者也不会带来任何的改变,面向开发者的依然是原有的小程序技术体系。
多平台维护:基本上只需要维护 Dart 和 C++ 代码,平台相关代码可以最小化。
注:由于开发阶段方案变化较快,此处对比数据并未在同样的设备下测定,仅以相对 WebView 渲染提升为例做为说明。
五、总结与展望
回顾一下上下文,微信在客户端跨平台开发方案的探索从最早期的打造高质量、开源化的基础组件,到现在尝试探索大前端技术栈的业务跨平台开发方案,始终是从提升研发团队效能和最终产品用户体验两个角度出发,去思考如何能够不断地提高移动研发技术水平。如果把我们的视线重新拉回来这一根本出发点,今天我们所分享的渲染方案也并不一定是小程序技术栈作为跨平台开发的唯一优化方案选择。WebView 渲染真的无法有突破性提升?跨平台开发只有大前端技术选择?随着大前端技术不断的发展和深入,相信未来一定会继续出现新的技术方案去解决现有研发流程中的问题,也欢迎大家继续关注我们的最新进展。
Q & A