【Flutter】 Container と三項演算子と Element の初期化
例えば以下のようなアプリがあったとします。
コードとしては以下の通りで、画面の真ん中にはカウンターがあり、右下の FloatingActionButton
をタップすると State
に保持する _colored
のフラグが切り替わってリビルドされ、背景色が変わるというシンプルな構成です。
void main() => runApp(const MyApp());
class MyApp extends StatefulWidget {
const MyApp({super.key});
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool _colored = false;
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(),
body: Padding(
padding: const EdgeInsets.all(16),
child: Container(
color: _colored ? Colors.blue.shade100 : null,
padding: const EdgeInsets.all(16),
child: const _TinyClock(),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() => _colored = !_colored);
},
child: const Icon(Icons.add),
),
),
);
}
}
_colored
の値は Container
の color
引数を渡す部分で使われていて、三項演算子で true
の場合は Colors.blue.shade100
を、 false
の場合は null
を渡すような実装になっています。
// _colored を使っている Container のコードだけ抜粋
child: Container(
color: _colored ? Colors.blue.shade100 : null,
padding: const EdgeInsets.all(16),
child: const _TinyClock(),
),
child
に渡している _TinyClock
は、Timer
を使って毎秒カウントアップを続けるだけのシンプルなカウンター Widget です。
class _TinyClockState extends State<_TinyClock> {
var _passed = const Duration(seconds: 0);
late final Timer _timer;
void initState() {
super.initState();
final start = DateTime.now();
// 毎秒カウンターを更新
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _passed = DateTime.now().difference(start));
});
}
void dispose() {
_timer.cancel();
super.dispose();
}
Widget build(BuildContext context) {
return Center(
child: Text(
_passed.inSeconds.toString(),
style: const TextStyle(fontSize: 24),
),
);
}
}
さて、ここで FloatingActionButton
をタップすると、カウントはどうなるでしょうか。
リビルドが発生して Widget が作り直されても State
が保持する値はそのままのはずなので、背景色が変わるのみで数字はそのままカウントアップし続けてほしいところです。
実際に試してみるとボタンをタップするたびにカウントが 0 に戻ってしまいました。これはなぜでしょうか。
この記事では、 Container
の内部実装と Widget のリビルドの仕組みを確認しながら、この事象が発生する原因を理解していきたい と思います。
Container の内部実装
ではまず Container
のコードを読んでみましょう。手元の Flutter アプリの適当な場所に Container
を配置し、カーソルを合わせて F12 で定義元にジャンプしましょう。
いろいろ書いてありますが、大事なのは以下の 2 点です。
-
Container
はStatelessWidget
のサブクラスである -
color
引数はbuild()
メソッドで使われている
まずはクラス定義ですが、ジャンプした先のコードを少し上にスクロールすると以下の定義が見つかります。
class Container extends StatelessWidget {
これはわれわれアプリ開発者にとって見慣れた定義ですね。どうやら Contaienr
は StatelessWidget
のサブクラスのようです。ということは、いつもわれわれがやるように build()
メソッドで Widget を組み立てていることが予測できます。
ということでコードを今度は下にスクロールしていくと、以下の書き出しで始まる build()
メソッドの実装が見つかります。
Widget build(BuildContext context) {
Widget? current = child;
Flutter 内部のコードとはいえど、ものによってはわれわれアプリ開発者が書くコードと大差ないものもあるということが見えてきます。
コードリーディングを続けて color
引数が使われているコードを探しましょう。build()
の中をざっとみていくと、以下の処理が見つかります。
if (color != null) {
current = ColoredBox(color: color!, child: current);
}
current
は build()
の最初の行で宣言されていた Widget ですね。どうやら color
が null
でなければ current
を ColoredBox
の child
として包んでいるようです。
そのまま下まで読んでいくと、最後に current
を return
して build()
メソッドは終了しています。
return current!;
Widget ツリーの状態
以上でコードリーディングは終了です。ここから何がわかるでしょうか。
まず、color
引数が null
の場合の Container
周辺の Widget ツリーは以下のようになります。
次に、 color
引数に Colors.blue.shade100
を渡した場合の Widget ツリーを見てみます。
先ほどコードで確認した通り、Container
と _TinyClock
の間に ColoredBox
が挟まっています。
この変化が今回の事象を把握する上でとても大切です。
リビルドの仕組み
Flutter はリビルドが発生した際、新しく生成された Widget と古い Widget を比較して差分を判断し、必要最低限のレイアウト再計算と再描画を行うことで UI の更新を高速化しています。[1]
どのように比較しているのか、詳しくは以下の記事を参照していただければと思いますが、
今回の場合は、以下のような判断が行われています。
Widget ツリーの新旧比較はツリーの上の方から順番に行われます。
上の図では、青い矢印は「新旧を比較して key
と runtimeType
は同一だがオブジェクトは異なる」を表していて、赤い矢印は「新旧を比較して runtimeType
が異なる」を表しています。
ここで大事になるのが Widget
とペアになる Element
の挙動です。[2]
青い線の場合、Element
は使いまわされ、そのままツリーの子孫のリビルドに進みます(リビルドの伝播)。
一方で赤い線の場合、 Element
は一度破棄され、そこに紐づく State
も破棄され、どちらも新しい Widget を元に新しいオブジェクトが再生成されます。 これがカウンターがゼロに戻ってしまっていた原因です。
この辺りの挙動については、公式ドキュメントなどではよく Widget ツリーの「深さ」が変わることによって云々というような表現で説明されています。
同様に表現すると、 color
引数の有無によって Widget ツリーの深さが変わることによって Element
とそれに紐づく State
が破棄・再生成されるため、カウンターが 0 に戻ってしまう ということがここまでの調査から分かったのではないかと思います。
対策
ということで、ここまでの話が理解できていれば対策はカンタンです。つまり Widget ツリーの深さを変えなければ良い のです。
Container
の実装として、「color
引数が null
の場合は ColoredBox
で child
を包む」という実装になっていたのは先ほど確認した通りですので、「常に ColoredBox
で包んでもらうことによって Widget ツリーの深さを変えない」がストレートな対策となるでしょう。つまり、「色をつけない」場合に color
に渡す値を null
にするのではなく、同じ見た目になる Color
オブジェクト(たとえば Colors.transparent
)を渡すようにしてみましょう。
child: Container(
// color: _colored ? Colors.blue.shade100 : null,
color: _colored ? Colors.blue.shade100 : Colors.transparent,
padding: const EdgeInsets.all(16),
child: const _TinyClock(),
),
実行結果はこちらです。
これで意図した挙動になりましたね。
まとめ
ここまで、Container
の実装と Flutter の仕組みを確認しながら、冒頭の事象がなぜ発生するのかについてじっくりと調査してみました。
Container
の build()
メソッドのコードを見直してみていただければわかる通り、そのほとんどは「引数が null
じゃなかったらこの Widget で包む」という処理で成り立っています。つまり、color
に限らず同様の三項演算子を書いていると同じような事象は発生し得るといえるでしょう。
このような実装になっている Widget はあまり Container
以外では見かけないですし[3]、あったとしても場合によって null
を渡したり渡さなかったりという使い方をしなかったり、child
に State
を必要とする Widget を渡さなかったりで同様の落とし穴にハマることは経験上ほぼありませんが、今回説明した仕組みは知っておいて損はないはずです。
また、ここで注意しなければならないのは、「今回は child
が StatefulWidget
だったからこんなことになったのか。じゃあ StatefulWidget
じゃなくて riverpod
使った方がいいね」という判断が常に適切とは限らない、ということです。
riverpod
を使って State 管理をした場合も、Provider
に .autoDispose
がついていれば同様の挙動になります(Element
が破棄されることで Provider
も破棄されるため)。では .autoDispose
をつけなければ良いかというと、今度は無駄に Provider
が残ってしまって破棄・初期化されるべきタイミングでもカウンターがカウントアップを止めてくれない、という別の問題が発生するリスクがあります。
(2/19訂正)今回のように、Widget の階層が変わることによって State が破棄される問題に対処する目的であれば、今回の状況では .autoDispose
では破棄されない(理由はコメント参照)ため _TinyClock
単体の状態管理に riverpod
を利用することで解決は可能です。訂正します。(訂正ここまで)
根本的な原因は Widget ツリーの変化による Element
の破棄にある ことが理解できていれば、同じような事象に出くわしても目の前のコードとパッケージの利用方法と照らし合わせて最適な解決策が判断できるはずです。
(2/19追記)コメントに記載した通り、修正のためにどこまでの範囲のコードを変更できるかによって採用できる対策が変わってきそうです。詳しくは別途追記予定です。(追記ここまで)
Discussion
これは誤りだと思います。WidgetのElement破棄と、Providerのdisposeは完全には連動しておらず、同様の挙動にならないです。つまりRiverpodを使うとこの罠にハマらず、適切な実装の1つです。
どのような
Provider.autoDispose
の実装をして同様の挙動になることを確認しましたか?概ねそれで良いとは思いつつも、Containerの内部実装によって壊れ得る脆さを抱えたままの対症療法的な対策にも感じます。
Widgetツリーの深さが変わることによって状態が意図せず破棄される作りになっていること自体も問題だと思います。
このコード例の場合、問題が起こるコード例を作るために_TinyClockState側で状態管理しまっているのが根本の原因であり、_MyAppStateで保持するのが確実な解の1つだと思います。
ただ、秒数管理は本来_TinyClock側で済ませられるべきものであり、つまりやはり上述の通りRiverpodだとContainerなどの内部実装を意識せず普通に組むだけで問題起こらず都合良い場面だと思います。
あるいは、_MyAppStateでGlobalKeyを保持して_TinyClockに渡すのも確実な対策です。
riverpod版 _TinyClock
こちらのコードをイメージしつつ記事に記載の理解のまま動作確認せず書いてしまいましたが、確かに動作確認すると破棄されないですね。訂正します。
コードと動作を追ってみた感じ、
Element
が破棄されても Rivepod 内部の_ProviderScheduler._performDispose
が呼び出される前に他のWidgetRef
がwatch
すれば Provider は破棄されないということと読み取りました。この理解で間違っていなければ、リビルドによって
_TinyClock
の階層が変わっても同じリビルド処理の中で新たに生成された_TinyClock
がcounterProvider
をwatch
し直すため破棄されない、ということですかね。対策については、丁寧に考えるのであれば
_MyAppState
)のみ修正できる場合(標準Widgetやパッケージを利用する側など)_TinyClock
)のみ修正できる場合(パッケージ開発者など)それぞれで適切な方法が異なりそうですね。
この記事内で例として出している
_TinyClock
自体は「何か独自の State を持った共通 Widget」くらいの意味しかないので、「時計機能として秒数データをどこで持つか」を考えることにあまり意味は無いと思っています。それよりも汎用的に活用できる対処の例を時間を見つけて追記しようと思います。ご指摘ありがとうございます。
以下はその例だと思っています。