【Flutter】 無駄なリビルドを防ぐたった1つの方法

Flutter では StatelessWidget や StatefulWidget（以下、Widget） の `build()` が頻繁に呼ばれる（リビルドされる）ことを、 [【Flutter】build() でやってはいけない 3 つのこと](https://qiita.com/chooyan_eng/items/976adeea8eed1b5ebad4) では説明しました。

`build()` が頻繁に呼ばれること自体は Flutter フレームワークとして想定通りの動作ですので、ここに「変な」処理を書かない限り リビルドが原因でパフォーマンスが低下するようなことは通常ありませんが、アプリの規模が大きくなり Widget の入れ子（以下「Widget ツリー」と呼びます）が深くなってくると少しずつ「リビルド範囲が大きすぎる」ためにパフォーマンスが落ちてくる場合があるのも事実です。

この記事では、 __UI の変化が必要ない Widget の無駄なリビルドを防いでパフォーマンスを改善する__ にはどうすれば良いのかを理解するために、 Flutter フレームワークがリビルドを行わない条件を確認しつつ、その条件を満たすための実装について考えていきたいと思います。

# インスタンスが変わらなければリビルドは発生しない

まず Flutter フレームワークが Widget をリビルドしない条件ですが、それは __リビルド対象の Widget のインスタンスがリビルド前後で同一である__ 場合です。

通常以下のように `SomeWidget` の `build()` の中で `OtherWidget` を生成している場合、

```dart
class SomeWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return OtherWidget();
  }
}
```

`_SomeWidgetState` の `build()` が呼び出されると `OtherWidget` の `build()` もそれに続けて呼び出されます。 もし `OtherWidget` の `build()` の中でさらに別の Widget が生成されている場合はその Widget の `build()` も呼び出され、以下同様に Widget ツリーの末端に到達するまで `build()` が連続で呼ばれます。これを __リビルドの伝播__ と呼んだりします。

何も考えずに上記のようにコーディングすると、 `OtherWidget` のインスタンスは `_SomeWidgetState` の `build()` が呼び出されるたびに新しく生成されるため、前回の `build()` で生成したインスタンスとは別のものになってしまいます。 

しかし、ここにひと工夫入れて __何度 `build()` が呼ばれたとしても同じ `OtherWidget` インスタンスを使い回す__ ことで、 Flutter フレームワークに対してそのインスタンスをリビルドする必要がない、と判断させることができます。[^1]

# Widget のインスタンスを使い回すための 3 つのテクニック

では、インスタンスが変わらなければリビルドが発生しないことを、以下のサンプルアプリで確かめていきたいと思います。

![count_app.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/134943/a4e4037c-e2ce-394e-e1ed-35281ff525c7.gif)

このアプリでは、 + アイコンをタップすると横の数字がカウントアップします。その上には固定の文言が表示されています。

ソースコードは以下のようになっています。（レイアウトを整えるためのコードは省略しています）

```dart:labeled_counter.dart
/// カウントアップでリビルドが発生する StatefulWidget
class LabeledCounter extends StatefulWidget {
  @override
  _LabeledCounterState createState() => _LabeledCounterState();
}

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SomeFixedWidget(), // _LabeledCounterState の build() が呼ばれてもリビルドさせないようにしたい
        Row(
          children: [
            Text('$_counter', style: TextStyle(fontSize: 32)),
            IconButton(
              icon: Icon(Icons.add),
              onPressed: () => setState(() {
                _counter++; 
              }),
            ),
          ],
        ),
      ],
    );
  }
}

/// リビルドを発生させたくない Widget
class SomeFixedWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('I don\'t want to rebuild this widget.');
  }
}
```

通常このように記述すると、先ほど説明した通り `_LabeledCounterState` の `build()` が呼び出されたタイミングで `SomeFixedWidget` のインスタンスも新しく生成され、 `SomeFixedWidget` の `build()` が続けて呼び出されてしまいます。

しかし今回のアプリでは、 `SomeFixedWidget` の内容は状態（`_counter` の値）によって変化しないため、無駄なリビルドが発生しないように修正していきたいと思います。

`SomeFixedWidget` のインスタンスが `_LabeledCounterState` のリビルドごとに変わらなければ `SomeFixedWidget` のリビルドが発生しないのは先述した通りですが、ここからはそれを実現するための具体的な方法として以下の 3 つを見ていきたいと思います。

## const を使う

まず一番取り入れやすいのが `const` をつけてインスタンスを生成する方法です。

`const` は Dart の文法の1つで、 __コンパイル時に 1 つだけインスタンスを生成し、何度同じ処理が実行されても 1 つのインスタンスを使い回す__ という仕組みです。

つまり、 `const` を付けなかった場合、サンプルアプリの `SomeFixedWidget()` はこの処理が呼び出されるたびに毎回違うインスタンスを生成しますが、 `const` をつけた場合、 __`const SomeFixedWidget()` は何度この処理が呼び出されたとしてもコンパイル時に生成された SomeFixedWidget インスタンスを再利用します__。[^3]

`const` をつけるためには、インスタンスを生成される `SomeFixedWidget` クラスに `const` コンストラクタを用意しておく必要があります。

```dart:some_fixed_widget.dart
/// リビルドを発生させたくない Widget
class SomeFixedWidget extends StatelessWidget {
  // const コンストラクタを用意
  const SomeFixedWidget();

  ..以下略..
```

`const` コンストラクタが用意できたら、あとは `SomeFixedWidget` のインスタンスを生成するコードに `const` をつけるだけです。 先ほどのサンプルアプリのコードは以下のように修正できます。

```dart

/// const を使ってリビルドを回避するパターンのサンプル
class UseConstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('const を使う例')),
      body: LabeledCounter(),
    );
  }
}

/// リビルドを発生させたくない Widget
class SomeFixedWidget extends StatelessWidget {
  // const コンストラクタを用意する
  const SomeFixedWidget();

  @override
  Widget build(BuildContext context) {
    return Text('I don\'t want to rebuild this widget.');
  }
}

/// カウントアップでリビルドが発生する StatefulWidget
class LabeledCounter extends StatefulWidget {
  @override
  _LabeledCounterState createState() => _LabeledCounterState();
}

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const SomeFixedWidget(), // const をつけてインスタンスを生成する
        Row(
          ..省略..
        ),
      ],
    );
  }
}
```

この対策は `Text` や `Padding` など、 Flutter が用意する Widget でも、その Widget に `const` コンストラクタが定義されている限り同様に使えます。

Lint でコードをチェックすると「`const` がついていない」旨の警告が出る場合がありますが、それはこの例のように __`const` コンストラクタを利用するだけで無駄なリビルドを防げる__ ためです。（単純にインスタンスの生成コストを抑えるのが理由の場合もあります）

## State にキャッシュする
別の方法として、 `生成した Widget インスタンスを State にキャッシュする` という方法も考えられます。

これは `StatefulWidget` のドキュメントにも記載されているパフォーマンス改善の工夫で、以下のように記載されています。

> If a subtree does not change, cache the widget that represents that subtree and re-use it each time it can be used. It is massively more efficient for a widget to be re-used than for a new (but identically-configured) widget to be created.
> 訳） サブツリーが変化しない場合、そのサブツリーを表す Widget をキャッシュして使い回してください。 Widget を使い回すのは新しい（でも内容は同一の） Widget を再生成するよりも効率的です。

https://api.flutter.dev/flutter/widgets/StatefulWidget-class.html

やり方は単純で、 State クラスのフィールドに Widget を保持する変数を宣言するのと同時にインスタンスを生成してしまうだけです。

```dart

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  // State のフィールドで Widget のインスタンスを生成しておく
  final _widgetCache = SomeFixedWidget();

  ..以下略..
```

ポイントは __`State` のフィールド__ でこれをやるということです。何度も説明している通り、 Widget は Flutter フレームワークによって何度もリビルドされ、インスタンスが再生成されます。つまり、 Widget のフィールドにキャッシュを置いたとしても、その Widget 自体が破棄＆再生成がされてしまうため、キャッシュの意味がなくなってしまうのです。

一方で State は Widget ツリーの構造に変化がない限り破棄されることはありませんので、 State にキャッシュしておくことでより長い間同じインスタンスを使い回せる、というわけです。これは `StatelessWidget` にはできない、 `StatefulWidget` だからこそ可能な工夫といえます。

この工夫を取り入れたサンプルアプリのコードは以下のようになります。

```dart:use_cache_page.dart
/// State でのキャッシュでリビルドを回避するパターンのサンプル
class UseCachePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('State でキャッシュする例')),
      body: LabeledCounter(),
    );
  }
}

/// カウントアップでリビルドが発生する [StatefulWidget]
class LabeledCounter extends StatefulWidget {
  @override
  _LabeledCounterState createState() => _LabeledCounterState();
}

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  // State のフィールドで Widget のインスタンスを生成しておく
  final _widgetCache = SomeFixedWidget();

  @override
  Widget build(BuildContext context) {
    // レイアウト関連の記述は省略しています
    return Column(
      children: [
        _widgetCache, // _widgetCache を使い回す
        Row(
          ..省略..
        ),
      ],
    );
  }
}
```

もちろん、 `const` コンストラクタが使える場合は `const` を優先して使った方が、たとえ State が破棄されたとしても同じインスタンスが使いまわされるため効率的です。記述も少なく不具合も起こりづらいです。

何らかの理由で `const` が使えない場合（たとえば状況に応じてキャッシュする Widget の引数を変えたい場合など）はこの方法を検討してみてください。

## 外から渡す
3 つめに説明するのが、「外から渡す」という方法です。今まではサンプルアプリの `LabeledCounter` 内で完結する方法でしたが、この方法では __`LabeledCounter` を使う__ クラスにも手を入れます。

つまり、 `LabeledCounter` クラスのコンストラクタに「使いまわしたい Widget」を受け取るための引数を用意し、それをフィールドに保持して使い回す、という方法です。

この方法が他の 2 つと違う点は、 `LabeledCounter` を使う側が自由に固定表示部分の Widget を指定できる、という点です。 

例えば今回のサンプルアプリの場合、 `LabeledCounter` クラスを以下のように修正します。

```dart
class LabeledCounter extends StatefulWidget {
  // リビルドさせたくない Widget を外から受け取る
  final Widget label;

  const LabeledCounter({Key key, this.label}) : super(key: key);
  
  ..以下略..
```

そして、この `LabeledCounter` クラスを使う側で、以下のように `SomeFixedWidget` を生成して引数に渡します。

```dart
/// LabeledCounter を利用する Widget
class UseInjectionPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('外からインスタンスを渡す例')),
      body: LabeledCounter(
        label: SomeFixedWidget(), // ここで SomeFixedWidget インスタンスを生成して渡す
      ),
    );
  }
}
```

こうすることで、 LabeledCounter を利用する `UseInjectionPage` がリビルドされない限りは（つまり + ボタンがタップされただけの場合は） `SomeFixedWidget` インスタンスが再生成されることはないため、何度 `_LabeledCounterState` の `build()` が呼ばれたとしてもインスタンスが変わることはなく、 `SomeFixedWidget` のリビルドも発生しない、というわけです。

この方法を取り入れると、サンプルアプリは以下のように修正できます。

```dart:use_injection_page.dart
/// 外から Widget のインスタンスを渡してリビルドを回避するパターンのサンプル
class UseInjectionPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('外からインスタンスを渡す例')),
      body: LabeledCounter(
        label: SomeFixedWidget(), // ここで [SomeFixedWidget] インスタンスを生成して渡す
      ),
    );
  }
}

/// カウントアップでリビルドが発生する [StatefulWidget]
class LabeledCounter extends StatefulWidget {
  // リビルドさせたくない Widget を外から受け取る
  final Widget label;

  const LabeledCounter({Key key, this.label}) : super(key: key);

  @override
  _LabeledCounterState createState() => _LabeledCounterState();
}

class _LabeledCounterState extends State<LabeledCounter> {
  var _counter = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        widget.label, // 外から受け取った Widget を使い回す
        Row(
          ..省略..
        ),
      ],
    );
  }
}
```

# まとめ

リビルドが発生した（つまり `build()` が呼ばれた） Widget の中に StatelessWidget / StatefulWidget があるとき、それらも続けてリビルドされるかどうかは __インスタンスが同一かどうか__ で決定します。

そのため、親の Widget の状態の変化に応じて自身の UI を変化させる必要がないのであれば、この記事で説明したような方法を使って同一のインスタンスを使い回すことで、無駄なリビルドを防ぐことが可能です。

とはいえ、無駄なリビルドを防ぐためにこれらの工夫を「入れなければならない」かどうかは状況次第です。 Flutter はビルドを大量に行ってもパフォーマンスが落ちないように工夫されているフレームワークですので、わざわざこれらの工夫を入れて（記述を増やして）リビルド範囲を狭めようと努力するのは、アプリの規模が大きくなり Widget ツリーが深くなってリビルドごとに画面の更新がカクつくようになった時に検討する程度で良いと思います。（1つ目の `const` をつける方法はほぼデメリットなくさっと行えるので習慣化すると良いと思いますが）

今回のサンプルアプリは以下のリポジトリに PUSH してあります。

[chooyan-eng/prevent_rebuild_sample | GitHub](https://github.com/chooyan-eng/prevent_rebuild_sample)

Flutter 自体のソースコードが読めるようになるとさらに詳しくこのあたりの仕組みがわかるかと思いますが、まずはとりあえずできることとして今回紹介したサンプルプロジェクトを上記の GitHub から落とすなり自分で書いてみるなりして実行し、手元でブレークポイントを張りながら動作確認してみると良いでしょう。この記事には書ききれなかった発見があると思います。

[^1]: なぜそのような挙動になるのか、興味のある方はまず `Element` について理解した上で、 [Flutter のソースコードの Element.updateChildren あたり](https://github.com/flutter/flutter/blob/eb5ab56d0b585c1903a3d5d0c6ff18824398bfec/packages/flutter/lib/src/widgets/framework.dart#L5577) の処理を追ってみると良いでしょう。

[^2]: 理解しやすく、 Flutter フレームワークが提供する Widget の実装でもたびたび見られるパターンをいくつか挙げていますが、これが全てではありません。

[^3]: 当然、引数を変えてインスタンスを生成するようなことはできません。詳しくは [Language tour | Dart](https://dart.dev/guides/language/language-tour#final-and-const) を確認してください。