effidevFlutter・Cloudflareエッジ・クラウドコスト最適化

Flutterアプリが初回起動時だけカクつくなら: シェーダーコンパイルジャンク(Shader Compilation Jank)徹底攻略

Flutterアプリが初回起動時だけカクつくなら: シェーダーコンパイルジャンク(Shader Compilation Jank)徹底攻略

デバッグモードでは60fpsで滑らかに動いていたアニメーションが、リリースビルドをストアに公開した途端、新しい端末から「カクカクする」という報告が届く。再現しようと開発機で開き直しても問題なく動く。QAチームから受け取ったログを見ても特に例外は出ていない。constウィジェットを一通り付け、ListView.builderに書き換え、RepaintBoundaryまで挟んでみても症状は変わらない。このパターンにぴったり当てはまるなら、犯人はウィジェットツリーではなく**シェーダーコンパイルジャンク(shader compilation jank)**である可能性が高い。

本記事は「constウィジェットを使いましょう」「buildメソッドを軽くしましょう」といった一般論にとどまらず、なぜこの問題がデバッグモードでは絶対に現れず実機のプロファイル・リリースビルドでだけ表面化するのか、DevToolsで正確に何を見るべきなのか、そして広く知られているSkSLウォームアップ手順が最新のFlutterでは実際にどう変わったのかを、ソースコードと公式ドキュメントを直接突き合わせてまとめた実践記録である。

要点まとめ

Jankの定義とフレーム予算が実戦で意味すること

Flutterの公式ドキュメントはjankをこう定義している: 「フレームが普段よりはるかに時間がかかってドロップすると、アニメーションがカクカクして見える。例えば、あるフレームが普段の10倍の時間がかかると、そのフレームはドロップされる可能性が高く、結果としてアニメーションがぎこちなく見える」。ポイントは「たまに時間がかかるフレームがひとつ」あるだけで、体感パフォーマンス全体が損なわれるという点だ。平均フレームタイムではなく、**最悪のフレーム(worst frame)**こそが本当の指標である。

この予算は2つのスレッドにまたがって消費される。

シェーダーコンパイルジャンクはほぼ常にRasterスレッドで発生する。UIスレッドのグラフは問題ないのにGPU/Rasterのグラフだけに大きな赤いバーが飛び出るパターンこそ、この問題の指紋である。

なぜデバッグモードでは再現されないのか

ここがこの問題が最も誤解されやすいポイントである。Flutterのビルドモードは単なる最適化スイッチではなく、まったく異なる実行経路なのだ。

つまりデバッグモードはそもそも「実行速度」を最適化目標としていないため、性能問題そのものが別の形で現れるか、まったく表面化しない。さらにシェーダーコンパイルは根本的に端末固有のGPUハードウェア・ドライバに依存する。FlutterフレームワークのShaderWarmUpクラスのドキュメントコメントはこれを次のように説明している:

「このウォームアップは端末ごとに個別に実行されなければならない。シェーダーコンパイルは、その端末が持つ特定のGPUハードウェアとドライバに依存するためである。エンジンは端末に依存しないため、Flutterエンジンのコンパイル時点であらかじめ計算しておくことはできない」

同じドキュメントは、シェーダー1つをコンパイルするのにかかる時間を具体的に明記している: 「コンパイルは遅い場合がある(20ms〜200ms)」。1つの画面に初めて見るグラデーション、ブラー、シャドウ、カスタムShapeBorderの組み合わせが5〜6個集中するだけでも、算術的には100〜1000ms台のrasterスパイクが出得るということだ(あくまでドキュメントに明記されたシェーダーあたりのコンパイルコストを単純に掛け合わせた理論上の範囲である)。60Hz基準で16msのフレーム予算数十個分を一度に食いつぶす計算になるため、ユーザーの目には「画面が一瞬固まってから再生される」ように映る。

そして、このコンパイルは端末ごとに、その端末が該当の描画演算に初めて出会う瞬間にのみ発生する。開発者が使い慣れたテスト端末は、すでに何度もその画面を開いているためキャッシュが温まっている可能性があるが(ただし、このキャッシュはアプリの再インストール・アップデート・OSのキャッシュクリア時に消える)、たった今ストアからアプリをダウンロードした新規端末は、すべてのシェーダーに一から出会う「冷えた」状態にある。「新しい端末でだけ現れる」という報告が、実は再現性の問題ではなく正確に予測可能な構造的結果である理由はここにある。

DevToolsのPerformanceビューで本当の原因を突き止める

ステップ1: 必ずプロファイルモード + 実機で実行

flutter run --profile

エミュレータ・シミュレータではプロファイルモード自体が無効化されているため、実機(できれば問題を報告してきたものと似た低スペック・新型の端末)に接続する必要がある。

ステップ2: Frame chartでダークレッドのフレームを見つける

