Zenn
🪁

Flutter WebのWasmバイナリをFirebase Hostingで動かしてみた

に公開
3
CHANGELOG
  • 2025.01.27
    • コメント 頂いた HTTP レスポンスヘッダーの COEP について セクションを追記
    • その他、HTML レンダラーがドキュメントから正式に削除され指定方法も変わったため --web-renderer のオプション指定の削除および関連箇所の記載を更新
    • 当時の Wasm ビルドでの出力先は /build/web_wasm であったが、 /build/web に統一されたため関連箇所の更新
  • 2024.05.14
    • Google I/O 2024にて Flutter stable 3.22リリースとともに WasmサポートもStable となったため関連箇所を修正

背景

2023年5月10日の Google I/O 2023 にて、Flutter 3.10/Dart 3 の正式リリースが発表されたのと同時に、Flutter の Web Assembly(以下、Wasm)対応もプレビュー版ですが公開されました。
Flutter の Wasm に関するドキュメントは「Support for WebAssembly (Wasm)」というタイトルで、今年の3月辺りから少しずつ更新されておりましたが、今回からビルド方法などの手順が詳細に記載されるようになりました。

https://docs.flutter.dev/platform-integration/web/wasm

こちらは Flutter 3.10 における Flutter Web のアップデート内容の紹介ですが、Wasm は 「What's next for Web」 というセクションで紹介されています。現在の Flutter Web サポートの全体像が見えるので非常にオススメな動画です。
https://www.youtube.com/watch?v=PY42FysQTgw&t=191s

今回は、この Flutter Web の Wasm ビルドを Firebase Hosting 上で動かしてみて、CanvasKit や HTML レンダラーの従来形式と比較しながら、その特徴について気づいたことなど記載します。

Dart と Wasm

これまで、Wasm バイナリにコンパイルできる言語は C や Rust のようなガベージコレクション(GC)を利用しない低級言語のみであり、GC を要する言語についても プロポーザル などで標準化に向けた仕様策定が進められていました。

その後、今年1月末に開催された Flutter Forward 2023 にて、Flutter/Dart の Wasm サポートが発表され、GC を必要とする言語での初のサポートとなりました。これには Chromium V8 の開発チームとの連携などが不可欠で、Chrome の開発チームと協力して開発したことでいち早いサポートができたのだと思っています。現在、この WasmGC は Google Chrome や Firefox で安定版として提供されており、Safariも今年の4月に開発着手 しており、各ブラウザが出揃ってきた感が伺えます。

Flutter/Dart/WasmGC の詳細について今回は割愛しますが、今年3月に開催された WASM I/O で説明されているので参考にしてみると良いと思います(Flutter, Dart, Wasm-GC - Wasm I/O Conference - March 23, 2023 - [shared publicly] - Google スライド)。
https://www.youtube.com/watch?v=Nkjc9r0WDNo

なにが嬉しいのか

Wasm 対応する一般的なメリットは処理の高速化です。本来 JavaScript で処理しないといけなかったものがバイナリで処理されるため、それだけでもイメージしやすいと思います。身近な例では、Google Meetsの背景ぼかし処理Figmaの読み込み速度3倍 などが挙げられます。

最近公開された Tim 氏によるこちらの記事においても、「初期のベンチマークでは、実行速度が 3 倍ほど向上」と記載されており、高い性能で処理できていることが伺えます。

In some early benchmarks, we’ve seen a boost of 3× for execution speeds, which translates into yet richer web-based experiences. And Wasm couples this with easier integration with code written in other languages like Kotlin and C++.

またユーザビリティ観点では、Wasm はネイティブコードに近い性能なよりリッチで快適なウェブベースの体験を提供してくれると記載されており、これまで Flutter のビルド対象となる Platform の中で個人的に唯一微妙だなと感じていた Web アプリが、Wasm によって一気に普及しそうだなと思っています。

“WebAssembly excites us with its potential to bring the performance of native code to the web.”

https://medium.com/flutter/racing-forward-at-i-o-2023-with-flutter-and-dart-df2a8fa841ab#4121

Wasm にビルドする

ビルドは至って簡単です。以上です。

flutter build web --wasm

ただし、実行するには少し制約があります。
端的に言うと「最新の master チャンネル」と「最新の Google Chrome ブラウザ」が必要です。

