Flutter DocumentationのDevelopment > Data & backend > State managementのところを全部読む

公式ドキュメントをしっかり読み込むことの重要性を身にしみて感じているので、これからFlutter公式ドキュメントを少しずつでも読んでまとめていきたいと思い立ち、今日から始めます。 状態管理をちゃんと分かりたいので、まずはState Managementの項から、要点を箇条書きしていきます。箇条書きとコード引用のところ以外の部分は僕の感想になっています。 (2022年12月30日時点のドキュメントですので、お読みの時には古くなっている可能性があります)

Start thinking declaratively

docs.flutter.dev

  • Flutterは命令型ではなく宣言型のフレームワークである。
  • 状態の変更がトリガーとなってUIがゼロから再描画される。

命令型と宣言型の違いは、ReactとかVue.js入門みたいな文脈でよく見かける気がします。命令型ではたとえば「divタグを作れ。そのclass名はhelloClass変数の値にしろ。その中のテキストをこんにちはにしろ。」のように順番に命令して組み立てていきますが、これはコードから完成形がイメージしづらいという問題があります。 一方の宣言型では(Vue.jsですが)

<div :class="helloClass">
  こんにちは
</div>

のように出来上がりの形を書いて「これになれ」と言うイメージですね。

アプリの状態が変化したときにいちいちUIをゼロから再構築する、というのはWebの感覚からすると「遅くならない?」と思ってしまいますが、遅くならないそうです。すごいですね。(Flutter Webだとどうなんだろう)

Differentiate between ephemeral state and app state

docs.flutter.dev

  • ここでは状態(ステート)を「任意の時点でUIを再構築するために必要なあらゆるデータ」と定義する。
  • 自分で管理する状態(ステート)には、Appステートとエフェメラルステートの2つの種類がある。

エフェメラル(ephemeral)は聞き慣れない単語だと思います。もともと古典ギリシア語のephēmeros(エペーメロス)という形容詞から来ていて、これはepi-(upon) + hēmera(day) = 「一日(だけ)の」、転じて「短命の、儚い」という意味です。状態が短命、つまり一時的な状態ということですね。 Flutterアプリで管理する状態には、エフェメラルステートとAppステートの2種類があるとのことです。エフェメラルが「短命」なので、Appステートの方は「長生き」する状態という立ち位置になりそうな予感がしますね。

エフェメラルステート

  • エフェメラルステートは、UIステートまたはローカルステートと呼ばれることもある。
  • エフェメラルステートは、1 つのウィジェットに収められる状態であり、他のウィジェットからアクセスする必要がないもの。
  • エフェメラルステートの例
    • 現在のページPageView
    • 複雑なアニメーションの現在の進行状況
    • BottomNavigationBarで現在選択されているタブ
  • エフェメラルステートに必要なのはStatefulWidgetだけ。
  • ユーザーがアプリを閉じて再起動して状態がリセットされても気にする必要がないもの。
  • 以下の例では、_indexがエフェメラルステートである。
class MyHomepage extends StatefulWidget {
  const MyHomepage({super.key});

  @override
  State<MyHomepage> createState() => _MyHomepageState();
}

class _MyHomepageState extends State<MyHomepage> {
  int _index = 0;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      currentIndex: _index,
      onTap: (newIndex) {
        setState(() {
          _index = newIndex;
        });
      },
      // ... items ...
    );
  }
}

エフェメラルステートは、要するにStatefulWidgetのフィールドで保持するような状態のことのようです。 他のウィジェットからアクセスする必要がなさそうな状態については、Stateクラスのフィールドで十分ということですね。

Appステート

  • アプリケーションの多くの部分で共有し、ユーザーセッション間で保持したいような一時的でない状態をアプリケーションステート(共有ステートとも呼ばれる)と呼ぶ。
  • アプリの状態を管理するには、アプリの複雑さや性質、チームのこれまでの経験など、さまざまな観点から選択肢を調査する必要がある。
  • アプリケーションステートの例
    • ユーザー設定
    • ログイン情報
    • ソーシャルネットワーキングアプリの通知機能
    • Eコマースアプリのショッピングカート
    • ニュースアプリの記事の既読/未読状態

