0%

【文章翻譯】How It’s Made: I/O Photo Booth

【文章內容使用 Gemini 1.5 Pro 自動產生】

深入探討使用 Flutter 和 Firebase 建立 Web 應用程式

我們(Very Good Ventures 的團隊)與 Google 合作,為今年的 Google I/O 帶來了互動式體驗:一個 照片亭!您可以與知名的 Google 吉祥物合影: Flutter 的 Dash、Android Jetpack、Chrome 的 Dino 和 Firebase 的 Sparky,並使用貼紙裝飾照片,包括派對帽、披薩、時髦眼鏡等等。最後,您可以將照片分享到社交媒體,並下載它們以更新您的活動個人檔案照片!

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

我們使用 網頁上的 FlutterFirebase 建立了 I/O 照片亭。由於 Flutter 現在支援 Web 應用程式,我們認為這將是一個很好的方法,讓今年的虛擬 Google I/O 全球的與會者都能輕鬆地使用這個應用程式。Flutter 的 Web 支援消除了必須從應用程式商店安裝應用程式的障礙,也讓您能夠選擇在您偏愛的設備上運行它:行動裝置、桌面或平板電腦。這讓任何有瀏覽器和設備的人都能使用 I/O 照片亭,而無需下載。

儘管 I/O 照片亭被設計為 Web 體驗,但所有程式碼都是使用平台無關的架構撰寫的。當相机插件等元素的原生支援在各自的平台上可用時,同一程式碼可以在所有平台(桌面、Web 和行動裝置)上運行。

使用 Flutter 建立虛擬照片亭

為 Web 建立 Flutter 相機 Plugin

第一個挑戰是為網頁上的 Flutter 建立一個相機 Plugin。最初,我們聯繫了 Baseflow 的團隊,因為他們維護著現有的開源 Flutter 相機 Plugin。雖然 Baseflow 致力於為 iOS 和 Android 建立頂級的相機 Plugin 支援,但我們很樂意使用 聯合 Plugin 方法 並行開發 Plugin 的 Web 支援。我們盡可能地遵循官方 Plugin 介面,以便在 Plugin 準備就緒時能夠將它合併回官方 Plugin 中。

我們識別了兩個對在 Flutter 中建立 I/O 照片亭相機體驗至關重要的 API。

  1. 初始化相機: 應用程式首先需要存取您的設備相機。在桌面裝置上,這可能是網路攝影機,而在行動裝置上,我們選擇的是前置相機。我們還提供了 1080p 的預期解析度,以根據您的設備最大限度地提高相機品質。
  2. 拍攝照片: 我們使用了內建的 HtmlElementView,它使用平台視圖將原生 Web 元素渲染為 Flutter Widget。在此專案中,我們將 VideoElement 渲染為一個原生 HTML 元素,這就是您在拍攝照片之前在螢幕上看到的內容。我們使用 CanvasElement,它被渲染為另一個 HTML 元素。這使我們可以在您點擊拍攝照片按鈕時從媒體流中捕獲圖片。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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,
);
}

相機權限

在我們讓 Flutter 相機 Plugin 在 Web 上運作之後,我們建立了一個抽象層來顯示不同的 UI,具體取決於相機權限。例如,在等待您允許或拒絕瀏覽器使用相機的權限,或者沒有可用的相機可以存取時,我們可以顯示一個說明訊息。

1
2
3
4
5
6
7
8
9
Camera(
controller: _controller,
placeholder: (_) => const SizedBox(),
preview: (context, preview) => PhotoboothPreview(
preview: preview,
onSnapPressed: _onSnapPressed,
),
error: (context, error) => PhotoboothError(error: error),
)

在此抽象層中,placeholder 返回初始 UI,因為應用程式正在等待您授予對相機的權限。preview 在您授予權限後返回 UI,並提供相機的即時視訊流。error builder 允許我們在發生錯誤時捕獲錯誤,並渲染對應的錯誤訊息。

鏡像照片

我們的下一個挑戰是鏡像照片。如果我們直接使用相機拍攝照片,您看到的將不是您在鏡子中看到的那樣。 一些設備有設定可以精確處理這一點,因此,如果您使用前置相機拍攝照片,您會在拍攝時看到鏡像版本。

在我們的第一種方法中,我們嘗試捕獲預設的相機視圖,然後在 y 軸上應用 180 度變換。這似乎有效,但後來我們遇到了 一個問題,Flutter 有時會覆蓋變換,導致視訊恢復為未鏡像的版本。

