Flutter Web从0到部署上线的实践
1.前言
首先说明一下,这篇文章是给客户端开发同学看的(有Flutter
基础最好)。Flutter
的诞生虽然来自Google
的Chrome
团队,但大家都知道Flutter
最先支持的平台是Android
和iOS
,至今最核心的维护平台依然是Android
和iOS
。dart
语言的学习成本不高,Flutter
的响应式UI与Compose
和SwiftUI
都有极大的相似之处,整体的架构思路也更偏向于客户端的模式,再加上为了实现很多硬件或Native
相关的基础功能也需要专业的客户端开发知识,所以Flutter
更多的是被客户端开发同学认可并使用(在我们的团队中,Flutter
已经是客户端开发同学的必备基本技能)。虽然Flutter
最大的亮点就是跨端,但其实客户端和web
端之间跨端由于差异性较大所以并不普遍,所以在此背景下,Flutter
最初并不在web
端上发力。但Flutter
本身就是携带了web
的基因啊,所以在Flutter2
发布的时候终于发布了web
的稳定版。
那么既然客户端和web
端之间的跨端并不普遍,前端开发同学大概也不会使用Flutter
进行web
开发(确实没必要,包体积增加且有一定的性能损失,还需要学习新语言与开发思路,原生开发不香么),Flutter Web
到底有什么用呢?
带着这样的想法,在使用Flutter
后的很长时间都不曾调研过web
端的支持。但随着业务和内部需求的发展变化,Flutter Web
的优势也逐渐展现出来了。下面我来说一下使用Flutter Web
主要的三个场景。
2.Flutter Web的使用场景
- 1.客户端团队内部的web需求:在后疫情时代降本增效的大背景下,我们会更多的使用自研工具。自研工具的使用和结果展示的可视化通常以网页的形式展现,虽然团队里有前端开发同学,但考虑到自研工具更多的是组内的尝试且与业务无关,自然不应让前端同学承担这部分工作。而客户端同学使用
Flutter Web
进行网页开发学习成本低,完全可以快速的开发网页(本人在使用Vue
框架进行web
端开发时感受出客户端和前端的UI布局思路还是有很大不同的,css
很灵活约束性低,这个与客户端布局的强约束性差异很大。对于没有Flutter
基础的客户端开发来说,Flutter
的学习成本显然更低,开发时使用起来更顺手。对于全员掌握Flutter
技能的我们团队来说已经是0成本了)。 - 2.不需长期维护的web业务需求:
web
端承载了很多活动需求,这些需求的特点是时效性强,功能较简单,且不需长期维护。但这些需求经常是在某一时间段大量产生的(比如逢年过节的一些活动或榜单),或突然产生的(比如蹭热点的即时需求)。这些工作的插入有时会导致一些长期迭代的web
端需求需要延期,影响团队的整体排期。由于这些需求开发难度不大,性能要求不高,不需长期维护(意味着即使团队里不再有人使用Flutter
或Flutter Web
有一天挂了也没什么影响),那么就特别适合分摊到客户端开发上。客户端开发同学加入进来后,平摊了一部分工作,以此来提升整个团队的效率。 - 3.客户端与web端的跨端:个人认为这部分需求比较少。但万一有这种需求,那么我们就可以节约很多人力资源去重新开发一套
web
端了。
好的既然有了需求,我们就好好来走一下Flutter Web
是怎么开发部署上线的流程。
3.Flutter Web工程的创建和业务实现
3.1.创建与运行
我们使用Android Studio
作为IDE,以Flutter 3.10.5
版本为基础创建一个Flutter Web
工程。
创建一个New Flutter Project
,在选择Platforms
的时候只勾选Web
,然后直接Create
。
然后我们发现在工程目录里多了个web
的文件夹:
如果想要run
起来只需选择chrome
浏览器,点击run
就行了:
然后我们就可以在浏览器看到运行结果了,当然我们也可以打开开发者模式方便查看与调试:
这部分跑通后,非常恭喜你可以愉快的用Flutter
开发网页了,接下来我们实现一个业务需求:做一个网页搜索功能。
业务功能上的开发实现我就不做赘述了,可以告诉做过Flutter
开发的同学,没什么不同,基础配置/网络模块/数据共享/路由等该怎么封装就怎么封装,我也不过是直接拿了之前客户端Flutter
工程相应模块的代码,稍作修改而已。UI
上的开发也是该怎么布局怎么布局,业务的开发体验上和客户端使用Flutter
没什么不同。
3.2.调试
跑通后应该如何调试呢?
如果熟悉浏览器开发者模式,可直接使用浏览器进行调试,打log
或debug
都是没问题的,也可以看到源码,可以抓包:
当然客户端同学可能不熟悉浏览器开发者模式,也没关系,利用Android Studio
,之前在客户端写Flutter
怎么调试,现在写web
端依旧可以怎么调试。
3.3.window
在web
端开发的时候我们通常会使用window
对象进行一些操作。window
对象代表一个浏览器窗口或一个框架。常用的event
监听,打开网页等操作都需要window
对象。
Flutter
自带的dart:html
封装了window
,我们可以通过它来实现获取window
的属性或对window
进行操作,比如:
//打开网页
window.open("http://www.baidu.com","");
//监听event
window.addEventListener("mousedown", (event) => {
//do something
});
另外window
也可以帮助我们区分运行环境。
3.4.浏览器运行环境区分
客户端通常需要区分的是Android
和iOS
这两个不同的运行环境,而web
端是需要通过UA
来区分不同的浏览器环境的,不同环境下的UI/逻辑等会有差别。在国内,我们最常需要区分PC
端/移动端/Android
端/iOS
端/微信网页/微信小程序这几个。那么我们可以定义一个类,利用window.navigator.userAgent
去区分这些环境:
import 'dart:html';
class DeviceUtil {
static final DeviceUtil _instance = DeviceUtil._private();
static DeviceUtil get() => _instance;
factory DeviceUtil() => _instance;
late String ua;
DeviceUtil._private() {
ua = window.navigator.userAgent;
}
//移动端
isMobile() {
return RegExp(
r'phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone')
.hasMatch(ua);
}
//iOS端
isIos() {
return RegExp(r'\(i[^;]+;( U;)? CPU.+Mac OS X').hasMatch(ua);
}
//Android端
isAndroid() {
var isAndroid = ua.contains("Android") || ua.contains("Adr");
return isAndroid;
}
//微信环境
isWechat() {
return ua.contains("MicroMessenger");
}
//微信小程序环境
isMiniprogram() {
if (ua.contains("micromessenger")) {
//微信环境下
if (ua.contains("miniprogram")) {
//小程序;
return true;
}
}
return false;
}
}
3.5.开发/测试/生产环境区分
同客户端一样,web
端也需要区分开发/测试/生产环境。同客户端的方式一样,我们还是可以通过配置不同的入口文件来实现环境的区分。如:
- main_dev.dart
void main() {
AppConfig.init(ConfigType.dev);
root_main.main();
}
- main_test.dart
void main() {
AppConfig.init(ConfigType.test);
root_main.main();
}
- main_online.dart
void main() {
AppConfig.init(ConfigType.online);
root_main.main();
}
在AppConfig.init()
就可以根据不同的环境做不同的配置了。
3.6.其他常用库或插件
关于数据共享/网络/UI/动画等库就不做介绍了,因为这些库和平台不相关,用各自熟悉的就好,下面是来介绍一下为了实现一些浏览器相关功能需要用到的插件。
- shared_preferences
在客户端开发的时候,我们知道如果需要对一些数据实现轻量级的本地序列化可以使用
shared_preferences
,其实现对应Android
的SharedPreferences
和iOS
的NSUserDefaults
。而在进行web
开发的时候,我们知道如需在本地序列化一些数据的话,可以使用LocalStorage
。其实Flutter
的shared_preferences
插件也是支持web
的,其实现也正是封装了LocalStorage
。关于shared_preferences
的使用也不做赘述了,已经非常熟悉了。 - image_picker_for_web
来自于我们熟悉的
image_picker
插件。根据浏览器的不同,支持或部分支持拍照/拍视频/读取图片/读取视频等。 - js
这个插件是用来使用注解的方式帮助你用
Dart
调用JavaScript API
或用JavaScript
调用Dart API
的。
好了,到此为止,我觉着使用Flutter
开发一个常规的web
业务已经不成问题了。接下来我们探讨一下如何打包部署上线呢?
4.打包部署上线
4.1.打包
Flutter Web
的打包非常简单,运行:
flutter build web
即可。但这样显然是不够的,因为我们需要区分环境来打不通的包。
在上一章节我们配置了不同的入口文件,我们以dev
环境为例,其入口文件是main_dev
,那么我们的打包命令就变成了:
flutter build web -t lib/main_dev.dart
这行命令执行完成后,报错了,报错信息如下:
这是个图标数据加载问题,我们加上--no-tree-shake-icons即可。执行命令如下:
flutter build web -t lib/main_dev.dart --no-tree-shake-icons
然后我们就会在项目根目录的build
文件夹下找到web
这个文件夹,对应的就是web
前端打出来的dist
文件夹。包含了以下文件:
编译产物有了,那么如何部署呢?
4.2.部署
官方给了如下的部署方式:
flutter.cn/docs/deploy…
看了官方文档后我发现,这三种部署方式并不适用于我们的项目。由于CDN
具有提高网站性能和用户体验,减轻原始服务器的负载等优势,目前我们团队已经搭建了CDN
部署平台。既然如此,我们的部署方案也需要往这方面靠。
4.2.1.方案1——修改index.html
我先来简单说明一下FlutterWeb
编译产物,重点有两个:flutter.js
和main.dart.js
。其中flutter.js
为入口的js
文件,我们可以打开web
目录下index.html
:
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="flutter_web">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>flutter_web</title>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
var serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code --></script>-->
<script src="flutter.js" defer></script>
</head>
<body>
<script>
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: function(engineInitializer) {
engineInitializer.initializeEngine({
}).then(function(appRunner) {
appRunner.runApp();
});
}
});
});
</script>
</body>
</html>
看到<script src="flutter.js" defer></script>
这行。而main.dart.js
是我们的dart
业务代码被编译成的js
文件。flutter.js
会加载main.dart.js
和其它文件。默认情况下,flutter.js
会加载各个文件,包括资源文件(assets
)都使用的是相对路径。首先就是通过loadEntrypoint ()
方法加载main.dart.js
这个文件:
//flutter.js
async loadEntrypoint(options) {
const { entrypointUrl = `${baseUri}main.dart.js`, onEntrypointLoaded } =
options || {};
return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded);
}
但我们发现貌似entrypointUrl
是可以自己传递的,于是我们从官网文档里找到了自定义web应用初始化
的链接:
flutter.cn/docs/platfo…
有如下的参数可传:
其中loadEntrypoint()
方法可以传递entrypointUrl
参数来指定main.dart.js
的路径。而initializeEngine()
方法可以通过传递assetBase
参数来指定CDN
资源路径。这么看来我们完全可以通过将这两个参数设置为绝对路径来解决main.dart.js
的加载与CDN
资源路径的问题。需要注意的是initializeEngine()
方法是Flutter3.7.0
开始才支持的。
我们改一下index.html
:
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
entrypointUrl: "YOUR_CDN_ABSOLUTE_PATH/main.dart.js",
onEntrypointLoaded: function(engineInitializer) {
engineInitializer.initializeEngine({
assetBase: "YOUR_CDN_ABSOLUTE_PATH"
}).then(function(appRunner) {
appRunner.runApp();
});
}
});
});
我们再打个包,还是会报错,找不到flutter.js
,还是因为路径问题。处理方式更简单了,直接在index.html
里配置成绝对路径即可。另外我们发现Icon-192.png
,favicon.png
,manifest.json
这几个文件也是相对路径,那么我们一次性都改成绝对路径:
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="flutter_web">
<link rel="apple-touch-icon" href="YOUR_CDN_ABSOLUTE_PATH/icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="YOUR_CDN_ABSOLUTE_PATH/favicon.png"/>
<title>flutter_web</title>
<link rel="manifest" href="YOUR_CDN_ABSOLUTE_PATH/manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
var serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="YOUR_CDN_ABSOLUTE_PATH/flutter.js" defer></script>
</head>
再打个包上传到CDN
,嗯一切都正常了~
到这里看上去都完美了,但突然想起来不对啊,我们是区分开发/测试/生产环境的,相应的CDN
路径也是不同的。修改index.html
的方式指定的都是绝对路径,不符合我们的需求啊。经过调研,找到了另一种方式。
4.2.2方案2——--base-href
重新看index.html
的代码,发现最上面注释:
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
大概意思是,我们可以在使用flutter build
打包的使用通过--base-href
参数指定base href
的值。赶紧查看了一下base href
的相关说明:
base标记是一个基链接标记,是一个单标记。用以改变文件中所有连结标记的参数内定值。它只能应用于标记与之间。 你网页上的所有[相对路径]在链接时都将在前面加上基链接指向的地址。
既然如此,我们就试试吧~ 打包命令更新如下:
flutter build web -t lib/main_dev.dart --base-href YOUER_CDN_PATH --no-tree-shake-icons
需要注意的是YOUER_CDN_PATH
并非绝对路径,而是去掉host
的路径。比方你的绝对路径是:
cdn-path.com/your/busine… 那么你的
YOUER_CDN_PATH
应为:
/your/business/path/dev/
再打个包上传到CDN
上,一切真正的完美了~
5.总结
我们利用Flutter
完成了一个web
项目的开发,并且部署到CDN
上。另外在web
端还有一些常见的问题,比方说跨域问题,这些需要和服务端同学共同解决,都是现成的方案。FlutterWeb
其实已经稳定了挺长时间了,但由于使用场景不多所以并没有发展起来。但存在即合理,对于我们客户端开发来说,在拥有了Flutter
的技能后,除去我们所熟悉的Android
和iOS
跨端开发,完全可以拓展自己的业务范畴,进行部分的web
端开发,为自己的团队增加更多的业务可能。