ユーザー設定やログイン情報のようにアプリ使用中ほとんど常に必要な状態などはアプリケーションステート(さっきまでAppステートと書いていたもの)ということのようです。そして状態管理のやり方はアプリやチームによって考えるべきということで、選択肢が色々ありそうな雰囲気ですね。

明確なルールは無い

  • ある変数がエフェメラルステートかアプリケーションステートかを区別する明確で普遍的なルールは無い。
  • StateとsetState()を使ってアプリのすべての状態を管理することができる。
  • エフェメラルステートから初めて、アプリケーションステートに移行する必要があるかもしれない。
  • この項を要約すると、
    • Flutterアプリには2種類のステートがある。
    • エフェメラルステートはStateとsetState()を使って実装でき、多くの場合1つのウィジェットにローカルに存在する。
    • どちらのタイプもFlutterアプリに存在し、その使い分けはあなたの好みとアプリの複雑さによって決まる。

エフェメラルステートかアプリケーションステートという区別は明確なルールはなく、例に挙げたものだってすべてエフェメラルステートでやろうと思えばできるが、その使い分けは好みとアプリの複雑さによるとのこと。急に丸投げされた感じですが、基本的にはエフェメラルステートを使い、アプリ全体で使いうるものはアプリケーションステート、と考えればよいでしょうか。

docs.flutter.dev

シンプルなアプリケーションステート管理

  • アプリケーションステート管理をやってみよう。
  • Providerパッケージから始めるべき。理解しやすく、コードもあまり使わないし、他のどのアプローチでも適用可能な概念を使用している。

Providerを改良して同じ作者がRiverpodというのを作ったという話は知っているので、Flutter公式がProviderパッケージの方を紹介しているのは意外。Riverpodよりも簡単ということなんでしょうか。(全然関係ないけどRiverpodがProviderのアナグラムであることに今気づいた)

そしてここからは感想を挟まずにガッと最後まで読んでいきます。

Our example

  • MyCatalogとMyCartからなるアプリで考えてみよう
  • MyCatalogはMyAppBarとMyListItemのScrollViewからなる
  • MyApp
    • MyCart
    • MyCatalog
      • MyAppBar
      • ScrollView
        • MyListItem
        • MyListItem
        • MyListItem
  • カートの状態をどこに置くかが問題になる

状態を引き上げる

  • Flutterでは状態はそれを使用するウィジェットの上の階層に置いておく。
  • Flutterではコンテンツが変わるたびに新しいウィジェットをbuildする。
  • MyCart.updateWith(somethingNew)のようなメソッドではなく、MyCart(contents)のようなコンストラクタで新しいウィジェットをbuildする。
  • 新しいウィジェットは親のbuildメソッドでしか構築ができない。
  • MyCartの親のMyAppにコンテンツを格納するので、MyCartはライフサイクルを気にせずに何を受け取り何を表示するかを宣言するだけでいい。
  • ウィジェットは、変化せずに古いものが新しいものと交換されるので、イミュータブル(不変)と言える。

状態にアクセスする

  • カートはMyListItemより上の階層にいる。どうやってカートに追加しようか?
  • 単純な方法は、MyListItemがクリックされた時に呼び出せるコールバック関数を渡すことだが、アプリケーションステートをさまざまな場所から変更する必要がある場合は多くのコールバックを渡さなければならなくなる。
  • ウィジェットがその子孫にデータやサービスを提供できるInheritedWidgetなどがあるが、ここでは低レベルなので扱わない。
  • providerパッケージを使えば、コールバックやInheritedWidgetを使わなくていい。その代わり次の3つを理解する必要がある。
    • ChangeNotifier
    • ChangeNotifierProvider
    • Consumer

ChangeNotifier

  • ChangeNotifierはFlutter SDKに含まれるシンプルなクラス。
  • リスナーはChangeNotifierの変更を購読できる。
  • providerでは、ChangeNotifierはアプリケーションの状態をカプセル化するための方法の1つ。
  • 非常にシンプルなアプリケーションでは、1つのChangeNotifierで十分。 複雑なアプリケーションでは、いくつかのモデルを持つことになり、したがっていくつかのChangeNotifierを持つことになる。
  • providerを使ってChangeNotifierを使う必要は全くないが、簡単に扱える。
  • Cartの状態をChangeNotifierで管理すると
class CartModel extends ChangeNotifier {
  /// カートの内部のプライベートな状態
  final List<Item> _items = [];

