先日、モバイルアプリとして成功したB2B在庫管理システムをFlutter Webに移植するプロジェクトを担当しました。「コードベースは95%共有できる、楽勝だ」とチームは楽観的でしたが、最初のステージング環境へのデプロイ後、クライアントから緊急の連絡が入りました。「画面が真っ白のまま動きません。」
ログを確認してもエラーはゼロ。原因はエラーではなく、Flutter Web特有の巨大な初期バンドルサイズとWasmのロード待ちでした。特に4G回線下のモバイルブラウザでは、First Contentful Paint (FCP) までに6秒以上かかっており、ユーザーはアプリが壊れたと判断してタブを閉じていたのです。本記事では、この「ロード地獄」を解決するために行ったレンダラーの選定戦略と、具体的なビルド設定について共有します。
レンダリングエンジンの落とし穴:CanvasKit vs HTML
問題の根本を理解するには、FlutterがWeb上でどう動いているかを知る必要があります。通常のDOMベースのSPA(ReactやVue)とは異なり、Flutterは画面全体を<canvas>タグとして扱い、その中にピクセルを描画します。
開発環境(MacBook Pro M1, Chrome)では快適でしたが、実際のユーザー環境は多様です。Flutter 3以降、Webビルドには主に2つのモードがあります。
- HTML Renderer: HTML要素、CSS、Canvas API、SVGを組み合わせて描画。バンドルサイズは小さいが、パフォーマンスと忠実度(Fidelity)が劣る。
- CanvasKit Renderer: WebAssembly (Wasm) 版のSkiaグラフィックエンジンを使用。パフォーマンスと忠実度はネイティブに近いが、初期ロードで約2MB〜の
canvaskit.wasmをダウンロードする必要がある。
auto設定では、デスクトップブラウザは自動的にCanvasKitを選択します。しかし、低速な社内Wi-Fi環境では、この2MBのダウンロードがボトルネックとなり、真っ白な画面が数秒間続く「White Screen of Death」を引き起こしていました。
単純に「ロードインジケータを出せばいい」と思われがちですが、Flutterエンジン自体が初期化されるまでは、Flutter製のローディング画面すら表示できません。つまり、index.htmlレベルでの対策が必須となります。
失敗談:安易なHTMLレンダラーへの切り替え
最初に試みたのは、ビルドコマンドで強制的にHTMLレンダラーを指定することでした。これによりWasmのダウンロードを回避しようとしたのです。
flutter build web --web-renderer html --release
確かにロード時間は1.5秒まで短縮されました。しかし、アプリを開いた瞬間、デザイナーから悲鳴が上がりました。シャドウ(影)の表現が崩れ、カスタムフォントのカーニング(文字間隔)がガタガタになり、円形の画像をクリッピングしている箇所が四角く表示されるバグが発生しました。特にBackdropFilter(すりガラス効果)を使用している箇所は、著しいパフォーマンス低下を招きました。HTMLレンダラーは「速い」ですが、複雑なUIを持つアプリには「雑」すぎたのです。
解決策:ハイブリッド構成とCSSローダーの注入
最終的に採用した解決策は、レンダラーの自動選択(auto)を維持しつつ、ユーザー体験を損なわないための「2段階ロード戦略」の実装です。
具体的には、Flutterエンジンがロードされるまでの間、軽量なCSSアニメーションを表示し、JSバンドルの読み込みが完了した瞬間にフェードアウトさせるロジックをindex.htmlに組み込みました。また、フォントファイルの読み込みを最適化しました。
1. index.htmlへのローダー実装
Flutterが描画を開始する前に、HTML/CSSだけで完結するローディング画面を表示します。これにより「止まっている」という誤解を防ぎます。
<!-- web/index.html -->
<body>
<!-- 静的なローディング表示エリア -->
<div id="loading-indicator" style="display: flex; justify-content: center; align-items: center; height: 100vh;">
<div class="loader"></div> <!-- CSSでスピナーを作成 -->
</div>
<script>
window.addEventListener('load', function(ev) {
// エンジン初期化開始
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: function(engineInitializer) {
engineInitializer.initializeEngine().then(function(appRunner) {
// アプリ実行
appRunner.runApp();
// Flutter描画開始後にローダーを削除
setTimeout(function() {
var loader = document.getElementById('loading-indicator');
if (loader) loader.remove();
}, 500); // フェードアウト用に少し遅延
});
}
});
});
</script>
</body>
上記のコードでは、_flutter.loader.loadEntrypointのコールバックを利用して、アプリの起動プロセスをフックしています。重要なのは、Wasmのダウンロード中もユーザーにフィードバックを与え続けることです。
2. ビルドコマンドの最適化
次に、デプロイメント環境に合わせてビルドオプションを調整します。最近のFlutterドキュメントでも推奨されていますが、PWA(Progressive Web App)としてのキャッシュ機能を有効活用します。
# ツリーシェイキングを最大化し、ソースマップを除外してビルド
flutter build web --release --pwa-strategy=offline-first --no-source-maps
--pwa-strategy=offline-firstを指定することで、2回目以降のアクセス時にはService Workerにキャッシュされたリソースが使われるため、CanvasKitであっても爆速で起動します。初回ロードの重さは「CSSローダー」で体感時間を短縮し、2回目以降は「キャッシュ」で物理時間を短縮するという戦略です。
パフォーマンス比較と検証
この構成を適用した前後でのパフォーマンス比較を行いました。テスト環境はLighthouseを使用し、ネットワーク条件は「Fast 3G」にスロットリングしています。
| 指標 | 対策前 (Auto/Default) | 対策後 (Custom Loader + PWA) |
|---|---|---|
| FCP (First Contentful Paint) | 5.8秒 | 0.8秒 (CSSローダー表示) |
| TTI (Time to Interactive) | 7.2秒 | 7.5秒 (※体感は向上) |
| Lighthouse Score | 42 | 78 |
| 2回目以降のロード | 1.2秒 | 0.3秒 |
TTI(操作可能になるまでの時間)自体はCanvasKitのダウンロードが必要なため劇的には変わりませんが、FCPが0.8秒になったことで、ユーザーの離脱率は大幅に改善しました。ユーザーは「アプリが読み込まれている」と認識できれば、数秒の待機時間は許容してくれる傾向にあります。
公式ドキュメント:Web初期化のカスタマイズ注意点:SEOとSafariの挙動
このソリューションにも限界があります。特にSEO(検索エンジン最適化)に関しては、Flutter Webは依然として弱点があります。
また、iOSのSafariでは、オーバースクロール(バウンス効果)がFlutterのスクロールと競合することがあります。これを防ぐために、index.htmlのメタタグに以下を追加しておくと安全です。
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
結論
Flutter Webは魔法のツールではありませんが、その特性(CanvasKitの重さと美しさ、HTMLの軽さと制約)を理解し、適切な初期化フローを構築することで、プロダクションレベルの品質を出すことは十分に可能です。特に「白い画面」を絶対に見せない工夫は、技術力以上にUXへの配慮としてエンジニアに求められるスキルです。
Post a Comment