注意事項

  • master チャンネルのみ
  • Google Chrome(Chromium ベースのブラウザ)のみで、Version 112 以降のみが対応
  • Google Chrome ブラウザの以下 2 つの flags(chrome://flags/)の有効化が必要
    • enable-experimental-webassembly-stack-switching
    • enable-webassembly-garbage-collection
  • サポートはビルドのみ(flutter run や DevTools は非対応)
  • ブラウザと JavaScript API をターゲットにするため、dart:htmlpackage:js を使用している場合はコンパイルできない

私はこちらの環境で実行しました。

❯ fvm flutter --version
Flutter 3.8.0-16.0.pre.18 • channel master • https://github.com/flutter/flutter.git
Framework • revision 0c1ed75915 (3 months ago)2023-02-23 10:39:41 +0000
Engine • revision d4bae2887e
Tools • Dart 3.0.0 (build 3.0.0-266.0.dev) • DevTools 2.22.1

正常にビルド完了し、後述の Firebase Hostingへデプロイ などを参考にホスティングした後、Developer Console の Network で確認すると、JavaScript に加え Wasm バイナリが実行されていることが確認できます。

WasmビルドされたFlutter Web

ビルド方法の比較

今回の Wasm 対応が追加されることで、Flutter Web のビルドモードが DefaultWebAssemblyの2種類となります。Default は従来でいうところの CanvasKit(--web-renderer canvaskit と指定していたビルド方法)と一緒です。HTML レンダラーが正式になくなったことで CanvasKit でのビルドをデフォルトの位置づけにしたようです。

Build options Compiled Renderer
--wasm Wasm、JavaScript skwasm or canvaskit
指定なし JavaScript canvaskit

ちなみに、--wasm が Wasm だけでなく JavaScript にもコンパイルされるのは、Wasm では DOM を直接触れず JavaScript を介して利用するなど、Web 周辺の技術を扱うには JavaScript が必要なためです。Wasm 自体も JavaScript を代替するものではなく、共存していく形が業界標準のはずです。

下記の図が出力結果の比較です。差分はごく僅かで、以下の 2 ファイルが Wasm ビルド時には生成されていることがわかります。

canvaskit skwasm

やや理解に自信がない部分もありますが、それぞれの役割はざっと以下だと思います。

  • main.dart.wasm
    • ロジックなどのアプリケーション処理
    • 従来のビルド形式では main.dart.js で実装されていた処理
  • main.dart.mjs
    • main.dart.wasm からコンパイルした WebAssembly.Module を実行できるようにしているランタイム(?)
    • 内部で dart2wasm を使っているぽい

Wasm ファイルが読み込まれるまで

前述の通り、Wasm の違いは .mjs.wasm ファイルが存在している程度です。他にも canvaskitskwasm 系のファイルがなくなっていりしていますが今回は割愛します。
従来のビルド形式同様、index.html から flutter.js を読み込み、FlutterEntrypointLoader クラスで main.dart.js をエントリーポイントとして読み込みます。ここまでは一緒です。flutter.js の initialize については Customizing web app initialization | Flutter が参考になります。

flutter.js
async loadEntrypoint(options) {
  const { entrypointUrl = `${baseUri}main.dart.js`, onEntrypointLoaded } =
    options || {};
  return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded);
}

大きな違いは main.dart.js の中身です。従来のビルド形式の場合は minified された JavaScript としてそのまま実行されていましたが、Wasm ビルドの場合は以下のコードとなっており、内部で .mjs の import と .wasm のコンパイルをしていることがわかります。

main.dart.js
// 読みやすさのため、try-catchのエラーハンドリング部分を削っています
(async function () {
  let dart2wasm_runtime;
  let moduleInstance;
  // Wasmをストリームして`WebAssembly.Module`へコンパイル
  // ref. https://developer.mozilla.org/ja/docs/WebAssembly/JavaScript_interface/compileStreaming
  const dartModulePromise = WebAssembly.compileStreaming(fetch("main.dart.wasm"));
  dart2wasm_runtime = await import('./main.dart.mjs');
  moduleInstance = await dart2wasm_runtime.instantiate(dartModulePromise, {});

  if (moduleInstance) {
    await dart2wasm_runtime.invoke(moduleInstance);
  }
})();

以上をまとめるとこちらの図のイメージです。

Firebase Hostingへデプロイ

Google I/O 2023 では、Firebase Hosting の Flutter Web Wasm 対応も同時に発表されました。

Today, we’re announcing official Firebase Hosting support for WebAssembly Flutter web, which includes new step-by-step instructions to help you get set up quickly.
https://firebase.blog/posts/2023/05/whats-new-at-google-io#support-for-flutter-web

