👶

Flutterの状態管理の基礎

2021/12/06に公開
2

概要

この記事は、Qiita Advent Calendar Flutter6日目の記事です。

Flutterの初学習者が最初につまづく可能性が高いのは、StatelessWidget/StatefulWidgetなどの状態管理に関することではないかと思います。
私自身Flutterを触り始めて1ヶ月弱なのですがStatelessWidget/StatefulWidgetのところで詰まり、ここが最初の鬼門だなと感じました。
そこで今回はStatelessWidget/StatefulWidget、そしてそこから派生してInheritedWidgetについて、これらがどういうものでどういう役割を持っているかを見ていきたいと思います。

状態管理とは

まず状態管理とはどういうものを指しているのか、状態状態管理について定義を改めて確認しておきます。

状態

状態とは、UIを(再)構築するために必要なデータのことを指しています。
宣言的UIでは、UI = f(state)という関数のようにUIが構築されます。

Flutterにおいて、fはWidget、fはbuild()、stateがデータ(状態)になります。

stateつまり状態が変化するとUIが再構築されます。

また状態には大きく分けて2種類あります。
一つはEphemeral state(ローカル状態)、もう一つはApp state(共有状態) です。
Ephemeral stateとは、画面(クラス)間で共有しないなど、スコープが閉じたデータ(状態)のことです。
例えばBottomNavigationBarの選択中のタブのインデックスの状態は、基本的に他の画面(クラス)から参照されることはないのでEphemeral stateと言えます。

一方App Stateとは、複数の画面(クラス)間で共有するなど、複数で共有されるデータ(状態)のことです。
例えばショッピングアプリのカートの状態やログイン状態は、複数箇所から参照され、状態が共有される可能性があるのでApp Stateと言えます。

状態管理

状態管理とは、状態の整合性が破綻しないように管理することです。
例えば旅館を予約して予約一覧画面では予約済みのステータスになっているのに、予約確認画面では予約済みになっていない場合、これは状態の整合性が取れておらず、ユーザーを困惑させてしまいます。このような問題を起こさないために状態管理は重要となってきます。

ここから本題のStatelessWidget/StatefulWidgetについて見ていきます。まずはStatelessWidgetについてです。

StatelessWidget

StatelessWidgetはFlutterにおいて最も基本的なWidgetであり、Flutterプロジェクトを作成した際に生成されるコードの中でも使われています。

StatelessWidgetの性質と役割

StatelessWidgetは名前から状態を持たないWidgetと推測できますが、これは半分正解で半分不正解です。正確にはミュータブルな状態を持たないWidgetです。
実際にコードを見てみましょう。

mypage.dart
class MyPage extends StatelessWidget {
  final title = "Setting"; // finalをつけることでimmutableの要件を満たしている。また本来は`_`をつけてプライベートにしておくほうが良い。
  // String title = "Setting"; // immutableの要件を満たしていない
  
  const MyPage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Scaffold(
       appBar: AppBar(
         title: Text(title)
       ),
      body: ListView(
        children: const [
          ListTile(title: Text("Profile"),),
          ListTile(title: Text("Setting"),),
          ListTile(title: Text("Privacy policy"),),
          ListTile(title: Text("Terms"),),
        ],
      )
    );
  }
}

ここで重要なのは、状態はすべて定数として持たなければならないということです。
String title = "Setting"のように、変数として定義するとコンパイルエラーが発生します。
この理由はStatelessWidgetの親クラスである、Widgetという抽象クラスに@immutableというアノテーションがついているためです。
@immutableをつけたクラスはイミュータブルな要件を満たさないといけません。
前述のように上記コードをString title = "Setting"とした場合、titleの状態が外部からも内部からも変更される可能性があります。つまりこのクラスはイミュータブルな要件を満たしていないということになります。

StatelessWidgetは設定一覧画面など、常に同じ状態で良い画面、つまり静的な画面に使うとよさそうです。

