【Flutter】 Container と三項演算子と Element の初期化

2023/02/07に公開
3

例えば以下のようなアプリがあったとします。

アプリのキャプチャ

コードとしては以下の通りで、画面の真ん中にはカウンターがあり、右下の FloatingActionButton をタップすると State に保持する _colored のフラグが切り替わってリビルドされ、背景色が変わるというシンプルな構成です。

main.dart
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 の値は Containercolor 引数を渡す部分で使われていて、三項演算子で 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 点です。

  • ContainerStatelessWidget のサブクラスである
  • color 引数は build() メソッドで使われている

まずはクラス定義ですが、ジャンプした先のコードを少し上にスクロールすると以下の定義が見つかります。

class Container extends StatelessWidget {

これはわれわれアプリ開発者にとって見慣れた定義ですね。どうやら ContaienrStatelessWidget のサブクラスのようです。ということは、いつもわれわれがやるように build() メソッドで Widget を組み立てていることが予測できます。

ということでコードを今度は下にスクロールしていくと、以下の書き出しで始まる build() メソッドの実装が見つかります。

  
  Widget build(BuildContext context) {
    Widget? current = child;

Flutter 内部のコードとはいえど、ものによってはわれわれアプリ開発者が書くコードと大差ないものもあるということが見えてきます。

コードリーディングを続けて color 引数が使われているコードを探しましょう。build() の中をざっとみていくと、以下の処理が見つかります。

if (color != null) {
  current = ColoredBox(color: color!, child: current);
}

currentbuild() の最初の行で宣言されていた Widget ですね。どうやら colornull でなければ currentColoredBoxchild として包んでいるようです。

そのまま下まで読んでいくと、最後に currentreturn して build() メソッドは終了しています。

return current!;

Widget ツリーの状態

以上でコードリーディングは終了です。ここから何がわかるでしょうか。

まず、color 引数が null の場合の Container 周辺の Widget ツリーは以下のようになります。

colorがnullの場合のWidgetツリー

次に、 color 引数に Colors.blue.shade100 を渡した場合の Widget ツリーを見てみます。

colorがColors.blue.shade100の場合のWidgetツリー

先ほどコードで確認した通り、Container_TinyClock の間に ColoredBox が挟まっています。

この変化が今回の事象を把握する上でとても大切です。

リビルドの仕組み

Flutter はリビルドが発生した際、新しく生成された Widget と古い Widget を比較して差分を判断し、必要最低限のレイアウト再計算と再描画を行うことで UI の更新を高速化しています。[1]

どのように比較しているのか、詳しくは以下の記事を参照していただければと思いますが、

https://zenn.dev/chooyan/books/934f823764db62/viewer/703605

今回の場合は、以下のような判断が行われています。

Widgetツリーの比較

Widget ツリーの新旧比較はツリーの上の方から順番に行われます。

上の図では、青い矢印は「新旧を比較して keyruntimeType は同一だがオブジェクトは異なる」を表していて、赤い矢印は「新旧を比較して runtimeType が異なる」を表しています。

ここで大事になるのが Widget とペアになる Element の挙動です。[2]

青い線の場合、Element は使いまわされ、そのままツリーの子孫のリビルドに進みます(リビルドの伝播)。

一方で赤い線の場合、 Element は一度破棄され、そこに紐づく State も破棄され、どちらも新しい Widget を元に新しいオブジェクトが再生成されます。 これがカウンターがゼロに戻ってしまっていた原因です。

Elementの作り直し

この辺りの挙動については、公式ドキュメントなどではよく Widget ツリーの「深さ」が変わることによって云々というような表現で説明されています。

同様に表現すると、 color 引数の有無によって Widget ツリーの深さが変わることによって Element とそれに紐づく State が破棄・再生成されるため、カウンターが 0 に戻ってしまう ということがここまでの調査から分かったのではないかと思います。

対策

ということで、ここまでの話が理解できていれば対策はカンタンです。つまり Widget ツリーの深さを変えなければ良い のです。

Container の実装として、「color 引数が null の場合は ColoredBoxchild を包む」という実装になっていたのは先ほど確認した通りですので、「常に 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 の仕組みを確認しながら、冒頭の事象がなぜ発生するのかについてじっくりと調査してみました。

Containerbuild() メソッドのコードを見直してみていただければわかる通り、そのほとんどは「引数が null じゃなかったらこの Widget で包む」という処理で成り立っています。つまり、color に限らず同様の三項演算子を書いていると同じような事象は発生し得るといえるでしょう。

このような実装になっている Widget はあまり Container 以外では見かけないですし[3]、あったとしても場合によって null を渡したり渡さなかったりという使い方をしなかったり、childState を必要とする Widget を渡さなかったりで同様の落とし穴にハマることは経験上ほぼありませんが、今回説明した仕組みは知っておいて損はないはずです。

また、ここで注意しなければならないのは、「今回は childStatefulWidget だったからこんなことになったのか。じゃあ StatefulWidget じゃなくて riverpod 使った方がいいね」という判断が常に適切とは限らない、ということです。

riverpod を使って State 管理をした場合も、Provider.autoDispose がついていれば同様の挙動になります(Element が破棄されることで Provider も破棄されるため)。では .autoDispose をつけなければ良いかというと、今度は無駄に Provider が残ってしまって破棄・初期化されるべきタイミングでもカウンターがカウントアップを止めてくれない、という別の問題が発生するリスクがあります。
(2/19訂正)今回のように、Widget の階層が変わることによって State が破棄される問題に対処する目的であれば、今回の状況では .autoDispose では破棄されない(理由はコメント参照)ため _TinyClock 単体の状態管理に riverpod を利用することで解決は可能です。訂正します。(訂正ここまで)

根本的な原因は Widget ツリーの変化による Element の破棄にある ことが理解できていれば、同じような事象に出くわしても目の前のコードとパッケージの利用方法と照らし合わせて最適な解決策が判断できるはずです。

(2/19追記)コメントに記載した通り、修正のためにどこまでの範囲のコードを変更できるかによって採用できる対策が変わってきそうです。詳しくは別途追記予定です。(追記ここまで)

脚注
  1. 「リビルドによって Widget が再生成された」がそのまま「再描画」につながるわけではないことに注意してください。 ↩︎

  2. Element についても詳しくは「Element とは何か」の記事を参照してみてください。 ↩︎

  3. 便利 Widget として用意されている一方落とし穴も多い Container 特有の問題という印象です。 ↩︎

GitHubで編集を提案

Discussion

monomono

riverpod を使って State 管理をした場合も、Provider に .autoDispose がついていれば同様の挙動になります(Element が破棄されることで Provider も破棄されるため)。

これは誤りだと思います。WidgetのElement破棄と、Providerのdisposeは完全には連動しておらず、同様の挙動にならないです。つまりRiverpodを使うとこの罠にハマらず、適切な実装の1つです。
どのようなProvider.autoDisposeの実装をして同様の挙動になることを確認しましたか?

ということで、ここまでの話が理解できていれば対策はカンタンです。つまり Widget ツリーの深さを変えなければ良い のです。

概ねそれで良いとは思いつつも、Containerの内部実装によって壊れ得る脆さを抱えたままの対症療法的な対策にも感じます。
Widgetツリーの深さが変わることによって状態が意図せず破棄される作りになっていること自体も問題だと思います。

このコード例の場合、問題が起こるコード例を作るために_TinyClockState側で状態管理しまっているのが根本の原因であり、_MyAppStateで保持するのが確実な解の1つだと思います。
ただ、秒数管理は本来_TinyClock側で済ませられるべきものであり、つまりやはり上述の通りRiverpodだとContainerなどの内部実装を意識せず普通に組むだけで問題起こらず都合良い場面だと思います。

あるいは、_MyAppStateでGlobalKeyを保持して_TinyClockに渡すのも確実な対策です。

中條 剛(ちゅーやん)中條 剛(ちゅーやん)
riverpod版 _TinyClock
final counterProvider = NotifierProvider.autoDispose<_TimerCounter, int>(() {
  return _TimerCounter();
});

class _TimerCounter extends AutoDisposeNotifier<int> {
  
  int build() {
    final start = DateTime.now();
    Timer.periodic(const Duration(seconds: 1), (_) {
      state = DateTime.now().difference(start).inSeconds;
    });
    return 0;
  }
}

class _CounterText extends ConsumerWidget {
  const _CounterText();

  
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Center(
      child: Text(
        count.toString(),
        style: const TextStyle(fontSize: 24),
      ),
    );
  }
}

こちらのコードをイメージしつつ記事に記載の理解のまま動作確認せず書いてしまいましたが、確かに動作確認すると破棄されないですね。訂正します。

コードと動作を追ってみた感じ、Element が破棄されても Rivepod 内部の _ProviderScheduler._performDispose が呼び出される前に他の WidgetRefwatch すれば Provider は破棄されないということと読み取りました。

この理解で間違っていなければ、リビルドによって _TinyClock の階層が変わっても同じリビルド処理の中で新たに生成された _TinyClockcounterProviderwatch し直すため破棄されない、ということですかね。


対策については、丁寧に考えるのであれば

  • コード全体を修正できる場合
  • 使う側(記事でいう _MyAppState)のみ修正できる場合(標準Widgetやパッケージを利用する側など)
  • 使われる側(記事でいう _TinyClock )のみ修正できる場合(パッケージ開発者など)

それぞれで適切な方法が異なりそうですね。

この記事内で例として出している _TinyClock 自体は「何か独自の State を持った共通 Widget」くらいの意味しかないので、「時計機能として秒数データをどこで持つか」を考えることにあまり意味は無いと思っています。それよりも汎用的に活用できる対処の例を時間を見つけて追記しようと思います。

ご指摘ありがとうございます。

monomono

それよりも汎用的に活用できる対処の例

以下はその例だと思っています。

あるいは、_MyAppStateでGlobalKeyを保持して_TinyClockに渡すのも確実な対策です。