用 Flutter 和 Firebase 輕鬆構建 Web 應用

做者 / Very Good Ventures Teamhtml

咱們 (Very Good Ventures 團隊) 與 Google 合做,在今年的 Google I/O 大會上推出了 照相亭互動體驗 (I/O Photo Booth)。您能夠與深受喜好的 Google 吉祥物合影: Flutter 的 Dash、Android Jetpack、Chrome 的 Dino 和 Firebase 的 Sparky,並用各類貼紙裝飾照片,包括派對帽、披薩、時髦眼鏡等。固然,您也能夠經過社交媒體下載並分享,或者用做您的我的頭像!git

△ Flutter 的 Dash、Firebase 的 Sparky、Android Jetpack 和 Chrome 的 Dino

△ Flutter 的 Dash、Firebase 的 Sparky、Android Jetpack 和 Chrome 的 Dinogithub

咱們使用 Flutter webFirebase 構建了 I/O 照相亭。由於 Flutter 如今支持打造 Web 應用,咱們認爲這將是一個很好的方式,可讓世界各地的與會者在今年的線上 Google I/O 大會上輕鬆訪問這一應用。Flutter web 消除了必須經過應用商店安裝應用的障礙,同時用戶還能夠靈活選擇運行應用的設備: 移動設備、桌面設備或平板電腦。所以,只要能使用瀏覽器,用戶即可無需下載直接使用 I/O 照相亭。web

儘管 I/O 照相亭旨在提供 Web 體驗,但全部代碼均採用與平臺無關的架構編寫而成。當相機插件等原生功能的支持在各個平臺就緒後,這套代碼便可在全部平臺 (桌面、Web 和移動設備) 通用。canvas

使用 Flutter 構建虛擬照相亭

構建 Web 版 Flutter 相機插件後端

第一個挑戰即在 Web 上爲 Flutter 構建攝像頭插件。最初,咱們聯繫了 Baseflow 團隊,由於他們負責維護現有的開源 Flutter 攝像頭插件。Baseflow 致力於構建適用於 iOS 和 Android 的一流攝像頭插件支持,咱們也很樂於與其合做,使用 聯合插件 方法爲插件提供 Web 支持。咱們儘量符合官方插件接口,以便咱們能夠在準備就緒時將其合併回官方插件。api

咱們肯定了兩個對於在 Flutter 中構建 I/O 照相亭相機體驗相當重要的 API。瀏覽器

  • 初始化攝像頭: 應用首先須要訪問您的設備攝像頭。對於桌面設備,訪問的多是網絡攝像頭,而對於移動設備,咱們選擇了訪問前置攝像頭。咱們還提供了 1080p 的預期分辨率,以根據用戶設備類型充分提升拍攝質量。
  • 拍照: 咱們使用了內置的 HtmlElementView,該控件使用平臺視圖將原生 Web 元素渲染爲 Flutter widget。在此項目中,咱們將 VideoElement 渲染爲原生 HTML 元素,這即是您在拍照前會在屏幕上看到的內容。咱們還使用了一個 CanvasElement,用於在您點擊拍照按鈕時從媒體流中捕獲圖像。
Future<CameraImage> takePicture() async {
 final videoWidth = videoElement.videoWidth;
 final videoHeight = videoElement.videoHeight;
 final canvas = html.CanvasElement(
   width: videoWidth,
   height: videoHeight,
 );
 canvas.context2D
   ..translate(videoWidth, 0)
   ..scale(-1, 1)
   ..drawImageScaled(videoElement, 0, 0, videoWidth, videoHeight);
 final blob = await canvas.toBlob();
 return CameraImage(
   data: html.Url.createObjectUrl(blob),
   width: videoWidth,
   height: videoHeight,
 );
}

攝像頭權限安全

在 Web 上完成 Flutter 攝像頭插件後,咱們建立了一個抽象佈局,以根據相機權限顯示不一樣的界面。例如,在等待您容許或拒絕使用瀏覽器攝像頭時,或者若是沒有可供訪問的攝像頭時,咱們能夠顯示一條說明性消息。網絡

Camera(
 controller: _controller,
 placeholder: (_) => const SizedBox(),
 preview: (context, preview) => PhotoboothPreview(
   preview: preview,
   onSnapPressed: _onSnapPressed,
 ),
 error: (context, error) => PhotoboothError(error: error),
)