StatefulWidget

次にStatefulWidgetについてです。
こちらもStatelessWidgetと同様に最も基本的なWidgetであり、Flutterプロジェクトを作成した際に生成されるコードの中でも使われています。

StatefulWidgetの性質と役割

StatefulWidgetは名前から状態を持つWidgetと推測できますが、こちらも半分正解で半分不正解です。正確にはミュータブルな状態も持つこともできるWidgetであるが、実際はStatefulWidgetではミュータブルな状態を持つことができないと言えます。
何を言っているんだ、と思うかもしれませんが、実際のコードを見ていきましょう。

myhome_page.dart
// StatefulWidgetを継承したクラスではイミュータブルな状態を持つことはできない。
class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

// State<T extends StatefulWidget>を継承したクラスで状態を持つ。
// カウンターアプリの場合は、カウント状態をこのクラスで持っている。
class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() => setState(() => _counter++);

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

上記はFlutterプロジェクトを作成したときに実装されているカウンターアプリのコードそのままです。まずMyHomePageが継承しているStatefulWidgetの継承関係について見ていきます。

StatefulWidgetStatelessWidget同様、Widgetを継承しています。
つまりStatefulWidgetミュータブルな状態を持つことができません
実際にミュータブルな状態を持っているのはStatefulWidgetと1対1で対応しているState<T extends StatefulWidget>という抽象クラスです。
このStateを継承したクラスでミュータブルな状態を持ち、カウンターの状態を変更しています。

またこのアプリで持っているカウンターの状態はEphemeral stateであり、変更があっても外部(その他の画面など)に影響を与えることはありません。
StatefulWidgetはミュータブルな状態を持ちたいが、状態は閉じたスコープである画面に用いると良さそうです。(具体例が思いつきませんでした。。)

今回はカウンターアプリというシンプルな仕様のアプリをStatefulWidgetで実装しましたが、もう少し複雑なアプリをStatefulWidgetで実装した場合にどうなるでしょうか?
次はStatefulWidgetで簡単なスムージーアプリを実装してみたいと思います。

スムージーアプリ by StatefulWidget

まず簡単に実装したいスムージーアプリの仕様を見ていきます。

仕様

スムージーのメニューの画面があり、各アイテムの追加ボタンをタップすると、カートにスムージーが追加されます。追加したスムージーの一覧は右上のカートボタンをタップすると見ることができます。とてもシンプルなアプリケーションです。

Menu画面 Cart画面

このアプリケーションを実装する上でのポイントは、Menu画面とCart画面で状態を共有しているということです。
Menu画面のチェッマークがついているアイテムはカートに入っていることを指しており、Cart画面でそのアイテムの詳細が一覧化されています。つまりカートはApp Stateに該当します。

ではStatefulWidgetApp Stateをどうやって持つのかコードを見ていきます。

home_page.dart
class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  
  HomePageState createState() => HomePageState();
}

class HomePageState extends State<HomePage> {
  Set<Smoothie> _favoritesSmoothies = {}; // ※1. お気に入りしている商品一覧
  Set<Smoothie> get favoritesSmoothies => _favoritesSmoothies;

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Menu"),
        actions: [
          IconButton(
              onPressed: () async {
                var result = await Navigator.push(
                    context,
                    MaterialPageRoute(
                      builder: (context) => MyCartPage(smoothies: _favoritesSmoothies,),
                      fullscreenDialog: true,
                    )
                );
                final smoothies = result as Set<Smoothie>;
                setState(() => _favoritesSmoothies = smoothies);
              },
              icon: const Icon(Icons.shopping_cart_rounded))
        ],
      ),
      body: const MenuPage(),
    );
  }

  void insert(Smoothie smoothie) => _favoritesSmoothies.add(smoothie);

  void remove(Smoothie smoothie) => _favoritesSmoothies.remove(smoothie);
}

