株式会社Neverのshoheiです。
株式会社Neverは「NEVER STOP CREATE 作りつづけること」をビジョンに掲げ、理想を実現するためにプロダクトを作り続ける組織です。モバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽にお問合せください。
TL;DR
- Dartのメモリ管理はガベージコレクションを利用しており、ガベージコレクターが定期的に参照の無くなったオブジェクトを探し、メモリから解放する
- Dev Toolsでメモリの状況を確認でき、
Diff Snapshots
を使うとメモリに割り当てられたクラスオブジェクトを確認できる - 静的解析に従えば特に気にすることないが、メモリを食う重たいリストの遅延読み込みを怠ると
Out of memory
のリスクがあるので注意
概要
社内でFlutter(Dart)のメモリ管理について説明する機会があったので、いっそのこと記事にまとめました。
公式ドキュメントにメモリ管理について詳しく書かれております。本記事では、様々なパターンの実装をしてみてメモリの状況がどうなるか解説します。
メモリ管理の仕組み
FlutterはDart Runtime1によりメモリ管理が行われています。作成されたオブジェクトは、メモリのヒープ領域に割り当てられ、そのオブジェクトが使用されなくなったらメモリから解放されます。
Dartのメモリ管理はガベージコレクション2を利用しています。ガベージコレクターが定期的に参照の無くなったオブジェクトを探し、メモリから解放します。3
void main() {
// main関数のスコープ内では DummyObject が生成され、メモリに割り当てられる
final data = DummyObject.create();
debugPrint(data.text);
}
// main関数の処理が終わるとスコープから外れるため、
// dataの参照できる状態がなくなり、DummyObject がメモリから解放される
この例ではDummyObject
がルートオブジェクトとなり、このオブジェクトの参照がなくなるとメモリから解放されます4
また、グローバルオブジェクトの場合は、グローバルスコープ上で常に参照できる状態であるため、main関数のスコープから外れてもメモリが割り当てられた状態になります。解放する場合は、DummyObject? data
にnullをセットします。
// グローバルオブジェクト
DummyObject? data = DummyObject.create();
void main() {
debugPrint(data?.text);
}
// main関数の処理が終わっても DummyObject はメモリから解放されない
// 解放する場合はnullをセットする
// data = null;
メモリリークとは
メモリリークとは、確保したメモリの解放を忘れてしまい、不要にメモリを確保し続けることです。
メモリリークが増えると、アプリが利用できるメモリリソースが枯渇し、アプリのパフォーマンスが低下して最終的にはOut of memory
となり強制終了します。
Dartは普通に実装していればメモリリソースが枯渇するような現象は起きにくい言語ですが、オブジェクトを参照していないはずが参照していたり、破棄されるはずが破棄されていないなど、意図しないメモリ確保が増え続けるとOut of memory
になる恐れがあります。
では、どのぐらいのメモリリソースを使うとOut of memory
になるかは、端末によってメモリリソースの上限は変わるため、厳密にはわかりません。5ただ、メガバイト単位の大きなデータがリークし続けない限り、Out of memory
になることはあまりないと考えます。
実装者として、メモリリークのリスクを減らすために「意図しないメモリ確保を防ぐこと」と「画像等のサイズが大きいデータを適切に扱うこと」を意識して実装することが大切だと考えます。
Dev Toolsでメモリ割り当てを見る
FlutterのDev Toolsを使うことで、メモリの状況を確認できます。
Diff Snapshots
を使い、メモリに割り当てられたオブジェクトを確認できます。確認できるオブジェクトはクラスオブジェクトのみです。
また、デフォルトではFlutter SDKが提供するオブジェクトも一覧に表示されてしまっているので、プロジェクト内のオブジェクトのみ表示したい場合は、Class
横のフィルターアイコンから選択できます。
Show only
を選択すると、プロジェクト内のオブジェクトのみ表示されます。
表の説明は以下の通りです。
- Class: メモリに割り当てられたオブジェクトのクラス名
- Instances: メモリに割り当てられた数
- Shallow Dart Size: このオブジェクトタイプによって使用されているメモリサイズ
- Retained Dart Sise: このクラスのすべてのインスタンスのために保持されているメモリの合計サイズ
公式ドキュメントに項目の詳しい説明が見当たりませんが、こちらのドキュメントと同等だと思いますので抜粋しました。
その他にもメモリの状況を確認するための機能がありますが、本記事ではDiff Snapshots
のみで確認します。それ以外の機能についてはこちらの記事をご確認ください。
実装をしてメモリ割り当ての状況を見る
では、Dev Toolsを使ってメモリ割り当ての状況を見ていきます。Dev Toolsではクラスオブジェクトしか状況を見ることができないので、Dev Toolsで確認できないものはdebugPrint
で確認します。
グローバルオブジェクトの状態
グローバルオブジェクトの状態を確認します。以下のDummyObject
を使って確認します。
class DummyObject {
DummyObject(this.text);
factory DummyObject.create() => DummyObject(
List.generate(1024, (_) => '0').fold('', (a, b) => a + b),
);
final String text;
}
グローバルオブジェクトは生成されると、グローバルスコープ上で常に参照している状態であるため、func
関数のスコープから外れてもメモリが割り当てられた状態になります。
DummyObject? dummyObject;
void func() {
dummyObject = DummyObject.create();
}
func
関数を実行すると、1KBのDummyObject
のインスタンスが1つ割り当てられていることを確認できました。
次に、メモリから解放するためにはnullをセットします。
DummyObject? dummyObject;
void func() {
dummyObject = null;
}
dummyObject
がnullになると、dummyObject
のオブジェクトが破棄されます。スナップショットよりDummyObject
が消えている事を確認できました。
定数のstaticとgetter
定数をstatic final
で提供する場合とstatic get
で提供する場合のメモリの状況を確認します。
static final
で提供した場合、1度参照するとメモリから解放することができませんが、常にそのメモリ上にオブジェクトがセットされているため、どこからでも一意のオブジェクトを参照できます。
static get
で提供した場合、参照する度に、オブジェクトが生成されるため、参照が終わるとメモリから解放されます。
class Title {
Title(this.value);
final String value;
}
class Message {
Message._();
static final Title title1 = Title('タイトル1');
static Title get title2 => Title('タイトル2');
}
void func1() {
final title = Message.title1;
debugPrint(title.hashCode.toString());
// 以降どこから参照しても同じオブジェクトになる
// func1が何回呼ばれても、title1のhashCodeは同じ
}
void func2() {
final title = Message.title2;
debugPrint(title.hashCode.toString());
// スコープ内でしか、同じオブジェクトは参照できない
// func2が呼ばれる度に、title2のhashCodeは異なる
}
func1
関数が1度でも呼ばれると、16BのTitle
のインスタンスが1つ割り当てられていることを確認できました。
func2
関数の場合、スコープから外れるとtitle2
で生成したTitle
はメモリから解放されます。メモリの状況を確認したい場合は、以下の実装のようにFuture.delayed
を使って待ち合わせている間、スナップショットするとメモリの割り当てを確認できます。待ち合わせが終わってからスナップショットするとメモリが解放されていることが分かります。
Future<void> func2() async {
final title = Message.title2;
// 待つ間にスナップショットをみると、メモリが割り当てられていることが分かる
await Future<void>.delayed(const Duration(seconds: 3));
debugPrint(title.hashCode.toString());
}
画面遷移後のオブジェクト
Navigator.of(context, rootNavigator: true).push
で画面遷移した場合、遷移先で保持するオブジェクトのメモリ状況を確認します。
class CompositionData {
CompositionData(this.text);
final String text;
}
class CompositionTest extends StatelessWidget {
const CompositionTest(this.data, {super.key});
final CompositionData data; // 👈 このオブジェクトがどうなるか確認
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(runtimeType.toString()),
),
body: Center(
child: Text(
data.hashCode.toString(),
),
),
);
}
}
CompositionTest
の画面へ遷移します。
void show() {
Navigator.of(context, rootNavigator: true).push<void>(
CupertinoPageRoute<void>(
settings:
const RouteSettings(name: 'composition_test'),
builder: (_) {
// 👇 builder内でCompositionDataを生成
final data = CompositionData('test');
return CompositionTest(data);
},
),
);
}
CompositionTest
が表示されるとCompositionData
はメモリに割り当てられます。
CompositionTest
を閉じると、CompositionData
が解放されます。
面白いことに、CompositionData
の生成するタイミングにより、CompositionTest
を閉じてもメモリが解放されないケースがあります。以下の実装の場合です。
void show() {
// 👇 builder外でCompositionDataを生成
final data = CompositionData('test');
Navigator.of(context, rootNavigator: true).push<void>(
CupertinoPageRoute<void>(
settings:
const RouteSettings(name: 'composition_test'),
builder: (_) {
return CompositionTest(data);
},
),
);
}
CompositionTest
が表示されるとCompositionData
はメモリに割り当てられます。
CompositionTest
を閉じても、CompositionData
が解放されません。
Shortest Retaining Path for Instances of CompositionData
を見てみると、_List -> _RouteEntiry -> CupertinoPageRoute -> _Clousre -> Context
経由で参照しているようです。なお、show
関数の呼び元である画面が閉じられると、CompositionData
はメモリから解放されます。
ログより、画面遷移元のページがまだCupertinoPageRoute
を参照しているようで、その影響?でbuilder内
でCompositionData
を参照し続けているようです。循環参照のような形がまずいのかもしれません。async - awaitをして画面遷移の終わりを待ち合わせても同じでした。この現象について、十分に調査ができていないため、分かり次第追記致します。
constを付けた時と付けなかった時のWidget
不要な描画を防ぐために良く使われるconst Widget
について確認します。以下の実装でメモリの状況がどうなるか確認します。
class ConstTest extends StatelessWidget {
const ConstTest({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(runtimeType.toString()),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ConstWidget(), // 👈 確認する
const ConstStatefulWidget(), // 👈 確認する
NoConstWidget(), // 👈 確認する
],
),
),
);
}
}
ConstWidget
const
を付けたConstWidget
のオブジェクトがどうなるか見てみます。
class ConstWidget extends StatelessWidget {
const ConstWidget({super.key});
@override
Widget build(BuildContext context) {
return Text(hashCode.toString());
}
}
...
class ConstTest extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return ...
const ConstWidget(),
...
}
}
ConstTest
を表示時に、ConstWidget
がメモリに割り当てられます。
ConstTest
を閉じた時、ConstWidget
がメモリから解放されずそのまま残り続けます。
ログより、Rootから常に参照されるようになり、Widgetのオブジェクトはメモリに残り続けるようです。描画が走っても既に割り当てられているメモリからWidgetのオブジェクトを利用するようになります。
※コメントでご指摘があった通り、constはコンパイル時に定数値としてメモリに設定されるため、const Widget
は既にメモリに割り当てられてる状態であり、参照することでDev Toolsから見えるようになっただけかと思われます。
ConstStatefulWidget
ConstStatefulWidget
内で保持するオブジェクトがどうなるか見てみます。
class ConstStatefulWidget extends StatefulWidget {
const ConstStatefulWidget({super.key});
@override
State<ConstStatefulWidget> createState() => ConstStatefulState();
}
class ConstStatefulState extends State<ConstStatefulWidget> {
final DummyObject dummyObject = DummyObject.create();
@override
Widget build(BuildContext context) {
return Text(dummyObject.hashCode.toString());
}
}
...
class ConstTest extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return ...
const ConstStatefulWidget(),
...
}
}
ConstTest
を表示時、ConstStatefulWidget
とDummyObject
がメモリに割り当てられます。
ConstTest
を閉じた時、ConstStatefulWidget
のみメモリに残り続けますが、DummyObject
はメモリから解放されました。
NoConstWidget
const
をつけなかった場合を見てみます。NoConstWidget
の実装はConstWidget
と同じなので割愛します。
...
class ConstTest extends StatelessWidget {
...
@override
Widget build(BuildContext context) {
return ...
NoConstWidget(),
...
}
}
ConstTest
を表示時に、NoConstWidget
がメモリに割り当てられます。
ConstTest
を閉じた時、NoConstWidget
がメモリから解放されます。
const
をつけていない場合は描画が走る度に、
「以前のものをメモリから解放 → Widgetを生成 → メモリに割り当てる」
といった流れを繰り返すかと思います。
ScrollControllerのListener
ScrollController
にはスクロールアクションが発生する度に、指定のコールバック関数を発火させることができます。 addListener
にコールバック関数を指定することで、スクロールする度にその関数が呼ばれます。
final scrollController = ScrollController();
void add() {
final obj = DummyObject.create();
// コールバック関数
void listener() {
debugPrint('scroll: ${obj.hashCode}'); // スクロールする度に呼ばれる
}
scrollController.addListener(listener);
}
注意点として、addListener
したコールバック関数を更新したい場合です。
例では、add
関数を複数回呼ぶと、void listener()
の新しいオブジェクトが作られるため、addListener
には新しいコールバック関数としてセットされてしまいます。これにより、同じロジックの処理がスクロールする度に重複して発火されます。
removeListener
でコールバック関数を解除できますが、解除したいコールバック関数のオブジェクトがなければ解除できません。scrollController.dispose
する以外で解除する方法がなく、scrollController
がメモリから解放されるまで不要なコールバック関数のオブジェクトはメモリに残り続けてしまいます。
対応としては、セットした古いコールバック関数のオブジェクトを保持し、そのオブジェクトのListener
を削除してから新しいコールバック関数をaddListener
することです。
final scrollController = ScrollController();
VoidCallback? oldListener;
void add() {
// 古いコールバック関数を削除
final listener = oldListener;
if (listener != null) {
scrollController.removeListener(listener);
}
final obj = DummyObject.create();
// 新しいコールバック関数をセット
void newListener() {
debugPrint('scroll: ${obj.hashCode}'); // スクロールする度に呼ばれる
}
scrollController.addListener(newListener);
// いつでも削除できるよう、古いコールバック関数として保持する
oldListener = newListener;
}
また、例のように、DummyObject
のオブジェクトをコールバック関数から参照するような実装をすると、そのコールバック関数を解除されるまで、DummyObject
はメモリから解放されません。解放するためには、scrollController.removeListener
を使ってscrollController
からの参照をなくし、oldListener
にnullをセットすることです。
StreamControllerのStream
Streamを使うことで、イベント送信を監視して、イベントの受信処理を実施できます。
final streamController = StreamController<DummyObject>.broadcast();
StreamSubscription<DummyObject>? disposer;
void listen() {
// イベント送信を監視
disposer = streamController.stream.listen((event) {
// イベント受信後の処理
// 待つ間にスナップショットをみると、メモリが割り当てられていることが分かる
await Future<void>.delayed(const Duration(seconds: 3));
debugPrint('event: ${event.hashCode}');
});
}
void add() {
// イベントを送信
final data = DummyObject.create();
streamController.sink.add(data);
}
void dispose() {
// イベントの監視を止める
disposer?.cancel();
disposer = null;
}
stream.listen
をすることで、stream
から流れてくるイベントを受信して処理できます。
実装例として、listen
関数を実行後、add
関数を実行するとDummyObject
のオブジェクトがstream
へ送信され、stream.listen
内の処理が走り、debugPrint
が表示されます。
DummyObject
オブジェクトへの参照は、stream.listen
の処理が終わるとなくなるため、DummyObject
オブジェクトはメモリに残り続けません。
注意点として、stream.listen
のcancel()
忘れです。cancel()
を忘れると、イベントの受信を継続してしまうので、重複したイベントが受信される等の意図しない挙動につながってしまいます。
stream.listen
の購読オブジェクトはdisposer
として保持し、監視が不要になったらcancel()
を実行して監視をキャンセルします。イベントを送信しても、イベントは受信しないのでstream.listen
内の処理は走りません。
RiverpodのautoDispose
RiverpodではProvider
にautoDispose
を付与することで、参照がなくなるとメモリから解放されます。autoDispose
が付与されていない場合、参照されると一意のオブジェクトがメモリに割り当てられ続けるので、どこからでも同じオブジェクトを参照できます(中で依存する他のProvider
の状態が更新されたり、invalidate
やrefresh
するとオブジェクトが書き換わりますが)
final dummyObjectProvider = Provider<DummyObject>((ref) {
ref.onDispose(() {
debugPrint('dispose');
});
return DummyObject.create();
});
final dummyObjectAutoDisposeProvider = Provider.autoDispose<DummyObject>((ref) {
ref.onDispose(() {
debugPrint('dispose');
});
return DummyObject.create();
});
void checkObject(Ref ref) {
final value = ref.read(dummyObjectProvider);
debugPrint('${value.hashCode}');
}
void checkObjectAutoDispose(Ref ref) {
final value = ref.read(dummyObjectAutoDisposeProvider);
// 待つ間にスナップショットをみると、メモリが割り当てられていることが分かる
await Future<void>.delayed(const Duration(seconds: 3));
debugPrint('${value.hashCode}');
}
checkObject
関数を実行すると、DummyObject
がメモリに割り当てられます。再度実行しても、同じDummyObject
が取得されます。
checkObjectAutoDispose
関数を実行すると、スコープ内ではDummyObject
がメモリに割り当てられます。
スコープ外になるとメモリから解放されます。
ListViewで遅延読み込み
ListViewやCustomScrollView + SliverList系を使うことで表示するリストの遅延読み込みができます。これは「見えている範囲」+ α(cacheExtentの数値)のWidgetだけをメモリに割り当てるため、アプリのメモリリソースを効率良く扱うことができます(GridView系も同様)
以下はSliverList
を使った例です。
class SliverListPage extends StatelessWidget {
const SliverListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(runtimeType.toString()),
),
body: CustomScrollView(
slivers: [
const SliverToBoxAdapter(
child: Center(
child: Text(
'ヘッダー',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
SliverList.builder(
itemBuilder: (context, index) {
return CustomListTile(index.toString());
},
itemCount: 100,
),
],
),
);
}
}
初回表示時に、メモリの状況を確認します。見えている範囲 + その前後(cacheExtentの数値により変わる)のWidgetがメモリに割り当てられています。
スクロールをしても、メモリに割り当てられるWidget数はほとんど変わりがありません。一度割り当てられたWidgetは、見えなくなったタイミングでメモリから解放されます。
これは画像等の重たいWidgetを一覧表示する場合にとても有効です。遅延読み込みがなければ、全てのWidgetをメモリに割り当ててしまうため、Out of memory
になる可能性があります。
遅延読み込みがあることでOut of memory
のリスクを抑えれます。
考察
様々な実装パターンでオブジェクトがどのようにメモリに割り当て解放されるか確認しました。おおむねイメージ通りでした。
const Widget
は、描画される度に既に生成されたWidget
を使い回していることが分かりました。ログをみるとWidget
の中で参照するオブジェクトはWidget
の参照がなくなると解放されていたので、恐らくWidget
のみメモリに確保し、その先のElement
とRenderObject
は解放しているのではないかと推測されます(そこまでログで確認できなかった)
また、const
を付与することでメモリ上に残り続けて良いのか?ということですが、回答としては数バイトのオブジェクトであれば問題ないと考えます。Flutterのパフォーマンスに直結する部分であり、60fpsのFlutterは1フレーム当たりのレンダリング時間を16msに抑えることが好ましいとされています。変更がない状態をconst
にすることで、不要なオブジェクトの生成をなくすことができ、レンダリング時間を良くすることに繋がります。定数化したいオブジェクトは数バイト程度のものがほとんどだと思いますので、数バイトでパフォーマンスが良くなるなら使おうといった考えです。また、静的解析でもconst
をつけるよう警告がでるので、それに従っていれば問題ないと考えています。
その他にも、ListViewの遅延読み込みは、画像等の重たいWidgetをリストで扱う場合は必ず使うべきだと思います。遅延読み込みがされず、アプリが落ちる問題はよく観測されていたので、期待通りに遅延読み込みができているかDev Tools等で確認することも大事です。
あと、Dev Toolsがもう少し使いやすくなってほしいと思いました。触っていると、結構な頻度でDev Toolsがエラーを吐いて落ちることがあったので大変でした。さらに、Dev Tools内の設定は永続化されないので、開く度にいちいちフィルターの設定をするのが面倒でした笑。この辺りの改善に期待します。
まとめ
Flutter(Dart)のメモリ管理について、実装を交えてDev Toolsで確認しました。DartはSwiftのARC(自動参照カウント)の仕組みと同じかな?と思いましたが、ガベージコレクションでした。6
実装者のメモリ管理のコストを減らしてくれる良い仕組みだと思います。オブジェクトを意図せずに参照し続けてしまう実装にしないよう注意は必要です。
今回の検証用コードはこちらのリポジトリにまとめています。