[Flutter] サードパーティパッケージを使わずに状態を管理する

[Flutter] サードパーティパッケージを使わずに状態を管理する

Clock Icon2024.09.24

はじめに

「状態管理」、Flutterに限らずアプリケーション開発において考えなくてはならないトピックです。
Flutterのアプリ開発ではRiverpodなどの状態管理パッケージを使うことが多いと思いますが、公式ドキュメント、First week experience of Flutter の State management にFlutterが標準で提供している状態管理について書かれていました。
理解を深めたいと思い、読んだり自分でもコードを書いたりしてみました。

サンプルコードはお馴染みの「ボタンをタップすると値がカウントアップする」アプリです。

StatefulWidgetを使う

状態を管理する一番シンプルな方法です。
以下が実現できています。

  • 状態のカプセル化
    • MyCounterを使うウィジェットからはMyCounterで管理しているStateは見えず、変更もできない。
  • ライフサイクル
    • _MyCounterStateオブジェクトはMyCounterウィジェットが初めて構築された時に生成され、画面から取り除かれるまで存在する。

非常にシンプルですね。

class MyCounter extends StatefulWidget {
  const MyCounter({super.key});

  
  State<MyCounter> createState() => _MyCounterState();
}

class _MyCounterState extends State<MyCounter> {
  int count = 0;

  
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Count: $count'),
          TextButton(
            onPressed: () {
              setState(() {
                count++;
              });
            },
            child: const Text('Increment'),
          )
        ],
      ),
    );
  }
}

ウィジェット間でStateを共有する

複数のウィジェット間でStateを共有したい場合、複数のアプローチがあります。

コンストラクタ引数でStateを渡す

まず、真っ先に思いつくのはStateをそれを必要としているウィジェットに渡すことです。
コンストラクタ引数でStateを受け取るようにして内部で保持すればbuildメソッドの中でも使えます。

class MyCounter extends StatelessWidget {
  final int count;
  const MyCounter({super.key, required this.count});

  
  Widget build(BuildContext context) {
    return Text('$count');
  }
}

ウィジェットを使う側もコンストラクタ引数にStateを渡すだけなのでわかりやすいです。

Column(
  children: [
    MyCounter(
      count: count,
    ),
    MyCounter(
      count: count,
    ),
    TextButton(
      child: Text('Increment'),
      onPressed: () {
        setState(() {
          count++;
        });
      },
    )
  ],
)

でもこれって、Reactでいうpropsで情報を渡してるだけだなと思っていたら

sometimes called "prop drilling" in other frameworks

との記載がありました。やはりそのようです。
いわゆるバケツリレーなので、ウィジェットの階層が深くなるとReactのコンポーネントでも起きたのと同じような以下の問題が起きることになります。

  • Stateを使うウィジェットに届けるまでに同じようなコードを書かないといけない
  • Stateを使うウィジェットだけでなく中間のウィジェットもムダにbuildメソッドが呼ばれる

コードの保守性の観点からもパフォーマンスの観点からも良いとは言えないです。
そこでFlutterはInheritedWidget というウィジェットを提供しています。

InheritedWidgetを使う

InheritedWidgetを使うと、ウィジェットの上位階層で保持しているStateを
階層を超えてStateを利用したいウィジェットに通知することができます。

InheritedWidgetを継承したクラスを作り、Stateを保持します。
staticなofメソッドを定義し、BuildContextdependOnInheritedWidgetOfExactTypeメソッドを使い、その結果を返すようにしておきます。
dependOnInheritedWidgetOfExactTypeメソッドは引数のcontextから見てツリー上の祖先で直近のInheritedWidgetを検索・取得するAPIです。

また、Stateが変化した時に購読者(ofメソッドを呼んだウィジェット)に通知できるようにupdateShouldNotifyメソッドをオーバーライドします。古いStateと今のStateが異なっていたらtrueを返すことで購読者に通知されます。

class MyState extends InheritedWidget {
  const MyState({
    super.key,
    required this.count,
    required super.child,
  });

  final int count;

