作者简介:卢苇白,到家App端架构师,到家Flutter项目负责人之一
章文顺,到家高级前端开发工程师,到家Flutter for web前端负责人
项目背景
在面对日益增加的业务需求,到家前端团队需要面对越来越繁重的开发需求,所以需要不断地提升团队的开发效率。在Flutter出现以前,一个开发需求需要iOS、Android以及Web团队同时进行开发。在Flutter出现之后,由于其出色的渲染性能以及跨端的能力,使得iOS以及Android融合出现了可能。
在完成了iOS以及Android的Flutter融合项目之后,原生升级成了Flutter,这样就导致了一个问题:当需要降级页面的时候,Flutter自动降级为native的页面,但是原生的页面不会再投入精力去维护,导致降级成原生页面的时候,版本不匹配。如果继续使用原有native端的页面进行维护,反而要消耗更多的人力来维护原有的页面,大大背离了我们对于增加团队开发效率的初衷。于是我们决定使用前端Web页面来代替原生作为降级页面。但是,有一些页面并不是双端都有,或者双端统一开发的。如果Web页面缺失,就会导致降级页面的缺失。那么有没有可能使用Flutter的页面直接转换为Web页面呢?
在2019年5月份谷歌的IO大会上,谷歌发布了Flutter for web的初版,让这个想法成为了可能。在经过了一年多不断地迭代,大部分API被融合与合并,在2019年9月10号,原Flutter for web仓库不再维护,谷歌将这个项目与Flutter主工程合并,从这之后,Flutter for web作为更加稳定的版本提供给开发者使用。我们利用Flutter for web的特性,借助之前Flutter双端融合的经验,探索了Flutter页面直接转换为Web的实现方案。
Flutter for web 原理
Flutter for web(以下简称FFW)和Flutter for app(以下简称FFA)都是基于Dart去进行开发,二者在大部分API的部分是一致的,只有少数要用到原生平台的API目前还没有支持,根据目前迭代的速度,可以预见在不久的将来FFW会逐步完善对于这部分API的支持。
FFW和FFA的最大的区别是,FFA是通过自己的渲染底层,通过Skia绘图引擎调用GPU来进行绘制,而FFW沿用了类似RN的思想,将Flutter的代码转换为JavaScript,Flutter web engine将替换FFA中使用的C++引擎,将UI部分转换为标准HTML标签以及Canvas绘制的自定义标签,最终生成可绘制的dom树。
由上图可以看出来,FFW中的渲染与FFA的概念并不一致,无法直接使用浏览器的渲染底层,中间经过了一层转换,导致了部分性能的缺失。
FFW预研
可行性探索
先来看下谷歌对于FFW的定义:
Our goal is to add web support as a first-tier platform in the Flutter SDK alongside iOS and Android. The code in this repository is a stepping stone to that goal, providing web-only packages that implement (almost) the entire Flutter API.
https://github.com/flutter/flutter_web
FFW是为支撑Web运行在iOS和Android平台上而诞生,FFW引擎几乎涵盖了所有的Flutter现有API。但是目前并不稳定,官方也不推荐作为上线使用,所以我们对于FFW目前的目标还是存在于作为降级页面来对待。
来自丹麦的 Reflectly,使用 Flutter 开发的移动和 Web 应用体验几乎完全一致
关于渲染效率上来说,我们进行了一些测试和分析,结合网上对于这方面的讨论,总结下来就是对于
比较简单的页面:用户体验是几乎一致的,无明显卡顿
比较复杂的页面:由于自动生成dom树非常的复杂,所以转换后渲染效率不如Web原生效率高,所以用户在使用过程中是可以感知到卡顿的,但是就目前来说足以应对我们对于降级页面的需求。
而这种复杂页面,也存在可行的优化方案:需要从Dart本身着手,针对Listview嵌套等复杂结构拆分优化,达到渲染效率的提升,网上有非常多的相关资料,本文不做过多表达。
京东到家Flutter for web
京东到家FFW架构
如上图所示,对于Flutter需要使用的服务抽离了一个综合的三端适配接口层,主要是用来消除平台间的差异化。Adpater适配层层主要负责消除平台间服务接口的差异。在Web services接口层通过联合插件的形式,重新将实例化后的Web插件给到适配层,最终通过JS适配层引入Web端成熟的服务框架来提供服务。
FFW只支持beta channel,最低支持版本是1.2.0,我们选择了beta版本中相对来说更为稳定的1.21.0来进行本次的平台改造
Flutter版本:1.21.0-9.1pre
pods版本:1.6.2
IDE:Google Chrome 81.0.4044.129
原有的Flutter业务使用了channel来进行iOS和Android平台的适配转发,加入了Web之后,需要对于这一层进行抽离,提炼出所需的抽象接口以及适配器,在适配器下,封装出应对Dart原生平台、App平台以及Web平台的服务提供实例。
abstract class CommonServiceInterface extends PlatformInterface {
/// CommonServiceInterface构造器
CommonServiceInterface() : super(token: _token);
static final Object _token = Object();
static CommonServiceInterface _instance = CommonServiceApp();
static CommonServiceInterface get instance => _instance;
///根据平台注册实例对象(服务提供方)
static set instance(CommonServiceInterface instance) {
PlatformInterface.verifyToken(instance, _token);
_instance = instance;
}
//实例对象中提供的具体服务,由web和app自行实现
Future<Util> get Util async {
throw UnimplementedError('Util has not been implemented.');
}
}
在上面的代码中,我们分别实现了一个抽象出来的服务层接口的构造器、对应的实例对象,即服务提供方、以及对应平台的实例以及API接口的实现。通过构造器的不同,服务的提供方也会不同,从而实现具体服务的区分。
区分不同平台
Flutter提供了联合插件(Federated Plugin)来对于多平台进行适配,我们选择沿用这种形式。根据官方文档的定义
Federated plugins are a a way of splitting support for different platforms into separate packages. So, a federated plugin can use one package for iOS, another for Android, another for web, and yet another for a car (as an example of an IoT device)
https://flutter.dev/docs/development/packages-and-plugins/developing-packages
联合插件可以用于不同平台间分发packages。针对iOS,Android以及Web甚至可以支持IoT物联设备进行拓展。
引入了联合插件之后,就可以针对不同平台进行yaml文件的配置。官方脚手架生成的是使用MethodChannel与JS打通进行通讯,但通过试验和调研后,发现它有一些缺点。
首先,通过MethodChannel调用插件的方法调用是不必要的开销。为什么这么说,因为在Web中,整个应用程序会被编译成一个JavaScript bundle,导致插件中那些Web已定义的没有被使用到的方法也会被序列化和反序列化而额外占用空间以及时间。
使用MethodChannel的另一个缺点是编译时很难剔除(通过Tree shaking)未使用的插件代码。Web插件根据MethodChannel传递的方法调用的名称调用对应的方法,因此编译器没有办法判定此方法是否存在。
我们进行了相应的改写,将interface所使用的实例对于不同平台进行了相应的替换。通过官方提供的plugin_platform_interface package,抽象出上层接口类且只对外暴露单例(具体代码见上文适配层),该类存放了对外输出的所有待集成方法。各平台集成该类,并且实现接口方法返回用于操作的实例。
通过如此改写,可以明确让编译器知道哪些方法是被引用或者未被引用,经过编译时的Tree Shaking,移除未被引用到的代码(Dead Code)。也解决了MethodChannel的弊端,剔除了不必要的消耗。
附上联合插件使用yaml配置样例:
flutter:
plugin:
platforms:
android:
package: com.example.hello
pluginClass: HelloPlugin
ios:
pluginClass: HelloPlugin
web:
package: com.example.hello.web
pluginClass: HelloPlugin
environment:
sdk: ">=2.1.0 <3.0.0"
flutter: ">=1.12.0 <2.0.0"
打通JS平台
接下来需要引入JS Package做JS interface适配层:
@JS()
library main;
import 'package:js/js.dart';
@JS('Utils.API')
external Promise api(dynamic params, String type, dynamic object);
在上面的代码中,API被映射到JS服务包中的Utils.API方法,在实际调用中,packages被替换成为Web的packages,从而进行平台差异化替换。
在打通平台服务之后,那么我们还需要实现不同平台之间的通讯。有两种方式可以用来实现JS和Dart互相调用:
Dart提供了`dart:js`package,该库提供了底层支持,Dart与JavaScript交互通讯。它提供了在Dart中对JavaScript对象的访问,允许Dart代码获取和设置属性,调用JavaScript对象的方法和调用JavaScript函数。示例如下:
例如,我们调用window.alert,示例代码如下:
import 'dart:js';
main() => context.callMethod('alert', ['Hello from Dart!']);
从JavaScript构造函数创建`JsObject`并访问其属性
import 'dart:js';
main() {
var object = JsObject(context['Object']);
object['greeting'] = 'Hello';
object['greet'] = (name) => "${object['greeting']} $name";
var message = object.callMethod('greet', ['JavaScript']);
context['console'].callMethod('log', [message]);
}
此处需要注意的是,根据官方文档:
This library does not make Dart objects usable from JavaScript, their methods and properties are not accessible, though it does allow Dart functions to be passed into and called from JavaScript.
https://api.dart.dev/stable/2.10.2/dart-js/dart-js-library.html
虽然JS可以调用到Dart中的方法,但是并不可以使用Dart中的实例,必须将实例转换成基础JSON类型通过String来传输。
JS调用Dart
JS调用Dart与JS与原生沟通相类似,我们可以利用context,创建我们需要的桥接方法:
context['javascriptFunctionName'] = (parameter) {
//call any Dart method
}
然后在Js中直接调用:
javascriptFunctionName({'param': 'value'});
二、使用 'JS' Package
同样可以满足Dart与JavaScript交互通讯,使用装饰器的方式,开发起来更加直观。完全可以替代`dart:js`。
Dart调用JS
例如,我们调用JSON.stringify,示例代码如下:
library stringify;
import 'package:js/js.dart';
external String stringify(Object obj);
library print_options;
import 'package:js/js.dart';
void main() {
// dart call printOptions
printOptions(Options(responsive: true));
}
external printOptions(Options options);
class Options {
external bool get responsive;
// Must have an unnamed factory constructor with named arguments.
external factory Options({bool responsive});
}
然后在JS中直接调用:
// JavaScript call printOptions
printOptions({responsive: true});
原生Platform判断:
Platform接口在Web转换之后会报错。一般会使用Platform.isAdroid,Platform.isIOS区分平台,不能直接用于Web平台。需要优先判断Web平台,避免直接使用Platform。判断是否是Web的方法:`bool isWeb = identical(0, 0.0);`
渐变图层:
SweepGradient、RadialGradient绘制类当前不能用于Web平台,官方已加入修复计划,具体进度可关注该[Github Issue](https://github.com/flutter/flutter/issues/41389)的状态
第三方库兼容性:
有些不能用于Web平台的package,如:ffi等。目前在pub仓库,只有60%左右的三方package支持了FFW
编译之后包大小:
编译出来的代码大小非常大,gzip压缩前,大小约为1.5M左右,压缩后是400多k。会影响页面加载速度。目前可行的方案是对于某些大的模块进行异步加载,使用到了再下载下来,这样可以节约大概50多k的空间,具体实例代码如下:
import 'greeter.dart' deferred as greeter;
void main() async {
await greeter.loadLibrary();
runApp(App(title: greeter.EnglishGreeter().greet('World')));
}
导航条不兼容:
Web的导航条和页面导航条重复显示:判断不同平台,对于Web平台需要屏蔽掉Web的导航条
FFW携带FFA的冗余代码:
由于无法进行区分import,工程上无法解决FFW与原生代码共存问题,目前可行的方案:使用build_runner注释来自动化生成与import相关代码:https://pub.flutter-io.cn/packages/build_runner
成果
FFW转换完的页面与Flutter原生完全一致
目前已经完成对于一个包含Listview的页面的完整转换并且上线。可以看出,转换完的页面与转换前是一致的,功能完整。除了对于UI的转换,还对于网络层以及登录组件进行了Web端的适配,并且输出了统一的适配方案。
总结
本文探索了Flutter代码直接转换为Web代码的可行性,在使用最新的Flutter-Web引擎转换完的Web页面可以很好的完成作为降级页面的需求,但是对于一些第三方库的支持还比较有限,需要开发者再行适配。性能上来说,转换完的页面还存在着与原生Web一定的性能差距。当然,随着越来越多的Web开发者投入到Flutter-web的开发中,适配FFW的方案也会越来越完善。
我们团队会持续探索FFW对于性能的优化方案以及自动化适配的方案。我们相信,随着FFW的不断完善,对于性能的提高,我们团队会逐步进行Web到App端的融合,真正实现一套代码,三端运行的目标。