Flutter Web の Wasm ビルドは、前述の通り現時点では build しかサポートしていないためデバッグ実行では確認できません。そのため、Firebase Hosting にデプロイして動作確認します。

手順は 公式ドキュメント の通り進めれば、とくに難しいポイントも少ないので割愛します。躓いた場合や Firebase をそもそも使ったことがないという方は、以下の記事がキャプチャ付きで丁寧に解説されているので参考にしてみると良さそうです。
https://zenn.dev/pressedkonbu/articles/deploy-flutter-web-app-with-firebase-hosting

上記の記事にも記載されていますが、Firebase CLI でセットアップするだけで、裏でよしなに Firebase Hosting へのデプロイ権限を持つサービスアカウントを作成し、GitHub Actions のシークレットキーに登録してくれます。また、以下を利用した GitHub Actions のワークフローファイルまで作成してくれるので、ほぼセットアップ不要でデプロイ環境が整います。
https://github.com/FirebaseExtended/action-hosting-deploy

Wasmホスティング時のHTTPリクエストヘッダーの指定

WebAssembly Mode のレンダラーである skwasm では、マルチスレッドのために SharedArrayBuffer のセキュリティ要件を満たしている必要があります。

To take advantage of multiple threads, the web server must meet the SharedArrayBuffer security requirements.

SharedArrayBuffer のセキュリティ要件を満たすために、ホスティングされるサーバー側で以下のレスポンスヘッダーの指定が必要です。

Cross-Origin-Embedder-Policy: require-corp # credentiallessも可能だがrequire-corpの方がセキュア
Cross-Origin-Opener-Policy: same-origin

https://docs.flutter.dev/platform-integration/web/wasm#serve-the-built-output-with-an-http-server

Firebase Hosting を利用する場合は、headers を以下のように指定することで対応できます。
https://firebase.google.com/docs/hosting/full-config?hl=ja#headers

firebase.json
{
  "target": "wasm",
  "public": "build/web",
  "headers": [
    {
      "source": "**/*",
      "headers": [
        {
          "key": "Cross-Origin-Embedder-Policy",
          "value": "require-corp"
        },
        {
          "key": "Cross-Origin-Opener-Policy",
          "value": "same-origin"
        }
      ]
    }
  ]
},

もし、上記の COEP リクエストヘッダーの指定が漏れていたり、ブラウザが WasmGC に対応していない場合は Wasm モードでビルドしても skwasm レンダラーで描画はされず、canvaskit レンダラーにフォールバックされます。bool.fromEnvironment('dart.tool.dart2wasm') のフラグを用いて Wasm がランタイムで処理されているかを判定でき、COEP ヘッダーがない場合はこの値が false となることを確認しました。

複数サイトをホストする

やや脱線しますが、Firebase Hosting は 1 つのプロジェクトで複数のサイトをホスティングできます。
https://firebase.google.com/docs/hosting/multisites?hl=ja

今回のサンプルリポジトリでは、ビルド方法による違いを比較するために 3 つのサイトそれぞれにデプロイしています。hosting:sites:create コマンドを叩くか、あるいは GUI コンソールからポチポチしても作成できます。

# hostingするサイトを新規で作成
❯ firebase hosting:sites:create flutter-web-assembly-sandbox

こんな形で、ビルド形式によってホスティングする URL を 3 つ用意しました。

❯ firebase hosting:sites:list
Sites for project flutter-web-assembly-sandbox

┌───────────────────────────────────┬───────────────────────────────────────────────────┬─────────────────┐
│ Site ID                           │ Default URL                                       │ App ID (if set) │
├───────────────────────────────────┼───────────────────────────────────────────────────┼─────────────────┤
│ flutter-web-assembly-sandbox      │ https://flutter-web-assembly-sandbox.web.app      │ --              │
├───────────────────────────────────┼───────────────────────────────────────────────────┼─────────────────┤
│ flutter-web-assembly-sandbox-html │ https://flutter-web-assembly-sandbox-html.web.app │ --              │
├───────────────────────────────────┼───────────────────────────────────────────────────┼─────────────────┤
│ flutter-web-assembly-sandbox-js   │ https://flutter-web-assembly-sandbox-js.web.app   │ --              │
└───────────────────────────────────┴───────────────────────────────────────────────────┴─────────────────┘

デプロイターゲットを設定する

作成したサイトの Site ID をそのまま使うのはやや大変なので、デプロイターゲットを設定して分かりやすい名前を付けます。コマンドが直感的に理解できるようになります。
https://firebase.google.com/docs/cli/targets?hl=ja#set-up-deploy-target-hosting

