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

はじめに

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

この記事は、『【Flutter状態管理解説】change_notifier.dartの仕組みと内部実装を学ぶ【How Flutter Works①】Open in new tab』の続編です。

Flutterバージョン3.27.2に基づき、Flutter の foundation パッケージ内に含まれるchange_notifier.dart について、コードを追いながら詳しく解説していきます。

前回の記事では、Listenable や ValueListenable<T> の詳細に触れ、それらを実装する ValueNotifier<T> などのクラスについて解説しました。今回は、前回割愛した ChangeNotifier について、コードリーディングを通じてその設計と実装の深層に迫っていきます。

ChangeNotifier は、Flutter / Dart の状態管理の中心的な役割を果たしており、通知処理を行うための基盤となるインターフェースを提供します。

この記事では、ChangeNotifier の実装を詳しく読み解き、その機能をコードリーディングを通じて解説していきます。

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

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

コードリーディング: 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() {...}

ChangeNotifier は、Listenable を実装する mixin class です。

今回は以下の実装について読み進めて行きます。

静的プロパティ

  • static List<VoidCallback?> _emptyListeners

静的メソッド

  • static bool debugAssertNotDisposed (ChangeNotifier notifier)
  • static void maybeDispatchObjectCreation (ChangeNotifier object)

プロパティ

  • int _count
  • List<VoidCallback?> listeners
  • int _notificationCallStackDepth
  • int _reentrantlyRemovedListeners
  • bool _debugDisposed
  • bool _creationDispatched

メソッド

  • void addListener (VoidCallback listener)
  • void _removeAt (int index)
  • void removeListener (VoidCallback listener)

※記事ボリュームの都合、 void dispose() と void notifyListeners() の二つのメソッドについては次稿で解説します。

mixin class 宣言

