无影Flutter for Web技术预研

lxf2023-03-12 17:44:01

介绍

Flutter是Google推出并开源的跨平台开发框架,它采用Skia渲染并兼容了Android、iOS、Mac、Windows、Linux及Web,Flutter在2.0版本正式发布了对Web的支持

无影Flutter for Web技术预研

Flutter使用Dart开发,Dart本身能通过dart2js将Dart语言转成JavaScript。在Flutter中分为框架层和引擎层。框架层提供了布局渲染更新方式和手势等抽象能力,还提供了常用的组件。引擎层提供了平台差异的抽象同时也抹平了不同平台差异。Flutter for Web通过映射web平台API的代码来取代移动应用所使用的底层C++渲染引擎。

无影本身是一个支持多端的产品,目前支持MacOS、Windows、零终端(Linux)、iOS、Android及Web端,目前PC三端都是通过同一个Flutter工程输出,iOS和Android因为逻辑与UI差异很大,没有使用跟PC端同一工程来开发,但是部分功能如登录逻辑也是通过与PC端引入同一个库的形式集成的。Web端一直采用的是纯Web开发,但目前无影的产品功能迭代很快,经常一个版本UI及功能都有重大变化,目前两端使用不同技术开发给我们开发周期及功能同步带来了很大的挑战。在我们Flutter项目将Flutter引擎升级到3.0.2的时机,提出了将我们Flutter项目转Web的需求,于是有了这次技术预研。

现有网站案例

  • code.irobot.com/#/
  • www.omnichess.club/
  • rive.app
  • demo.invoiceninja.com/#/

使用

环境

Flutter: 3.0.2 MacOS: 12.0.1

确保Flutter版本在2.0及以上,使用下面命令创建项目,默认会生成支持Web的项目结构

flutter create {PROJECTNAME}

如果想要对已有项目的支持,只需要控制台进入项目,执行

flutter create --platforms web .

想要知道项目是否支持Web,可以查看项目文件夹是否包含web文件夹,其默认生成的结构如下

web
├─ favicon.png 
├─ icons
│    ├─ Icon-192.png
│    ├─ Icon-512.png
│    ├─ Icon-maskable-192.png
│    └─ Icon-maskable-512.png
├─ index.html  入口文件,通过引入编译后的JS渲染页面
└─ manifest.json  配置PWA

注:icons里的图片是与PWA配合使用,当用户将项目安装到本地时(PWA),该图标会被当做启动图标使用

然后我们就可以在控制台输入下面命令启动项目了

flutter run -d chrome

无影Flutter for Web技术预研

两种编译模式

flutter提供了两种编译模式,分别适用于开发环境和生产环境。

  • flutterdev: 一种支持增量开发编译模式,可实现代码快速生效。当我们使用flutter run启动项目时就是使用的该方式,它可像开发客户端应用一样支持hot reloadhot restart
  • dart2js: 它是一个优化的编译器,可以将Dart代码编译为快速、紧凑的JavaScript代码,可以极大提高代码的包大小及运行效率。

渲染模式

Flutter for Web提供了两种渲染模式,HTML和CanvasKit,我们在编译的时候可以选择不同的编译模式

  • auto(默认)自动选择要使用的渲染器。当应用程序在移动浏览器中运行时,此选项选择HTML渲染器,当应用程序在桌面浏览器中运行时,采用CanvasKit渲染器。
  • html 使用HTML渲染器。使用HTML元素、CSS、Canvas元素和SVG元素的组合来渲染。此渲染方式采用的包大小更小。
  • canvaskit 使用CanvasKit渲染器。使用WebAssemblyWebGL渲染,将得到与桌面端渲染的一致性,且相比HTML渲染有更高的性能。但是它相比HTML渲染会多7MB左右的包大小(当前使用canvaskit0.33.0)。

我们可以通过编译时传入参数来指定渲染模式

flutter run -d chrome --web-renderer html
flutter build web --web-renderer canvaskit

或者在index.html中注入JavaScript的方式来指定渲染模式(只有编译时选择了auto才生效)

<script type="text/javascript">
  window.flutterWebRenderer = "html"; //or
  // window.flutterWebRenderer = "canvaskit";
</script>

html模式相比canvaskit除了渲染性能更低,还有一些其它问题

  • 不支持Image.toByteData
  • 不支持OffsetLayer.toImageScene.toImage
  • 无法访问动画中的帧数据(Codec.getNextFrame,frameCount始终为1,repetitionCount始终为0)
  • 不支持ImageShader
  • 图像上使用shader功能支持有限
  • 图片无法控制内存,dispose回调不会执行(图片内存管理都被浏览器接管了)

