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