【Flutter状態管理解説】change_notifier.dartの仕組みと内部実装を学ぶ【How Flutter Works③】

プロフィール画像

田原 葉

2025年02月19日

リンクをコピーXでシェアするfacebookでシェアする

はじめに

こんにちは、メンバーズ Cross ApplicationOpen in new tab カンパニーの田原です。

この記事は、『【Flutter状態管理解説】change_notifier.dartの仕組みと内部実装を学ぶ【How Flutter Works②】Open in new tab』の続編として、change_notifier.dartの内部実装をさらに深掘りします。

Flutterバージョン3.27.2に基づき、Flutter の foundation パッケージ内に含まれるchange_notifier.dart のコードを詳しく読み解き、その仕組みを解説します。

前回の記事では、ChangeNotifier について、addListener や removeListener メソッドのリスナーの追加・削除とメモリ効率化の仕組みを解説しました。今回は、リスナーを破棄する dispose メソッドと、リスナーに通知をする notifyListener メソッドがどのように機能しているのか、コードリーディングを通じてその設計と実装の深層に迫っていきます。

この記事で、『change_notifier.dartの仕組みと内部実装を学ぶ』シリーズは完結です。

ドキュメントコメントなどの詳細は省略しつつ、重要なポイントをわかりやすく解説していきますので、ぜひ最後までお付き合いください!

▼▼▼実際のリポジトリを読みながら進めたい方はこちらから▼▼▼

コードリーディング: ChangeNotifier

まずは ChangeNotifier のコード全体を確認します。

