対象者
- 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の仕組み
スタックとヒープ(超要約)
- スタック: 関数スコープの局所変数。呼び出し終了で自動解放(超高速)。
- ヒープ:
Widget
、List
、画像・キャッシュなどのオブジェクト領域。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は、リークしがち。
ChangeNotifier
もremoveListener
&dispose
を忘れずに。
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:スクロールでカクつく大規模リスト
- DevToolsでFrameとMemoryを記録
- スクロール中の老世代GCが重ならないか確認
const
化・セル内計算のキャッシュ・画像縮小/キャッシュaddAutomaticKeepAlives=false
等で過剰保持を削減- 再計測。割当/フレーム落ち/老世代増の改善を確認
レシピB:長時間稼働でメモリが戻らない
- 一定間隔でHeap Snapshotを採取 → 生存集合を比較
- 参照元チェーンから未解約の購読/リスナを特定
dispose()
/cancel()
/removeListener()
/dispose()
を徹底- 静的参照・シングルトンの参照切りも点検
レシピC:重い計算でフレームを圧迫
cpu profiler
とframe chart
で計算ホットスポットを特定- 結果キャッシュ or Isolate移送(メッセージ最小化)
- 再計測し、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、減らしていきましょう
参考
-
Next
記事がありません