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

デバッグモードでは60fpsで滑らかに動いていたアニメーションが、リリースビルドをストアに公開した途端、新しい端末から「カクカクする」という報告が届く。再現しようと開発機で開き直しても問題なく動く。QAチームから受け取ったログを見ても特に例外は出ていない。constウィジェットを一通り付け、ListView.builderに書き換え、RepaintBoundaryまで挟んでみても症状は変わらない。このパターンにぴったり当てはまるなら、犯人はウィジェットツリーではなく**シェーダーコンパイルジャンク(shader compilation jank)**である可能性が高い。
本記事は「constウィジェットを使いましょう」「buildメソッドを軽くしましょう」といった一般論にとどまらず、なぜこの問題がデバッグモードでは絶対に現れず実機のプロファイル・リリースビルドでだけ表面化するのか、DevToolsで正確に何を見るべきなのか、そして広く知られているSkSLウォームアップ手順が最新のFlutterでは実際にどう変わったのかを、ソースコードと公式ドキュメントを直接突き合わせてまとめた実践記録である。
要点まとめ
- シェーダーコンパイルジャンクは、GPUが初めて目にするdraw演算に対して端末ごとに、実行時にシェーダー/パイプラインを新規作成しなければならないために生じるストールである。一度コンパイルされれば、それ以降は再発しない。
- デバッグモードはJITベースであるため、この問題はそもそも別の形で隠れてしまい、プロファイル・リリースモードはエミュレータ/シミュレータでは完全に無効化されているため、必ず実機で確認する必要がある。
- DevToolsのPerformanceビューのFrame chartで濃い赤色(dark red)で表示されるフレームが、シェーダーコンパイルスパイクのシグネチャである。
flutter run --profile --cache-sksl+--bundle-sksl-pathと続く古典的なSkSLウォームアップ手順は、Flutter 3.32(2025年5月のstable)からCLIで完全に削除されている。今この通りにコマンドを実行するとエラーになる。- Impellerがデフォルトレンダラーになったことで、この問題の大部分は構造的に解消されたが、カスタムフラグメントシェーダーや旧型Android端末のフォールバック経路など、依然として残る落とし穴もある。
Jankの定義とフレーム予算が実戦で意味すること
Flutterの公式ドキュメントはjankをこう定義している: 「フレームが普段よりはるかに時間がかかってドロップすると、アニメーションがカクカクして見える。例えば、あるフレームが普段の10倍の時間がかかると、そのフレームはドロップされる可能性が高く、結果としてアニメーションがぎこちなく見える」。ポイントは「たまに時間がかかるフレームがひとつ」あるだけで、体感パフォーマンス全体が損なわれるという点だ。平均フレームタイムではなく、**最悪のフレーム(worst frame)**こそが本当の指標である。
- 60Hzディスプレイ: 公式ドキュメント基準で、各フレームは「約16ms」以内にレンダリングを終えなければjankが発生する。
- 120Hzディスプレイ: Flutterは対応端末では120fpsも目標としており、算術上フレーム予算は約8.3msまで縮む(1000ms ÷ 120)。予算が半分以下に減る分、同じシェーダーコンパイルストールでも120Hz端末では体感するジャンクがより深刻に感じられる。
この予算は2つのスレッドにまたがって消費される。
- UIスレッド: Dart VM上でアプリコードとFlutterフレームワークのコードを実行する。ウィジェットのビルド、レイアウト、ペイントコマンドを収めたレイヤーツリーを作成し、rasterスレッドへ渡す。公式ドキュメントは「このスレッドをブロックしてはならない」と明示的に警告している。
- Rasterスレッド: レイヤーツリーを受け取り、実際にGPUへ描画するスレッドである。SkiaとImpellerはこのスレッド上で動作する。このスレッドは開発者が直接手を入れることはできず、ここが遅いとすれば結局のところDartコードが指示した内容(何を描画せよと指示したか)に起因する。ちなみにこのスレッドはかつて「GPUスレッド」と呼ばれており、今でも
--trace-skiaフラグのヘルプ文言には“raster thread (formerly known as the GPU thread)“という表現がそのまま残っていて、名称が変更された経緯を確認できる。
シェーダーコンパイルジャンクはほぼ常にRasterスレッドで発生する。UIスレッドのグラフは問題ないのにGPU/Rasterのグラフだけに大きな赤いバーが飛び出るパターンこそ、この問題の指紋である。
なぜデバッグモードでは再現されないのか
ここがこの問題が最も誤解されやすいポイントである。Flutterのビルドモードは単なる最適化スイッチではなく、まったく異なる実行経路なのだ。
- デバッグモード: 「高速な開発・実行サイクルに合わせてコンパイルされ、実行速度・バイナリサイズ・配布には最適化されていない」と公式ドキュメントが明記している。assertが有効になっており、ソースレベルデバッグのためにDevToolsを接続できる。
- プロファイルモード: 「一部のデバッグ機能を保持しつつ、性能プロファイリングには十分なレベル」と定義され、モバイル基準ではリリースモードとほぼ同一である(トレーシングと一部のサービスエクステンションだけが追加で有効になる)。そして決定的な点として: 「プロファイルモードはエミュレータ・シミュレータでは無効化される。その環境での挙動は実際の性能を代表しないためだ」。
- リリースモード: assertを除去し、デバッグ情報を除去し、「高速な起動・高速な実行・小さいパッケージサイズに最適化」される。
つまりデバッグモードはそもそも「実行速度」を最適化目標としていないため、性能問題そのものが別の形で現れるか、まったく表面化しない。さらにシェーダーコンパイルは根本的に端末固有の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の呼び出しが見え、その親の呼び出しとしてFillRectOp、CircularRRectOpのようなXyzOp形式のdraw演算名が付いているなら、そのdraw演算が新しいシェーダーコンパイルを引き起こしたということだ(これらのトレース名はSkiaバックエンド基準であり、Impellerでは別名のパイプライン生成イベントとして現れる)。
ステップ4: Enhance tracingで解像度を上げる
Performanceタブの「Enhance tracing」ドロップダウンには3つのオプションがある。
- Track Widget Builds: タイムラインに
build()メソッドのイベントとウィジェット名を表示する - Track Layouts: レンダーオブジェクトのlayoutイベントを表示する
- Track Paints: レンダーオブジェクトのpaintイベントを表示する
ただし、公式ドキュメントが警告しているように**「これらのオプションを有効にすると、フレームタイム自体に影響が出ることがある」**。原因を絞り込むときだけオンにし、実際の数値を計測するときはオフにすべきだ。同じタブの「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 run、flutter build apk/appbundle/ios、flutter driveからSkSLバンドリング関連のオプションを全て削除しており、コミットメッセージはこう説明している。
「SkSLの事前コンパイルはそもそもiOSでのみ意味があった。他のプラットフォームではSkiaがターゲットアーキテクチャごとにシェーダーを生成するため、他の端末では無効になり得るとして使用を推奨していなかった。ところが今やiOSではSkiaを一切使えなくなった」
この変更はFlutter 3.32.0(2025年5月のstable)から含まれている。実際にローカルにインストールしたFlutter 3.44 SDKでflutter run --help -v、flutter build apk --help -v、flutter build web --help -vを全て確認しても、cache-sksl、bundle-sksl-pathという文字列はただの一箇所にも残っていない。つまり**「SkSLウォームアップキャッシュを作ってバンドルせよ」というアドバイスそのものが、最新のFlutter基準ではもはや実行可能な手順ではない。** 公式ドキュメント(docs.flutter.dev/perf/rendering-performance)の最新版も、シェーダーコンパイルジャンクに関するモバイル向けの助言を「FlutterのデフォルトレンダラーであるImpellerを使っているか確認せよ」という一文に置き換えている。
では今この問題に遭遇したら何をすべきか
- まずImpellerをそのままにしておく。
--no-enable-impellerでオフにしない限り、最新のFlutterではすでにImpellerがデフォルトになっている。後述するように、ImpellerはSkSLキャッシュが担っていた役割を構造的に肩代わりしてくれる。 ShaderWarmUpAPIはフレームワークに今も残っている。PaintingBinding.shaderWarmUpにカスタムShaderWarmUpサブクラスをrunApp呼び出し前に登録すると、アプリ起動時にオフスクリーンキャンバスへ代表的なdraw演算をあらかじめ描画し、コンパイルコストを起動遅延側に移すことができる。ただし、このAPIのドキュメント自体がGrGLProgramBuilder、--trace-skiaといったSkia時代の概念を基準に書かれているため、Impeller経路では以前ほど決定的な効果を保証するものではない。- レガシープロジェクトなら旧手順は依然として有効である。 Flutter 3.32以前のバージョンに固定されている、あるいはAndroidで明示的に
--no-enable-impellerを有効にして旧型のSkia/OpenGL経路を使っているプロジェクトなら、--cache-sksl/--bundle-sksl-pathの組み合わせはそのバージョン内ではそのまま動作する。ただし、移行計画なしにこの経路に頼り続けることは推奨しない。
Impeller導入後に変わった点と、なお残る限界
Impellerはシェーダーコンパイルジャンクを「直す」のではなく、問題が発生するタイミング自体をずらしてしまうアプローチである。公式ドキュメントが明らかにしている4つの設計目標は次の通りだ。
- 予測可能性(Predictable): コンパイル・キャッシングの判断を実行前にあらかじめ確定しておく
- 計測可能性(Instrumentable): グラフィックリソースにラベルを付け、プロファイリング・キャプチャを容易にする
- 移植性(Portable): シェーダーを一度書けば、Metal/Vulkan/GLESなどバックエンドごとのフォーマットに変換する
- モダン・並行性(Modern & concurrent): 最新のグラフィックAPIを活用し、作業を複数のスレッドに分散する
プラットフォーム別の現状は次の通りである(公式ドキュメント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.h、common/settings.h)にはimpeller-lazy-shader-modeという内部スイッチがあり、コメントは次のように説明している。
「Impellerバックエンドに必要な全てのPSO(Pipeline State Object)の初期化を遅延させるかどうか。デフォルトはfalse」
つまりデフォルト(false)ではImpellerは必要なPSOを全てアプリ起動時点で即座(eager)に初期化する。以前は開発者が手動でShaderWarmUpを書いて「コンパイルコストをアニメーション中ではなく起動時点へ移す」作業をしていたが、それをImpellerはエンジンレベルでデフォルトとして肩代わりしているわけだ。AndroidのAndroidManifest.xmlにio.flutter.embedding.android.ImpellerLazyShaderInitializationメタデータをtrueで与えると、この初期化を遅延させてコールドスタートを多少速くできるが、その代償として初回使用時に昔のSkSL時代と似たストールが再び現れることがある。「Impellerだから無条件にジャンクがない」のではなく、トレードオフのデフォルト値が変わっただけという理解が正確だ。
それでも残る限界
- カスタムフラグメントシェーダー:
pubspec.yamlのshaders:で登録した.fragアセットは、impellercがビルド時点でバックエンドごとのフォーマットにオフラインコンパイルする。ただし、珍しいブレンドモード、BackdropFilter、PlatformViewの合成経路の組み合わせは、事前コンパイル済みのパイプライン集合の外側にあることがあり、まれに初回使用時にストールが残る。Flutterチームはこうしたリグレッションを[Impeller]という接頭辞を付けてイシュー報告するよう案内している。 - Vulkan非対応の旧型Android: ImpellerのOpenGLフォールバック経路は、Vulkan経路ほど最適化されていない。
- Web: CanvasKitベースであるため、依然としてSkia時代の特性が残っている。
プロファイリング前後のデータで改善を検証する
修正が実際に効いたかどうかは、勘ではなく数字で確認しなければならない。再現可能なシナリオをひとつ決める(例: アプリ起動 → グラデーション・シャドウの多い詳細画面へ遷移 → リストを3回スクロール)。このシナリオを以下の2つの条件でそれぞれ実行し、DevToolsのPerformanceタブのフレームリストを比較する。
- 修正前、キャッシュ削除状態:
flutter run --profile --purge-persistent-cacheで実行し「新規端末の初回起動」を再現する - 修正後、同一条件: Impellerの確認/ウォームアップ対応を適用した後、再度
--purge-persistent-cacheで実行する
比較する際に見るべきは平均ではなく、最悪のフレーム(worst frame)のraster時間である。改善前は特定のフレームが予算(16msまたは8.3ms)を何倍も超えてダークレッドで表示され、改善後は同じシナリオでそのスパイクが消え、すべてのフレームが予算内に収まっているかどうかを、Frame chartで目視により、Timeline eventsでコールスタック単位で確認する。CIに性能回帰テストを組み込みたいなら、flutter driveで同一シナリオを自動化し、実行のたびに--purge-persistent-cacheを強制して「温まったキャッシュ」のせいで回帰を見逃す事態を防ぐことが肝心だ。
チェックリスト
- 問題を実機のプロファイルまたはリリースビルドでのみ再現しようとしたか(エミュレータ・デバッグモードではそもそも見えない)
- DevToolsのPerformanceタブでRasterスレッドのダークレッドのフレームを確認したか
-
--purge-persistent-cacheで「新規端末の初回起動」状態を強制的に再現してテストしたか - 現在のFlutterバージョンでImpellerが実際に有効になっているか確認したか(
--no-enable-impellerでオフにしていないか) -
--cache-sksl/--bundle-sksl-pathはFlutter 3.32以降で削除されている点を認識しているか - カスタム
.fragシェーダーや特殊なブレンドモード/BackdropFilterを使う画面があれば、別途疑っているか - 修正前後を最悪のフレームのraster時間基準で比較したか