まえがき
InheritedWidget
の使い方の基本をまとめます!
Flutterを勉強していてよくわからなくなるポイントの一つがこのInheritedWidget
だと思います。
筆者自身、これを理解するのにかなり時間がかかったので、「こういう説明があったらよかったな」と思う説明を書きます。
なお、以下では、InheritedWidget
クラスを継承したクラスのことを単にInheritedWidget
と呼びますね。他のStatefulWidget
なども同様です。
前提知識
FlutterアプリがWidgetのツリー構造でできていることを知っていて、StatefulWidget
とsetState
は使えるものとします。
BuildContext
の知識も少しだけ要りますが、「Widgetツリー内での位置を表現するもの」程度の理解で十分です。
それについてはこちらをお読みください。
記事内の例の実行方法
該当WidgetをMaterialApp
で包んでrunApp
に渡すか、GitHubのrepositoryからプロジェクトをダウンロードして実行してください。
それぞれの例がページ別にわかれたアプリになっています。
InheritedWidget
の存在理由
InheritedWidget
の目的は、次の2つです。
- Widgetツリーの下層の
BuildContext
から、プロパティにO(1)
でアクセスすること - プロパティが変化した時に下層の
BuildContext
に更新を伝えてリビルドさせること
一つずつ説明します。
Widgetツリーの下層のBuildContext
から、プロパティにO(1)
でアクセスする
このO
はランダウの記号と言います。ゼロではなくてオーです。
O(1)
というのはアクセスにかかる時間が何に依存して増えるかを表す表現なのですが、O(1)
ということは、そのアクセス時間が何にも依存しない(つまり増えない。常に最速でアクセスできる)ことになります。
ランダウの記号#無限大における漸近挙動と計算量の見積り - Wikipedia
dependOnInheritedWidgetOfExactType
を使え!
BuildContext
にはdependOnInheritedWidgetOfExactTypeというメソッドがあります。
context.dependOnInheritedWidgetOfExactType<Hoge>()
のように書くと、そのcontext
からWidgetツリーをさかのぼった時に一番近くにあるHoge
というInheritedWidget
を返します。
その時の計算時間はO(1)
です。つまり最速です。
基本的にfindAncestorStateOfType
は使うな!
BuildContext
にはfindAncestorStateOfTypeというメソッドもあり、同じようにBuildContext
からWidgetツリーをさかのぼって一番近くにある、指定した型のState
を返すのですが、その時の計算時間はO(n)
です。
このn
はBuildContext
から実際に返るState
までの、Widgetツリーの深さを表します。つまりWidgetツリーが深くなると、それに比例して計算に時間がかかるようになり、アプリが重くなっていくのです。
使用例
InheritedWidget
は、StatelessWidget
やStatefulWidget
と同様に、InheritedWidget
クラスをextends
したクラスを自分で作って使います。
この例ではMessageData
というInheritedWidget
を作っています。
updateShouldNotify
については後ほど説明しますので今はスルーしてください。
下記のMainWidget()
を、MaterialApp
で覆ってrunApp
に渡します。
import 'package:flutter/material.dart';
// InheritedWidgetを作ります。
class MessageData extends InheritedWidget {
const MessageData({Key key, @required Widget child})
: assert(child != null),
super(key: key, child: child);
// InheritedWidgetのプロパティは変更不可能なので必ずfinal
final String message = 'InheritedWidgetが保持してるメッセージだよ〜〜〜〜';
// updateShouldNotifyについては後で説明します。
@override
bool updateShouldNotify(MessageData old) {
return true;
}
}
class MainWidget extends StatelessWidget {
const MainWidget();
@override
Widget build(BuildContext context) {
// Widgetツリーの上層部に、作ったInheritedWidgetを配置
return MessageData(
child: Scaffold(
appBar: AppBar(),
body: WidgetA(),
));
}
}
class WidgetA extends StatelessWidget {
const WidgetA();
@override
Widget build(BuildContext context) {
// InheritedWidgetからO(1)でmessageを取得!!
final message =
context.dependOnInheritedWidgetOfExactType<MessageData>().message;
return Text(message);
}
}
WidgetA
内で、上層のInheritedWidget
からmessage
が取得され、表示されます。
Widgetツリー内でInheritedWidget
を上書きする
Widgetツリー内の一部でだけ異なるMessageData
を使いたい時は、もう一度MessageData
Widgetを配置すればいいです。
Theme, MediaQuery, Directionalityなど多くのFlutter Widgetがこの性質を持っていますね。実はそこでもInheritedWidget
が利用されています。
次の例を見てください。
MessageData
は2箇所配置されていますが、より呼び出し元のBuildContext
に近い方からmessage
が取得されていることを確認してください。
MessageData
のプロパティmessage
はコンストラクタで外から受け取れるようにしました。
import 'package:flutter/material.dart';
// InheritedWidgetを作ります。
class MessageData extends InheritedWidget {
const MessageData({
Key key,
@required Widget child,
@required this.message,
}) : assert(child != null),
super(key: key, child: child);
// 今回はmessageをコンストラクタで受け取ります。
final String message;
@override
bool updateShouldNotify(MessageData old) {
return true;
}
}
class MainWidget extends StatelessWidget {
const MainWidget();
@override
Widget build(BuildContext context) {
return MessageData(
message: 'メッセージその1だよ',
child: Scaffold(
appBar: AppBar(),
// 内容を変えたMessageDataウィジェットを挟み込みます
body: MessageData(
message: 'メッセージその2ですよ〜〜〜',
child: WidgetA(),
),
));
}
}
class WidgetA extends StatelessWidget {
const WidgetA();
@override
Widget build(BuildContext context) {
final message =
context.dependOnInheritedWidgetOfExactType<MessageData>().message;
return Text(message);
}
}
プロパティが変化した時に下層のBuildContext
に更新を伝えてリビルドさせる
InheritedWidget
のインスタンスは、中身を変更できません。
にもかかわらず更新を伝播するとは、一体どういうことなのでしょう?変更不可能なのに更新するとは??
筆者は以前、この部分で混乱していました。
答えはこうです。
InheritedWidget
のインスタンスを何度も作り直して置き換える
状態を更新可能なStatefulWidget
などと組み合わせて、Widgetツリー内にあるInheritedWidget
のインスタンスを作り直して再配置します。
プロパティの異なるInheritedWidget
を作って置き直せば、プロパティの変更が、それに依存するBuildContext
に伝播され、リビルドされるわけです。
使用例
実際の例で見てみましょう。
StatefulWidget
を使って、1秒に1回、プロパティを1ずつ増やしたInheritedWidget
を作って再配置します。作るInheritedWidget
の名前はCountData
としています。
InheritedWidget
のchild
に渡すWidgetは、変更せず、同じインスタンスを毎回渡すことにします。
import 'dart:async';
import 'package:flutter/material.dart';
// InheritedWidgetはmessageではなくcountを保持することにします。
class CountData extends InheritedWidget {
const CountData({Key key, @required Widget child, @required this.count})
: assert(child != null),
super(key: key, child: child);
final int count;
@override
bool updateShouldNotify(CountData old) {
return true;
}
}
// StatefulWidgetを使います。
class MainWidget extends StatefulWidget {
MainWidget();
// InheritedWidgetのchildには、毎回このインスタンスを渡します。
final Widget child = Scaffold(
appBar: AppBar(),
body: WidgetA(),
);
@override
_MainWidgetState createState() => _MainWidgetState();
}
class _MainWidgetState extends State<MainWidget> {
int timeCount = 0;
Timer timer;
@override
void initState() {
super.initState();
// Stateの初期化時に、毎秒setStateを呼ぶタイマーを起動します。
timer = Timer.periodic(Duration(seconds: 1), (timer) {
setState(() {
timeCount++;
});
});
}
@override
void dispose() {
// initStateでタイマーを起動・disposeでタイマーを破棄。この2つはセットです。
timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// タイマーにより毎秒setStateが呼ばれるので、このbuildメソッドが毎秒呼ばれます。
// その際、このCountDataコンストラクタでCountDataのインスタンスが毎回作り直されます。
// 渡す引数が異なるので、作られるCountDataのプロパティは毎回異なります。
return CountData(
count: timeCount,
// StateではなくStatefulWidgetが保持しているchildを渡します。
// つまり、毎秒変更されるインスタンスはCountDataだけであり、他は変化しません。
child: widget.child,
);
}
}
class WidgetA extends StatelessWidget {
const WidgetA();
@override
Widget build(BuildContext context) {
// ここでこのWidgetAのBuildContextがCountDataの変更通知対象として登録されるので、
// CountDataのインスタンスが置き換わるたびにこのbuildメソッドが呼ばれます。
final count = context.dependOnInheritedWidgetOfExactType<CountData>().count;
return Text('count: $count');
}
}
updateShouldNotify
で更新を間引きする
InheritedWidget
のよくわからないポイントの一つ、updateShouldNotify
です。
ここまでの説明で、InheritedWidget
のインスタンスの置き換えが行われる際に更新が伝播されるようなことを言いましたが、
実は、実際に更新が伝播されるのはupdateShouldNotify
がtrue
を返した時だけです。
インスタンスの置き換えの際、古い方のインスタンスも手元に手に入るので、比較して、更新通知するか制御可能です。
例えば先程の例で、更新を2回に1回にする(つまり、count
を2で割った商が変化した時だけ更新する)場合、updateShouldNotify
は次のように書けます。
@override
bool updateShouldNotify(CountData old) {
return count ~/ 2 != old.count ~/ 2;
}
他は先程の例と全く同じようにすると、次のような動きになります。
updateShouldNotify
の中身を、場合によって都合のいいように実装すればいいわけですね!
慣例として、of
を実装する
ここまで、動きの理解を重視して、BuildContext.dependOnInheritedWidgetOfExactTypeを直接呼んでいましたが、
Flutterの慣例として、このメソッドは直接呼ばず、InheritedWidget
にof
というstaticメソッドを実装して、そこから呼ぶのが普通です。
例
最初のMessageData
の例ですと、このようになります。
import 'package:flutter/material.dart';
class MessageData extends InheritedWidget {
const MessageData({Key key, @required Widget child})
: assert(child != null),
super(key: key, child: child);
final String message = 'InheritedWidgetが保持してるメッセージだよ〜〜〜〜';
@override
bool updateShouldNotify(MessageData old) {
return true;
}
// ofを実装します!!!
static MessageData of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<MessageData>();
}
class MainWidget extends StatelessWidget {
const MainWidget();
@override
Widget build(BuildContext context) {
return MessageData(
child: Scaffold(
appBar: AppBar(),
body: WidgetA(),
));
}
}
class WidgetA extends StatelessWidget {
const WidgetA();
@override
Widget build(BuildContext context) {
// of経由で呼びます!!!
// final message = context.dependOnInheritedWidgetOfExactType<MessageData>().message;
final message = MessageData.of(context).message;
return Text(message);
}
}
こうすると、Theme
などで見かけたTheme.of(context)
という見た目にそっくりになりましたね。
以上でInheritedWidget
の基本的な使い方の説明を終わります。
(応用)下層から更新する場合はどうするの?
InheritedWidget
を、アプリの状態管理にガッツリ使うことを考えます。
先程のタイマーを使った例では、更新がStatefulWidget
のState
内部から自発的に発生していました。
一方実際のアプリでは、Widgetツリー下層にあるRaisedButton
が押されたら、上層で保持している状態を更新する、という動きがよくあります。
となると、Widgetツリーの下層から上層の状態を更新するメソッドを呼ぶ必要があるわけです。
下層から更新用メソッドにアクセスできるようにする方法は、例えば
- その更新用メソッドを、毎回Widgetのコンストラクタに渡して下層まで持っていく
という方法がありますが、これは中間層すべてのコンストラクタに記述が必要で、イマイチです。
せっかくInheritedWidget
があるので、更新用メソッドにも下層からO(1)
でアクセスしましょう!
今回は、公式のList of state management approachesからリンクされている
これらを参考に、一つ方法を紹介します。
InheritedWidget
でState
自体を下層に流せ!
StatefulWidget
を使う場合、プロパティも更新用メソッドもState
が持っていることが多いです。更新時にsetState
を呼ばないといけないので、更新用メソッドもState
が持たないといけない。
ならば、State
自体をInheritedWidget
で下層に流してしまえば、そのプロパティにアクセスするのも更新用メソッドにアクセスするのも簡単ですね!
その方法を簡単にまとめるとこうです。
-
StatefulWidget
とInheritedWidget
のペアで使う -
InheritedWidget
はprivate
にする -
State
をpublic
にする -
InheritedWidget
はdata
というプロパティを持ち、State
を格納する -
of
メソッドはStatefulWidget
が持ち、State
(InheritedWidget
のdata
プロパティ)を返す -
StatefulWidget
はchild
を受け取り、State
のbuild
でInheritedWidget
の直下に入れて返す - メソッドだけ欲しい場合は
BuildContext.getElementForInheritedWidgetOfExactType
を使うと、更新伝播の対象にならない
実例でコメントと一緒に見るのが早いと思いますので、次をご覧ください。
使用例
ボタンが押された回数をテキストで表示するだけのアプリを考えます。
ただし、状態管理WidgetはWidgetツリーの上層にあり、ボタンとテキストはツリーの違う枝に置きます。
今回は状態更新用WidgetとアプリUI用Widgetでファイルを分けます。
count
を管理するStatefulWidget
+InheritedWidget
のセット
まず、状態管理用のWidgetを用意します。
import 'package:flutter/material.dart';
// InheritedWidgetはStateを保持するだけの役。
// privateにします。
class _StateContainer extends InheritedWidget {
const _StateContainer({Key key, @required Widget child, this.data})
: assert(child != null),
super(key: key, child: child);
// CountManagerStateのインスタンスを保持し、下層に流します。
final CountManagerState data;
@override
bool updateShouldNotify(_StateContainer old) {
return true;
}
}
// 状態管理用Widgetです。
class CountManager extends StatefulWidget {
const CountManager({this.child});
final Widget child;
// ofメソッドはStatefulWidgetが持ちます。
// ofにlisten引数を追加します。デフォルトはtrueです。
static CountManagerState of(BuildContext context, {bool listen = true}) =>
listen
? context.dependOnInheritedWidgetOfExactType<_StateContainer>().data
// listen==falseの場合、このcontextが更新の対象にならないようにします。
: (context
.getElementForInheritedWidgetOfExactType<_StateContainer>()
.widget as _StateContainer)
.data;
@override
CountManagerState createState() => CountManagerState();
}
class CountManagerState extends State<CountManager> {
// 下層からアクセスしたいプロパティです。
int count = 0;
// 下層からアクセスしたい状態更新用メソッドです。
void increment() {
setState(() {
count++;
});
}
@override
Widget build(BuildContext context) {
return _StateContainer(
// InheritedWidgetのdataに、このStateインスタンス自体を渡す。
data: this,
child: widget.child,
);
}
}
BuildContext
のgetElementForInheritedWidgetOfExactTypeは、InheritedWidget
の中身にアクセスしつつも、BuildContext
を状態更新時のリビルド対象として登録しません。
状態更新用メソッドだけを取得して、リビルド対象にしたくない場合、こちらを使います。
(BuildContext
のgetElementForInheritedWidgetOfExactTypeの返り値はInheritedWidget
ではなくInheritedElementなので、返り値の処理が若干変わってます)
UI構築
import 'package:flutter/material.dart';
import 'package:learn_inherited_widget/count_manager.dart';
class MainWidget extends StatelessWidget {
const MainWidget();
@override
Widget build(BuildContext context) {
// CountManagerの傘下に、ボタンと表示用テキストを配置します。
return CountManager(
child: Scaffold(
appBar: AppBar(),
body: Column(
children: [
const ButtonWidget(),
const ShowCount(),
],
),
),
);
}
}
class ButtonWidget extends StatelessWidget {
const ButtonWidget();
@override
Widget build(BuildContext context) {
return RaisedButton(
child: const Text('increment!!'),
onPressed: () {
// ofメソッドでCountManagerの状態更新用メソッドにアクセスします!
// ボタンをリビルドする必要はないので、listen:falseとします。
CountManager.of(context, listen: false).increment();
});
}
}
class ShowCount extends StatelessWidget {
const ShowCount();
@override
Widget build(BuildContext context) {
// ofメソッドでCountManagerのプロパティにアクセスします!
// 更新を反映する必要があるので、listenはデフォルト(true)にします。
final count = CountManager.of(context).count;
return Text('count: $count');
}
}
Providerパッケージについて
先程の例のcount_manager.dart
は50行近くあるのですが、count
プロパティとincrement
メソッド以外は、毎回ほぼ同じ内容を書くことになります。
そこで、それをパッケージ化すると便利そうだなと思うわけなのですが、
そうして出来上がったパッケージがProviderパッケージです。
(実際には、他にもいろいろな機能が追加されているので単純にそうとは言えませんが)
ということで、基本的にはProvider
パッケージを使えばいいと思います。
同じ作者によってさらなる改良がなされたRiverpodもありますね。
もちろん、他にもお好みの状態管理手法を使ってもいいです。
この記事で紹介した方法を使ってもいいと思います。
この記事で紹介した方法は、Flutter以外のパッケージに一切依存しないところがメリットですね。
おわり
おわりです!これでInheritedWidget
を理解してもらえたら嬉しいです!