【Flutter】ガベージコレクション(GC)入門 ─ 仕組みと実践テクまとめ

対象者

  • Flutterアプリの描画やアニメーションの“カクつき(jank)”を減らしたいフロントエンドエンジニア / UI実装者
  • DevToolsでメモリを観測し、原因特定~改善までをひとりで回したいモバイル開発者
  • リアルタイム系(株価・チャート・ストリーム)や大規模リストで滑らかさ安定性を両立したい人

はじめに

Flutterは“ウィジェットを大量に生成しては破棄する”前提で最適化されています。
この循環を下支えしているのが Dart のガベージコレクション(GC)
適切に設計すれば 60fps / 120fps を持続できますが、誤ると フレーム予算(16.67ms/8.33ms) を超え、目に見える**スタッター(jank)**に直結します。

本記事は、CG関連のブログ記事を読みあさり、以下の内容をまとめました。

  • GCの仕組み(若/老世代・停止ポイント)
  • Flutter 3.29+ のDevToolsでの観測方法
  • リークを起こしがちな落とし穴と対策
  • 実戦レシピ & チェックリスト

フレーム予算(Frame Budget): 1フレームあたりに使える時間の上限。コストだと、結果のニュアンスを含むので、予算に翻訳してます。

GCの要点を60秒で

  • DartのGCは世代別(generational)
    **若世代(Nursery)**は高速・頻繁、**老世代(Tenured)**は稀だが重い。
  • 若世代GCは基本的に無害。問題は、老世代GCの停止時間(10–50ms程度になり得る)。
  • Flutterエンジンはアイドル時にGCをスケジュールし、UI停止の体感を最小化。
  • **“多くのオブジェクトはすぐ死ぬ”**前提(weak generational hypothesis)に最適化されており、
    “ウィジェットを握り続けて再利用する”ような最適化は 逆効果 になりがち。

DartのメモリモデルとGCの仕組み

スタックとヒープ(超要約)

  • スタック: 関数スコープの局所変数。呼び出し終了で自動解放(超高速)。
  • ヒープ: WidgetList、画像・キャッシュなどのオブジェクト領域。GCの対象。

世代別GC

  • 若世代(Cheney方式)
    新規オブジェクトは連続領域に“バンプ”で素早く確保。半空間コピーで“生きているものだけ”次へ移動。
    非常に速い。スクロールやアニメーション中の短命オブジェクト向き。
  • 老世代(Parallel Mark & Sweep / 時にコンパクション)
    何度も若世代GCを生き延びた“長命オブジェクト”を管理。
    マーク(到達性解析)→ スイープ(未到達を解放)。
    停止が発生。老世代が膨らむ設計は体感jankの温床。

スケジューリングとコンパクション

  • Flutterはユーザー無操作のアイドル時間をフックし、
    Mark/Sweepやスライディング・コンパクションを走らせやすい。
  • それでも老世代が混雑したり断片化が進むと、可視の停止に。

Isolates

  • 各Isolateは独立ヒープ
    重い計算/変換/集計を別Isolateへ逃がせば、UI側のGCやレンダを邪魔しにくい。

まず“観測”から:Flutter 3.29+ のDevToolsでやること

基本動線

flutter run --profile
  • DevTools → Memoryタブ

    • リアルタイムGCイベント/割当量/老世代・若世代の推移
    • Heap Snapshot & Allocation Profile(リーク経路や保持グラフ)
    • Frame Chart と合わせて相関(GCが起きた瞬間にjankが出ていないか)

ここを見れば分かる

  • 短時間に老世代が右肩上がり → 長命オブジェクトが溜まっているサイン
  • Snapshot間での“生き残りセット”比較 → 参照が切れていない種類を特定
  • 特定画面でだけメモリが戻らない → リスナー/コントローラ未解放の疑い大

7つの実戦テク(効果順に近い並び)

1) constの徹底(再生成ゼロ化)

