105
72

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】build() でやってはいけない 3 つのこと

Last updated at Posted at 2021-01-07

Flutter で仕事したい人のための Widget 入門 で説明した通り、Flutter では基本的に StatelessWidgetStatefulWidget を継承したクラスで build() をオーバーライドし、そこに UI を構築する処理を書いていきます。 (厳密には、 StatefulWidget の場合は State クラスの build()

例↓

login_page.dart
class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(32),
      child: Column(
        mainAxisSize: MainAxisSize.max,
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          TextFormField(
            decoration: InputDecoration(
              hintText: 'chooyan@example.com',
            ),
          ),
          const SizedBox(height: 32),
          ElevatedButton(
            onPressed: () {},
            child: Text('ログインメールを送る'),
          ),
        ],
      ),
    );
  }
}

初めて Flutter を触る人にとっては、この build()そのページの「初期化処理」と捉えてしまいがち なのではないかと思います。自分もそうでした。

しかし、build() を「初期化処理」だと考えて「やってはいけないこと」を書いてしまうと、思わぬパフォーマンスの低下や不具合を引き起こしてしまう可能性があります。

この記事では、そんな build() でやってはいけない 3 つのパターン を理由つきで説明していきます。

build() は何度でも呼ばれる

まず知っておかなければならないのは、 build() は何度でも呼ばれる、ということです。最初の1回だけではありません。

そもそも Flutter は、「状態」が変わるたびに何度も Widget を破棄し、新しい「状態」を使って Widget を作り直すことで画面を変化させる 設計になっています。

例えば Flutter プロジェクトを新規作成した時にテンプレートとして作られる「カウンターアプリ」では、 FloatingActionButton として配置されたカウントアップボタンをタップするたびに _counter(状態を表す変数)をインクリメントして _MyHomePageState の中の build() メソッドが呼ばれます。

_MyHomePageStatebuild() では、以下のように _counter 変数の値を Text で画面に表示するようにコーディングされていますので、

Text(
  '$_counter',
  style: Theme.of(context).textTheme.headline4,
),

build() が呼ばれるたびにその時点での _counter の値を持った Text が作り直され、画面の表示内容が変化する仕組みになっています。

build() が呼ばれるタイミング

そんな build() が呼ばれるタイミングは様々です。

一番分かりやすいのは、 StatefulWidget(が生成する State)で setState() が呼ばれたときです。

カウンターアプリでも、 FloatingActionButton がタップされた時に呼び出される _incrementCounter メソッドには

void _incrementCounter() {
  setState(() {
    _counter++;
  });
}

とコーディングされていて、 setState() を呼び出すことと、 _counter をインクリメントすることが書かれています。

Flutter では、この setState() 内に書いた処理を実行したあとに build() を呼び出す1作りになっているため、新しく build() で作られる Text にはインクリメントした後の _counter の値がセットされる、というわけです。

他にも、 MediaQueryProvider など、 InheritedWidget が保持する値が更新された場合や、同様のことが Widget ツリーの先祖で発生して build() が伝播してきた場合など、いろいろなタイミングがあります。2

特にアニメーションが発生する場合、60 fps、つまり 16 ミリ秒に 1 回という速度(参考)で毎フレームこの build() が呼ばれる場合もあります。

とにかく、 build() は様々なタイミングで、高速に何度でも呼ばれる可能性がある 、ということを忘れてはいけません。

build() でやってはいけない 3 つのこと

build() が高速に何度でも呼ばれる可能性がある ということを踏まえると、以下のような処理はそこに書いてはいけないことが見えてきます。

1. 初期化処理

build() は画面が出ている間(場合によっては画面に見えていない間も)何度でも呼ばれる可能性があります。そのため、状態を管理する変数を初期化するようなコードは書いてはいけません。

int _count;

@override
Widget build(BuildContext context) {
  _count = 0; // 0で初期化
  return Container(
    child: (省略)
  );
}

このように書いてしまうと、何かのきっかけで build() が呼ばれ直した瞬間にそれまでのカウントが 0 に戻ってしまいます。

また、 Firebase やローカルのデータベースとの接続処理であったり、表示するデータの取得処理などもここに書いてしまうと繰り返し呼び出されてバグの原因につながります。サーバーにアクセスする処理であればサーバーの負荷にも影響が出てしまうでしょう。

このような初期化処理は、基本的に State クラスが持っている initState() をオーバーライドするなど、確実にその画面が初めて生成されるタイミングに 1 回だけ呼ばれる場所に書くのが良いでしょう。

2. ひとつだけあれば良いオブジェクトの生成

たとえば GoogleMaps やアニメーション関係の Controller であったり、一度生成してキャッシュしておく Widget の生成処理などはここに書かいてはいけません。

// 良くない例
class MapSampleState extends State<MapSample> {

  Completer<GoogleMapController> _controller;

  @override
  Widget build(BuildContext context) {
    // build ごとに mapController を生成してはいけない
    _controller = Completer(); 
    
    return GoogleMap(
      (省略)
      onMapCreated: (GoogleMapController controller) {
        _controller.complete(controller);
      },
    );
  }
}
// 良くない例
class FixedComponentState extends State<FixedComponent> {

  List<Widget> cachedMenu;

  @override
  Widget build(BuildContext context) {
    // 使い回す Widget をここで生成してはいけない
    cachedMenu = [
      Text('メニューその1'),
      Text('メニューその2'),
      Text('メニューその3'),
    ];
    return Column(
      children: cachedMenu,
    );
  }
}