在 Flutter 團隊的幫助下,我們透過將 VideoElement 包裹在 DivElement 中並更新 VideoElement 以填充 DivElement 的寬度和高度解決了這個問題。這使我們能夠將鏡像應用到 video element 上,而不會讓 Flutter 覆蓋變換效果,因為父元素是 div。這種方法為我們提供了所需的鏡像相機視圖!

Un-mirrored view
Mirrored view

堅持嚴格的縱橫比

對於大型螢幕,強制執行 4:3 的嚴格縱橫比,對於小型螢幕,強制執行 3:4 的縱橫比比想像的要難!在整個 Web 應用程式的設計中強制執行這個比率,以及確保在您將照片分享到社交媒體時,照片看起來完美無瑕,這一點非常重要。這是一項具有挑戰性的任務,因為設備上內建相機的縱橫比差異很大。

為了強制執行嚴格的縱橫比,應用程式首先使用 JavaScript getUserMedia API 請求設備相機所能提供的最大解析度。然後,我們將此 API 饋送到 VideoElement 流中,這就是您在相機視圖中看到的內容(當然是鏡像的)。我們還應用了一個 object-fit CSS 屬性,以確保 video element 覆蓋其父容器。這使用 Flutter 中內建的 AspectRatio Widget 設定縱橫比。結果,相機不會對顯示的縱橫比做出任何假設;它始終返回支援的最大解析度,然後符合 Flutter 提供的約束(在本例中為 4:3 或 3:4)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 朋友合影並添加道具。您可以將朋友和道具拖放到照片中,以及調整它們的大小和旋轉它們,直到得到一張您喜歡的圖片。您會注意到,在將朋友添加到螢幕時,您可以拖動和調整它們的大小。朋友們也有動畫 - 精靈表來實現這個效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
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),
),

為了調整物件的大小,我們建立了一個可拖動、可調整大小的 Widget,它可以包裝在任何 Flutter Widget 周圍,在本例中是朋友和道具。此 Widget 使用 LayoutBuilder 來根據視窗的約束處理 Widget 的縮放。在內部,我們使用 GestureDetectors 來接入 onScaleStartonScaleUpdateonScaleEnd。這些回調提供關於手勢的詳細資訊,這些資訊需要反映您對朋友和道具所做的更改。

Transform Widget 和 4D 矩陣變換根據您使用多個 GestureDetectors 報告的各種手勢,處理朋友和道具的縮放和旋轉。

1
2
3
4
5
6
7
Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..scale(scale)
..rotateZ(angle),
child: _DraggablePoint(...),
)

最後,我們建立了一個單獨的套件來確定您的設備是否支援觸控輸入。可拖動、可調整大小的 Widget 根據觸控功能進行適應。在支援觸控輸入的設備上,可調整大小的錨點和旋轉圖示不可見,因為您可以捏合和拖動來直接操作圖片,而在沒有觸控輸入的設備(例如您的桌面設備)上,會添加錨點和旋轉圖示以適應點擊和拖動。

優先考慮網頁上的 Flutter

使用 Flutter 進行以 Web 為中心的開發

這是我們使用 Flutter 建立的第一個 Web 專案,它與行動應用程式具有不同的特性。

我們需要確保應用程式對任何設備上的任何瀏覽器都 響應式和自適應。也就是說,我們必須確保 I/O 照片亭會根據瀏覽器大小進行縮放,並且能夠處理行動裝置和 Web 輸入。我們通過以下幾種方式實現了這一點:

  • 響應式調整大小: 您應該能夠將瀏覽器調整到所需的尺寸,並且 UI 應相應地調整。如果您的瀏覽器視窗處於縱向模式,則相機將從具有 4:3 縱橫比的橫向視圖翻轉到具有 3:4 縱橫比的縱向視圖。
  • 響應式設計: 桌面瀏覽器的設計將 Dash、Android Jetpack、Dino 和 Sparky 顯示在右側,而行動裝置則顯示在頂部。桌面設計還使用相機右側的抽屜,而行動裝置則使用 BottomSheet 類別。
  • 自適應輸入: 如果您從桌面設備存取 I/O 照片亭,則滑鼠點擊被視為輸入,如果您使用的是平板電腦或手機,則使用觸控輸入。這在調整貼紙大小並將它們放置在照片中時尤其重要。行動裝置支援捏合和拖動,而桌面裝置支援點擊和拖動。

可擴展架構