Widget build(BuildContext context) {
  return const Column(
    children: [
      Text('Hello World'),
      Icon(Icons.star),
    ],
  );
}
  • **静的UIはconst**で再利用。
  • 同じconst式は1インスタンスだけ使用するので、無駄な割当が激減 → 若世代GCの回数低下

2) 3つの“Dispose”を漏らさない

class _MyWidgetState extends State<MyWidget> {
  late final TextEditingController _controller = TextEditingController();
  late final AnimationController _anim = AnimationController(vsync: this);
  StreamSubscription<int>? _sub;

  @override
  void dispose() {
    _sub?.cancel();         // ✅ Stream購読は必ず解約
    _anim.dispose();        // ✅ Animation/Timerは止める
    _controller.dispose();  // ✅ コントローラ類は明示破棄
    super.dispose();
  }
}
  • 未解約の購読/リスナー/Timerは、リークしがち。
  • ChangeNotifierremoveListenerdisposeを忘れずに。

3) 画像のサイズで一工夫

CachedNetworkImage(
  imageUrl: url,
  memCacheWidth: 500, memCacheHeight: 500,
  maxWidthDiskCache: 1000, maxHeightDiskCache: 1000,
)
  • 不要に原寸読み込みは避け、適切な解像度でキャッシュ
  • サムネ/一覧は縮小、詳細画面のみ原寸、など段階戦略を。

4) リスト最適化(生成・再描画・保持の三点)

ListView.builder(
  addAutomaticKeepAlives: false,
  addRepaintBoundaries: false,
  addSemanticIndexes: false,
  itemBuilder: (_, i) {
    return const ListTile(
      leading: Icon(Icons.person),
      title: Text('Item'),
    );
  },
);
  • builder遅延生成し、const再利用で割当を減らす
  • “常時KeepAlive”は慎重に。不要な保持は老世代膨張の原因。

5) 計算はキャッシュ するか Isolateを使う

class OptimizedListItem extends StatelessWidget {
  final ItemData data;
  final double _cachedScore;
  const OptimizedListItem({required this.data, required double cachedScore})
      : _cachedScore = cachedScore;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        CachedNetworkImage(imageUrl: data.imageUrl),
        Text(data.title),
        Text(data.description),
        Text('Score: $_cachedScore'), // ✅ 毎回計算しない
      ],
    );
  }
}
  • 高コスト計算は一度だけ(記憶化)、大量処理はIsolateで非同期化。

6) 状態管理は“破棄のしやすさ”で選ぶ

  • Riverpod: スコープと自動破棄がわかりやすく、長命化を防ぎやすい
  • Provider: 破棄/ライフサイクルを明示設計できれば◎
  • Bloc: 生成物は多めだが、明確なDisposeで安定
  • GetX: 軽量だが手動破棄の徹底が前提

7) フレーム予算を考える

  • 60fps → 16.67ms、120fps → 8.33ms/frame
  • GCで10ms使えば残りは6ms/(あるいは -1.67ms…)
  • レンダ・レイアウト・Dart処理の全体配分を常に意識

ありがちなリークと処方箋

未解約のStream/Listener/Timer

  • 症状: 画面を閉じてもメモリが戻らない
  • 対策: dispose()cancel() / removeListener() / dispose()必ず

クロージャによる“不用意なキャプチャ”

  • 症状: 古いStateや巨大オブジェクトを無自覚に捕まえ続ける
  • 対策: 長命クロージャが何を捕まえるかを意識。必要最小に分離。

静的変数・シングルトンに何でも置く

  • 症状: アプリ終了まで生存=老世代の永続滞在
  • 対策: 本当にグローバルで良いのか?必要がなくなればnull代入で参照切り。

画像・キャッシュの無制限膨張

  • 症状: 一定時間後に老世代がじわ増し→ 定期的な長いGC
  • 対策: サイズ制限・明示的クリーン、シーン別キャッシュ戦略。