mixin class ChangeNotifier implements Listenable {

ChangeNotifier は、Listenable を実装する mixin class で、オブジェクトの状態が変更されたことをリスナーに通知する機能を提供します。

Dartにおける mixin class とは、class として振る舞うと同時に、mixin としても利用できる特殊な型で、抽象クラスと同様に継承や再利用が可能です。

ChangeNotifier は、mixin として他のクラスに通知機能を追加する再利用性と、単独でインスタンス化して、シンプルな通知機能を持つクラスを直接利用できる利便性を備えた設計であるといえます。

参考:MixinsOpen in new tab

プロパティ

  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;

_count は、現在登録されているリスナーの数です。

_emptyListeners は空のリスナーのリスト(List<VoidCallback?>)を指す、静的なフィールドです。クラス内で使用される空のリスナーリストについて、同じインスタンスを使用できるようになり、メモリ効率が向上します。

_listeners は、リスナーを保持するリストで、初期値に空のリスト _emptyListeners が指定されています。リスナーは VoidCallback? です。

_notificationCallStackDepth は、notifyListeners() メソッドが再帰的に呼び出される際、どの深さまでリスナーに通知したかを追跡します。これにより、通知の途中でリスナーを削除する際の整合性が保たれます。

_reentrantlyRemovedListeners は、 通知中に削除されたリスナーの数を追跡します。通知が完了するまでリストの縮小を遅らせます。

_debugDisposed は、デバッグ用のフラグで、オブジェクトが破棄(dispose())されたかどうかを追跡します。このフラグを使うことで、破棄されたオブジェクトに対してリスナーを追加しようとしたり、通知しようとしたりするバグを防ぐことができます。

_creationDispatched は、内部的な「作成時イベントが発生済みかどうか」を追跡します。リスナー登録後に特定の初期化イベントを発生させたい場合、このフラグを使用して一度だけ処理を行うように制御します。

静的メソッド

static bool debugAssertNotDisposed
(ChangeNotifier notifier)

  static bool debugAssertNotDisposed(ChangeNotifier notifier) {
    assert(() {
      if (notifier._debugDisposed) {
        throw FlutterError(
          'A ${notifier.runtimeType} was used after being disposed.\n'
          'Once you have called dispose() on a ${notifier.runtimeType}, it'
          'can no longer be used.',
        );
      }
      return true;
    }());
    return true;
  }

debugAssertNotDisposed(ChangeNotifier notifier) は、引数に指定した ChangeNotifier が破棄(dispose())された後に使用されていないかを確認する、デバッグ用の静的メソッドです。

以下にコードを詳しく見ていきます。

  static bool debugAssertNotDisposed(ChangeNotifier notifier) {
    assert(() {

assert は、Dartでデバッグモードでのみ有効になる安全チェック機能です。条件が false の場合にエラーをスローします。引数には、bool 値を評価する条件を受け取ります。ここでは無名関数(ラムダ式)を渡しており、その戻り値が条件として評価されます。

参考:AssertOpen in new tab
参考:Anonymous functionsOpen in new tab

 if (notifier._debugDisposed) {
        throw FlutterError(
          'A ${notifier.runtimeType} was used after being disposed.\n'
          'Once you have called dispose() on a ${notifier.runtimeType}, it '
          'can no longer be used.',
        );
      }
      return true;
    }());

notifier._debugDisposed が true であれば、該当の ChangeNotifier インスタンスはすでに dispose() されており、使用するべきではない状態です。この場合、FlutterError がスローされます。

${notifier.runtimeType} は、このインスタンスの型名を文字列として取得し、エラーメッセージに含めています。

エラーがスローされなかった場合、無名関数は true を返し、assert は成功します。

    return true;
  }

debugAssertNotDisposed メソッドは、常に true を返します。この実装により、リリースビルドでは assert が無視されても、メソッドとしての整合性が保たれます。

bool get hasListeners

  @protected
  bool get hasListeners => _count > 0;

@protected は、Dart のアノテーションで、クラス内やそのサブクラスでのみアクセスされることを示すために使われます。

hasListeners はゲッターです。hasListeners を呼び出すと、内部で _count > 0 が評価され、その結果がbool値で返されます。ここでは、現在登録されているリスナーが存在するかどうかを返します。

参考:protectedOpen in new tab

void maybeDispatchObjectCreation
(ChangeNotifier object)

  @protected
  static void maybeDispatchObjectCreation(ChangeNotifier object) {
    if (kFlutterMemoryAllocationsEnabled && !object._creationDispatched) {
      FlutterMemoryAllocations.instance.dispatchObjectCreated(
        library: _flutterFoundationLibrary,
        className: '$ChangeNotifier',
        object: object,
      );
      object._creationDispatched = true;
    }
  }

maybeDispatchObjectCreation (ChangeNotifier object) は、指定された ChangeNotifier オブジェクトが作成されたことを、メモリ管理システムに通知するための静的メソッドです。この通知は、メモリの追跡機能が有効であり、かつオブジェクトが未通知の場合にのみ実行されます。

以下にコードを詳しく見ていきます。

  @protected
  static void maybeDispatchObjectCreation(ChangeNotifier object) {
    if (kFlutterMemoryAllocationsEnabled && !object._creationDispatched) {

kFlutterMemoryAllocationsEnabled は、メモリの割り当てを追跡する機能が有効かどうかを示すフラグです。通常はデバッグモードでのみ true を返します。

object._creationDispatched は、ChangeNotifier オブジェクトが既に作成通知を送信済みかどうかをチェックしています。もし object._creationDispatched が true なら、通知が送信済みであることを示します。

この条件式では、「メモリの割り当てを追跡する機能が有効」かつ「オブジェクトの作成通知が未送信」である場合に、処理を実行します。

参考:kFlutterMemoryAllocationsEnabledOpen in new tab

      FlutterMemoryAllocations.instance.dispatchObjectCreated(
        library: _flutterFoundationLibrary,
        className: '$ChangeNotifier',
        object: object,
      );

FlutterMemoryAllocations.instance は、memory_allocations.dart モジュールで定義されている、FlutterMemoryAllocations クラスのシングルトンインスタンスで、オブジェクト作成時や破棄時のメモリ管理を行います。シングルトンパターンにより、アプリ全体で単一のインスタンスを共有します。

dispatchObjectCreated() メソッドは、渡された情報を基に、新しいオブジェクトの作成をメモリ管理システムに通知するメソッドです。引数は以下の通りです。

  • library:このオブジェクトが属するライブラリ名。ここでは、_flutterFoundationLibrary('package:flutter/foundation.dart'`を示す定数)を指定してあります。
  • className: 作成されたオブジェクトのクラス名。ここでは文字列補完 '$ChangeNotifier' で指定しています。
  • object:作成された ChangeNotifier オブジェクト自体を渡します。このオブジェクトは通知の対象となります。

このメソッドの呼び出しにより、メモリ割り当てが追跡システムに登録され、以降の分析で使用可能になります。

参考:dispatchObjectCreatedOpen in new tab

      object._creationDispatched = true;
    }
  }

作成通知が送信された後、object._creationDispatched を true に設定します。これにより、同じオブジェクトに対して通知が重複して行われることを防ぎます。

ここで、object は、引数に受け取った ChangeNotifier 型のオブジェクトであり、_creationDispatched フラグはChangeNotifier に定義されています。

メソッド

 void addListener
(VoidCallback listener)

  @override
  void addListener(VoidCallback listener) {
    assert(ChangeNotifier.debugAssertNotDisposed(this));

    if (kFlutterMemoryAllocationsEnabled) {
      maybeDispatchObjectCreation(this);
    }

    if (_count == _listeners.length) {
      if (_count == 0) {
        _listeners = List<VoidCallback?>.filled(1, null);
      } else {
        final List<VoidCallback?> newListeners = List<VoidCallback?>.filled(
          _listeners.length * 2,
          null,
        );
        for (int i = 0; i < _count; i++) {
          newListeners[i] = _listeners[i];
        }
        _listeners = newListeners;
      }
    }
    listeners[count++] = listener;
  }

addListener メソッドは、抽象クラス Listenable で定義されたメソッドであり、 ChangeNotifier がこれをオーバーライドしています。このメソッドは、リスナー(状態変更を監視する関数)を登録することで、ChangeNotifier の状態が変更された際に通知を受け取れるようにします。

以下にコードを詳しく見ていきます。

  @override
  void addListener(VoidCallback listener) {
    assert(ChangeNotifier.debugAssertNotDisposed(this));

assert で、ChangeNotifier が dispose() された後に呼び出されていないことを保証しています。

    if (kFlutterMemoryAllocationsEnabled) {
      maybeDispatchObjectCreation(this);
    }

kFlutterMemoryAllocationsEnabled は、メモリ割り当ての追跡が有効かどうかを示す定数で、通常はデバッグモードでのみ true を返します。

条件が true の場合、maybeDispatchObjectCreation() メソッドで、オブジェクトの作成をメモリ管理システムに通知します。引数には現在の ChangeNotifier のインスタンスを渡します。

    if (_count == _listeners.length) {

現在登録されているリスナー数(_count)と、リスナーリストの現在の容量(listeners.length)が一致するかどうかを比較しています。この条件が true の場合、リストに新しいリスナーを追加するために、リストの容量を増加させる必要があります。

      if (_count == 0) {
        _listeners = List<VoidCallback?>.filled(1, null);

現在リスナーが登録されていない場合、リストを初期化します。

長さ1の VoidCallback? 型のリストを生成し、初期値として null を埋め込みます。この null は、新しいリスナーを追加するためのプレースホルダーとなります。

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

既にリスナーが登録されている場合、現在の容量(_listeners.length)の2倍の長さを持つ新しいリストを作成します。

ポイント

リスト容量が不足するたびに再割り当てを行うコストを削減するため、リスト容量を指数的に増加させています。この設計には、指数バックオフのアプローチが取り入れられていると考えられます。

参考:Exponential backoffOpen in new tab

        for (int i = 0; i < _count; i++) {
          newListeners[i] = _listeners[i];
        }
        _listeners = newListeners;
      }

古いリスナーリスト(_listeners)の要素を、新しいリスナーリスト(newListeners)に順番にコピーします。コピーが完了した後、新しいリストを現在のリスナーリスト(_listeners)として設定します。

    listeners[count++] = listener;
  }

新しいリスナー(listener)をリストの最後の空きスロットに追加します。

後置インクリメント(_count++)を使用して、リスナー追加後にリスナー数を 1 増加させます。

void _removeAt(int index)

 void _removeAt(int index) {
    _count -= 1;
    if (_count * 2 <= _listeners.length) {
      final List<VoidCallback?> newListeners = List<VoidCallback?>.filled(_count, null);

      for (int i = 0; i < index; i++) {
        newListeners[i] = _listeners[i];
      }

      for (int i = index; i < _count; i++) {
        newListeners[i] = _listeners[i + 1];
      }

    } else {
      for (int i = index; i < _count; i++) {
        listeners[i] = listeners[i + 1];
      }
      listeners[count] = null;
    }
  }

_removeAt(int index) は、指定されたインデックス位置からリスナーを削除し、リスナーリストを更新するためのプライベートメソッドです。このメソッドでは、リスナー数やリストの容量を適切に調整するための処理が行われます。

以下にコードを詳しく見ていきます。

  void _removeAt(int index) {
    _count -= 1;

リスナー数(_count)をデクリメントし、リスナーが1つ減少したことを反映します。

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

条件 _count * 2 <= _listeners.length は、現在のリスナー数の2倍がリストの容量以下である場合を判定します。この条件が true の場合、リストの容量が大きすぎると判断され、リストを縮小します。

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

リスナー数(_count)と同じ長さの新しいリスト(newListeners)を作成し、初期値として null を設定します。これにより、無駄な容量を削減します。

      for (int i = 0; i < index; i++) {
        newListeners[i] = _listeners[i];
      }

index より前の要素を、元のリスト(_listeners)から新しいリスト(newListeners)にコピーします。

      for (int i = index; i < _count; i++) {
        newListeners[i] = _listeners[i + 1];
      }

削除されたリスナーの次の要素を1つ前の位置にコピーします。この操作により、削除されたリスナーの位置が詰められます。

    } else {
      for (int i = index; i < _count; i++) {
        listeners[i] = listeners[i + 1];
      }
      listeners[count] = null;
    }
  }

容量の縮小が不要な場合、この分岐ではリストを縮小せず、削除されたリスナーの位置以降の要素を1つ前にシフトします。

最後に、削除されたリスナーの元の位置をnullで埋めることで、リストの状態を明示的に更新します。

void removeListener
(VoidCallback listener)

  @override
  void removeListener(VoidCallback listener) {
    for (int i = 0; i < _count; i++) {
      final VoidCallback? listenerAtIndex = _listeners[i];
      if (listenerAtIndex == listener) {
        if (_notificationCallStackDepth > 0) {
          _listeners[i] = null;
          _reentrantlyRemovedListeners++;
        } else {
          _removeAt(i);
        }
        break;
      }
    }
  }

removeListener(VoidCallback listener) は、リスナーリストから指定されたリスナーを削除するメソッドです。ChangeNotifier が Listenable を実装しているため、removeListener はそのオーバーライドメソッドとして動作します。

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

リスナーリスト(_listeners)を先頭から順に探索し、指定されたリスナー(listener)が存在するか確認します。

現在のインデックス(i)に対応するリスナーを listenerAtIndex として取り出します。

      if (listenerAtIndex == listener) {

取り出したリスナー(listenerAtIndex)が指定されたリスナー(listener)と一致する場合に処理を行います。

        if (_notificationCallStackDepth > 0) {
          _listeners[i] = null;
          _reentrantlyRemovedListeners++;

_notificationCallStackDepth は、リスナーへの通知が行われている回数を追跡するカウンタです。もしこの値が0より大きい場合、現在リスナーに対して通知が進行中であることを意味します。この場合、リスナーを即座に削除するのではなく、削除予約を行います。

該当リスナー(_listeners[i])を null に設定し、削除されたリスナーの数を _reentrantlyRemovedListeners で記録します。記録されたリスナーは、通知処理完了後に notifyListener メソッド内で削除されます。

通知中にリストを変更すると、リストの状態が不整合になる可能性があります。そのため、リスナーを即時削除せず、null でマークします。

        } else {
          _removeAt(i);
        }

通知中でない場合は、_removeAt(i) を呼び出して該当リスナーをリストから即座に削除します。

_removeAt メソッドは、指定されたインデックスでリスナーを削除し、必要に応じてリストを縮小します

        break;
      }

一度リスナーを削除した後は、ループを終了します。

学び

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

リスト容量管理によるメモリ効率化

addListener や removeListener メソッドの内部では、リスナー数に応じてリスト容量を動的に変更することで、メモリ効率を最適化しています。

リスト容量とリスナー数を比較して、リスナー数の二倍ずつ容量を増やしたり、二分の一ずつ容量を減らす仕組みは、リストの再割り当てのコストを低減する指数バックオフ的なアプローチが取り入れられていました。

リストのデータ整合性を保つ削除予約

removeListener メソッドには、通知処理中にリスト変更によるデータ不整合を防ぐ「削除予約」の仕組みがあります。

notifyListener メソッド内で、予約されたリスナーの削除処理が実行されます。

まとめ

この記事では、Flutter の状態管理を支える重要なコンポーネント「ChangeNotifier」 の実装を詳しく読み解きました。

リスナーの追加・削除のメソッドの内部処理のコードリーディングを通して、複数のリスナーを管理するメモリ効率と処理効率のバランスを取る仕組みについても学びました。

次稿では、dispose メソッドと notifyListener メソッドを解説し、ChangeNotifier のリスナーの破棄と通知の仕組みを詳しく解説します。

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

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

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

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

この記事を書いた人

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