Flutter（Dart）のメモリ管理について

[株式会社Never](https://neverjp.com/)のshoheiです。

株式会社Neverは「NEVER STOP CREATE 作りつづけること」をビジョンに掲げ、理想を実現するためにプロダクトを作り続ける組織です。モバイルアプリケーションの受託開発、技術支援、コンサルティングを行っております。アプリ開発のご依頼や開発面でのお困りの際はお気楽にお問合せください。

https://neverjp.com/service/

# TL;DR

- Dartのメモリ管理はガベージコレクションを利用しており、ガベージコレクターが定期的に参照の無くなったオブジェクトを探し、メモリから解放する
- Dev Toolsでメモリの状況を確認でき、`Diff Snapshots`を使うとメモリに割り当てられたクラスオブジェクトを確認できる
- 静的解析に従えば特に気にすることないが、メモリを食う重たいリストの遅延読み込みを怠ると`Out of memory`のリスクがあるので注意

# 概要

社内でFlutter（Dart）のメモリ管理について説明する機会があったので、いっそのこと記事にまとめました。

[公式ドキュメント](https://docs.flutter.dev/tools/devtools/memory)にメモリ管理について詳しく書かれております。本記事では、様々なパターンの実装をしてみてメモリの状況がどうなるか解説します。

# メモリ管理の仕組み

FlutterはDart Runtime[^1]によりメモリ管理が行われています。作成されたオブジェクトは、メモリの[ヒープ領域](https://ja.wikipedia.org/wiki/%E3%83%92%E3%83%BC%E3%83%97%E9%A0%98%E5%9F%9F)に割り当てられ、そのオブジェクトが使用されなくなったらメモリから解放されます。

Dartのメモリ管理はガベージコレクション[^2]を利用しています。ガベージコレクターが定期的に参照の無くなったオブジェクトを探し、メモリから解放します。[^3]

```dart
void main() {
 // main関数のスコープ内では DummyObject が生成され、メモリに割り当てられる
 final data = DummyObject.create();
 debugPrint(data.text);
}
// main関数の処理が終わるとスコープから外れるため、
// dataの参照できる状態がなくなり、DummyObject がメモリから解放される
```

この例では`DummyObject`がルートオブジェクトとなり、このオブジェクトの参照がなくなるとメモリから解放されます[^4]


また、グローバルオブジェクトの場合は、グローバルスコープ上で常に参照できる状態であるため、main関数のスコープから外れてもメモリが割り当てられた状態になります。解放する場合は、`DummyObject? data`にnullをセットします。

```dart
// グローバルオブジェクト
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を使うことで、メモリの状況を確認できます。

![スクリーンショット 2023-07-26 9.18.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/765a6aaf-3803-626e-1268-2208e230bdca.png)

`Diff Snapshots`を使い、メモリに割り当てられたオブジェクトを確認できます。確認できるオブジェクトはクラスオブジェクトのみです。

また、デフォルトではFlutter SDKが提供するオブジェクトも一覧に表示されてしまっているので、プロジェクト内のオブジェクトのみ表示したい場合は、`Class`横のフィルターアイコンから選択できます。

![スクリーンショット 2023-07-26 9.18.53.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/ff037b9c-c667-6271-df1b-b9211517832e.png)

![スクリーンショット 2023-07-26 9.19.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/bd43b049-7cb8-5c83-7be2-152ce660461c.png)

`Show only`を選択すると、プロジェクト内のオブジェクトのみ表示されます。

![スクリーンショット 2023-07-26 9.25.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/8c94cd5c-a8d8-b517-07cd-ac4e02cff56f.png)

表の説明は以下の通りです。

- Class: メモリに割り当てられたオブジェクトのクラス名
- Instances: メモリに割り当てられた数
- Shallow Dart Size: このオブジェクトタイプによって使用されているメモリサイズ
- Retained Dart Sise: このクラスのすべてのインスタンスのために保持されているメモリの合計サイズ

公式ドキュメントに項目の詳しい説明が見当たりませんが、こちらの[ドキュメント](https://developer.android.com/studio/profile/memory-profiler?hl=ja#capture-heap-dump)と同等だと思いますので抜粋しました。

その他にもメモリの状況を確認するための機能がありますが、本記事では`Diff Snapshots`のみで確認します。それ以外の機能についてはこちらの[記事](https://medium.com/@fluttergems/mastering-dart-flutter-devtools-memory-view-part-7-of-8-e7f5aaf07e15)をご確認ください。

# 実装をしてメモリ割り当ての状況を見る

では、Dev Toolsを使ってメモリ割り当ての状況を見ていきます。Dev Toolsではクラスオブジェクトしか状況を見ることができないので、Dev Toolsで確認できないものは`debugPrint`で確認します。

## グローバルオブジェクトの状態

グローバルオブジェクトの状態を確認します。以下の`DummyObject`を使って確認します。

```dart
class DummyObject {
  DummyObject(this.text);

  factory DummyObject.create() => DummyObject(
        List.generate(1024, (_) => '0').fold('', (a, b) => a + b),
      );

  final String text;
}
```

グローバルオブジェクトは生成されると、グローバルスコープ上で常に参照している状態であるため、`func`関数のスコープから外れてもメモリが割り当てられた状態になります。



```dart
DummyObject? dummyObject;

void func() {
  dummyObject = DummyObject.create();
}
```

![スクリーンショット 2023-07-26 15.27.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/dd9d8017-3bc5-8b8a-0071-000ea809d928.png)

`func`関数を実行すると、1KBの`DummyObject`のインスタンスが1つ割り当てられていることを確認できました。

次に、メモリから解放するためにはnullをセットします。

```dart
DummyObject? dummyObject;

void func() {
  dummyObject = null;
}
```

`dummyObject`がnullになると、`dummyObject`のオブジェクトが破棄されます。スナップショットより`DummyObject`が消えている事を確認できました。

![スクリーンショット 2023-07-26 15.29.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/d5abbc23-0653-78a9-a96f-2cedf7dd330d.png)

## 定数のstaticとgetter
定数を`static final`で提供する場合と`static get`で提供する場合のメモリの状況を確認します。

`static final`で提供した場合、1度参照するとメモリから解放することができませんが、常にそのメモリ上にオブジェクトがセットされているため、どこからでも一意のオブジェクトを参照できます。

`static get`で提供した場合、参照する度に、オブジェクトが生成されるため、参照が終わるとメモリから解放されます。

```dart
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つ割り当てられていることを確認できました。

![スクリーンショット 2023-07-26 15.31.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/25fc8c0b-1457-dc99-5490-cea74c607a41.png)


`func2`関数の場合、スコープから外れると`title2`で生成した`Title`はメモリから解放されます。メモリの状況を確認したい場合は、以下の実装のように`Future.delayed`を使って待ち合わせている間、スナップショットするとメモリの割り当てを確認できます。待ち合わせが終わってからスナップショットするとメモリが解放されていることが分かります。

```dart
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`で画面遷移した場合、遷移先で保持するオブジェクトのメモリ状況を確認します。

```dart
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`の画面へ遷移します。

```dart
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`はメモリに割り当てられます。

![スクリーンショット 2023-07-26 15.34.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/4d7bcd74-ef8f-5b84-07ad-6811ac91f79c.png)


`CompositionTest`を閉じると、`CompositionData`が解放されます。

![スクリーンショット 2023-07-26 15.35.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/f7783642-eff1-e17e-e161-258f8c2daab1.png)


面白いことに、`CompositionData`の生成するタイミングにより、`CompositionTest`を閉じてもメモリが解放されないケースがあります。以下の実装の場合です。

```dart
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`はメモリに割り当てられます。

![スクリーンショット 2023-07-26 15.36.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/62fae6ea-19b5-9915-b8c9-50316b308c16.png)


`CompositionTest`を閉じても、`CompositionData`が解放されません。

![スクリーンショット 2023-07-26 15.36.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/03e0e4e8-1cd9-e2c9-749a-528f8d885e36.png)


`Shortest Retaining Path for Instances of CompositionData`を見てみると、`_List -> _RouteEntiry -> CupertinoPageRoute -> _Clousre -> Context`経由で参照しているようです。なお、`show`関数の呼び元である画面が閉じられると、`CompositionData`はメモリから解放されます。

ログより、画面遷移元のページがまだ`CupertinoPageRoute`を参照しているようで、その影響？で`builder内`で`CompositionData`を参照し続けているようです。循環参照のような形がまずいのかもしれません。async - awaitをして画面遷移の終わりを待ち合わせても同じでした。この現象について、十分に調査ができていないため、分かり次第追記致します。


## constを付けた時と付けなかった時のWidget
不要な描画を防ぐために良く使われる`const Widget`について確認します。以下の実装でメモリの状況がどうなるか確認します。

```dart
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`のオブジェクトがどうなるか見てみます。

```dart
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`がメモリに割り当てられます。

![スクリーンショット 2023-07-26 15.22.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/5dcf01e6-f4f1-31d2-fdf8-d2d283b48197.png)

`ConstTest`を閉じた時、`ConstWidget`がメモリから解放されずそのまま残り続けます。

![スクリーンショット 2023-07-26 15.22.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/643edea7-036c-fae6-7107-138a7024124a.png)


ログより、Rootから常に参照されるようになり、Widgetのオブジェクトはメモリに残り続けるようです。描画が走っても既に割り当てられているメモリからWidgetのオブジェクトを利用するようになります。


※コメントでご指摘があった通り、constはコンパイル時に定数値としてメモリに設定されるため、`const Widget`は既にメモリに割り当てられてる状態であり、参照することでDev Toolsから見えるようになっただけかと思われます。

### ConstStatefulWidget

`ConstStatefulWidget`内で保持するオブジェクトがどうなるか見てみます。

```dart
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`がメモリに割り当てられます。

![スクリーンショット 2023-07-26 15.21.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/3b892ad1-0d94-9fd1-bd00-e71d543d2e47.png)

`ConstTest`を閉じた時、`ConstStatefulWidget`のみメモリに残り続けますが、`DummyObject`はメモリから解放されました。

![スクリーンショット 2023-07-26 15.21.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/1d33937a-adbb-c334-1457-3540083f2046.png)

### NoConstWidget

`const`をつけなかった場合を見てみます。`NoConstWidget`の実装は`ConstWidget`と同じなので割愛します。

```dart
...

class ConstTest extends StatelessWidget {
    ...
    @override
    Widget build(BuildContext context) {
        return ...
            NoConstWidget(), 
        ...
    }
}
```

`ConstTest`を表示時に、`NoConstWidget`がメモリに割り当てられます。

![スクリーンショット 2023-07-26 15.23.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/f52f8261-c7ee-a195-c409-ff8d9b5b606a.png)

`ConstTest`を閉じた時、`NoConstWidget`がメモリから解放されます。

`const`をつけていない場合は描画が走る度に、

「以前のものをメモリから解放 → Widgetを生成 → メモリに割り当てる」

といった流れを繰り返すかと思います。

## ScrollControllerのListener

`ScrollController`にはスクロールアクションが発生する度に、指定のコールバック関数を発火させることができます。 `addListener`にコールバック関数を指定することで、スクロールする度にその関数が呼ばれます。

```dart
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`することです。

```dart
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を使うことで、イベント送信を監視して、イベントの受信処理を実施できます。

```dart
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`するとオブジェクトが書き換わりますが）

```dart
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`が取得されます。

![スクリーンショット 2023-07-26 16.52.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/f37ea54a-7d93-fe69-f8ee-f435ecf15f61.png)


`checkObjectAutoDispose`関数を実行すると、スコープ内では`DummyObject`がメモリに割り当てられます。

![スクリーンショット 2023-07-26 16.58.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/d5c22500-b5e4-b0a5-d9b5-1ef533587abc.png)

スコープ外になるとメモリから解放されます。

## ListViewで遅延読み込み
[ListView](https://api.flutter.dev/flutter/widgets/ListView-class.html)や[CustomScrollView + SliverList系](https://api.flutter.dev/flutter/widgets/CustomScrollView/slivers.html)を使うことで表示するリストの遅延読み込みができます。これは「見えている範囲」+ α（cacheExtentの数値）のWidgetだけをメモリに割り当てるため、アプリのメモリリソースを効率良く扱うことができます（GridView系も同様）

以下は`SliverList`を使った例です。

```dart
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がメモリに割り当てられています。

![スクリーンショット 2023-07-26 17.13.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183031/32cad077-96d7-eba8-e971-be9ff52f8488.png)

スクロールをしても、メモリに割り当てられる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]

実装者のメモリ管理のコストを減らしてくれる良い仕組みだと思います。オブジェクトを意図せずに参照し続けてしまう実装にしないよう注意は必要です。

今回の検証用コードはこちらの[リポジトリ](https://github.com/never-inc/flutter_experiment)にまとめています。

[^1]: [Dart overview: The Dart runtime](https://dart.dev/overview)
[^2]: [Flutter: Don’t Fear the Garbage Collector](https://medium.com/flutter/flutter-dont-fear-the-garbage-collector-d69b3ff1ca30)
[^3]: [Mastering Dart & Flutter DevTools — Part 7: Memory View](https://medium.com/@fluttergems/mastering-dart-flutter-devtools-memory-view-part-7-of-8-e7f5aaf07e15)
[^4]: [Root object, retaining path, and reachability](https://docs.flutter.dev/tools/devtools/memory#root-object-retaining-path-and-reachability)
[^5]: [Reasons to use the memory view](https://docs.flutter.dev/tools/devtools/memory#reasons-to-use-the-memory-view)
[^6]: [Memory Cycles in Flutter](https://stackoverflow.com/a/71293509/14154450)