mixin class ChangeNotifier implements Listenable {
  int _count = 0;
  static final List<VoidCallback?> _emptyListeners = List<VoidCallback?>.filled(0, null);
  List<VoidCallback?> listeners = emptyListeners;
  int _notificationCallStackDepth = 0;
  int _reentrantlyRemovedListeners = 0;
  bool _debugDisposed = false;
  bool _creationDispatched = false;

  static bool debugAssertNotDisposed(ChangeNotifier notifier) {...}

  @protected
  bool get hasListeners => _count > 0;

  @protected
  static void maybeDispatchObjectCreation(ChangeNotifier object) {...}

  @override
  void addListener(VoidCallback listener) {...}

  void _removeAt(int index) {...}

  @override
  void removeListener(VoidCallback listener) {...}

  @mustCallSuper
  void dispose() {...}

  @protected
  @visibleForTesting
  @pragma('vm:notify-debugger-on-exception')
  void notifyListeners() {...}

今回は下記メソッドについて、詳しく読み進めて行きます。

  • void dispose()
  • void notifyListeners()

void dispose()

  @mustCallSuper
  void dispose() {
    assert(ChangeNotifier.debugAssertNotDisposed(this));
    assert(
      _notificationCallStackDepth == 0,
      'The "dispose()" method on $this was called during the call to '
      '"notifyListeners()". This is likely to cause errors since it modifies '
      'the list of listeners while the list is being used.',
    );
    assert(() {
      _debugDisposed = true;
      return true;
    }());
    if (kFlutterMemoryAllocationsEnabled && _creationDispatched) {
      FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
    }
    listeners = emptyListeners;
    _count = 0;
  }

dispose メソッドは、ChangeNotifier クラスでリソースを解放するためのメソッドです。リスナーリストやその関連リソースが解放され、再利用時に問題が発生しないようにします。

  @mustCallSuper
  void dispose() {

@mustCallSuper アノテーションは、このメソッドをオーバーライドする際に、親クラスの dispose メソッドも呼び出すことを強制するものです。これにより、親クラスで行われるリソースの解放処理が適切に呼ばれることが保証されます。

    assert(ChangeNotifier.debugAssertNotDisposed(this));

debugAssertNotDisposed は、このオブジェクトが既に解放されていないかをチェックします。すでに破棄されたオブジェクトに対して dispose メソッドが再度呼ばれることを防ぎます。

この処理は、デバッグ時にのみ有効になります。

    assert(
      _notificationCallStackDepth == 0,
      'The "dispose()" method on $this was called during the call to '
      '"notifyListeners()". This is likely to cause errors since it modifies '
      'the list of listeners while the list is being used.',
    );

_notificationCallStackDepth == 0 というチェックは、notifyListeners メソッドが呼ばれている最中に dispose を呼び出すことを防ぐためのものです。notifyListeners メソッドがリスナーに通知を行う過程でリスナーリストを操作するため、その最中に dispose を呼ぶと、リスナーリストが変更されることになり、予期しない挙動やエラーが発生する可能性があります。

そのため、条件が false の場合、AssertionError がスローされて、dispose の実行が禁止されます。

この処理は、デバッグ時にのみ有効になります。

参考:AssertionErrorOpen in new tab

    assert(() {
      _debugDisposed = true;
      return true;
    }());

_debugDisposed フラグを true に設定し、オブジェクトが解放されている状態を示します。

この処理は、デバッグ時にのみ有効になります。

    if (kFlutterMemoryAllocationsEnabled && _creationDispatched) {
      FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
    }

kFlutterMemoryAllocationsEnabled でメモリ割り当ての追跡が有効かどうか(デバッグモードかどうか)を確認し、_creationDispatched でオブジェクトの作成が通知済みかどうかを確認します。

条件が true の場合、FlutterMemoryAllocations.instance.dispatchObjectDisposed が呼ばれ、メモリ管理ツールに対してオブジェクトが解放されたことを通知します。

    listeners = emptyListeners;
    _count = 0;
  }

_listeners に空のリスナーリスト(_emptyListeners)を設定し、リスナーの数(_count)を 0 にリセットします。これにより、dispose 後にオブジェクトが保持していたリスナーへの参照がすべて解放され、メモリリークが防止されます。

void notifyListeners()

  @protected
  @visibleForTesting
  @pragma('vm:notify-debugger-on-exception')
  void notifyListeners() {
    assert(ChangeNotifier.debugAssertNotDisposed(this));
    if (_count == 0) {
      return;
    }

    _notificationCallStackDepth++;

    final int end = _count;
    for (int i = 0; i < end; i++) {
      try {
        _listeners[i]?.call();
      } catch (exception, stack) {
        FlutterError.reportError(
          FlutterErrorDetails(
            exception: exception,
            stack: stack,
            library: 'foundation library',
            context: ErrorDescription('while dispatching notifications for $runtimeType'),
            informationCollector:
                () => <DiagnosticsNode>[
                  DiagnosticsProperty<ChangeNotifier>(
                    'The $runtimeType sending notification was',
                    this,
                    style: DiagnosticsTreeStyle.errorProperty,
                  ),
                ],
          ),
       );
      }
    }
    _notificationCallStackDepth--;

    if (_notificationCallStackDepth == 0 && _reentrantlyRemovedListeners > 0) {
      final int newLength = count - reentrantlyRemovedListeners;

      if (newLength * 2 <= _listeners.length) {
        final List<VoidCallback?> newListeners = List<VoidCallback?>.filled(newLength, null);

        int newIndex = 0;
        for (int i = 0; i < _count; i++) {
          final VoidCallback? listener = _listeners[i];
          if (listener != null) {
            newListeners[newIndex++] = listener;
          }
        }

        _listeners = newListeners;
      } else {
        for (int i = 0; i < newLength; i += 1) {
          if (_listeners[i] == null) {
            int swapIndex = i + 1;
            while (_listeners[swapIndex] == null) {
              swapIndex += 1;
            }
            listeners[i] = listeners[swapIndex];
            _listeners[swapIndex] = null;
          }
        }
      }

      _reentrantlyRemovedListeners = 0;
      _count = newLength;
    }
  }
}