  static MyState of(BuildContext context) {
    // This method looks for the nearest `MyState` widget ancestor.
    final result = context.dependOnInheritedWidgetOfExactType<MyState>();

    assert(result != null, 'No MyState found in context');

    return result!;
  }

  
  // This method should return true if the old widget's data is different
  // from this widget's data. If true, any widgets that depend on this widget
  // by calling `of()` will be re-built.
  bool updateShouldNotify(MyState oldWidget) => count != oldWidget.count;
}

InheritedWidgetを継承したMyStateを使う側は以下のようになります。
MyStateコンストラクタでStateと子ウィジェットを渡します。
渡しているのはChild1ですが、Stateを使っているのはChild2です。
Child2buildメソッド中のMyState.of(context).count;でStateを取得しています。
Stateが変わるたびにChild2buildメソッドが呼び出されUIが更新されます。

Stateを使うChild2ではStateを保持していません。
また、Child2の親のChild1にはStateに関するコードはありません。
バケツリレーが無くなりました 😄

class MyCounter extends StatefulWidget {
  const MyCounter({super.key});

  
  State<MyCounter> createState() => _MyCounterState();
}

class _MyCounterState extends State<MyCounter> {
  int count = 0;

  
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          MyState(count: count, child: const Child1()),
          TextButton(
            onPressed: () {
              setState(() {
                count++;
              });
            },
            child: const Text('Increment'),
          )
        ],
      ),
    );
  }
}

class Child1 extends StatelessWidget {
  const Child1({super.key});

  
  Widget build(BuildContext context) => const Child2();
}

class Child2 extends StatelessWidget {
  const Child2({super.key});

  
  Widget build(BuildContext context) {
    final count = MyState.of(context).count;
    return Text('Count: $count');
  }
}

コールバックで親ウィジェットにStateを渡す

これまで親から子にStateを共有する方法を見てきました。
子がStateを管理していて、親に通知したい場合にはコールバックを使います。

ValueChanged型の定義は

typedef ValueChanged<T> = void Function(T value);

となっていて、指定した型の値を1つ受け取れる関数型です。

class Parent extends StatelessWidget {
  const Parent({super.key});

  
  Widget build(BuildContext context) {
    return MyCounter(
        // コールバック経由で値を受け取る
        onChanged: (newCount) => {
              debugPrint('New count: $newCount'),
            });
  }
}

class MyCounter extends StatefulWidget {
  const MyCounter({super.key, required this.onChanged});

  final ValueChanged<int> onChanged;

  
  State<MyCounter> createState() => _MyCounterState();
}

class _MyCounterState extends State<MyCounter> {
  int count = 0;

  
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('Count: $count'),
          TextButton(
            onPressed: () {
              final newCount = count + 1;
              setState(() {
                count = newCount;
              });

              // コールバック関数を呼ぶ
              widget.onChanged(newCount);
            },
            child: const Text('Increment'),
          )
        ],
      ),
    );
  }
}

状態管理をWidgetから切り離す

ここまでStateをウィジェット間で共有する方法を見てきましたが、あくまでStateの管理はStatefulWidget内で行ってきました。
同じStateを複数のウィジェットで共有するなら特定のウィジェットではなく、それ用のオブジェクトに状態管理を任せた方が良さそうです。Stateを更新するロジックもそのオブジェクトに実装できるとウィジェットからState更新のロジックが無くなってスッキリしそうです。

ChangeNotifierを使う

ChangeNotifierを使うと、Stateを保持し、Stateの更新を購読者に通知することができます。

class CounterNotifier extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

final counterNotifier = CounterNotifier();

使う側はListenableBuilderのパラメータにcounterNotifierを渡し、
Stateが通知された時に呼ばれるbuilder関数でウィジェットを返します。

class MyCounter extends StatelessWidget {
  const MyCounter({super.key});

  
  Widget build(BuildContext context) {
    return Center(
        child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        ListenableBuilder(
            listenable: counterNotifier,
            builder: (context, child) {
              return Text('counter: ${counterNotifier.count}');
            }),
        TextButton(
          child: const Text('Increment'),
          onPressed: () {
            counterNotifier.increment();
          },
        ),
      ],
    ));
  }
}

ValueNotifierを使う

ValueNotifierChangeNotifierをシンプルにしたものです。
その名の通り、1つの値を保持し、更新を通知できます。
使う側はChangeNotifierの時と同様ListenableBuilderも使うことができますが、
ValueListenableBuilderを使うとbuilder関数の引数で更新された値を直接受け取ることができます。