在上面的抽象佈局中,placeholder 會在應用等待您授予攝像頭權限時返回初始界面。Preview 則會在您授予權限後返回真實的界面,並顯示攝像頭的實時視頻流。結尾的 Error 構造語句則能夠在錯誤發生時捕獲錯誤並顯示相應的消息。

生成鏡像照片

咱們的下一個挑戰是生成鏡像照片。若是咱們照原樣使用攝像頭拍攝的照片,那麼您看到的內容將與您在照鏡子時所看到的內容不同。某些設備會提供專門處理這一問題的設置選項,因此,若是您用前置攝像頭拍照,您看到的實際上是照片的鏡像版本。

在咱們的第一種方法中,咱們嘗試捕捉默認的攝像頭視圖,而後圍繞 y 軸對其進行 180 度翻轉。這種方法彷佛有效,但後來咱們遇到了 一個問題,即 Flutter 偶爾會覆蓋這個翻轉,致使視頻恢復到未鏡像的版本。

在 Flutter 團隊的幫助下,咱們將 VideoElement 放在 DivElement 中,並更新 VideoElement 以填充 DivElement 的寬度和高度,解決了這個問題。這樣一來,咱們可以爲視頻元素應用鏡像,同時由於父元素是 div,因此不會被 Flutter 覆蓋翻轉效果。如此一來,咱們便得到了所需的鏡像攝像頭視圖!

△ 未鏡像的視圖

△ 未鏡像的視圖

△ 鏡像視圖

△ 鏡像視圖

保持寬高比

在大屏幕上保持 4:3 寬高比,以及在小屏幕上保持 3:4 寬高比,這個操做起來比看起來更難!保持寬高比很是重要,既要符合 Web 應用的總體設計,又要確保在社交媒體上分享照片時,令其中的像素呈現出清晰的本色效果。這是一項具備挑戰性的任務,由於不一樣設備上內置攝像頭的寬高比差別很大。

爲了強制保持寬高比,應用首先使用 JavaScript getUserMedia API 從設備攝像頭請求可能的最大分辨率。隨後,咱們將此 API 傳遞到 VideoElement 流中,這即是您在攝像頭視圖中看到的內容 (固然是已鏡像的版本)。咱們還應用了 object-fit CSS 屬性來確保視頻元素能蓋住其父級容器。咱們使用 Flutter 自帶的 AspectRatio widget 來設置寬高比。所以,攝像頭不會對顯示的寬高比作出任何假設;它始終返回支持的最大分辨率,而後遵照 Flutter 提供的約束條件 (在本例中爲 4:3 或 3:4)。

final orientation = MediaQuery.of(context).orientation;
final aspectRatio = orientation == Orientation.portrait
   ? PhotoboothAspectRatio.portrait
   : PhotoboothAspectRatio.landscape;
return Scaffold(
 body: _PhotoboothBackground(
   aspectRatio: aspectRatio,
   child: Camera(
     controller: _controller,
     placeholder: (_) => const SizedBox(),
     preview: (context, preview) => PhotoboothPreview(
       preview: preview,
       onSnapPressed: () => _onSnapPressed(
         aspectRatio: aspectRatio,
       ),
     ),
     error: (context, error) => PhotoboothError(error: error),
   ),
 ),
);

經過拖放添加貼紙

I/O 照相亭的一大重要體驗在於與您最喜歡的 Google 吉祥物合影並添加道具。您可以在照片中拖放吉祥物和道具,以及調整大小和旋轉,直到得到您喜歡的圖像。您也會發現,在將吉祥物添加到屏幕上時,您能夠拖動它們並調整其大小。吉祥物們仍是有動畫效果的——這種效果由 sprite sheet 來實現。

for (final character in state.characters)
 DraggableResizable(   
   canTransform: character.id == state.selectedAssetId,
   onUpdate: (update) {
     context.read<PhotoboothBloc>().add(
       PhotoCharacterDragged(
         character: character, 
         update: update,
       ),
     );
   },
   child: _AnimatedCharacter(name: character.asset.name),
 ),