DevToolsのPerformanceタブを開くと、フレームごとにUIスレッドのバーとRasterスレッドのバーが対になって表示される。公式ドキュメントはこう説明している: 「シェーダーコンパイルを行うフレームは濃い赤色(dark red)で表示される」。この色こそがシェーダーコンパイルジャンクの視覚的な指紋である。一般的なjank(ビルド/レイアウトの過負荷)ではUIスレッドのバーが先に赤く跳ねるのに対し、シェーダーコンパイルジャンクはRasterスレッドのバーだけが単独で数百ms跳ね上がるパターンを示す。

ステップ3: Timeline eventsでコールスタックを確認する

疑わしいフレームをクリックしてTimeline eventsタブに入ると、ShaderWarmUpクラスのドキュメントが案内しているのと同じパターンを見つけられる。アニメーション中に長く伸びたGrGLProgramBuilder::finalizeの呼び出しが見え、その親の呼び出しとしてFillRectOpCircularRRectOpのようなXyzOp形式のdraw演算名が付いているなら、そのdraw演算が新しいシェーダーコンパイルを引き起こしたということだ(これらのトレース名はSkiaバックエンド基準であり、Impellerでは別名のパイプライン生成イベントとして現れる)。

ステップ4: Enhance tracingで解像度を上げる

Performanceタブの「Enhance tracing」ドロップダウンには3つのオプションがある。

ただし、公式ドキュメントが警告しているように**「これらのオプションを有効にすると、フレームタイム自体に影響が出ることがある」**。原因を絞り込むときだけオンにし、実際の数値を計測するときはオフにすべきだ。同じタブの「More debugging options」でClip / Opacity / Physical Shapeレイヤーを個別にオフにしてみると、過度なクリッピングやシャドウ効果が新しいパイプラインを引き起こしているかどうかも切り分けて確認できる。

ステップ5: --trace-skia--purge-persistent-cacheで再現性を確保する

flutter run --profile --trace-skia

--trace-skiaは「rasterスレッド(旧名GPUスレッド)のデバッグに有用」だとヘルプに明記されており、オーバーヘッドが大きいためデフォルトではオフになっている。そして実戦で最も重要なフラグは別にある。

flutter run --profile --purge-persistent-cache

このフラグは現行のstable版Flutterにもそのまま残っており、ヘルプには正確にこう書かれている: 「既存のすべての永続キャッシュを削除する。これにより、通常アプリの初回起動時にのみ発生するシェーダーコンパイルジャンクを再現したり、コンパイルジャンクの修正(例: シェーダーウォームアップ)を信頼性高くテストしたりできる」。つまり、開発者が使い込んだテスト端末上でも「新規端末での初回起動」状態を強制的に再現できる公式な手段である。QAや回帰テストでこのフラグなしに繰り返し実行するだけだと、キャッシュが温まった状態しか目にすることができず、問題を永遠に見逃しかねない。

SkSLウォームアップキャッシュ: 古典的な解決策、そして実戦で引っかかる落とし穴

インターネット上に出回っているほとんどのチュートリアルは、ここで次の手順を案内している。

# 1. プロファイルモードで実行し、実際に遭遇するシェーダーを記録する
flutter run --profile --cache-sksl

# 2. アプリをあちこち操作してアニメーション・画面遷移をできるだけ多くトリガーしてから終了
#    → build/ 配下に.sksl.jsonキャッシュファイルが生成される

# 3. そのキャッシュをリリースビルドにバンドルする
flutter build apk --release --bundle-sksl-path flutter_01.sksl.json
flutter build appbundle --release --bundle-sksl-path flutter_01.sksl.json
flutter build ios --release --bundle-sksl-path flutter_01.sksl.json

この手順は、「GPUが新規端末で初めて見るシェーダーを実行時にコンパイルする」というSkia時代の構造的問題を回避するためのものだった。アプリ実行中に実際に使われたシェーダーの組み合わせを事前にキャプチャし、ビルドにキャッシュとして入れておくことで、アプリ起動時に一括で準備させ、アニメーション中のストールをなくす仕組みである。