final ValueNotifier<int> counterNotifier = ValueNotifier(0);

class MyCounter extends StatelessWidget {
  const MyCounter({super.key});

  
  Widget build(BuildContext context) {
    return Center(
        child: Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        ValueListenableBuilder(
            valueListenable: counterNotifier,
            builder: (context, value, child) {
              return Text('counter: $value');
            }),
        TextButton(
          child: const Text('Increment'),
          onPressed: () {
            counterNotifier.value++;
          },
        ),
      ],
    ));
  }
}

アプリケーションアーキテクチャとしてMVVMを使う

これまでに、Stateの更新やウィジェットへの通知の仕組みを見てきました。
これらの仕組みをMVVMに適用する例が載っていたのでご紹介します。

Model

まずはModel部分の定義です。
ModelはHTTP通信などのローレベルの処理を担当します。
Flutterに依存しないような作りにすることでモックへの差し替えがしやすく、テストしやすい作りになっています。

import 'package:http/http.dart';

class CounterData {
  CounterData(this.count);

  final int count;
}

class CounterModel {
  Future<CounterData> loadCountFromServer() async {
    final uri = Uri.parse('https://myfluttercounterapp.net/count');
    final response = await get(uri);

    if (response.statusCode != 200) {
      throw ('Failed to update resource');
    }

    return CounterData(int.parse(response.body));
  }

  Future<CounterData> updateCountOnServer(int newCount) async {
    // ...
  }
}

ViewModel

ChangeNotifierを継承したクラスをViewModelとして定義します。
Stateを内部に持ち、notifyListenersで通知します。
Viewからのイベントは increment メソッドを通じて実行します。
ガイドではViewModelはレストランのウェイターのようなものだと解説されています。
Modelがキッチン、Viewが顧客で、キッチンと顧客の間を仲介するのがViewModelです。
確かにレストランで顧客がキッチンと直接やりとりすることはないですよね。

import 'package:flutter/foundation.dart';

class CounterViewModel extends ChangeNotifier {
  final CounterModel model;
  int? count;
  String? errorMessage;
  CounterViewModel(this.model);

  Future<void> init() async {
    try {
      count = (await model.loadCountFromServer()).count;
    } catch (e) {
      errorMessage = 'Could not initialize counter';
    }
    notifyListeners();
  }

  Future<void> increment() async {
    var count = this.count;
    if (count == null) {
      throw('Not initialized');
    }
    try {
      await model.updateCountOnServer(count + 1);
      count++;
    } catch(e) {
      errorMessage = 'Count not update count';
    }
    notifyListeners();
  }
}

View

最後にViewです。
ViewModelはChangeNotifierなのでStateが更新された時にUIの更新も行うことができます。
Viewはレストランで言う顧客ですね。顧客がすることはなんでしょうか?
ウェイターに注文して(ViewModelへの依頼)、提供された料理(State)を食べます(消費します)(=Stateを使ってUIを構築します)

ListenableBuilder(
  listenable: viewModel,
  builder: (context, child) {
    return Column(
      children: [
        if (viewModel.errorMessage != null)
          Text(
            'Error: ${viewModel.errorMessage}',
            style: Theme.of(context)
                .textTheme
                .labelSmall
                ?.apply(color: Colors.red),
          ),
        Text('Count: ${viewModel.count}'),
        TextButton(
          onPressed: () {
            viewModel.increment();
          },
          child: Text('Increment'),
        ),
      ],
    );
  },
)

MVVMを適用することでViewはUIの構築だけを考えればよくなりました。
ViewModelはModelとViewの仲介役として機能し、ViewのためのStateを管理したり、Viewからイベントを受けとり、Modelを使ってデータの取得などを行います。
Modelは例ではHTTP通信などのローレベルな処理だけでしたが、アプリによってはビジネスロジックを担当する部分となります。

おわりに

First week experience of Flutter の State management を読んで、Flutterが標準で提供している状態管理の仕組みを理解しました。

実際のアプリケーション開発の現場ではRiverpodなどの状態管理パッケージを使うことが多いと思いますが、Flutterが標準で提供している状態管理の仕組みの理解が深まりました。

この記事をシェアする

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.