HomePageというMenu画面の上位Widgetで、お気に入りされている商品の状態を持っています。そしてMenu画面とCart画面からそれぞれHomePageで持っているお気に入り状態を参照するようにします。

Menu画面の+ボタンをタップするとカートに追加され、✔をタップするとカートから削除される処理は以下のように実装しています。

smoothie_row.dart
class _SmoothieRowState extends State<SmoothieRow> {
  
  Widget build(BuildContext context) {
    final state = context.findAncestorStateOfType<HomePageState>()!;

    return Container(
        margin: const EdgeInsets.only(bottom: 16.0),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Container(・・・),
            Flexible(・・・),
            IconButton(
                onPressed: () {
                  setState(() {
		    // ボタンをタップされた時にお気に入りの状態によって追加・削除を判断している
                    state.favoritesSmoothies.contains(widget.smoothie)
                        ? state.remove(widget.smoothie)
                        : state.insert(widget.smoothie);
                  });
                },
		// お気に入り状態によって表示アイコンを変更している
                icon: state.favoritesSmoothies.contains(widget.smoothie)
                    ? const Icon(Icons.check)
                    : const Icon(Icons.add)
            )
          ],
        )
    );
  }
}

ポイントは、
final state = context.findAncestorStateOfType<HomePageState>()!;
です。
findAncestorStateOfTypeメソッドを使用することで、State<T extends StatfulWidget>を取得することができます。今回の場合はHomePageStateのインスタンスを取得しています。

ただfindAncestorStateOfTypeは以下2つの問題があるので、使用には注意が必要です。

findAncestorStateOfTypeの注意点

計算量がO(N)

NはWidgetのツリーの深さです。なので深さが大きくなればなるほどfindAncestorStateOfTypeの処理は重くなってしまいます。

同じWidgetツリーに存在しているStateしか参照できない

同じWidgetツリーに存在している祖先のStateしか参照することはできません。存在しないStateを取得しようとした場合はNullが返ってきます。

Cart画面

Cart画面は以下のように実装しています。

mycart_page.dart
class MyCartPage extends StatefulWidget {
  final Set<Smoothie> smoothies;
  const MyCartPage({
    Key? key,
    required this.smoothies,
  }) : super(key: key);

  
  _MyCartPageState createState() => _MyCartPageState();
}

class _MyCartPageState extends State<MyCartPage> {
  late Set<Smoothie> smoothies;

  // 商品詳細をリスト化するWidgetを生成する
  Widget myCartList() {
    return ListView.separated(・・・);
  }

  
  Widget build(BuildContext context) {
    smoothies = widget.smoothies;
    return Scaffold(
      appBar: AppBar(・・・),
      body: WillPopScope(
        onWillPop: () {
          Navigator.of(context).pop(smoothies);
          return Future.value(false);
        },
        child: Center(
            child: smoothies.isEmpty
                ? const Text("Your cart is empty")
                : myCartList()
        ),
      ),
    );
  }
}

ここでのポイントは、以下2点です。

  1. MyCartPageのイニシャライザにお気に入りしているスムージーのリストを渡している
  2. Navigator.of(context).pop(smoothies)で前画面に戻る時に、Cart画面で操作したお気に入り状態を渡している

なぜこのようなことをしているのでしょうか。Menu画面と同様findAncestorStateOfTypeを使って状態の取得・変更ができそうな気もします。
理由は、MyCartPageをモーダル表示したいために遷移処理が以下のようになっているためです。

home_page.dart
Navigator.push(
  context,
  MaterialPageRoute(
    builder: (context) => MyCartPage(smoothies: _favoritesSmoothies,),
    fullscreenDialog: true,
  )
)

MaterialPageRouteを使って遷移するとMyCartPageHomePageと異なるWidgetツリーに属すことになります。そうなるとfindAncestorStateOfTypeは同一Widgetツリーの祖先しか取得できないためMyCartPageからHomePageStateは取得できません。
そのためHomePageStateでMyCartPageにお気に入り状態を引数にしたり、お気に入り状態の更新を行わざる負えません。

