Why not login to Qiita and try out its useful features?

We'll deliver articles that match you.

You can read useful information later.

52
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

FlutterAdvent Calendar 2021

Day 12

Riverpod、ProviderScopeどこに置く? ~ Providerをcontextを使って初期化する方法 ~

Last updated at Posted at 2021-12-11

##概要##
Flutterの状態管理手法のRiverpod、あなたは活用されていますでしょうか。
管理したい状態をグローバルで定義できるのはとても便利ですよね。
ただ、Riverpodを使うにあたって、忘れてはいけないことがあります。
それがProviderScopeです。
ProviderScopeの中で状態を使用しないと、
Bad state: No ProviderScope found
のエラーが出てしまいます。
Riverpodを使ったことがある方なら、一度は経験したことがあるのではないでしょうか?

ほとんどの方は、一番最初のrunAppの後にProviderScopeを置いていることでしょう。
中には状態の使用箇所にだけおく、という考え方の人もいるかもしれません。

本記事ではProviderScopeを置く場所についての検討を行います。
内容は以下の通りです。

  • Riverpod の紹介
  • 状態初期化の問題点
  • 対応策の紹介
  • 対応策により発生する事象の紹介
  • 解決策

また、結論は以下の通りです。

  • MaterialAppより前にProviderScopeを置く
  • Providerを後から初期化したいならば、それができる位置にProviderScope追加する

##Riverpodとは##
Riverpodとは Flutterの状態管理手法の一つです。

StatefulWidgetではそのWidget内で状態を管理するため、
他のWidgetに状態を共有する、というのは難しいです。

これを解決する方法はいくつかあるのですが、
その中の一つがRiverpodとなります。

管理したい状態をグローバルで定義し、
好きな場所でその状態を使用することが可能となります。

基本的な使い方は以下の通りです。

  1. 管理したい状態をグローバルで定義する(Providerの作成)
  2. 使用Widgetの先祖にProviderScopeを配置する
  3. 使用したいWidgetConsumerWidgetを継承させ、ref.watch(counterProvider)で状態を取得する
//管理したい状態をグローバルで定義する(Providerの作成)
final counterProvider = StateProvider((ref) => 0);

//使用Widgetの先祖にProviderScopeを配置する
void main() {
  runApp(
     const ProviderScope(child: MyApp()),
  );
}

//  ・・・

//使用したいWidgetにConsumerWidgetを継承させ、
//ref.watch(counterProvider)で状態を取得する
class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

##状態初期化の問題点##
さて、この便利なRiverpod、管理したい状態の初期化をグローバルで行なっています。
グローバルで行なっているがために、できないことが存在します。

例えば、
端末の使用言語が日本語なら日本語で、
英語なら英語で初期化する、
といったように、ローカライズしたStringで初期化することです。

ローカライズしたStringの取得にはAppLocalization.of(context)のように
contextを使用します。

グローバルの領域ではBuildContextはまだ作成されていないため、contextを使っての初期化はできません。

対応策はないのでしょうか?

##対応策##

この対応策は、contextが使える位置にProviderScopeを配置し、
overrideWithValueを使って再初期化する、です。

Riverpodには後からProviderの値を書き換えることができるように、
overrideの機能が存在します。
ProviderScopeoverridesプロパティの中で、
Provider.overrideWithValue(初期化したい値)
とすることで、Providerの再初期化ができます。

final counterProvider = StateProvider((ref) => 0);

void main() {
  runApp(
     const ProviderScope(
       overrides: [
         //counterProviderの値を100で再初期化
         counterProvider.overrideWithValue(StateController(100)),
       ],
       child: MyApp(),
     ),
  );
}

この再初期化はProviderScopeの位置に依存します。
BuildContextが使える位置にProviderScopeを移動させ再初期化を行えば、
contextを使った値でProviderを初期化することが可能となります。

サンプルコードは以下の通りです。
(この例ではcontextの使用例として画面サイズ(MediaQuery.of(context).size)を使って再初期化を行なっています。)

final counterProvider = StateProvider((ref) => 0);

void main() {
  runApp(
    const MyApp(),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Builder(builder: (context) {
        //コンテキストを使って値を取得
        int recount = MediaQuery.of(context).size.height.toInt();
        //移動したProviderScopeで再初期化
        return ProviderScope(
          overrides: [
            counterProvider.overrideWithValue(StateController(recount)),
          ],
          child: const PagesNavigator(),
        );
      }),
    );
  }
}

この対応で、ProviderScopeを移動させてしまいました。
これは本当に大丈夫なのでしょうか?

##発生事象 ダイアログでの状態の使用でBad state: No ProviderScope found##
ProviderScopeMaterialAppより下に置いた場合、
特殊な事例ですが、以下の状況の時にBad state: No ProviderScope foundとなります。

  • ダイアログの構成要素を、自作のConsumerWidgetで置き換え、ref.watchする

サンプルコードは以下の通りです。

class Home extends ConsumerWidget {
  const Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
        appBar: AppBar(
          title: const Text("Provider Scope example"),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              ElevatedButton(
                child: const Text("Show Dialog"),
                onPressed: () {
                  showCounterDialog(context);
                }
              ),
            ],
          ),
        ),
    );
  }
}