  /// カートの中のアイテムの変更不可のビュー
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// 現在の全アイテムの合計価格(全アイテムが42ドルとして)
  int get totalPrice => _items.length * 42;

  /// アイテムをカートに追加する。
  /// これとremoveAllが外からカートを編集する唯一の手段である。
  void add(Item item) {
    _items.add(item);
    // この呼び出しは、このモデルをリッスンしているウィジェットにrebuildを指示する
    notifyListeners();
  }

  /// カートからすべてのアイテムを削除
  void removeAll() {
    _items.clear();
    notifyListeners();
  }
}
  • ChangeNotifierに固有のコードは、notifyListeners() の呼び出しのみ。
  • アプリのUIを変更する可能性のある方法でモデルが変更された時は常にnotifyListeners()を呼び出す。
  • CartModelの他の全てはモデル自身とそのビジネスロジック
  • ChangeNotifierはflutter:foundationの一部で、Flutter の上位のクラスには依存しない。
  • 簡単にテストできる (ウィジェットテストを使う必要さえない)。
test('adding item increases total cost', () {
  final cart = CartModel();
  final startingPrice = cart.totalPrice;
  cart.addListener(() {
    expect(cart.totalPrice, greaterThan(startingPrice));
  });
  cart.add(Item('Dash'));
});

ChangeNotifierProvider

  • ChangeNotifierProviderは、ChangeNotifierのインスタンスをその子孫に提供するウィジェット。providerパッケージに含まれる。
  • ChangeNotifierProviderはアクセスする必要のあるウィジェットの上に置く。
  • ChangeNotifierProviderを必要以上に高い位置に配置することはない。(スコープを汚したくないため)
  • 今回のケースでは、MyCartとMyCatalogの両方の上にあるウィジェットは、MyApp だけ。
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}
  • CartModel の新しいインスタンスを作成するビルダーを定義していることに注意。
  • ChangeNotifierProviderは賢いので、絶対に必要でない限りCartModelをrebuildすることはない。
  • CartModelインスタンスが不要になった場合、自動的にCartModelのdispose()を呼び出す。
  • 複数のクラスを提供したい場合は、MultiProviderを使用する。
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        Provider(create: (context) => SomeOtherClass()),
      ],
      child: const MyApp(),
    ),
  );
}

Consumer

  • ここまでの準備で、CartModelは上部のChangeNotifierProvider宣言により、アプリ内のウィジェットに提供されるようになった。
  • CartModelはConsumerウィジェットを通して使う。
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },
);
  • この場合、CartModel が必要なので、Consumer と記述する。ジェネリック() を指定する必要がある。
  • Consumerウィジェットの必須引数はbuilderのみ。モデル内でnotifyListeners()を呼び出すと、対応するすべてのConsumerwidgetのbuilderメソッドが呼び出される。
  • ビルダーは3つの引数で呼び出される。
    • 第1引数はcontextで、これはすべてのbuildメソッドと同様。
    • 第2引数は、ChangeNotifier のインスタンス
    • 3番目の引数はchild で、これは最適化のためにある。 モデルが変わっても変化しない大きなウィジェットのサブツリーがConsumerの下にある場合、それを一度buildしてbuilderを通して取得することができる。
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Stack(
      children: [
        // ここで SomeExpensiveWidget を使うと、毎回rebuildせずに済む
        if (child != null) child,
        Text('Total price: ${cart.totalPrice}'),
      ],
    );
  },
  // ここでコストの高いウィジェットをbuildする
  child: const SomeExpensiveWidget(),
);
// DON'T DO THIS
return Consumer<CartModel>(
  builder: (context, cart, child) {
    return HumongousWidget(
      // ...
      child: AnotherMonstrousWidget(
        // ...
        child: Text('Total price: ${cart.totalPrice}'),
      ),
    );
  },
);
// DO THIS
return HumongousWidget(
  // ...
  child: AnotherMonstrousWidget(
    // ...
    child: Consumer<CartModel>(
      builder: (context, cart, child) {
        return Text('Total price: ${cart.totalPrice}');
      },
    ),
  ),
);

