LoginSignup
65
39

Flutter(Dart)のメモリ管理について

Last updated at Posted at 2023-07-27

株式会社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を使うことで、メモリの状況を確認できます。

スクリーンショット 2023-07-26 9.18.43.png

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

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

スクリーンショット 2023-07-26 9.18.53.png

スクリーンショット 2023-07-26 9.19.33.png

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

スクリーンショット 2023-07-26 9.25.57.png

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

  • 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();
}

スクリーンショット 2023-07-26 15.27.52.png

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

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

DummyObject? dummyObject;

void func() {
  dummyObject = null;
}

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

スクリーンショット 2023-07-26 15.29.42.png

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

スクリーンショット 2023-07-26 15.31.28.png

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

スクリーンショット 2023-07-26 15.34.27.png

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

スクリーンショット 2023-07-26 15.35.32.png

面白いことに、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はメモリに割り当てられます。

スクリーンショット 2023-07-26 15.36.26.png

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

スクリーンショット 2023-07-26 15.36.56.png

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

スクリーンショット 2023-07-26 15.22.31.png

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

スクリーンショット 2023-07-26 15.22.31.png

ログより、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を表示時、ConstStatefulWidgetDummyObjectがメモリに割り当てられます。

スクリーンショット 2023-07-26 15.21.15.png

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

スクリーンショット 2023-07-26 15.21.27.png

NoConstWidget

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

...

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

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

スクリーンショット 2023-07-26 15.23.46.png

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.listencancel()忘れです。cancel()を忘れると、イベントの受信を継続してしまうので、重複したイベントが受信される等の意図しない挙動につながってしまいます。

stream.listenの購読オブジェクトはdisposerとして保持し、監視が不要になったらcancel()を実行して監視をキャンセルします。イベントを送信しても、イベントは受信しないのでstream.listen内の処理は走りません。

RiverpodのautoDispose

RiverpodではProviderautoDisposeを付与することで、参照がなくなるとメモリから解放されます。autoDisposeが付与されていない場合、参照されると一意のオブジェクトがメモリに割り当てられ続けるので、どこからでも同じオブジェクトを参照できます(中で依存する他のProviderの状態が更新されたり、invalidaterefreshするとオブジェクトが書き換わりますが)

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

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

スクリーンショット 2023-07-26 16.58.23.png

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

ListViewで遅延読み込み

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

スクリーンショット 2023-07-26 17.13.04.png

スクロールをしても、メモリに割り当てられるWidget数はほとんど変わりがありません。一度割り当てられたWidgetは、見えなくなったタイミングでメモリから解放されます。

これは画像等の重たいWidgetを一覧表示する場合にとても有効です。遅延読み込みがなければ、全てのWidgetをメモリに割り当ててしまうため、Out of memoryになる可能性があります。

遅延読み込みがあることでOut of memoryのリスクを抑えれます。

考察

様々な実装パターンでオブジェクトがどのようにメモリに割り当て解放されるか確認しました。おおむねイメージ通りでした。

const Widgetは、描画される度に既に生成されたWidgetを使い回していることが分かりました。ログをみるとWidgetの中で参照するオブジェクトはWidgetの参照がなくなると解放されていたので、恐らくWidgetのみメモリに確保し、その先のElementRenderObjectは解放しているのではないかと推測されます(そこまでログで確認できなかった)

また、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

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

今回の検証用コードはこちらのリポジトリにまとめています。

  1. Dart overview: The Dart runtime

  2. Flutter: Don’t Fear the Garbage Collector

  3. Mastering Dart & Flutter DevTools — Part 7: Memory View

  4. Root object, retaining path, and reachability

  5. Reasons to use the memory view

  6. Memory Cycles in Flutter

65
39
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
65
39