CanvasKit是一种采用Skia开发然后通过WebAssemblyWebGL渲染的技术。我们引入canvaskit.jscanvaskit.wasm包后,就可以通过JavaScript使用Skia API来绘制页面,这里有一个简单的例子

PWA

Flutter转成Web后会默认会把项目编译成一个PWA项目(学习资料),它会提供一个PWA WEB清单文件并生成一个flutter_server_worker.js文件,Service Workers对Flutter内容进行缓存,当第一次加载完成后,再次请求资源下载都会走缓存。

当我们在Chrome中打开一个PWA项目时,浏览器url右边会出现一个下载按钮,下载后Web应用会被当成一个类似客户端应用来使用。

无影Flutter for Web技术预研

这就相当于一个简化版的无影客户端。

Flutter默认启动了PWA,我们可以编辑manifest.json文件来更改项目配置,具体可以查看Manifest来学习如何配置。

当然如果我们不想使用PWA,可以在编译时添加--pwa-strategy=none命令来禁止使用它。

平台兼容

由于Web平台和MacOS、Windows、Linux平台差异很大,所以很多功能需要针对平台进行改造

通过kIsWeb判断是否在Web平台

跟在客户端使用Platform.is*不一样,Flutter是通过kIsWeb来识别是否在Flutter平台,它使用一种巧妙的方式通过比较0与0.0是否同一个值类型来实现的,因为在JavaScript中不区分double和int,而dart中是需要区分的。当我们使用kIsWeb时就像使用Platform.is*一样,如

String getPlatformName() {
  if (kIsWeb) {
    return 'Flutter Web';
  } else if (Platform.isMacOS) {
    return 'MacOS';
  }
}

在Web环境中,使用Platform会在运行时报错导致代码执行中断(issue),如果项目要兼容Web平台,需要将项目中的Platform.is*代码都改成兼容代码。你可以自己封装一个类来兼容,如

import 'package:flutter/foundation.dart';
class MYPlatform {
  static final bool isMacOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.macOS;
  static final bool isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
  static final bool isIOS = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
  static final bool isFuchsia = !kIsWeb && defaultTargetPlatform == TargetPlatform.fuchsia;
  static final bool isWindows = !kIsWeb && defaultTargetPlatform == TargetPlatform.windows;
  static final bool isLinux = !kIsWeb && defaultTargetPlatform == TargetPlatform.linux;
  static final bool isWeb = kIsWeb;
}

使用MYPlatform.is*来全局替换Platform.is*

或者可以引入一个三方库universal_platform,然后使用UniversalPlatform.is*来替换Platform.is*

顺便提一下,在客户端工程中引入dart:html也会报找不到的错误,我们同样可以引入universal_html解决。

有条件导入与导出文件

Flutter提供了导入导出时通过条件判断来支持不同平台引入不同实现。官方文档

import 'package:flutter3_demo/ffi/show_image_platform_interface.dart'
    if (dart.library.io) 'show_image_io.dart'
    if (dart.library.html) 'show_image_web.dart';

通过这样的代码在静态编译时会默认引入show_image_platform_interface.dart代码,当在开发或者打包编译时,如果是Native项目,会引入show_image_io.dart文件,如果是Web项目,会引入show_image_web.dart文件。需要注意的是,我们需要在show_image_io.dartshow_image_web.dart拥有相同名称的类与方法或者变量。

这种方式是通过检查dart:*库是否存在实现的,比如在客户端应用程序中会导入dart:io库,所以相应的上面会导入show_image_io.dart,Web程序中会导入dart:html,所以会导入show_image_web.dart文件。

library的导入规则(部分库在Flutter中不会被引入,所以没列出来)

  • Core: dart:core, dart:async, dart:collection, dart:convert, dart:developer, dart:math, dart:typed_data
  • Native Platform: dart:ffi, dart:io, dart:isolate
  • Web: dart:html, dart:js, dar:js_util, package:js

Federated plugin

文档

Flutter提供了一种新的插件开发方式Federated plugin,我们可以在插件的pubspec.yaml文件中指定对应的平台实现

flutter:
  plugin:
    platforms:
      android:
        package: com.example.hello
        pluginClass: HelloPlugin
      ios:
        pluginClass: HelloPlugin
      macos:
        pluginClass: HelloPlugin
      web:
        pluginClass: HelloPlugin
        fileName: hello_web.dart

environment:
  sdk: ">=2.1.0 <3.0.0"
  flutter: ">=1.12.0"

例如上面例子,我们可以针对Web平台单独实现hello_web.dart,在hello_web.dart中,我们需要对插件进行注册。

class HelloPluginWindows extends HelloPluginPlatform {
  static void registerWith() {
    HelloPluginPlatform.instance = HelloPluginWindows();
  }
  //...
}

更多例子可以参考官方插件url_launcher的实现。

Dart与JavaScript互调