このようにApp StateStatefulWidgetのみで実現しようとするといくつか悩ましいところがあります。以下はここまで見てきたStatefulWidgetの問題点のまとめです。

StatefulWidgetの問題点

  1. findAncestorStateOfTypeを使ってしまっている
  2. findAncestorStateOfTypeではアクセスできないStateを操作したい場合は、書き方が冗長にならざる負えない
  3. 状態を伝播できないので、状態を共有する実装をするには工夫が必要になることがある

ではこれらの問題を解決するにはどうすればよいでしょうか?

InheritedWidget

Flutterが提供するWidgetにInheritedWidgetというものがあります。
InheritedWidgetの特徴は、以下のとおりです。

  1. 直近のInheritedWidgetにO(1) でアクセスすることができる
  2. 状態の変化を下位ツリーに効率的に伝播することができる

直近のInheritedWidgetにO(1) でアクセスすることができる

通常、祖先のWidgetを取得したい場合は、findAncestorWidgetOfExactTypeというメソッドを用います。ただfindAncestorWidgetOfExactTypeの計算量はO(N)であり、findAncestorStateOfType同様の問題点があります。

しかしdependOnInheritedWidgetOfExactTypeというメソッドを使うと直近のInheritedWidgetO(1) でアクセスすることができ、Widgetツリーが深くなってしまうような複雑なアプリケーションでも心配することはありません。

状態の変化を効率的に下位ツリーに伝播することができる

また必要な時に状態の変更を下位ツリーに伝播することができます。

以下はカートの状態を管理しているクラスのコードです。

mycart.dart
class MyCart extends StatefulWidget {
  final Widget child;

  const MyCart({
    Key? key,
    required this.child,
  }) : super(key: key);

  // 慣例的に`of`という命名のメソッドを生やし、状態をInheritedWidgetを返している。
  // InheritedWidgetを取得するためのメソッドに`dependOnInheritedWidgetOfExactType`を
  // 使っているので、取得コストは`O(1)`となっている。
  static MyCartState of(BuildContext context) {
    return context
        .dependOnInheritedWidgetOfExactType<InheritedMyCart>()!
        .data;
  }

  
  MyCartState createState() => MyCartState();
}

class MyCartState extends State<MyCart> {
  Set<Smoothie> _smoothies = {};
  Set<Smoothie> get smoothies => _smoothies;

  
  Widget build(BuildContext context) {
    return InheritedMyCart(
        data: this,
        child: widget.child,
    );
  }

  void insert(Smoothie smoothie) {
   setState(() => _smoothies.add(smoothie));
  }

  void remove(Smoothie smoothie) {
    setState(() => _smoothies.remove(smoothie));
  }
}

class InheritedMyCart extends InheritedWidget {
  final MyCartState data;
  const InheritedMyCart({
    Key? key,
    required this.data,
    required Widget child,
  }) : super(key: key, child: child);

  
  // データを変更を下位ツリーに伝播するか決定することができる
  bool updateShouldNotify(InheritedMyCart old) => true;
}

ポイントはupdateShouldNotifyというクラスです。
このメソッドの返り値であるBooltrueの場合は下位ツリーのWidgetをリビルドしますが、
falseの場合はリビルドさせないようにすることができます。
上記のコードではすべての変更に対してリビルドするようにしていますが、例えばこのウィジェットが保持するデータがoldWidgetが保持するデータと同じである場合、Widgetを再構築する必要はありません。
このように効率的に下位ツリーのWidgetにデータを伝播しリビルドさせることができます。

また実際に状態を管理しているのはStatefulWidgetを継承したMyCartというクラスですが、
このクラスをInheritedWidgetを継承したInheritedMyCartというクラスでラップすることで、
O(1)で下位ツリーから状態を管理しているクラスにアクセスすることができるようになりました。

