Flutter Web從0到部署上線的實踐

1.前言

首先說明一下,這篇文章是給具備Flutter開發經驗的客戶端同學看的。Flutter的誕生雖然來自GoogleChrome團隊,但大家都知道Flutter最先支持的平臺是AndroidiOS,至今最核心的維護平臺依然是AndroidiOS。由于dart語言的學習成本不高,Flutter的響應式UI與ComposeUISwiftUI都有極大的相似之處,整體的架構思路也更偏向于客戶端的模式,再加上為了實現很多硬件或Native相關的基礎功能也需要專業的客戶端開發知識,所以Flutter更多的是被客戶端開發同學認可并使用(在我們的團隊中,Flutter已經是客戶端開發同學的必備基本技能)。
在此背景下,Flutter最初并不在web端上發力。不過由于Flutter本身就是攜帶了web的基因,在Flutter2發布的同時也發布了web的穩定版。那么它有什么優勢和劣勢呢?

  • 優勢:
    1. 零學習成本:當你已經掌握了Flutter開發能力后,哪怕你對htmlcssJavaScript和主流的前端框架不那么了解,也不影響你開發web應用。
    2. 跨端能力:可將現有Flutter移動應用拓展到web,在多個平臺共享代碼,降低開發成本。
  • 劣勢:
    1. 兼容性問題:使用html模式來進行渲染時,應用的大小相對較小但可能會出現兼容性問題。
    2. 包體積增加:使用canvaskit模式來進行渲染時,雖然性能較好,且可以降低不同瀏覽器渲染效果不一致的風險,但會增加包體積。

分析了優勢劣勢后,我們發現如果單純的做個web端應用,Flutter并沒有優勢,前端開發同學大概也不會使用Flutter進行web開發(確實沒必要,比如包體積增加或有一定的性能損失,還需要學習新語言與開發思路,原生開發不香么),Flutter Web到底有什么用呢?
帶著這樣的想法,在使用Flutter后的很長時間都不曾調研過web端的支持。但隨著業務和內部需求的發展變化,我們有了使用Flutter進行web開發的想法。下面我來說一下使用Flutter Web主要的三個場景。

2.Flutter Web的使用場景

  • 1.客戶端團隊內部的web需求:在后疫情時代降本增效的大背景下,我們會更多的使用自研工具。自研工具的使用和結果展示的可視化通常以網頁的形式展現。客戶端同學使用Flutter Web進行網頁開發學習成本低,完全可以快速的開發網頁(本人在使用Vue框架進行web端開發時感受出客戶端和前端的UI布局思路還是有很大不同的,css很靈活約束性低,這個與客戶端布局的強約束性差異很大,所以對于客戶端開發來說,使用Flutter開發網頁應用時更順手。對于全員掌握Flutter技能的我們團隊來說已經是0成本了)。
  • 2.簡單的web端業務需求web端承載了很多活動需求,這些需求的特點是時效性強,功能較簡單,且不需長期維護。但這些需求經常是在某一時間段大量產生的(比如逢年過節的一些活動或榜單),或突然產生的(比如蹭熱點的即時需求)。這些工作的插入有時會導致一些長期迭代的web端需求需要延期,影響團隊的整體排期。由于這些需求開發難度不大,性能要求不高,不需長期維護(意味著即使團隊里不再有人使用FlutterFlutter Web有一天掛了也沒什么影響),那么就可以讓Flutter開發同學加入進來,平攤了一部分工作,以此來提升整個團隊的效率。
  • 3.客戶端與web端的跨端:隨著Flutter Web趨于穩定,用Flutter實現的App可以低成本的被打包成web版了,畢竟對于用戶來說使用瀏覽器打開個網頁比下載個App成本低多了。這種情況下我們就可以利用Flutter的跨端優勢,節約很多人力資源,避免去重新開發一套web端了。

好的既然有了使用場景,我們就好好來走一下Flutter Web是怎么開發部署上線的流程。

3.Flutter Web工程的創建和業務實現

3.1.創建與運行

我們使用Android Studio作為IDE,以Flutter 3.10.5版本為基礎創建一個Flutter Web工程。
創建一個New Flutter Project,在選擇Platforms的時候只勾選Web,然后直接Create

1688699928797.jpg

然后我們發現在工程目錄里多了個web的文件夾:
1688700083835.jpg

如果想要run起來只需選擇chrome瀏覽器,點擊run就行了:
1688700684131.jpg

然后我們就可以在瀏覽器看到運行結果了,當然我們也可以打開開發者模式方便查看與調試:
截屏2023-07-07 11.33.06.png

這部分跑通后,非常恭喜你可以愉快的用Flutter開發網頁了,接下來我們實現一個業務需求:做一個網頁搜索功能。
1688701369235.jpg

業務功能上的開發實現我就不做贅述了,可以告訴做過Flutter開發的同學,沒什么不同,基礎配置/網絡模塊/數據共享/路由等該怎么封裝就怎么封裝,我也不過是直接拿了之前客戶端Flutter工程相應模塊的代碼,稍作修改而已。UI上的開發也是該怎么布局怎么布局,業務的開發體驗上和客戶端使用Flutter沒什么不同。

3.2.調試

跑通后應該如何調試呢?
如果熟悉瀏覽器開發者模式,可直接使用瀏覽器進行調試,打logdebug都是沒問題的,也可以看到源碼,可以抓包:

1688709563331.jpg

1688709581159.jpg

1688709612744.jpg

當然客戶端同學可能不熟悉瀏覽器開發者模式,也沒關系,利用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

這行命令執行完成后,報錯了,報錯信息如下:


1688715398852.jpg

這是個圖標數據加載問題,我們加上--no-tree-shake-icons即可。執行命令如下:

flutter build web -t lib/main_dev.dart --no-tree-shake-icons

然后我們就會在項目根目錄的build文件夾下找到web這個文件夾,對應的就是web前端打出來的dist文件夾。包含了以下文件:

1688713886562.jpg

編譯產物有了,那么如何部署呢?

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
有如下的參數可傳:

1688716397910.jpg

截屏2023-07-07 15.53.54.png

其中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標記是一個基鏈接標記,是一個單標記。用以改變文件中所有連結標記的參數內定值。它只能應用于標記<head>與</head>之間。
你網頁上的所有[相對路徑]在鏈接時都將在前面加上基鏈接指向的地址。

既然如此,我們就試試吧~
打包命令更新如下:

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端還有一些常見的問題,比方說跨域問題,這些需要和服務端同學共同解決,都是現成的方案。當然如果是在本地調試階段(也僅限于本地調試的情況),你也可以通過以下步驟解決跨域問題:

  • 1.前往flutter\bin\cache文件夾,刪除flutter_tools.stamp文件。
  • 2.前往flutter\packages\flutter_tools\lib\src\web,打開chrome.dart文件。
  • 3.找到'--disable-extensions'這部分,在最下面添加'--disable-web-security',重新build即可。

FlutterWeb雖然已經穩定了一段時間了,但是除非是有明確的跨端需求,并不推薦大家將它用在需要長期迭代,大而重的項目中。不過對于我們客戶端開發來說,在擁有了Flutter的技能后,除去我們所熟悉的AndroidiOS跨端開發,完全可以拓展自己的業務范疇,分攤一些合適的web端項目進行開發,為自己的團隊增加更多的業務可能。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,967評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,273評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,870評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,742評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,527評論 6 407
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,010評論 1 322
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,108評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,250評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,769評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,656評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,853評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,371評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,103評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,472評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,717評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,487評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,815評論 2 372

推薦閱讀更多精彩內容