ここに実戦の落とし穴がある。 上記のコマンドを最新のFlutterでそのまま実行しても動作しない。実際、GitHubのイシュー(flutter/flutter#171585)には、Flutter 3.32以降で--bundle-sksl-pathを使おうとした開発者がCould not find an option named '--bundle-sksl-path'というエラーを受け取った事例がそのまま残っている。Flutterリポジトリのコミット履歴を直接確認すると原因は明確だ。2025年2月10日にマージされたコミット(33a4c95de07、“remove SkSL bundling and dump skp on compilation”)は、flutter runflutter build apk/appbundle/iosflutter driveからSkSLバンドリング関連のオプションを全て削除しており、コミットメッセージはこう説明している。

「SkSLの事前コンパイルはそもそもiOSでのみ意味があった。他のプラットフォームではSkiaがターゲットアーキテクチャごとにシェーダーを生成するため、他の端末では無効になり得るとして使用を推奨していなかった。ところが今やiOSではSkiaを一切使えなくなった」

この変更はFlutter 3.32.0(2025年5月のstable)から含まれている。実際にローカルにインストールしたFlutter 3.44 SDKでflutter run --help -vflutter build apk --help -vflutter build web --help -vを全て確認しても、cache-skslbundle-sksl-pathという文字列はただの一箇所にも残っていない。つまり**「SkSLウォームアップキャッシュを作ってバンドルせよ」というアドバイスそのものが、最新のFlutter基準ではもはや実行可能な手順ではない。** 公式ドキュメント(docs.flutter.dev/perf/rendering-performance)の最新版も、シェーダーコンパイルジャンクに関するモバイル向けの助言を「FlutterのデフォルトレンダラーであるImpellerを使っているか確認せよ」という一文に置き換えている。

では今この問題に遭遇したら何をすべきか

Impeller導入後に変わった点と、なお残る限界

Impellerはシェーダーコンパイルジャンクを「直す」のではなく、問題が発生するタイミング自体をずらしてしまうアプローチである。公式ドキュメントが明らかにしている4つの設計目標は次の通りだ。

プラットフォーム別の現状は次の通りである(公式ドキュメントdocs.flutter.dev/perf/impeller基準)。

プラットフォーム 現状
iOS Impellerが唯一サポートされるレンダラー。Skiaへは戻せない
Android API 29以上でデフォルト有効(Flutter 3.27から)。API 29未満、またはVulkan非対応の端末はImpellerのレガシーOpenGLバックエンドへ自動フォールバック
macOS --enable-impellerフラグによるオプトイン状態で、今後のリリースでオプトアウト自体が撤廃される予定
Web まだSkia(CanvasKit)を使用。将来的なImpeller採用の可能性に言及あり

興味深い事実がひとつある。他ならぬFlutter自体のCLIヘルプに、古びた文言が残っているのだ。flutter run --help -v--enable-impellerの説明を見ると、今でも“Impeller is the default renderer on iOS. On Android, Impeller is available but not the default”と表示される。flutter_toolsのソースにあるaddEnableImpellerFlag関数をgit blameで追跡してみると、この文言は2023年3月、つまりImpellerがちょうど導入された時点で書かれたまま、一度も更新されていない。公式ドキュメントとリリースノートは実際のデフォルト値変更を正確に反映しているため、CLIヘルプのこの文言は実際の挙動ではなく放置された説明文字列とみなすべきである。こうした些細な不一致さえも、実戦では「今の自分のプロジェクトでImpellerは本当に有効になっているのか?」を混乱させる原因になる。

Impellerがジャンクをなくす本当のメカニズム

核心は「シェーダーの大半をエンジンビルド時点でオフラインにあらかじめコンパイルしておく」という点にある。ここでもう一歩踏み込んでみると、Impellerエンジンのソース(shell/common/switch_defs.hcommon/settings.h)にはimpeller-lazy-shader-modeという内部スイッチがあり、コメントは次のように説明している。

「Impellerバックエンドに必要な全てのPSO(Pipeline State Object)の初期化を遅延させるかどうか。デフォルトはfalse」

つまりデフォルト(false)ではImpellerは必要なPSOを全てアプリ起動時点で即座(eager)に初期化する。以前は開発者が手動でShaderWarmUpを書いて「コンパイルコストをアニメーション中ではなく起動時点へ移す」作業をしていたが、それをImpellerはエンジンレベルでデフォルトとして肩代わりしているわけだ。AndroidのAndroidManifest.xmlio.flutter.embedding.android.ImpellerLazyShaderInitializationメタデータをtrueで与えると、この初期化を遅延させてコールドスタートを多少速くできるが、その代償として初回使用時に昔のSkSL時代と似たストールが再び現れることがある。「Impellerだから無条件にジャンクがない」のではなく、トレードオフのデフォルト値が変わっただけという理解が正確だ。

それでも残る限界

プロファイリング前後のデータで改善を検証する

修正が実際に効いたかどうかは、勘ではなく数字で確認しなければならない。再現可能なシナリオをひとつ決める(例: アプリ起動 → グラデーション・シャドウの多い詳細画面へ遷移 → リストを3回スクロール)。このシナリオを以下の2つの条件でそれぞれ実行し、DevToolsのPerformanceタブのフレームリストを比較する。

  1. 修正前、キャッシュ削除状態: flutter run --profile --purge-persistent-cacheで実行し「新規端末の初回起動」を再現する
  2. 修正後、同一条件: Impellerの確認/ウォームアップ対応を適用した後、再度--purge-persistent-cacheで実行する

比較する際に見るべきは平均ではなく、最悪のフレーム(worst frame)のraster時間である。改善前は特定のフレームが予算(16msまたは8.3ms)を何倍も超えてダークレッドで表示され、改善後は同じシナリオでそのスパイクが消え、すべてのフレームが予算内に収まっているかどうかを、Frame chartで目視により、Timeline eventsでコールスタック単位で確認する。CIに性能回帰テストを組み込みたいなら、flutter driveで同一シナリオを自動化し、実行のたびに--purge-persistent-cacheを強制して「温まったキャッシュ」のせいで回帰を見逃す事態を防ぐことが肝心だ。

チェックリスト