Controller が複数生成されてしまってその Controller 自体の初期化処理が何度も無駄に実行されてしまったり、せっかくキャッシュしておいた Widget が何度も再生成 & 上書きされてキャッシュの意味がなくなってしまいます。

このような場合は、例えば以下のようにフィールドの宣言と同時にインスタンスの生成までやってしまうと良いでしょう。

// 改善例 (google_maps_flutter 公式のサンプルコード通り)
class MapSampleState extends State<MapSample> {
  Completer<GoogleMapController> _controller = Completer();

  @override
  Widget build(BuildContext context) {
    return GoogleMap(
      (省略)
      onMapCreated: (GoogleMapController controller) {
        _controller.complete(controller);
      },
    );
  }
}
// 改善例
class FixedComponentState extends State<FixedComponent> {

  // FYI: 可能な限り const もつけるとなお良い
  List<Widget> cachedMenu = [
    Text('メニューその1'),
    Text('メニューその2'),
    Text('メニューその3'),
  ];

  @override
  Widget build(BuildContext context) {
    return Column(
      children: cachedMenu,
    );
  }
}

3. その他重い処理

その他、多量な計算や通信処理、ファイルの読み書きなど、重い処理を書けば書くほど画面の変化を端末に反映させるのが滞ってしまいます。なるべく build() には Widget の生成以外の処理を書かないようにしましょう。

もし重い処理を書く必要があるのであれば、やはり initState() など適切な場所で async つきのメソッドにまとめて処理が終わり次第 setState() で結果だけを反映させるような工夫を入れます。処理が終わるまでは CircularProgressIndicator でクルクルを表示するなどすると良いでしょう。

class ArticleListState extends State<FixedComponent> {

  List<Article> articles;
  
  Future<void> _loadArticles() async {
    final result = await ArticleLoader().all(); // 記事を全件取得する処理
    setState(() {
      articles = result;
    }
  }

  @override
  void initState() {
    _loadArticles();
    super.initState();
  }
  
  @override
  Widget build(BuildContext context) {
    // articles が null ならクルクルを表示、あれば ArticleList を表示
    return articles == null ? CircularProgressIndicator() : ArticleList(articles);
  }
}

まとめ

Flutter を入門すると、まずソースコードを書き始めるのが build() の中(StatelessWidget でも StatefulWidget でも)な関係上、どうしてもここが「最初に1回」やる処理を書く場所だと誤解してしまいがちです。

しかし実際は、 Flutter の仕組みとして Widget は使い捨てであり、 build() は何度でも呼び出されます。このことを初学者はまず知る必要があるでしょう。公式ドキュメントにも、以下のように記述されています。

This method can potentially be called in every frame and should not have any side effects beyond building a widget.
訳) このメソッドは毎フレーム呼び出される可能性があり、Widget を構築する以外の副作用を発生させてはいけません。

これは Flutter の根本的な設計の都合ですので、 Provider などの状態管理パッケージを使う場合でも同様の発想が必要になります。3

「1回だけやれば良い処理や重い処理は build() に書いてはいけない、なぜなら build() は高速に連続で呼び出される可能性があるから」 ということを頭に置いておくだけでも、 Flutter アプリ開発をする上で発生する多くの不具合を回避できるでしょう。

(2021.1.10 追記)
この記事の内容を気をつけていても、アプリの規模が大きくなるのにしたがって build() による画面の更新が重くなってしまう場合があります。そんな時に使えるテクニックと考え方を以下の記事に書いてみましたので、読んでみてください。

【Flutter】 無駄なリビルドを防ぐたった1つの方法
(追記ここまで)

併せて読みたい

この記事の内容をさらに詳しく理解する手助けになる記事です。それぞれの記事には公式サイトも参照先に挙げられていますので、そこまで読めればだいぶ理解を深められるでしょう。

記事中に出てきた InheritedWidget の役割や使い方を解説した記事です。 InheritedWidget は実際にアプリ開発者が直接使うことは少ない Widget のため参考資料も少なめですが、この記事は実際に動作するコードつきで段階的に説明してくれているので、 InheritedWidget というものを理解する上でとても勉強になると思います。

InheritedWidget の中身にフォーカスした記事です。Flutter の実装なども読みながらその仕組みを追っていきます。なお、↑の記事とは違い「どうやって使うのか」には一切触れていません。

setState() の実装を追いながらその仕組みを説明した記事です。フレームワーク内部のソースコードに興味のある方は読んでみると、 Flutter のソースコードの追い方などが見えてくるかもしれません。

Flutter の Widget が画面に表示されるまでの Flutter フレームワークの流れを詳しく解説した記事です。ちょっと長くて内容も「慣れてきた人」向けではありますが、いつかは読んでおくととても良い勉強になります。

  1. 実際には build() を直接呼び出すのではなく、 Flutter フレームワークが定期的にチェックしている「更新が必要な Widget リスト」に登録し、次のフレームでの更新対象に入れています。

  2. このあたりの内容は Flutter の学び初めの段階から知っている必要はありませんが、興味がある場合は 併せて読みたい の参考資料を読んでみてください。

  3. 対処方法やベストプラクティスは状態管理パッケージごとに異なると思いますので、利用するものに合わせてさらに研究してみてください。

105
72
1

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
105
72

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?