Flutter for Web 在贝壳容灾降级中的应用
开源地址
fluter for web 开源地址https://github.com/LianjiaTech/BKFlutterWebNativeBridge
一. 背景
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,发现下面代码
我们发现 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 mobile 的编译过程类似,都是通过源码生成中间 dill 文件。当我们调用 flutter build web 时会调用 flutter_tools/lib/src/build_system/targets/web.dart 中下面的代码,
这段代码会调用 dart2js 的 dart2jsSnapshot 命令,输入是项目的 main.dart 和 dart sdk 的 web 实现,最终将 dart 代码转换为 dill kernel 文件。其中 dart sdk 的 web 实现以 json 的形式进行索引,json 内容如下
我们看到 dart:io 库的 supported 字段是 false,也就是说 dart2js 不支持 dart:io 库(isolate 库也是不支持的)。这就是为什么 Flutter for Web 调用 Platform._operatingSystem 抛出异常了。dart:io 在我们的 app 中有着大量使用,如果 Flutter for Web 不支持 dart:io,我们就需要针对不同的平台有不同的实现,那我们的代码就可能会出现针对平台的判断,比如下面的代码:
由于我们已有的代码都只适配了 native,这样就需要对已有代码进行修改。使用这种方案的话成本就非常高,那如何在不修改原有代码的情况下实现一套代码既运行在 native 又运行在 web 呢?
2.1.2 操作系统判断问题解决方案
首先,我们发现 Flutter for Web 提供了 dart 调用 js 的能力,代码如下
我们使用 js/js_util.dart 中的 callMethod()方法调用 window 的 isAndroid/isIOS 方法,就能判断当前是运行在 Android/iOS 系统上。那么如何在不修改业务代码的情况下替换原有的 isAndroid/isIOS 调用呢?这里就用到了我们的面向切面库 Beike AspectD(https://github.com/LianjiaTech/Beike_AspectD)。我们将所有调用 isAndroid/isIOS 的地方通过 aop 的能力改为调用 window 的对应的方法,那么问题就顺利解决了。
2.2 Platform Channel 问题
在后面的探索中我们又还发现了 native 与 Flutter Web 无法通信的问题。为了理解这个问题,我们先从 Flutter 和 Flutter for Web 的架构说起。
从 Flutter(左)和 Flutter for Web(右)的架构我们发现, 两个平台 framework 层是一致的,为开发者提供了丰富的布局和基础库。Flutter mobile 的 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 层的调用。整个通道的具体调用流程如下:
App 启动后,当 Flutter 侧有 method channel 注册时,Flutter Web Channel 会生成相应的 channel,将调用方法名和对应的处理方法进行存储。
当 native 侧调用 method channel 的某一个方法时,被 hook 的 method channel 会调用到我们实现的 Native Channel 层。
Native Channel 层会接收 native 传过来的函数名、参数和回调,生成判断调用唯一性的 uniqueid,将回调和 uniqueid 绑定并存储在内存中。然后将函数名、uniqueid 和参数通过 js 桥接透传至 Flutter Web Channel 侧。
Flutter Web Channel 侧会遍历所有 Flutter Web 注册的所有 method channel,查找对应的处理方法,找到后调用该方法。
Flutter Web 业务处理完成后,Flutter Web Channel 层会将结果和 uniqueid 通过 js 桥接透传至 Native Channel 层。
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()方法来实现文件读取,我们只需要实现类似下面代码即可:
FlutterFileOverridePlugin 是我们实现的 plugin,需要 Android 和 iOS 侧分别实现文件读取的代码并将数据返回。
三. 容灾降级方案设计
解决了以上的几个主要问题,我们就可以将 Flutter Web 页面运行在客户端的 web 容器中。但是只有 Flutter Web 支持是不够的,我们需要一套完整的方案来支持客户端 Flutter 页面的容灾降级。整个方案我们从构建、降级配置和客户端支持把整个容灾降级系统分为了六个模块。下面介绍一下 Flutter 容灾降级的整体架构。
主要包括以下几部分:
持续集成平台。用于完成降级包的集成,能够做到自动配置集成代码。当工程师发现 Flutter 线上问题之后,可将修复后的 Flutter 代码上传,然后触发任务,任务拉取最新的代码后会触发 Flutter Web 编译,编译完成后,任务会对产物进行裁剪,然后将修改后的产物进行压缩上传。
包管理平台。如上图,当持续集成打包完成之后,会在包管理平台注册。用户可在包管理平台针对不同的应用、系统和版本进行包的新增、下载及上下线操作。
配置平台。主要负责降级配置管理,可针对不同的 app、平台和页面等配置降级包。在配置时,用户需要指定目标 URL 和替换 URL,目标 URL 是指需要降级的页面的路由 URL,替换 URL 是我们降级包中相应 Flutter Web 页面的 URL。我们也可以通过将目标 URL 设置为 AllFlutterPages 来将目标页面指定为所有 Flutter 页面,这样当客户端要跳转至 Flutter 页面时,路由拦截器会自动将该 Flutter 页面的路由 URL 转化为降级包中对应的 Flutter Web 页面的 URL 然后进行跳转。支持 Flutter 全页面的降级是为了应对 Flutter 引擎出现问题导致 Flutter 页面大面积出错的情况。
Native 客户端除了实现上面说到的包下载、配置下载、路由拦截器和路由转换器之外,还实现了 Flutter Web 的容器,该容器主要实现了以下功能:
在客户端搭建本地服务,加载降级包。
加载 Flutter Web 通道所需要的 JS 文件。
实现了贝壳 Flutter 容器对应的方法(主要是页面的生命周期方法),当这些方法被调用时容器会通过 JS 桥接调用 Flutter Web 相应的方法。
JS 层主要是我们随着客户端安装包一起发布的 JS 文件。
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 for Web 也能够帮助我们提升整个大前端的效率。
版权声明: 本文为 InfoQ 作者【贝壳大前端技术团队】的原创文章。
原文链接:【http://xie.infoq.cn/article/80c2351a8bd7475439fc5213c】。文章转载请联系作者。
评论