虽然现在Flutter生态很好,有很多三方库可以使用,但是相比Web生态还是差太多了,在使用Flutter开发时能够与JavaScript的函数互调是一个比较重要的能力。Flutter提供了dart:js_utilsdart:js工具来与Web端的JavaScript互相调用。

Dart调用JavaScript

在web文件夹下新建hello.js

let i = 0;
// 同步函数
window.bindHello = (args) => {
  alert(`Hello ${args}`);
  return i++ % 2 == 0;
}
// 异步函数
window.bindHelloAsync = (args) => {
  return new Promise((resolve) => {
    alert(`Hello ${args}`);
    resolve(i++ % 2 == 0);
  })
}

上面创建了一个同步函数和异步函数,分别演示Dart调用JavaScript的同步函数和异步函数方式。我们需要把函数绑定到window上,Dart才能通过window拿到函数。

index.html中引入hello.js

<script src="hello.js" defer></script>

然后在Dart中分别调用同步函数和异步函数

import 'dart:html' as html;
import 'dart:js' as js;
import 'dart:js_util' as js_util;
class CallJS {
  static bool callHello() {
    bool result = js.context.callMethod('bindHello', ['Flutter Web Sync']);
    return result;
  }

  static Future<bool> callHelloAsync() async {
    var result = js_util
        .callMethod(html.window, 'bindHelloAsync', ['Flutter Web Async']);
    bool returnObj = await js_util.promiseToFuture<bool>(result);
    return returnObj;
  }
}

Dart调用JavaScript有两种方式,可以通过js.context.callMethod(Object method, [List<dynamic>? args])或者js_util.callMethod(html.window,Object method, [List<dynamic>? args])js.context在Web端相当于window

当我们调用callHello时,就能拿到JavaScript执行后的返回结果,调用callHelloAsync时能拿到JavaScript异步的执行结果

JavaScript调用Dart

同步

可以通过js_util.setProperty来提供JavaScript调用Dart能力,例如

import 'dart:html' as html;
import 'dart:js' as js;
import 'dart:js_util' as js_util;
void bindJS() {
  js_util.setProperty(html.window, "callHello", js.allowInterop((args) {
        return '$args from dart';
      }));
}

我们可以通过setProperty第三个参数可以是值类型、数组(js.JsArray)、对象(js.JsObject)等,我们可以通过使用js.allowInterop将Dart函数转成JavaScript函数。

然后在JavaScript中调用

window.callHello('Flutter');

异步

Flutter并没有提供异步JavaScript调用Dart的方式,但我们可以通过使用DartJavaScript的能力间接达到异步能力。

Dart代码:

js_util.setProperty(html.window, "callHelloAsync",
        js.allowInterop((returnName, arg) async {
    // do some thing async
    console.log(arg); // 接收参数
    await Future.delayed(Duration(milliseconds: 1000));
    js.context.callMethod(returnName, ['Result from Dart']);
}));

JavaScript代码

function callDartAsync() {
  return new Promise((resolve) => {
    window.callHelloAsync('callResult', 'hello');
    window.callResult = (args) => {
      resolve(args);
      window.callResult = null;
    }
  })
}

这样JavaScript调用callDartAsync函数就能拿到Dart异步执行后返回的结果了。

首屏加载

资源下载

当Flutter项目转成web后有几个文件比较大

  1. main.dart.js 2.0+ MB
  2. canvaskit.wasm 7.0 MB
  3. MaterialIcons-Regular.otf 等字体或图标文件

Flutter提供了一系列JavaScript API来控制整个资源下载及加载过程,在index.html默认会生成这样的函数调用

window.addEventListener('load', function (ev) {
    // flutter.js加载完成,开始下载main.dart.js资源
    _flutter.loader.loadEntrypoint({
      serviceWorker: {
          serviceWorkerVersion: serviceWorkerVersion,
      }
    }).then(function (engineInitializer) {
      // 加载main.dart.js,下载canvaskit.js canvaskit.wasm 及字体资源
      return engineInitializer.initializeEngine();
    }).then(function (appRunner) {
      // 启动引擎,渲染界面
      return appRunner.runApp();
    }).then(() => {
      // load end
    });
});

当index.html开始加载到第一帧显示,Flutter提供了一系列加载函数并加载

  • window.addEventListener('load'): index.html中的flutter.js文件下载成功
  • loadEntrypoint: 下载favicon.png、main.dart.js、manifest.json等文件
  • initializeEngine: 加载main.dart.js,下载canvaskit.js、canvaskit.wasm、FontManifest.json、MaterialIcons-Regular.otf等项目中使用的资源
  • runApp: 运行app

我针对该过程测试了大致的时间消耗