WeakReference / Finalizer を使う前に

  • WeakReference: 参照はするがGCを妨げない。キャッシュ/監視者に有用。ただし**targetがnullか要確認**。
  • Finalizer: オブジェクト収集時に別Isolateでコール。外部リソース解放向け。重い処理は×。
  • どちらも通常のDartメモリ管理の代用ではない。まずは設計で寿命を短くするのが王道。

観測 → 改善の“実戦レシピ”

レシピA:スクロールでカクつく大規模リスト

  1. DevToolsでFrameMemoryを記録
  2. スクロール中の老世代GCが重ならないか確認
  3. const化・セル内計算のキャッシュ・画像縮小/キャッシュ
  4. addAutomaticKeepAlives=false 等で過剰保持を削減
  5. 再計測。割当/フレーム落ち/老世代増の改善を確認

レシピB:長時間稼働でメモリが戻らない

  1. 一定間隔でHeap Snapshotを採取 → 生存集合を比較
  2. 参照元チェーンから未解約の購読/リスナを特定
  3. dispose()/cancel()/removeListener()/dispose() を徹底
  4. 静的参照・シングルトンの参照切りも点検

レシピC:重い計算でフレームを圧迫

  1. cpu profilerframe chart計算ホットスポットを特定
  2. 結果キャッシュ or Isolate移送(メッセージ最小化)
  3. 再計測し、UI側のフレーム予算に収まることを確認

すぐ貼って効くスニペット集

1) “生成しない”ためのconst化テンプレ

const kTitle = Text('Dashboard', style: TextStyle(fontSize: 20));
const kIcon  = Icon(Icons.star);

class Header extends StatelessWidget {
  const Header({super.key});
  @override
  Widget build(BuildContext context) => const Row(children: [kIcon, kTitle]);
}

2) まとめて破棄する“お守りMixin”

mixin AutoDisposer<T extends StatefulWidget> on State<T> {
  final _disposables = <void Function()>[];

  D track<D>(D resource, void Function() dispose) {
    _disposables.add(dispose);
    return resource;
  }

  @override
  void dispose() {
    for (final d in _disposables.reversed) { d(); }
    super.dispose();
  }
}

使う側で
track(Stream.periodic(...).listen(...), () => sub.cancel());
track(AnimationController(vsync: this), () => anim.dispose()); のように登録。

3) 画像の段階読み

Widget buildThumb(String url) => CachedNetworkImage(
  imageUrl: url,
  memCacheWidth: 400, memCacheHeight: 400,
);

Widget buildDetail(String url) => CachedNetworkImage(imageUrl: url);

4) Isolateで重い集計

import 'dart:isolate';

Future<R> runInIsolate<Q, R>(R Function(Q) fn, Q message) async {
  final rp = ReceivePort();
  await Isolate.spawn(_entry, [rp.sendPort, fn, message]);
  return await rp.first as R;
}

void _entry(List<dynamic> args) {
  final send = args[0] as SendPort;
  final fn   = args[1] as Function;
  final msg  = args[2];
  send.send(fn(msg));
}

よくある誤解(Myth → Fact)

  • Myth: “ウィジェットは壊さずに持ち回した方が速い”
    Fact: Flutterは短命オブジェクト前提で最適化。握り続けると老世代が太るだけ。

  • Myth: “GCは悪、走らせない方がよい”
    Fact: 若世代GCは高速で、健康な循環の一部。老世代を太らせない設計が本質。

  • Myth: “静的/シングルトンに詰めればメモリ再利用で速い”
    Fact: 参照が残り回収不能に。寿命管理ができるスコープへ。

まとめ

  • Flutterの滑らかさは若世代中心の高速GC適切な寿命設計で守れる。
  • 老世代を膨らませない(参照を残さない・無駄に保持しない)が最重要。
  • 観測(DevTools)→ 原因特定 → const/dispose/画像・リスト最適化/Isolate化 の順で、
    フレーム予算内に処理を納めることが勝ち筋。
  • 「生成を減らし、保持を減らし、観測で確かめる」。jank、減らしていきましょう

参考