爲調整對象的大小,咱們建立了可拖動、可調整大小且能夠容納其餘 Flutter widget 的 widget,在本例中,即爲吉祥物和道具。該 widget 會使用 LayoutBuilder,根據窗口的約束條件來處理 widget 的縮放。在內部,咱們使用 GestureDetector 以掛接到 onScaleStart、onScaleUpdate 和 onScaleEnd 事件。這些回調提供了必要的手勢詳細信息,以反映用戶對吉祥物和道具的操做。

經過多個 GestureDetector 回饋的數據,Transform widget 和 4D 矩陣變換便可根據用戶所作的各類手勢處理縮放,以及旋轉吉祥物和道具。

Transform(
 alignment: Alignment.center,
 transform: Matrix4.identity()
   ..scale(scale)
   ..rotateZ(angle),
 child: _DraggablePoint(...),
)

最後,咱們建立了單獨的 package 來肯定您的設備是否支持觸摸輸入。可拖動、可調整大小的 widget 會根據觸摸功能作出相應的調整。在具備觸摸輸入功能的設備上,您並不能看到調整大小的錨點和旋轉圖標,由於您能夠經過雙指張合和平移手勢來直接操縱圖像;而在不支持觸摸輸入的設備 (例如您的桌面設備) 上,咱們則添加了錨點和旋轉圖標,以適應單擊和拖動操做。

針對 Web 優化 Flutter

使用 Flutter 針對 Web 進行開發

這是咱們使用 Flutter 構建的首批純 Web 項目之一,其與移動應用具備不一樣的特徵。

咱們須要確保該應用對任何設備上的任何瀏覽器都具備 響應性和自適應性。也就是說,咱們必須確保 I/O 照相亭能夠根據瀏覽器大小進行縮放,而且可以處理移動設備和 Web 端的輸入。咱們經過如下幾種方式作到了這一點:

  • 響應式調整大小: 用戶可以隨意調整瀏覽器的大小,而且界面能作出響應。若是您的瀏覽器窗口爲縱向,則相機會從 4:3 的橫向視圖翻轉爲 3:4 的縱向視圖。
  • 響應式設計: 針對桌面瀏覽器,咱們設計爲在右側顯示 Dash、Android Jetpack、Dino 和 Sparky,而對於移動設備,這些要素則會顯示在頂部。咱們針對桌面設備,在攝像頭右側設計使用了抽屜式導航欄,而對於移動設備,則使用了 BottomSheet 類。
  • 自適應輸入: 若是您使用桌面設備訪問 I/O 照相亭,則鼠標點擊操做將被視爲輸入,若是您使用的是平板電腦或手機,則使用觸摸輸入。在調整貼紙大小並將其放置在照片中時,這一點尤爲重要。移動設備支持雙指張合和平移手勢,桌面設備支持點擊和拖動操做。

可擴展架構

咱們還爲此應用構建了可擴展的移動應用。咱們的 I/O 照相亭在建立之初就具備穩固的基礎,包括良好的空安全性、國際化,以及從第一次提交開始就作到的 100% 單元和 widget 測試覆蓋率。咱們使用 flutter_bloc 進行狀態管理,由於它支持咱們輕鬆測試業務邏輯,並觀察應用中的全部狀態變化。這對於生成開發者日誌和確保可追溯性特別有用,由於咱們能夠準確地觀察到從一個狀態到另外一個狀態的變化,並更快地隔離問題。

咱們還實現了由功能驅動的單一代碼庫結構。例如,貼紙、分享和實時相機預覽,均在各自的文件夾中獲得實現,其中每一個文件夾包含其各自的界面組件和業務邏輯。這些功能也會用到外部依賴,例如位於 package 子目錄中的相機插件。利用這種架構,咱們的團隊可以在互不干擾的狀況下並行處理多個功能,最大限度地減小合併衝突,並有效地重用代碼。例如,界面組件庫是名爲 photobooth_ui 的單獨 package,相機插件也是單獨的。

經過將組件分紅獨立的 package,咱們能夠提取未與此特定項目綁定的各個組件,並將其開源。與 MaterialCupertino 組件庫相似,咱們甚至能夠將界面組件庫 package 作開源處理,以供 Flutter 社區使用。

Firebase + Flutter = 完美組合

Firebase Auth、存儲、託管等