以下の例では flutter-web-assembly-sandbox という Site ID を wasm というターゲットでデプロイできるようになります。

# サイトのデプロイターゲットを設定する
❯ firebase target:apply hosting wasm flutter-web-assembly-sandbox
✔  Applied hosting target wasm to flutter-web-assembly-sandbox

Updated: wasm (flutter-web-assembly-sandbox)

# これでデプロイできるようになる
❯ firebase deploy --only hosting:wasm

target:apply コマンドを実行すると、.firebaserc ファイルに以下の形式で追記されます。

.firebaserc
.firebaserc
"hosting": {
  "js": [
    "flutter-web-assembly-sandbox-js"
  ],
  "wasm": [
    "flutter-web-assembly-sandbox"
  ],
  "html": [
    "flutter-web-assembly-sandbox-html"
  ]
}

最後に firebase.jsonhosting を配列にし、さきほど作成したターゲットを指定すれば準備完了です。

firebase.json
firebase.json
"hosting": [
  {
    "target": "wasm",
    "public": "build/web_wasm"
  },
  {
    "target": "js",
    "public": "build/web"
  },
  {
    "target": "html",
    "public": "build/web"
  }
],

これで、以下コマンドでデプロイできるようになります。

firebase deploy --only hosting:wasm

最終的には、Melos を使ってビルドとデプロイをまとめて行うスクリプトにしました。

melos.yaml
hosting:wasm:
    run: |
      flutter build web --wasm
      cd ./firebase
      firebase deploy --only hosting:wasm

以上を構成するリポジトリはこちらになります。
https://github.com/htsuruo/flutter-web-assembly-sandbox

以下が、3つのビルド形式でホスティングした URL です。単なるカウンターアプリなので目立った違いはないですが、触って挙動をご確認いただけます。

BuildOptions Hosting URL
--wasm https://flutter-web-assembly-sandbox.firebaseapp.com/#/
--web-renderer canvaskit https://flutter-web-assembly-sandbox-js.firebaseapp.com/#/
--web-renderer html https://flutter-web-assembly-sandbox-html.firebaseapp.com/#/

まとめ

今回は、Flutter Web の Wasm ビルドを中心に、Firebase Hosting へのデプロイや従来のビルド形式(CanvasKit, HTML レンダラー)と比較しながらその挙動を確認してきました。
iOS/Android/macOS などと比べて Web プラットフォームでの Flutter の利用は、やはりまだ物足りなさ(SEO や OGP なども)がありますが、Wasm の正式サポートで利用機会がさらに増えていきそうに思いました。

今回はブラウザでの実行を扱いましたが、Wasm の真骨頂はエッジサーバやコンテナなど汎用的な場面で高速な処理ができる、いわゆる Run Anywhere なケイパビリティを持っている点にあると思いますので、今後あらゆる場面でさらに一層 Flutter の活躍が見られそうだなと期待しています。

参考

Discussion

Masaki SatoMasaki Sato

こちら大変勉強になりました🙌
一点、現状の僕の環境(Flutter,Chrome最新)だと、firebase.jsonのwasmターゲットに以下のようにhttpヘッダーを追記する必要があるようだったので、今後他の方の参考になればと思いコメントさせてもらいます..!

  "hosting": [
    {
      "target": "wasm",
      "public": "build/web",
      "headers": [
        {
          "source": "**/*",
          "headers": [
            {
              "key": "Cross-Origin-Embedder-Policy",
              "value": "require-corp"
            },
            {
              "key": "Cross-Origin-Opener-Policy",
              "value": "same-origin"
            }
          ]
        }
      ]
    },
// ...

<参考>
公式ドキュメント:
https://docs.flutter.dev/platform-integration/web/wasm#serve-the-built-output-with-an-http-server
ヘッダーについて:
https://firebase.google.com/docs/hosting/full-config?hl=ja#headers

ツルオカツルオカ

コメントありがとうございます!
確認次第追記させていただきます🙇

ツルオカツルオカ

ご指摘いただいた内容について手元で確認できましたので、セクションを追記させていただきました。
他、HTMLレンダラーが公式ドキュメントから削除されていたり、CanvasKitがDefaultモードに名称が変わっていたり本記事の内容も古い記載が多かったため、関連箇所の修正と注意書きを追記しました。
仕様変更にキャッチアップできてなかったため大変勉強になりました。
ご指摘ありがとうございました🙇‍♂️

ログインするとコメントできます