介绍
Flutter是Google推出并开源的跨平台开发框架,它采用Skia渲染并兼容了Android、iOS、Mac、Windows、Linux及Web,Flutter在2.0版本正式发布了对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的需求,于是有了这次技术预研。
现有网站案例
- https://code.irobot.com/#/
- https://www.omnichess.club/
- https://rive.app
- https://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提供了两种编译模式,分别适用于开发环境和生产环境。
- flutterdev: 一种支持增量开发编译模式,可实现代码快速生效。当我们使用flutter run启动项目时就是使用的该方式,它可像开发客户端应用一样支持
hot reload
和hot restart
。 - dart2js: 它是一个优化的编译器,可以将Dart代码编译为快速、紧凑的JavaScript代码,可以极大提高代码的包大小及运行效率。
渲染模式
Flutter for Web提供了两种渲染模式,HTML和CanvasKit,我们在编译的时候可以选择不同的编译模式
- auto(默认)自动选择要使用的渲染器。当应用程序在移动浏览器中运行时,此选项选择HTML渲染器,当应用程序在桌面浏览器中运行时,采用CanvasKit渲染器。
- html 使用HTML渲染器。使用HTML元素、CSS、Canvas元素和SVG元素的组合来渲染。此渲染方式采用的包大小更小。
- canvaskit 使用CanvasKit渲染器。使用
WebAssembly
与WebGL
渲染,将得到与桌面端渲染的一致性,且相比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.toImage
和Scene.toImage
- 无法访问动画中的帧数据(Codec.getNextFrame,frameCount始终为1,repetitionCount始终为0)
- 不支持
ImageShader
- 图像上使用shader功能支持有限
- 图片无法控制内存,dispose回调不会执行(图片内存管理都被浏览器接管了)
CanvasKit是一种采用Skia
开发然后通过WebAssembly
和WebGL
渲染的技术。我们引入canvaskit.js
和canvaskit.wasm
包后,就可以通过JavaScript使用Skia API来绘制页面,这里有一个简单的例子。
PWA
Flutter转成Web后会默认会把项目编译成一个PWA项目(学习资料),它会提供一个PWA WEB清单文件并生成一个flutter_server_worker.js
文件,Service Workers对Flutter内容进行缓存,当第一次加载完成后,再次请求资源下载都会走缓存。
当我们在Chrome中打开一个PWA项目时,浏览器url右边会出现一个下载按钮,下载后Web应用会被当成一个类似客户端应用来使用。
这就相当于一个简化版的无影APP客户端。
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.dart
和show_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_utils
和dart: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拿到函数(这里没找到不绑定到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的方式,但我们可以通过使用Dart
调JavaScript
的能力间接达到异步能力。
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后有几个文件比较大
- main.dart.js 2.0+ MB
- canvaskit.wasm 7.0 MB
- 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
我针对该过程测试了大致的时间消耗测试
网速 | load(s) | loadEntrypoint(s) | initializeEngine(s) | runApp(s) |
---|---|---|---|---|
4G(4Mb/s) | 0.22 | 9.96 | 31.346 | 31.447 |
WIFI(30Mb/s) | 0.12 | 2.115 | 6.645 | 6.645 |
比例 | 5% | 26% | 67% | 2% |
这样,我们就可以根据上面的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并兼容URL
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及以上
疑难问题
- 构建的项目中通过
https://unpkg.com/canvaskit-wasm@0.33.0/bin/
来引入canvaskit.js
和canvaskit.wasm
相关issue
如果使用canvasKit
渲染模式构建web,会引入canvaskit.js
和canvaskit.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/
- 构建未生成
flutter.js
文件和canvaskit
文件夹
如果构建未指定构建类型,可能会使用html渲染方式构建,所以需要构建时指定flutter build web --web-renderer canvaskit
- 构建后项目中会有一个字体文件指向了如
https://fonts.gstatic.com/s/roboto/
等地址
相关issue,解决方案是先去Roboto下载字体文件导入到项目中,然后在项目pubspec.yaml中引入
flutter:
fonts:
- family: Roboto
fonts:
- asset: assets/Roboto-Regular.ttf
效果演示
原客户端功能:
Flutter转Web:
总结
目前Flutter for Web逐渐趋于成熟,但是它本身还有一些问题
- 首次下载资源文件太大
- 无法友好支持搜索引擎SEO
- 与客户端API有兼容差异,工程化考验开发者能力
相应的也有一些好处
- 支持PWA
- 如果已有Flutter客户端工程,只需要维护一套代码
所以这些问题和收益需要团队自己去衡量。这次预研也有很多收获,如果后续在我们工程中应用,我再来分享我们在工程中遇到的挑战与实战干货。