照相亭利用 Firebase 生態系統進行各類後端集成。firebase_auth package 支持用戶在應用啓動後當即匿名登陸。每一個會話都使用 Firebase Auth 建立具備惟一 ID 的匿名用戶。

當您來到共享頁面時,此設置即會開始發揮做用。您能夠下載照片以保存爲我的頭像,也能夠直接將其分享到社交媒體。若是您下載照片,則該照片將存儲在您的本地設備上。若是您分享照片,咱們會使用 firebase_storage package 將照片存儲在 Firebase 中,以便稍後檢索並生成帖子經過社交媒體發佈。

咱們在 Firebase 的存儲分區上定義了 Firebase 安全規則,確保照片在建立後不可變。這能夠防止其餘用戶修改或刪除存儲分區中的照片。此外,咱們使用 Google Cloud 提供的 對象生命週期管理,定義了一個刪除 30 天前全部對象的規則,但您能夠按照應用中列出的說明請求儘快刪除您的照片。

此應用還使用 Firebase Hosting 快速安全地進行託管。咱們能夠藉助 action-hosting-deploy GitHub Action,根據目標分支,將應用自動部署到 Firebase Hosting。當咱們將變動合併到主分支時,該操做會觸發一個工做流,用於構建應用的特定開發版本,並將其部署到 Firebase Hosting。一樣,當咱們將變動合併到發佈分支時,該操做也會觸發部署生產版本。經過結合使用 GitHub Action 與 Firebase Hosting,咱們的團隊可以快速迭代,並始終獲得最新版本的預覽。

最後,咱們使用 Firebase 性能監測 來監控主要的 Web 性能指標。

使用 Cloud Functions 進行社交

在生成您的社交帖子以前,咱們首先會確保照片內容是像素級完美的。最終圖像包含漂亮的邊框,以呈現 I/O 照相亭特點,並按 4:3 或 3:4 的寬高比進行裁剪,以便在社交帖子上呈現出色的效果。

咱們使用 OffscreenCanvas API 或 CanvasElement 來合成原始照片、吉祥物和道具的圖層,並生成您能夠下載的單個圖像。這個處理步驟由 image_compositor package 負責執行。

而後,咱們利用 Firebase 強大的 Cloud Functions,來將照片分享到社交媒體。當您點擊分享按鈕時,系統會帶您前往新標籤頁,並在所選的社交平臺上自動生成待發布的帖子。該帖子還包含一個連接,鏈接到咱們編寫的 Cloud Functions。瀏覽器在分析網址時,會檢測 Cloud Functions 生成的動態元數據,並據此在您的社交帖子中顯示照片的精美預覽,以及一個指向分享頁面的連接,您的粉絲們能夠在該頁面上查看照片,並導航回 I/O 照相亭應用,以獲取他們本身的照片。

function renderSharePage(imageFileName: string, baseUrl: string): string {
 const context = Object.assign({}, BaseHTMLContext, {
   appUrl: baseUrl,
   shareUrl: `${baseUrl}/share/${imageFileName}`,
   shareImageUrl: bucketPathForFile(`${UPLOAD_PATH}/${imageFileName}`),
 });
 return renderTemplate(shareTmpl, context);
}

成品以下所示:

有關如何在 Flutter 項目中使用 Firebase 的更多信息,請查看 此 Codelab

最終成果

本項目詳細地示範瞭如何針對 Web 來構建應用的方法。令咱們感到驚喜的是,與使用 Flutter 構建移動應用的體驗相比,這個 Web 應用的構建工做流與之很是類似。咱們必須考慮窗口大小、自適應、觸摸與鼠標輸入、圖像加載時間、瀏覽器兼容性等元素,以及在構建 Web 應用時所必需考慮的其餘全部因素。可是,咱們仍然可使用相同的模式、架構和編碼標準來編寫 Flutter 代碼,這讓咱們在構建 Web 應用時感到很是自在。Flutter package 提供的工具和不斷髮展的生態系統,包括 Firebase 工具套件,幫助咱們實現了 I/O 照相亭。

△ 打造 I/O 照相亭的 Very Good Ventures 團隊

△ 打造 I/O 照相亭的 Very Good Ventures 團隊

咱們已經開放了全部源代碼,歡迎你們前往 GitHub 查看 photo_booth 項目,也別忘了多多拍照秀出來哦!