我們還將建立可擴展行動應用程式的方法應用於此應用程式。我們從一個堅實的基礎開始 I/O 照片亭,包括健全的空安全、國際化,以及從第一次提交開始的 100% 單元測試和 Widget 測試覆蓋率。我們使用 flutter_bloc 進行狀態管理,因為它允許輕鬆地測試業務邏輯並觀察應用程式中的所有狀態更改。這對開發人員日誌和追蹤性特別有用,因為我們可以精確地看到從一個狀態到另一個狀態的變化,並更快地隔離問題。

我們還實作了功能驅動的單一儲存庫結構。例如,貼紙、分享和實時相機預覽是在它們自己的資料夾中實作的,每個資料夾包含其各自的 UI 組件和業務邏輯。它們與外部相依項整合,例如相機 Plugin,這些相依項存在於 packages 子目錄中。這種架構使我們的團隊能夠並行處理多個功能,而不會中斷他人的工作,最大限度地減少了合併衝突,並使我們能夠有效地重複使用程式碼。例如,UI 組件函式庫是一個單獨的套件,稱為 photobooth_ui,相機 Plugin 也是單獨的。

透過將組件分成獨立的套件,我們可以提取和開源不與此特定專案相關聯的單獨組件。即使是 UI 組件函式庫套件也可以開源給 Flutter 社群,類似於 MaterialCupertino 組件函式庫。

Firebase + Flutter = 完美的搭配

Firebase 驗證、儲存、託管等等

照片亭利用 Firebase 生態系統進行各種後端整合。firebase_auth 包 支援在應用程式啟動時匿名登入使用者。每個工作階段都使用 Firebase Auth 建立一個具有唯一 ID 的匿名使用者。

這在您到達分享頁面時會發揮作用。您可以下載照片以儲存為您的個人檔案照片,或者您可以直接分享到社交媒體。如果您下載了照片,它將儲存在您的設備上。如果您分享了照片,我們會使用 firebase_storage 包 將照片儲存在 Firebase 中,以便我們以後能夠檢索它,為社交媒體貼文填充內容。

我們在 Firebase 儲存桶上定義了 Firebase 安全規則,以使照片在建立後不可變。這可以防止其他使用者修改或刪除此儲存桶中的照片。此外,我們使用 Google Cloud 提供的 Object Lifecycle Management,定義一個規則,刪除所有 30 天前的物件,但您可以按照應用程式中概述的說明請求更快地刪除您的照片。

此應用程式還使用 Firebase 托管 來快速且安全地託管 Web 應用程式。action-hosting-deploy GitHub Action 允許我們根據目標分支自動將部署到 Firebase 托管。當我們將更改合併到 main 分支時,action 會觸發一個工作流程,將應用程式的開發風味構建並部署到 Firebase 托管。同樣,當我們將更改合併到 release 分支時,action 會觸發生產部署。GitHub Action 與 Firebase 托管的組合使我們的團隊能夠快速迭代,並始終擁有最新構建的預覽。

最後,我們使用 Firebase Performance Monitoring 監控關鍵的 Web 效能指標。

使用 Cloud Functions 與社交媒體互動

在生成您的社交媒體貼文之前,我們首先要確保照片看起來完美無瑕。最終的圖片包括一個紀念 I/O 照片亭的精美框架,並裁剪為 4:3 或 3:4 的縱橫比,使其在社交媒體貼文中看起來很棒。

我們使用 OffscreenCanvas API 或 CanvasElement 作為 polyfill 來組合原始照片和包含您的朋友和道具的圖層,並生成一張您可以下載的單張圖片。image_compositor 包 處理此處理步驟。

然後,我們利用 Firebase 強大的 Cloud Functions 來幫助將照片分享到社交媒體。當您點擊分享按鈕時,您將被帶到所選平台上的新標籤頁,其中包含預先填寫的貼文。貼文包含一個 URL,該 URL 會重新導向到我們編寫的 Cloud Function。當瀏覽器分析 URL 時,它會檢測 Cloud Function 生成的動態元資訊。此資訊允許瀏覽器在您的社交媒體貼文中顯示照片的精美預覽圖片,以及指向分享頁面的連結,您的關注者可以在其中查看照片並導航回到 I/O 照片亭應用程式以拍攝自己的照片。

1
2
3
4
5
6
7
8
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 套件的工具和不斷發展的生態系統,包括 Firebase 工具套件,讓 I/O 照片亭成為可能。

Very Good Ventures 團隊,他們參與了 I/O 照片亭的開發

我們已將所有程式碼開源。在 GitHub 上查看 photo_booth 專案,並在 Facebook 和 Twitter 上使用 #IOPhotoBooth 向我們展示您的照片!


製作過程:I/O 照片亭 最初發佈在 Flutter 上的 Medium,人們在那裡透過突出顯示和回應這個故事來繼續討論。