37
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-01-09

Flutter では StatelessWidget や StatefulWidget(以下、Widget) の build() が頻繁に呼ばれる(リビルドされる)ことを、 【Flutter】build() でやってはいけない 3 つのこと では説明しました。

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

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

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

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

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

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

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

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

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

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

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

count_app.gif

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

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

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.');
  }
}

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

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

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

const を使う

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

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

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

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

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

  ..以下略..

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


/// 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(
          ..省略..
        ),
      ],
    );
  }
}

この対策は TextPadding など、 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 を再生成するよりも効率的です。

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


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

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

  ..以下略..

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

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

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

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 クラスを以下のように修正します。

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

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

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

/// 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 インスタンスが再生成されることはないため、何度 _LabeledCounterStatebuild() が呼ばれたとしてもインスタンスが変わることはなく、 SomeFixedWidget のリビルドも発生しない、というわけです。

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

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

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

  1. なぜそのような挙動になるのか、興味のある方はまず Element について理解した上で、 Flutter のソースコードの Element.updateChildren あたり の処理を追ってみると良いでしょう。

  2. 当然、引数を変えてインスタンスを生成するようなことはできません。詳しくは Language tour | Dart を確認してください。

37
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
37
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?