概要
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
の後のコードを変更することにより
ダイアログのエラーを確認することが可能です。
以上、ありがとうございました。