Flutter の BuildContext と InheritedWidget を理解する

この記事はFlutter 全部俺 Advent Calendar 6 日目の記事です。

このアドベントカレンダーについて

このアドベントカレンダーは @itome が全て書いています。

基本的に Flutter の公式ドキュメントとソースコードを参照しながら書いていきます。誤植や編集依頼は Twitter にお願いします。

BuildContext とは何か

Flutter を使っていると避けて通れない BuildContext ですが、抽象的な概念なので、なんとなく使ってしまっている人も多いと思います。 BuildContext が現れる箇所として、代表的なものは

BuildContext を理解することで、これらの機能がどうやって実現されているも理解することができます。

BuildContext のイメージをつかむ

Flutter の Widget は子 Widget は引数で受け取るため、子 Widget を参照することはできます。 一方で、親 Widget はそうはいきません。なぜなら、自分の親 Widget がなんであるかは、実際にレイアウトされるまでわからないからです。

しかし、自分の親についてわかった方が便利なことがあります。 例えば、親の状態を参照してそれによって自分の表示を変更したり、親が持っている自分のサイズ情報を取得したりすることです。

ここまで書くと勘の良い人なら気づくかもしれませんが、 BuildContext は実は親そのものです。(実際には Widget に対応する Element 、Element は内部に_parentを保持しているため親にアクセスができる)

BuildContext から親が取得できるということは、その親の BuildContext からさらにその親、そこからさらにそのまた親・・・という風に 自分の祖先が再起的に取得することができます。つまり、「自分がどのような祖先の流れの中にいるのか」という情報が BuildContext というわけです。

Text Widget の BuildContext

左のような Widget ツリーがあったときに、 Text Widget の BuildContext は右のようになります。ここで注意しなければいけないのは、 BuildContext はあくまで直系の祖先だけのことを指し、それ以外の Widget(例えば、 AppBar など)は参照できないということです。

BuildContext の実装

BuildContext のドキュメントには以下のように書いてあります。

BuildContext objects are actually Element objects. The BuildContext interface is used to discourage direct manipulation of Element objects.

(BuildContext は実は Element です。BuildContext という API は、Element を直接操作されることを防ぐために使われています。)

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

上でも書いた通り、 BuildContext の実体は親の Element です。 実際に BuildContextStatelessWidgetbuild メソッドに渡されるところを見てみましょう。

StatelssWidgetcreateElement メソッドで StatelessElement という Element を返しています。

abstract class StatelessWidget extends Widget {
  /// Initializes [key] for subclasses.
  const StatelessWidget({ Key key }) : super(key: key);

  /// Creates a [StatelessElement] to manage this widget's location in the tree.
  ///
  /// It is uncommon for subclasses to override this method.
  @override
  StatelessElement createElement() => StatelessElement(this);
  ///
  /// コメント省略
  ///
  @protected
  Widget build(BuildContext context);
}

StatelessElementbuild 関数で自分を作った Widget の build 関数を読んでいます。 ここで BuildContext 引数に自身( this )を渡しています。

/// An [Element] that uses a [StatelessWidget] as its configuration.
class StatelessElement extends ComponentElement {
  /// Creates an element that uses the given widget as its configuration.
  StatelessElement(StatelessWidget widget) : super(widget);

  @override
  StatelessWidget get widget => super.widget;

  @override
  Widget build() => widget.build(this);

  @override
  void update(StatelessWidget newWidget) {
    super.update(newWidget);
    assert(widget == newWidget);
    _dirty = true;
    rebuild();
  }
}

BuildContext の使われ方

BuildContext のもっともメジャーな使われ方は、 Theme.of(context) や、 Scaffold.of(context) などで 親 Widget (もしくは親の持っているフィールド)のインスタンスを取得することです。

たとえば Scaffold.of(context) のイメージは以下のようになります。

Scaffold.of(context)

画像のサンプルでは、末端の MyButton のクリックイベントに合わせて、 ScaffoldshowSnackBar メソッドを呼び出しています。 このように、実行時に親の Widget のインスタンスを使いたいときに BuildContext を使うことができます。

このときに注意しないといけないのは、どの WidgetBuildContext を使っているかということです。 たとえば上の画像のサンプルを実装するときに、以下のように書くとエラーになります。

class _MyHomePageState extends State<MyHomePage> {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            MyButton(
              onPressed: () {
                Scaffold.of(context).showSnackBar("Hello");
              }
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
          ],
        ),
      ),
      ...
    );
  }
}

ScaffoldMyButton の祖先にいるので、一見正しいコードのように思えますが、 Scaffold.of(context) に渡している BuildContextbuild 関数に渡されたもの、つまり _MyHomePageState のものです。 MyHomePage Widget の祖先には Scaffold がいないので、エラーになってしまいます。

これを修正するには、 MyButton を自作の StatelessWidget でラップするか、 Builder Widget を使う必要があります。 Builder Widget を使って修正すると以下のようになります。

class _MyHomePageState extends State<MyHomePage> {
  ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Builder(
              builder: (context) => MyButton(
                onPressed: () {
                  Scaffold.of(context).showSnackBar("Hello");
                }
              ),
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
          ],
        ),
      ),
      ...
    );
  }
}

Builder Widget を間に挟むことによって、 BuildContextBuilder Widget のものになりました。 Scaffold は Builder の祖先にあたるので、 Scaffold.of(context) で問題なく取得できるようになります。

Theme.of(context) を自分で実装してみる

自分で Theme.of(context) のように子孫から取得できる Widget を実装したいときは InheritedWidget を使います。 最近は7 日目のの記事で紹介する InheritedWidget をラップした Provider パッケージを使うことが多いので、最低限の紹介に止めます。

ancestorStateOfType

InheritedWidget について紹介する前に、 BuildContext.ancestorStateOfType を見てみましょう。 ancestorStateOfTypeScaffold.of(context) メソッドないで使われています。 このメソッドは祖先の StatefulWidgetState にアクセスしたいときに便利ですが、 計算量が O(N) であるというデメリットがあります。 BuildContext を親からその親へと順々に辿っていくので、 Widget のネストが深くなればなるほど、取得に時間がかかってしまうのです。

InheritedWidget

ancestorStateOfType のデメリットを解消するために使われる WidgetInheritedWidget です。 InheritedWidget は子孫 Widget から BuildContext.inheritFromWidgetOfExactType を使って O(1) で取得できます。

つまり Theme.of(context)MaterialApp などで設置した InheritedWidget から、 BuildContext.inheritFromWidgetOfExactType を使って Theme を取得する単なるラッパーメソッドです。

InheritedWidget を使って子孫からのアクセスをさせる Widget は慣習的に of(BuildContext context) メソッドを提供するようになっています。

先にも書いた通り、 Flutter 公式で、この機能をラップした Provider パッケージが提供されているので、 理由がない限りそちらを使う方がいいでしょう。

5 日目: Flutter の Widget が画面に描画されるまでを理解する :

https://itome.team/blog/2019/12/flutter-advent-calendar-day5

7 日目: Flutter の Provider パッケージを使いこなす : https://itome.team/blog/2019/12/flutter-advent-calendar-day7