网速begin(s)load(s)loadEntrypoint(s)initializeEngine(s)runApp(s)
4G(4Mb/s)00.229.9631.34631.447
WIFI(30Mb/s)00.122.1156.6456.645
比例(%)0526672

这样,我们就可以根据上面的Web资源加载函数配合上表的每一段函数执行时间比例做一个首屏资源加载进度条来提高用户体验。如果在我们Flutter Web页面前面还有其它Web页面,也可以利用这些API对资源进行预加载。

拆分文件

整个项目都打包到一个文件中,会使首次下载文件变大,Flutter提供了一些方式来拆分文件,只有在加载对应的页面时才下载对应文件,相关资料

代码方案

import 'mywidget.dart' deferred as foo
final Future<void> loadedLibrary = foo.loadLibrary();
Widget build(BuildContext context) {
  return FutureBuilder(future: loadedLibrary, builder:(context, snapshot) { return foo.MyWidget(); });
}

我们可以使用deferred as来引入其它文件,拿到的foo会有一个loadLibrary函数,它返回一个Future,当这个Future返回时,就能拿到引入文件的具体函数并执行了。

flutter build web后,会在文件夹中生成一个main.dart.js_x.part.js文件,它会在Flutter调用上面FutureBuilder时才下载文件并加载。一般情况下,我们可以使用它对不同页面进行路由拆分以获得最大收益。

路由兼容

传统的路由方式也能在Flutter Web上使用,但是不会更新浏览器的url,所以需要针对传统路由进行兼容处理。

方案1: 升级到Navigaoion2.0并添加兼容URI调整

Navigator2.0网络上有很多文章及教程,展开讲很啰嗦。可以参考Flutter Navigator 2.0 for Authentication and Bootstrapping,它是一篇关于Navigator 2.0使用系列文章,最后一篇讲的是Web适配。

方案2:使用go_router

go_router是Flutter官方基于Navigator 2.0出的一个响应式的三方库,它提供了更多路由通用功能,相比我们直接使用Navigator 2.0,很多功能不需要再重复造轮子。

import 'package:go_router/go_router.dart';

// GoRouter configuration
final _router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomeScreen(),
    ),
    GoRoute(
      path: '/users/:userId',
      builder: (context, state) => const UserScreen(id: state.params['userId']),
    )
  ],
);
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerDelegate: _router.routerDelegate,
      routeInformationParser: _router.routeInformationParser,
    );
  }
}

然后可以通过

context.go('/users/123')
//或者
GoRouter.of(context).push('/users/123');

进行路由跳转。

更多可以点击这里。

浏览器兼容性

  • Chrome: 84版本及以上
  • Firefox: 72.9版本及以上
  • Safari: 9及以上(对应MacOS10.11)
  • Edge: 1.2.0及以上

疑难问题记录

  1. 构建的项目中通过https://unpkg.com/canvaskit-wasm@0.33.0/bin/来引入canvaskit.jscanvaskit.wasm

相关issue

如果使用canvasKit渲染模式构建web,会引入canvaskit.jscanvaskit.wasm文件,默认它会指向一个默认的cdn地址,这个cdn地址可能在国内无法访问,解决办法是在index.html中添加script脚本指定文件地址

window.flutterConfiguration = {
    canvasKitBaseUrl: "/canvaskit/" // 指向本目录的canvaskit文件夹下,编译会自动生成该文件;或者指向自己的CDN地址
};

或者在构建时指定

flutter build web --web-renderer canvaskit --dart-define=FLUTTER_WEB_CANVASKIT_URL=/canvaskit/
  1. 构建未生成flutter.js文件和canvaskit文件夹

如果构建未指定构建类型,可能会使用html渲染方式构建,所以需要构建时指定flutter build web --web-renderer canvaskit

  1. 构建后项目中会有一个字体文件指向了如https://fonts.gstatic.com/s/roboto/等地址

相关issue,解决方案是先去Roboto下载字体文件导入到项目中,然后在项目pubspec.yaml中引入

flutter:
  fonts:
    - family: Roboto
      fonts:
        - asset: assets/Roboto-Regular.ttf

总结

目前Flutter for Web逐渐趋于成熟,但是它本身还有一些问题

  1. 首次下载资源文件太大
  2. 无法友好支持搜索引擎SEO
  3. 与客户端API有兼容差异,工程化考验开发者能力

相应的也有一些好处

  1. 支持PWA
  2. 如果已有Flutter客户端工程,只需要维护一套代码

所以这些问题和收益需要团队自己去衡量。这次预研也有很多收获,如果后续在我们工程中应用,我再来分享我们在工程中遇到的挑战与实战干货。

参考资料

  1. Flutter Dev
  2. Dart Dev
  3. Flutter Web 支持现已进入稳定版
  4. Flutter For Web多端一体化开发和原理分析