void showCounterDialog(BuildContext context) {
  showDialog<void>(
    barrierDismissible: false,
    context: context,
    builder: (BuildContext context) => AlertDialog(
      title: const Center(
        child: Text(
          "Dialog",
          style: TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      content: const DialogContent(),
      actions: <Widget>[
        Center(
          child: ElevatedButton(
            onPressed: () {
              Navigator.pop(context);
            },
            child: const Text("back"),
          ),
        )
      ],
    ),
  );
}

class DialogContent extends ConsumerWidget {
  const DialogContent({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return SingleChildScrollView(
      child: Center(
        child: Text("${ref.watch(counterProvider.state).state}"),
      ),
    );
  }
}

AlertDialogcontentに、
DialogContentというConsumerWidgetを継承したWidgetを配置しています。

普通の書き方のように見えますが、何がいけないのでしょうか。

showDialogメソッドの中を見ると、次のようになっています。

Future<T?> showDialog<T>({
  required BuildContext context,
  required WidgetBuilder builder,
  bool barrierDismissible = true,
  Color? barrierColor = Colors.black54,
  String? barrierLabel,
  bool useSafeArea = true,
  bool useRootNavigator = true,  //useRootNavigatorがtrueになっている
  RouteSettings? routeSettings,
}) {
//省略//

  return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(DialogRoute<T>(
    context: context,
    builder: builder,
    barrierColor: barrierColor,
    barrierDismissible: barrierDismissible,
    barrierLabel: barrierLabel,
    useSafeArea: useSafeArea,
    settings: routeSettings,
    themes: themes,
  ));
}

ここで注目すべきはNavigator.ofuseRootNavigatorです。
このパラメータがtrueだと、contextの中の一番遠い先祖のNavigatorをとってきます。
今回のアプリだと、MaterialAppです。
MaterialAppProviderScopeで囲まれていません。
BuildContextで参照する位置でProviderScopeで囲まれていないが故に、
Bad state: No ProviderScope foundが出たのだと考えられます。

##解決策##

この解決策は再初期化のためのProviderScopeと別に、
もう一つProviderScopeを用意し、
MaterialAppの前に置くことです。

2つ置いていいの?と思うかもしれませんが、問題ありません。
ProviderScopeのコードの一部を紹介します。

class ProviderScope extends StatefulWidget {
//省略//
}

//省略//

class ProviderScopeState extends State<ProviderScope> {
//省略//
  late ProviderContainer container;
//省略//
@override
  void initState() {
    super.initState();
    final scope = context
        .getElementForInheritedWidgetOfExactType<UncontrolledProviderScope>()
        ?.widget as UncontrolledProviderScope?;
//省略//
    container = ProviderContainer(
      parent: scope?.container,
      overrides: widget.overrides,
      observers: widget.observers,
//省略//
    );
  }

//省略//

  @override
  Widget build(BuildContext context) {
//省略//
    return UncontrolledProviderScope(
      container: container,
      child: widget.child,
    );
  }
//省略//
}

このコードからわかることは以下の通りです

  • ProviderScopeUncontrollProviderScopeを返すこと
  • UncontrollProviderScopeInheritedWidgetを継承していること
  • initStategetElementForInheritedWidgetOfExactType<UncontrolledProviderScope>()することにより、
        祖先のUncontrolledProviderScopeを取得し、ProviderContainerを取得していること

ProviderContainerProviderの状態を保持しています。

つまり、ProviderScopeは祖先のProviderScopeからProviderが管理している状態を取得しているわけです。

これらからProviderScopeは複数のProviderScopeが存在しても大丈夫なように設計されていることがわかります。

また、公式の例でもProviderScopeを2つ重ねて使用している例が紹介されています。

公式の例を引用

final themeProvider = Provider((ref) => MyTheme.light());

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        // Home uses the default behavior for all providers.
        home: Home(),
        routes: {
          // Overrides themeProvider for the /gallery route only
          '/gallery': (_) => ProviderScope(
            overrides: [
              themeProvider.overrideWithValue(MyTheme.dark()),
            ],
          ),
        },
      ),
    ),
  );
}

長くなりましたが、ProviderScopeの配置について結論としては以下の通りになります。

  • MaterialAppより前にProviderScopeを置く
  • Providerを後から初期化したいならば、それができる位置にProviderScope追加する

##まとめ##
RiverpodのProviderScopeをどこにおけばいいのか、について検討しました。

結論は以下の通りです。

  • MaterialAppより前にProviderScopeを置く
  • Providerを後から初期化したいならば、それができる位置にProviderScope追加する

ダイアログを使わないのであればMaterialAppより前にProviderScopeを置く必要はないのか、
については検討が必要です。
ProviderScopeMaterialAppより前に置かなかった場合、
Providerが勝手にdisposeされる、という事象が個人開発のアプリ作成の過程でありました。
こちらについては原因がわからず、手元で再現ができませんでした。

現状ProviderScopeを複数置くデメリットが見当たらないため、
MaterialAppより前に配置しておくのが安全かと考えます。

今回のコードは以下のGitHubに公開しています。
runAppの後のコードを変更することにより
ダイアログのエラーを確認することが可能です。

以上、ありがとうございました。

##参考##

52
29
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up

Comments

No comments

Let's comment your feelings that are more than good

Qiita Advent Calendar is held!

Qiita Advent Calendar is an article posting event where you post articles by filling a calendar 🎅

Some calendars come with gifts and some gifts are drawn from all calendars 👀

Please tie the article to your calendar and let's enjoy Christmas together!

52
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?