Provider.of

  • 時には、UIを変更するためにモデル内のデータを本当に必要としないが、それでもそれにアクセスする必要がある。
  • そのためにConsumerを使うこともできるが、rebuildする必要のないウィジェットをrebuildするようにフレームワークに要求することになる。
  • 例えばremoveAll()メソッドを呼びたいだけなら、listenパラメータをfalseに設定したProvider.ofを使用することができる。
Provider.of<CartModel>(context, listen: false).removeAll();
  • これをbuildメソッド内で使用しても、notifyListeners()が呼ばれたときにそのウィジェットをrebuildすることはない。

Putting it all together(まとめ)

  • もっとシンプルなものをお望みなら、シンプルなCounterアプリを providerで構築するとどんな感じになるかをご覧ください。
  • これらの記事に従うことで、ステートベースのアプリケーションを作成する能力を大幅に向上させることができました。
  • これらのスキルを習得するために、providerを使ったアプリケーションを自分で作ってみてください。

ドキュメントは読み終わったので実際に作ってみます

......ざっと最後まで読んでしまいましたが、後半は実際に作ってみた方が良さそうですよね。以下で作っていきます。

Android Studioのnew flutter projectで「flutter_state_management_practice」という名前でプロジェクトを作ります(もちろんコマンドでflutter create flutter_state_management_practiceでもいいです)。 これでいつものカウンターアプリが出来ました。

次にproviderパッケージを追加します。コンソールでflutter pub add providerです。 これで準備完了です。

あとはアプリの中身を作っていきます!完成形がこちらです。

main.dart

import 'package:flutter/material.dart';
import 'package:flutter_state_management_practice/cart_model.dart';
import 'package:flutter_state_management_practice/my_catalog.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CartModel(),
      child: const MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyCatalog(),
    );
  }
}

my_catalog.dart

import 'package:flutter/material.dart';
import 'package:flutter_state_management_practice/cart_model.dart';
import 'package:flutter_state_management_practice/cart_page.dart';
import 'package:provider/provider.dart';

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('My Catalog'),
        actions: [
          IconButton(
            icon: const Icon(Icons.shopping_cart),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => const CartPage(),
                ),
              );
            },
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text('Item $index'),
            trailing: IconButton(
              icon: const Icon(Icons.add_shopping_cart),
              onPressed: () {
                Provider.of<CartModel>(context, listen: false).add('Item $index');
              },
            ),
          );
        },
      ),
    );
  }
}

cart_model.dart

import 'dart:collection';

import 'package:flutter/foundation.dart';

class CartModel extends ChangeNotifier {
  final List<String> _itemNames = [];

  UnmodifiableListView<String> get itemNames => UnmodifiableListView(_itemNames);

  // 全部2000円とする
  int get totalPrice => _itemNames.length * 2000;

  void add(String item) {
    _itemNames.add(item);
    notifyListeners();
  }

  void removeAll() {
    _itemNames.clear();
    notifyListeners();
  }

  void removeAt(int index) {
    _itemNames.removeAt(index);
    notifyListeners();
  }
}

(UnmodifiableListViewについてよく知らないので後で調べます)

cart_page.dart

import 'package:flutter/material.dart';
import 'package:flutter_state_management_practice/cart_model.dart';
import 'package:provider/provider.dart';

class CartPage extends StatelessWidget {
  const CartPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Cart'),
        actions: [
          IconButton(
            icon: const Icon(Icons.remove_shopping_cart),
            onPressed: () {
              Provider.of<CartModel>(context, listen: false).removeAll();
            },
          ),
        ],
      ),
      body: Consumer<CartModel>(
        builder: (context, cart, child) {
          return ListView(
            children: [
              for (final item in cart.itemNames)
                ListTile(
                  title: Text(item),
                  trailing: IconButton(
                    icon: const Icon(Icons.remove_shopping_cart),
                    onPressed: () {
                      cart.removeAt(cart.itemNames.indexOf(item));
                    },
                  ),
                ),
              const Divider(),
              ListTile(title: Text('合計${cart.totalPrice}円')),
            ],
          );
        },
      ),
    );
  }
}

できました!これでカタログページからカートに追加をしたものがカートページにちゃんと入っており、カートページから個別削除・一括削除もできるようになりました。 モデルのメソッドを使いたいだけならProvider.of、モデルの中身を使って表示したい時にはConsumerを使うということで一旦理解しました。 単純なアプリならこれだけでかなり対応できそうな感じがしますね。