以下のようにMyCart画面を表すクラスから、MyCartの状態をO(1)で取得しています。

mycart_page.dart
class MyCartPage extends StatelessWidget {
  const MyCartPage({Key? key}) : super(key: key);

  Widget myCartList(BuildContext context) {
    final state = MyCart.of(context);
    return ListView.separated(・・・);
  }

  
  Widget build(BuildContext context) {
    // マイカートの状態を管理しているクラス`MyCartState`のインスタンスを取得。
    final myCart = MyCart.of(context);
    return Scaffold(
      appBar: AppBar(
        title: const Text('MyCart'),
      ),
      body: Center(
        // カートに入っているスムージーが存在するか否かでUIを変更している。
        child: myCart.smoothies.isEmpty
          ? const Text("Your cart is empty")
          : myCartList(context),
      )
    );
  }
}

InheritedWidgetを用いると複雑なアプリケーションでも、パフォーマンスを落とすこと無くアプリケーションを作っていけそうです。
ただInheritedWidgetを用いて実装すると、コードが少し冗長になってしまいます。
そこでInheritedWidgetが必要となるような複雑なアプリケーションを組む場合は、Riverpodなどの状態管理フレームワークを使う場合が多いみたいです。

まとめ

ここまでStatelessWidget/StatefeulWidget/InheritedWidgetについて見てきましたが、それぞれが異なる役割を持っているように感じました。
執筆前はStatefeulWidgetの代替がInheritedWidgetRiverpodなどの状態管理フレームワークだと思っていましたが、そうではないように思います。
Ephemeral Stateを持つ画面はStatefulWidgetでも問題なく、App Stateを必要とする場合にInheritedWidgetRiverpodなどの導入を検討する段階に入ると思います。
またそもそも状態を持たない画面については、StatelessWidgetで十分です。
つまりStatelessWidget/StatefeulWidget/InheritedWidgetriverpodは共存するものではないかと考えています。

参考URL

GitHubで編集を提案

Discussion

中條 剛(ちゅーやん)中條 剛(ちゅーやん)

Flutter標準のWidgetをまず理解できるよい記事と思いました!このあたりはどんな状態管理パッケージを使う場合も大事になる基礎的な部分だと思います。

ものすごい細かい部分ですが(Zennにも編集リクエスト機能がほしい)

fはWidget

正確には f は build()メソッド かと思います。その上の画像にも書かれている通りですね。

つまりStatefulWidgetもイミュータブルな状態を持つことができません。

ここは、「つまりStatefulWidgetも ミュータブルな 状態を持つことができません。」の誤植かと思います。

(具体例が思いつきませんでした。。)

これについては、Chip(がビルドする RawChip )などのStatefulWidgetを継承したFlutter標準のWidgetを参考にすると活用方法が思い浮かぶかと思います。

ざっくりいうと、「細かな状態はWidget自身で管理し、操作や変化の結果だけを親のWidgetにコールバックする」ようなWidgetですかね。アニメーションするWidgetなんかに多い気がします。(アニメーションが終わったらその結果変化した状態をコールバックする、みたいな)

長々とすみません。よい記事と思ってじっくり読んでしまいました!

Shun UematsuShun Uematsu

コメントありがとうございます!

正確には f は build()メソッド かと思います。

誤った認識だったので、ご指摘ありがとうございます!

「つまりStatefulWidgetも ミュータブルな 状態を持つことができません。」の誤植かと思います。

おっしゃる通りご誤植でした。。

またStatefulWidgetの具体系な活用方法も教えていただきありがとうございます!
内部で状態を管理し結果をコールバックするようなWidgetの作成時に活用できるという例は、分かりやすく、「確かに使えそう」とすぐイメージできました。

余談ですが、
Chipの実装を見る前はChipRawChipは継承関係にあるのかなとイメージしていたのですが、
実際はChipRawChipをラップしているだけなのは少し意外でした。こういう組み方もあるのかと勉強になりました。