最近開発していたソーシャルフィード系のFlutterアプリにおいて、深刻な「Jank(カクつき)」に直面しました。具体的には、タイムライン上で複雑なカード型ウィジェットをスクロールしている最中に、たった一つの小さな「いいね」アニメーションが発火するだけで、フレームレートが60fpsから30fps付近まで急落するという現象です。
このプロジェクトはFlutter 3.19、Dart 3.3を使用しており、ターゲットデバイスはAndroidのミドルレンジ(Pixel 4a等)を想定していました。ハイエンド機では誤魔化せていたレンダリングコストが、普及帯の端末で露骨に現れた形です。プロファイラを見て即座に気づいたのは、UIスレッド(Build/Layout)ではなく、Rasterスレッド(Paint)の負荷が異常に高いことでした。本稿では、単なる状態管理のスコープ縮小だけでは解決しなかったこの問題に対し、RepaintBoundaryを用いた描画分離アプローチでどのように解決したかを共有します。
Flutterパフォーマンス低下の構造的要因
多くのモバイルアプリ開発者が最初に疑うのは「不要なビルド」です。確かに、SetStateやnotifyListenersが親ウィジェットで呼ばれ、子孫ウィジェットが無駄にbuild()を実行するのは避けるべきです。しかし、今回のケースではログを確認してもbuildの実行時間はわずか2ms程度でした。問題はその後、GPUがピクセルを描画するフェーズにありました。
Flutterのレンダリングパイプラインは、Widget -> Element -> RenderObject -> Layer という流れで処理されます。ここで重要なのは、「親が再描画(Repaint)されると、同じレイヤーにある子は(変更がなくても)一緒に再描画される」という仕様です。
私たちが直面していたのは、画面の一部にある小さなアニメーション(プログレスバーやローディングインジケータ)が更新されるたびに、背景にある複雑なリストアイテム全体が「再ペイント(Repaint)」対象としてマークされていたことでした。これは、CPU資源を食うだけでなく、バッテリー消費にも直結する重大な欠陥です。
失敗談:const化とWidget分割の限界
最初に試みたのは、教科書的なウィジェット再構築の抑制でした。巨大なbuild()メソッドを細かく分割し、動的な部分だけをConsumerWidget(Riverpod使用)でラップし、静的な部分には徹底的にconstコンストラクタを付与しました。
論理的にはこれでElementツリーの更新は最小限になります。しかし、パフォーマンスは改善しませんでした。なぜなら、どれだけ論理的な更新(Build)をスキップしても、それらが「同じ描画レイヤー(Paint Layer)」に属している限り、汚染された領域(Dirty Region)としてまとめて再描画されてしまうからです。constはBuildフェーズの最適化には有効ですが、Paintフェーズの隔離には無力だったのです。
RepaintBoundaryによる描画隔離
解決策は、レンダリングツリーにおいて「ここから下の描画結果はキャッシュし、親の再描画に巻き込まない」と宣言することです。これを行うのがRepaintBoundaryです。
以下は、実際に修正を行ったコードの簡略版です。アニメーションする要素と静的な背景要素の関係に注目してください。
// 修正前: アニメーション時にContainer全体が再描画される
class ComplexListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
// 重いグラデーションやシャドウ処理
boxShadow: [BoxShadow(blurRadius: 10, color: Colors.black26)],
),
child: Column(
children: [
const StaticHeaderInfo(), // constでもPaintは巻き込まれる
AnimatedHeartIcon(), // ここが動くと全体が再描画
],
),
);
}
}
// 修正後: RepaintBoundaryでレイヤーを分離
class OptimizedListItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RepaintBoundary( // [1] 親の再描画境界を作成
child: Container(
decoration: BoxDecoration(
boxShadow: [BoxShadow(blurRadius: 10, color: Colors.black26)],
),
child: Column(
children: [
// 静的なコンテンツはキャッシュされたテクスチャとして扱われる
const RepaintBoundary(child: StaticHeaderInfo()),
// 動く部分だけが独立してGPUに送られる
AnimatedHeartIcon(),
],
),
),
);
}
}
上記のコードにおいて、[1] の RepaintBoundary は非常に強力です。これにより、AnimatedHeartIcon が毎フレーム更新されても、兄弟要素である StaticHeaderInfo や親の Container の背景装飾(高コストなシャドウ計算など)は再計算されず、既存のテクスチャが再利用されます。
さらに、debugRepaintRainbowEnabled = true; をコード内で一時的に有効にすることで、再描画領域を視覚的に確認できます。適用前は画面全体が虹色に点滅していましたが、適用後はアニメーションしているアイコン部分だけ枠が表示されるようになり、意図通り分離できていることが確認できました。
ListViewの子要素すべてにRepaintBoundaryをつけるのは、スクロールパフォーマンス向上における定石の一つですが、メモリ使用量とのトレードオフがあることを忘れてはいけません。
パフォーマンス検証結果
修正前後で、Pixel 4a実機にてプロファイリングを行った結果です。複雑なリストアイテムを10個表示し、そのうちの一つでアニメーションを実行した際の数値です。
| 指標 | 修正前 (Before) | 修正後 (After) | 改善率 |
|---|---|---|---|
| UI (Build) Time | 2.1 ms | 2.0 ms | - |
| Raster (Paint) Time | 18.4 ms (Jank発生) | 3.8 ms | 約480%改善 |
| FPS | ~32 fps | 59-60 fps | 安定 |
表の通り、Raster時間が劇的に削減されました。これは、GPUが毎フレーム「背景のシャドウとテキスト」を描画する命令を受け取らなくなり、単にキャッシュされたビットマップを合成するだけで済むようになったためです。これこそがDartチューニングにおける「レイヤー合成(Compositing)」の最適化の本質です。
Flutter公式ドキュメント: Performance Best Practices副作用と注意点:メモリへの影響
RepaintBoundaryは魔法の杖ではありません。このウィジェットは、子要素の描画結果をオフスクリーンバッファ(テクスチャ)としてメモリ上に保持します。つまり、VRAM(ビデオメモリ)を消費します。
以下のようなケースでは、逆効果になる可能性があります:
- 単純なテキストのみのリストなど、描画コストが元々低い場合(キャッシュのオーバーヘッドの方が高くなる)。
- 画像サイズが巨大なウィジェットを大量にBoundaryで囲む場合(メモリ不足によるクラッシュのリスク)。
結論
Flutterアプリのパフォーマンス最適化において、build()メソッドのリファクタリングは出発点に過ぎません。特にアニメーションや複雑なグラフィクスを含む画面では、DevToolsを活用して「Buildコスト」と「Paintコスト」のどちらがボトルネックかを正確に見極める必要があります。
今回のように、適切な箇所にRepaintBoundaryを配置することで、コードロジックを大きく変更することなく、レンダリングパイプラインを劇的に効率化できます。もしあなたのアプリで原因不明の重さを感じたら、まずはRasterスレッドの状態を確認してみてください。
Post a Comment