##概要##
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となります。
管理したい状態をグローバルで定義し、
好きな場所でその状態を使用することが可能となります。
基本的な使い方は以下の通りです。
- 管理したい状態をグローバルで定義する(
Provider
の作成) - 使用
Widget
の先祖にProviderScope
を配置する - 使用したい
Widget
にConsumerWidget
を継承させ、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
の機能が存在します。
ProviderScope
のoverrides
プロパティの中で、
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##
ProviderScope
をMaterialApp
より下に置いた場合、
特殊な事例ですが、以下の状況の時に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}"),
),
);
}
}
AlertDialog
のcontent
に、
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.of
の useRootNavigator
です。
このパラメータがtrue
だと、context
の中の一番遠い先祖のNavigator
をとってきます。
今回のアプリだと、MaterialApp
です。
MaterialApp
はProviderScope
で囲まれていません。
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,
);
}
//省略//
}
このコードからわかることは以下の通りです
-
ProviderScope
はUncontrollProviderScope
を返すこと -
UncontrollProviderScope
はInheritedWidget
を継承していること -
initState
でgetElementForInheritedWidgetOfExactType<UncontrolledProviderScope>()
することにより、
祖先のUncontrolledProviderScope
を取得し、ProviderContainer
を取得していること
ProviderContainer
はProvider
の状態を保持しています。
つまり、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
を置く必要はないのか、
については検討が必要です。
ProviderScope
をMaterialApp
より前に置かなかった場合、
Provider
が勝手にdispose
される、という事象が個人開発のアプリ作成の過程でありました。
こちらについては原因がわからず、手元で再現ができませんでした。
現状ProviderScope
を複数置くデメリットが見当たらないため、
MaterialApp
より前に配置しておくのが安全かと考えます。
今回のコードは以下のGitHubに公開しています。
runApp
の後のコードを変更することにより
ダイアログのエラーを確認することが可能です。
以上、ありがとうございました。
##参考##
Comments
Let's comment your feelings that are more than good