notifyListeners メソッドは、リスナーに通知を送るために使用されるメソッドです。ChangeNotifier クラスを継承したオブジェクトが通知を必要とする時に呼び出すことがあります。通知の処理にはエラーハンドリング、リスナーの状態管理、リスナーリストの更新が含まれています。

  @protected
  @visibleForTesting
  @pragma('vm:notify-debugger-on-exception')
  void notifyListeners() {

@protected は、このメソッドがクラス外部ではなく、このクラス及びサブクラスで使用されるべきであることを示すアノテーションです。

@visibleForTesting は、テストの目的でこのメソッドを利用できるようにするアノテーションです。

@pragma は、特定の動作(ここでは例外発生時にデバッガに通知)を Dart VM に伝えるためのものです。これにより、通知処理中に例外が発生した際にデバッガがトリガーされます

参考:visibleForTestingOpen in new tab
参考:pragmaOpen in new tab
参考:Introduction to Dart VMOpen in new tab

    assert(ChangeNotifier.debugAssertNotDisposed(this));
    if (_count == 0) {
      return;
    }

debugAssertNotDisposed(this) で、通知を送る前にオブジェクトが解放されていないかを確認します。この処理は、デバッグ時にのみ有効になります。

また、_count == 0 の時は通知するリスナーがいないため、処理を中断します。

    _notificationCallStackDepth++;

_notificationCallStackDepth を増やして、通知が何回ネストしているかを追跡します。この深さを管理することで、リスナーの削除が適切に行われることが保証されます。

    final int end = _count;
    for (int i = 0; i < end; i++) {
      try {
        _listeners[i]?.call();
      } catch (exception, stack) {
        FlutterError.reportError(
          FlutterErrorDetails(
            exception: exception,
            stack: stack,
            library: 'foundation library',
            context: ErrorDescription('while dispatching notifications for $runtimeType'),
            informationCollector:
                () => <DiagnosticsNode>[
                  DiagnosticsProperty<ChangeNotifier>(
                    'The $runtimeType sending notification was',
                    this,
                    style: DiagnosticsTreeStyle.errorProperty,
                  ),
                ],
          ),
        );
      }
    }

for 文を使用して、リスナーリストに含まれるすべてのリスナー(VoidCallBack)に対して順番に call() メソッドを呼び出し、通知を送ります。もしエラーが発生した場合、FlutterError.reportError を使用してエラーレポートが行われます。

参考:【Dart】call() メソッドを効果的に使うOpen in new tab

    _notificationCallStackDepth--;

通知がすべて送信された後、通知のスタックの深さをデクリメントします。これにより、通知処理が完了したことが示されます。

    if (_notificationCallStackDepth == 0 && _reentrantlyRemovedListeners > 0) {
      final int newLength = count - reentrantlyRemovedListeners;

_notificationCallStackDepth == 0 は、通知処理が完了しているかどうかを示します。

_reentrantlyRemovedListeners > 0 は、削除予約されているリスナーが存在するかどうかを示します。0よりも大きい場合、削除の必要があります。

条件式が true のとき、すなわち通知処理が完了していて、削除予約されたリスナーが存在する場合、リスナーの削除を行います。

削除後のリスナーリストの新しい長さ(newLength)は、現在のリスナー数(_count)から、削除が予約されたリスナーの数(_reentrantlyRemovedListeners)を減算して求められます。

      if (newLength * 2 <= _listeners.length) {

削除後のリスナー数が、現在のリストの長さの半分以下の場合、リストの再構築が必要と判断します。

        final List<VoidCallback?> newListeners = List<VoidCallback?>.filled(newLength, null);

        int newIndex = 0;
        for (int i = 0; i < _count; i++) {
          final VoidCallback? listener = _listeners[i];

          if (listener != null) {
            newListeners[newIndex++] = listener;
          }
        }

        _listeners = newListeners;

新しいリスナーリストを、新しい長さ(newLength)で初期化します。すべての要素の初期状態は null です。

for ループを使用して、古いリスナーリスト内を順番に見ていき、null でないリスナーを新しいリストにコピーします。newIndex は新しいリストの現在の挿入位置を追跡します。

すべてコピーし終えたら、再構築されたリスト(newListeners)を現在のリスナーリスト(_listeners)として更新します。

      } else {
        for (int i = 0; i < newLength; i += 1) {

新しいリストを作成しない場合、既存のリスナーリストを操作します。

リスナーリスト内の null 要素をリストの末尾に移動し、null でない要素を前方に詰める処理を行います。

          if (_listeners[i] == null) {
            int swapIndex = i + 1;
            while (_listeners[swapIndex] == null) {
              swapIndex += 1;
            }
            listeners[i] = listeners[swapIndex];
            _listeners[swapIndex] = null;
          }
        }
      }

現在の要素が null の場合、null でない要素を探して現在の要素と交換する必要があります。

swapIndex を現在の位置の次(i + 1)に設定し、while ループで null でない要素が見つかるまでインデックスを進めます。

swapIndex で見つけた要素を _listeners[i] に代入します。その後、swapIndex の位置に null を設定して、該当要素を末尾に移動します。

ポイント

while ループの条件式では、_listeners[swapIndex] == null が続く限りループを続けるため、もしリストの swapIndex 以降に null でない要素が存在しない場合、ループが終了せず無限ループに陥ります。このコードでは、無限ループを防ぐために以下の条件が常に満たされる必要があります。

  • _reentrantlyRemovedListeners の値が、_listeners 内の先頭から _count の範囲(0 ..< _count)における null の数と一致していること

このような設計の場合、一般的なアプリケーション開発では _reentrantlyRemovedListeners の正確な管理が開発者の責任となります。そのため、値の管理が少しでも不十分だと、動作不良やバグが発生する可能性があり、変更や修正が原因で問題が起こりやすい構造だといえます。

一方で、フレームワークレベルのコードでは、設計が一度確立されると滅多に変更されないため、このような効率性を重視した設計が採用されていると考えられます。

      _reentrantlyRemovedListeners = 0;
      _count = newLength;

削除されたリスナーを追跡するフラグ(_reentrantlyRemovedListeners)をリセットし、新しいリスナー数(newLength)に更新します。

これにより、リストの整合性が保たれます。

学び

お疲れ様です。
コードリーディングを終えたので、change_notifier.dart を読んだ学びを簡単にまとめます。

通知中の削除予約とデータ整合性

removeListener メソッドでは、リスナーを削除する際に通知処理中のリストが変更されることによるデータ不整合を防ぐための削除予約の仕組みが実装されていました。

notifyListener メソッドでは、_notificationCallStackDepth 変数の値を増減することで、通知処理の実行状態を管理していました。
実行中の通知処理がない場合には、予約されたリスナーの削除が実行されていました。

コアモジュールのシンプルなコード設計

予約されたリスナーの削除処理では、削除予約と削除の仕組み(while ループの条件式)が変更に弱い設計に見えました。
その一方で、変更頻度が少ない十分に検証されたモジュール内では、不要な例外のハンドリングを排除したシンプルな実装が選択肢となる学びがありました。

まとめ

この記事では、Flutter の状態管理を支える重要なコンポーネントである 「ChangeNotifier 」 の実装を詳しく読み解きました。また、addListener() や removeListener() の内部処理をコードリーディングを通じて分析し、リスナー管理のメモリ効率や処理効率を高める仕組みを学びました。

change_notifier.dart のような Flutter SDK の実装の詳細を理解することは、Flutter の動作原理を深く知るだけでなく、設計を含めたエンジニアリング力の底上げに資する試みだと考えます。

引き続き、Flutter の仕組みを読み解くことで、アプリケーション開発の理解をさらに深めていきましょう!

最後までお読みいただき、ありがとうございました。

▼▼▼本シリーズの第1弾、2弾はこちら▼▼▼

この記事を書いた人

田原 葉
田原 葉
2024年にメンバーズに中途で入社。前職はiOSエンジニア。現在はCross ApplicationカンパニーでFlutter技術をメインにモバイルアプリ開発支援を担当。
ページトップへ戻る