Flutter Web从0到部署上线的实践

1.前言

首先说明一下,这篇文章是给客户端开发同学看的(有Flutter基础最好)。Flutter的诞生虽然来自GoogleChrome团队,但大家都知道Flutter最先支持的平台是AndroidiOS,至今最核心的维护平台依然是AndroidiOSdart语言的学习成本不高,Flutter的响应式UI与ComposeSwiftUI都有极大的相似之处,整体的架构思路也更偏向于客户端的模式,再加上为了实现很多硬件或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端需求需要延期,影响团队的整体排期。由于这些需求开发难度不大,性能要求不高,不需长期维护(意味着即使团队里不再有人使用FlutterFlutter 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.调试

跑通后应该如何调试呢?
如果熟悉浏览器开发者模式,可直接使用浏览器进行调试,打logdebug都是没问题的,也可以看到源码,可以抓包:

当然客户端同学可能不熟悉浏览器开发者模式,也没关系,利用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.浏览器运行环境区分

客户端通常需要区分的是AndroidiOS这两个不同的运行环境,而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,其实现对应AndroidSharedPreferencesiOSNSUserDefaults。而在进行web开发的时候,我们知道如需在本地序列化一些数据的话,可以使用LocalStorage。其实Fluttershared_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.部署

官方给了如下的部署方式:
https://flutter.cn/docs/deployment/web#deploying-to-the-web
看了官方文档后我发现,这三种部署方式并不适用于我们的项目。由于CDN具有提高网站性能和用户体验,减轻原始服务器的负载等优势,目前我们团队已经搭建了CDN部署平台。既然如此,我们的部署方案也需要往这方面靠。

4.2.1.方案1——修改index.html

我先来简单说明一下FlutterWeb编译产物,重点有两个:flutter.jsmain.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应用初始化的链接:
https://flutter.cn/docs/platform-integration/web/initialization
有如下的参数可传:

其中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.pngfavicon.pngmanifest.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的路径。比方你的绝对路径是:

https://cdn-path.com/your/business/path/dev/
那么你的YOUER_CDN_PATH应为:

/your/business/path/dev/

再打个包上传到CDN上,一切真正的完美了~

5.总结

我们利用Flutter完成了一个web项目的开发,并且部署到CDN上。另外在web端还有一些常见的问题,比方说跨域问题,这些需要和服务端同学共同解决,都是现成的方案。FlutterWeb其实已经稳定了挺长时间了,但由于使用场景不多所以并没有发展起来。但存在即合理,对于我们客户端开发来说,在拥有了Flutter的技能后,除去我们所熟悉的AndroidiOS跨端开发,完全可以拓展自己的业务范畴,进行部分的web端开发,为自己的团队增加更多的业务可能。

最后

如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。

如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《Android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
img
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。

欢迎大家一键三连支持,若需要文中资料,直接扫描文末CSDN官方认证微信卡片免费领取↓↓↓

PS:群里还设有ChatGPT机器人,可以解答大家在工作上或者是技术上的问题
图片

猜你喜欢

转载自blog.csdn.net/weixin_43440181/article/details/131834776