一. 背景
1.1 Flutter for Web的发展现状
在2019年举办的Google IO开发者大会上,Flutter 发布了1.5版本,新加入对 Web 端的支持,即Flutter for Web。在经历了多个版本的迭代之后,随着Flutter 2的发布,Flutter Web正式进入stable渠道。
1.2 贝壳找房Flutter使用现状
贝壳找房从2018年开始调研并接入Flutter框架,在贝壳的所有App中已经有24款app接入Flutter,App接入率超过70%,在贝壳的B端App A+&Link App中超过88%的新页面都在使用Flutter进行开发。随着Flutter在贝壳的大量使用,如何快速解决Flutter线上问题,做到及时止损成为我们非常关注的一个问题。
针对这个问题,我们想到了Flutter for Web。如上图当Flutter页面出现问题时,把修改后的Flutter代码编译成web降级包下发到客户端,把出错的页面通过路由拦截的方式跳转至降级包中的Flutter Web页面,就能实现Flutter页面降级,在不需要重新发版的情况下做到线上问题及时止损。
二. Flutter for Web探索及主要问题和解决方案
2.1 操作系统判断问题
有了以上思路,我们首先尝试将Flutter项目编译成web直接运行在浏览器或者客户端web容器上。
我们发现,浏览器会报如上图错误。以Platform._operatingSystem方法为突破口,我们首先看一下flutter build web编译出的未压缩的main.dart.js,发现下面代码:
_Platform__operatingSystem: function() {
throw H.wrapException(P.UnsupportedError$("Platform._operatingSystem"));
}
我们发现Flutter Web产物的实现直接抛出异常(不仅_Platform__operatingSystem方法抛出异常, dart:io整个库在Flutter for Web都是不支持的)。那这段代码是如何添加到我们的main.dart.js里的呢?我们首先看一下Flutter for Web的前端编译流程。
2.1.1 Flutter for Web前端编译
Flutter for web编译的前端部分和Flutter for native的编译过程类似,都是通过源码生成中间dill文件。
当我们调用flutter build web时会调用flutter_tools/lib/src/build_system/targets/web.dart中下面的代码:
final List<String> sharedCommandOptions = <String>[
globals.artifacts.getArtifactPath(Artifact.engineDartBinary),
'--disable-dart-dev',
globals.artifacts.getArtifactPath(Artifact.dart2jsSnapshot),
'--libraries-spec=${globals.fs.path.join(globals.artifacts.getArtifactPath(Artifact.flutterWebSdk), 'libraries.json')}',
...?decodeDartDefines(environment.defines, kExtraFrontEndOptions),
if (buildMode == BuildMode.profile)
'-Ddart.vm.profile=true'
else
'-Ddart.vm.product=true',
for (final String dartDefine in decodeDartDefines(environment.defines, kDartDefines))
'-D$dartDefine',
];
final ProcessResult kernelResult = await globals.processManager.run(<String>[
...sharedCommandOptions,
'-o',
environment.buildDir.childFile('app.dill').path,
'--packages=.packages',
'--cfe-only',
environment.buildDir.childFile('main.dart').path, // dartfile
]);
if (kernelResult.exitCode != 0) {
throw Exception(kernelResult.stdout + kernelResult.stderr);
}
这段代码会调用dart2js的dart2jsSnapshot命令,输入是项目的main.dart和dart sdk的web实现,最终将dart代码转换为dill kernel文件。其中dart sdk的web实现以json的形式进行索引,json内容如下:
"dart2js": {
"libraries": {
….
"_http": {
"uri": "../dart-sdk/lib/_http/http.dart"
},
"io": {
"uri": "../dart-sdk/lib/io/io.dart",
"patches": "io_patch.dart",
"supported": false
},
"isolate": {
"uri": "../dart-sdk/lib/isolate/isolate.dart",
"patches": "../dart- sdk/lib/_internal/js_dev_runtime/patch/isolate_patch.dart",
"supported": false
},
….
}
}
我们看到dart:io库的supported字段是false,也就是说dart2js不支持dart:io库(isolate库也是不支持的)。这就是为什么Flutter for Web 调用Platform._operatingSystem抛出异常了。
dart:io在我们的app中有着大量使用,如果Flutter for Web不支持dart:io,我们就需要针对不同的平台有不同的实现,那我们的代码就可能会出现针对平台的判断,比如下面的代码:
if(kIsWeb) {
//web逻辑
} else {
//native逻辑
}
由于我们已有的代码都只适配了native,这样就需要对已有代码进行修改。使用这种方案的话成本就非常高,那如何在不修改原有代码的情况下实现一套代码既运行在native又运行在web呢?
2.1.2 操作系统判断问题解决方案
首先,我们发现Flutter for Web提供了dart调用js的能力,代码如下
js_util.callMethod(html.window, 'isAndroid', [])
我们使用js/js_util.dart中的callMethod()方法调用window的isAndroid/isIOS方法,就能判断当前是运行在Android/iOS系统上。
那么如何在不修改业务代码的情况下替换原有的isAndroid/isIOS调用呢?
这里就用到了我们的面向切面库Beike AspectD(https://mp.weixin.qq.com/s/tVnUXtmINMFwi8ySQHdxwg)。我们将所有调用isAndroid/isIOS的地方通过aop的能力改为调用window的对应的方法,那么问题就顺利解决了。
2.2 Platform Channel问题
在后面的探索中我们又还发现了native与Flutter Web无法通信的问题。为了理解这个问题,我们先从Flutter和Flutter for Web的架构说起。
从Flutter(左)和Flutter for Web(右)的架构我们发现, 两个平台framework层是一致的,为开发者提供了丰富的布局和基础库。Flutter for native的engine层实现了与底层操作系统的交互,Flutter for Web的browser层则是按照浏览器的标准API实现了web端的引擎。那么如果只是使用Flutter Web提供原有能力,我们是无法让Flutter Web和底层操作系统或者native进行通信的。
Flutter中与native通信比较常用的是Platform Channel,本文以MethodChannel为例。在Flutter中,负责Flutter与native交互的MethodChannel需要engine层来实现数据的传输。但是在Flutter for Web中没有了这个能力,Flutter Web和native是无法进行通信的。
为了打通Flutter for web与native通信的通道,我们设计了三层结构来实现Flutter Web和native的通信通道。
以iOS侧调用Flutter Web侧为例。
首先,在native侧,我们实现了一套自己的Native Channel来管理native对FlutterChannels的调用和Flutter的回调。我们使用了运行时技术来hook FlutterMethodChannel中的-(void)invokeMethod: arguments:和-(void)setMethodCallHandler:等方法。当业务方注册FlutterMethodChannel时,也会在我们的Native Channel注册,当业务方调用或者注册回调时,也会在我们的Native Channel调用或者注册回调。
同样的,在Flutter侧,我们也实现了自己的MethodChannel。我们再次运用Beike AspectD的aop能力对MethodChannel的Future<T> invokeMethod<T>和void setMethodCallHandler等方法进行了hook,当接收到native侧的调用时,会调用到我们hook的方法里进行处理。
中间的通信层我们使用了JavaScript与native通信的能力建立了桥接来打通Native Channel层和Flutter Web Channel层的调用。
整个通道的具体调用流程如下:
1) App启动后,当Flutter侧有method channel注册时,Flutter Web Channel会生成相应的channel,将调用方法名和对应的处理方法进行存储。
2) 当native侧调用method channel的某一个方法时,被hook的method channel会调用到我们实现的Native Channel层。
3) Native Channel层会接收native传过来的函数名、参数和回调,生成判断调用唯一性的uniqueid,将回调和uniqueid绑定并存储在内存中。然后将函数名、uniqueid和参数通过js桥接透传至Flutter Web Channel侧。
4) Flutter Web Channel侧会遍历所有Flutter Web注册的所有method channel,查找对应的处理方法,找到后调用该方法。
5) Flutter Web业务处理完成后,Flutter Web Channel层会将结果和uniqueid通过js桥接透传至Native Channel层。
6) Native Channel层通过uniqueid查找到存储的回调,将返回的结果传给调用方。
这样,就完成了一次从native到Flutter Web侧的调用与回调。Flutter Web调用native的方案类似,本文就不详细介绍了。
在后来的开发过程中,我们发现只是打通native和Flutter Web的通道还是不够的。比如我们发现另外一个问题,对于Flutter for native使用正常的一些API,web并没有相应的实现。下面跟大家分享一下这个问题和我们的解决方案。
2.3 dart:io文件系统API问题
dart:io中有另一个重要的包file.dart,开发者可以使用这个包来进行文件的操作。由于Flutter for Web不支持dart:io,所以使用file.dart库的代码也都会调用失败。
我们可以同样使用aop的方式来替换file.dart中api的实现然后通过我们上边实现的通道来调用native侧的实现,但是dart为我们提供了更加简便的方式,IOOverides类。这个类提供了让我们复写dart:io库中api的方法。比如我们要实现readAsBytes()方法来实现文件读取,我们只需要实现类似下面代码即可:
@pragma("vm:entry-point")
class FileOverride extends FileSystemEntity implements File {
@override
@pragma("vm:entry-point")
Future<Uint8List> readAsBytes() async {
return await FlutterFileOverridePlugin.overrideReadAsBytes(path);
}}
FlutterFileOverridePlugin是我们实现的plugin,需要Android和iOS侧分别实现文件读取的代码并将数据返回。
三. 容灾降级方案设计
解决了以上的几个主要问题,我们就可以将Flutter Web页面运行在客户端的web容器中。但是只有Flutter Web支持是不够的,我们需要一套完整的方案来支持客户端Flutter页面的容灾降级。
整个方案我们从构建、降级配置和客户端支持把整个容灾降级系统分为了六个模块。
下面介绍一下Flutter容灾降级的整体架构。
主要包括以下几部分:
1. 持续集成平台。用于完成降级包的集成,能够做到自动配置集成代码。当工程师发现Flutter线上问题之后,可将修复后的Flutter代码上传,然后触发任务,任务拉取最新的代码后会触发Flutter Web编译,编译完成后,任务会对产物进行裁剪,然后将修改后的产物进行压缩上传。
2. 包管理平台。如上图,当持续集成打包完成之后,会在包管理平台注册。用户可在包管理平台针对不同的应用、系统和版本进行包的新增、下载及上下线操作。
3. 配置平台。主要负责降级配置管理,可针对不同的app、平台和页面等配置降级包。在配置时,用户需要指定目标URL和替换URL,目标URL是指需要降级的页面的路由URL,替换URL是我们降级包中相应Flutter Web页面的URL。我们也可以通过将目标URL设置为AllFlutterPages来将目标页面指定为所有Flutter页面,这样当客户端要跳转至Flutter页面时,路由拦截器会自动将该Flutter页面的路由URL转化为降级包中对应的Flutter Web页面的URL然后进行跳转。支持Flutter全页面的降级是为了应对Flutter引擎出现问题导致Flutter页面大面积出错的情况。
4. Native客户端除了实现上面说到的包下载、配置下载、路由拦截器和路由转换器之外,还实现了Flutter Web的容器,该容器主要实现了以下功能:
- 在客户端搭建本地服务,加载降级包。
- 加载Flutter Web通道所需要的JS文件。
- 实现了贝壳Flutter容器对应的方法(主要是页面的生命周期方法),当这些方法被调用时容器会通过JS桥接调用Flutter Web相应的方法。
5. JS层主要是我们随着客户端安装包一起发布的JS文件。
6. Flutter Web主要支持了Flutter Web和native 的通道。这一部分代码内置在Flutter Web降级包中。
四. 总结
贝壳找房Flutter团队主要利用了Flutter跨平台的特性,将Flutter for Web运用在了Flutter的容灾降级并已接入到线上app中使用,为贝壳找房Flutter越来越多的运用提供了又一道保障。
我们也还有很多待完善的地方,比如使用IOOverides复写dart:io方法的方案,IOOverides中有差不多30个方法,如果要实现所有的方法需要耗费比较长的时间,我们现在仅实现了我们业务场景使用到的api。
除了将Flutter for Web运用于Flutter容灾降级。贝壳Flutter团队也在探索使用Flutter进行多端一体化的开发,充分利用Flutter的跨端特性,一份代码,可以同时运行在iOS/Android和web端。在使用Flutter for Web中,我们也遇到了产物大、滑动卡顿等一系列问题,后续会跟大家分享在Flutter多端一体化过程中遇到的挑战与方案。
经过三年的积累,Flutter的运用已为贝壳找房客户端的开发大幅提效,经过不断的积累与沉淀,我们相信Flutter也能够帮助我们提升整个大前端的效率。