🚧

【Flutter】さよなら、 Renderflex overflowed - 恐らくもっとも遠回りなUIエラーの解説 -

2022/12/20に公開

https://qiita.com/advent-calendar/2022/flutteruniv

この記事の目的

この記事の目的はレイアウトを組んだ際に発生するRenderflex overflowed、あのデバッグモードで表示される黄色と黒の縞々と金輪際おさらばする事です

その為に、Widgetがどの様に描画されるのか、どの様にサイズが決まり、どの様なルールに則っているのかを明確にしていきます

結果的に Renderflex overflowed含めたUIエラー全体についての解説となっています

理解しておく背景や情報量が多いので、目的に直接関係のないところは端折って行きますが、非常に参考になるリンクを貼っていくのでそちらをご覧頂ければと思います

TL;DR

恐らく長すぎて誰も最後まで読む事はないと思うので、要点だけまとめておきます

  • サイズの計算処理は Render オブジェクトの performLayoutメソッド に書かれている
  • サイズは親から渡される BoxConstraintsperformLayoutに記述された内容の組み合わせで決まる
  • UIエラーを引き起こす組み合わせは2つ
    1. 無制約 x 画面サイズ以上になろうとするWidget
    2. 無制約 x 無制限に大きくなろうとするWidget
  • 無制約 x 画面サイズ以上になろうとするWidget の解決方法
    1. ScrollableなWidgetでラップしてスクロール可能にする
    2. Expanded, Flexibleでラップして画面サイズに合わせる
  • 無制約 x 無制限に大きくなろうとするWidget の解決方法
    1. 子Widgetにサイズ制約を課す

今回の記事を書くにあたり参考にさせて頂いた情報を登壇や記事で発信してきた先人の方々に改めて感謝いたします

めちゃくちゃありがて〜〜〜と何度唸ったか分かりません

また今回解説するUIエラーとそれに対する解決法をコメントしたサンプルは全て下記レポジトリにまとめています

https://github.com/heyhey1028/flutter_samples/tree/main/samples/goodbye_renderflex_overflowed

サイズ計算に至る描画の全体像

Flutterフレームワークの全体像

FlutterでUIを作るとはどういう事でしょうか?我々が見る画面に表示される絵はどうやって描かれるのでしょうか?それを理解する為には軽くFlutterフレームワークの構造について触れていきます

Flutterというフレームワークは実は我々が通常記述するWidget以外にも非常に多くのレイヤーで出来ています

我々が記述するのは氷山の一角であり、水面下では非常に多くのコードが動いている事が分かりますね

大きくはDartで書かれたFramework、CやC++で書かれたEngine、各Platformに応じた言語で書かれたEmbedderの三層で分かれています

この内で描画に関係するレイヤーを抜き出すと下記の様なレイヤーとなります

参考元: https://www.youtube.com/watch?v=UCuf1vXPH3A

この中のMaterialCupertinoWidgetと書かれたレイヤーが普段よく使うクラスが存在するレイヤーです。描画をする際にはこの層を一層一層上から下に下っていく事になります

Widgetレイヤーのクラスがその下のRenderingレイヤーのAPIを呼び出し、RenderingレイヤーがEngine側にあるdart:uiレイヤーのAPIを呼び出し、この層が実際に画面に描画を行います

ここの層で利用されるのがC++で書かれたSkia(スキーア)というグラフィックスライブラリです

普段我々が実装するWidgetレイヤーは全体の設計図の役割をしており、それに応じてRenderingレイヤーではサイズと位置の計算が行われ、実際に描画を担当するdart:uiレイヤーに伝える事でディスプレイ上にUIを描画してもらいます

今回の主題である「Widgetのサイズ」はこのRenderingレイヤーで行われるわけです

3つのツリー構造

それではこのRenderingレイヤーはどのようなクラスで構成されているのでしょうか?

ここで登場するのが有名なFlutterを構築する3つのツリー構造です

FlutterはWidgetツリー、Elementツリー、Renderツリーの3つのツリー構造で出来ている為、非常に軽快かつ高パフォーマンスな描画処理が行われます

普段実装しているWidgetツリーは主に設計図の役割を担います。Elementツリーを構成するElementは状態などを管理し、Renderツリーを構成するRender Objectは描画を担います。

この描画を司るRender Objectこそが今回の主役であり、Renderingレイヤーを構成するクラスです。Renderツリー自体がRenderingレイヤーとも言えます

Constraints go down, Size go up

ではこのRender Objectで構成されたRenderツリーはどのようにサイズと位置を決定していくのでしょうか?

ここで登場するのが「constraints go down, size go up」という有名なフレーズです。Flutterを学習する過程で一度は聞いた事のあるフレーズかもしれません。でも意味はいまいち分かっていないという方も多いかもしれません、私は少なくとも全然分かっていませんでした。

このフレーズはRenderツリーがどの様にサイズと位置を決定するのかを端的に表した言葉です

まずWidgetはそれぞれに対応したRender Objectを生成し、Renderツリーが構成されます。

そのRenderツリーの各ノードとなるRender Objectはそれぞれ自身の子ノードに渡すサイズ制約constraintsを持っています。「自分自身」ではなく、「自分の子ノード」が取って良いサイズの制約です。

子ノードは親から渡されたそのサイズ制約を元に自分自身のサイズを決め、親クラスに自身が取るサイズを伝えます。

しかし親からconstraintsを渡されてすぐにサイズを決めるわけではありません

この「自身の子ノードにサイズ制約を渡す」という処理はRenderツリーに沿って、末端のノードまで繰り返し、子ノードを持たないノードに到達して初めてサイズ計算が行われます。

その後、末端のノードは自身のサイズを計算し、自身の親に対して自分のサイズを伝えます

親ノードは子から子のサイズを受け取り、それを元に子の位置を決め、今度は自分のサイズを計算、親ノードに自身のサイズを伝えます

こうやってルートノードに到達するまでサイズ計算とその伝播がRenderツリーを逆方向に行われていきます

フレーズの通り、このRenderツリーに沿ってサイズ制約constraintsが末端まで下っていき(Constraints go down)、今度は逆に末端から計算されたサイズがルートに到達するまでツリーを上って伝えられていく(Size go up)わけです

ここについては下記動画が非常に分かりやすく解説頂いています
https://www.youtube.com/watch?v=UCuf1vXPH3A

3つのツリーが構築されるまでの流れ

さて既にだいぶ迷子になっている人がいると思いますが、更に迷子の人を増やしていきましょう

もう少し具体的にどの様にWidgetからElementが生成され、ElementによってRender Obejctが生成され、更にそのRender Objectがサイズ計算を行うのかメソッド単位で見ていきましょう

まずWidgetが生成されるとそのWidgetが継承しているSingleRenderObjectWidgetが持つcreateElementメソッドが呼ばれます

Elementを生成
singleChildRenderObjectWidget
abstract class SingleChildRenderObjectWidget extends RenderObjectWidget {
  const SingleChildRenderObjectWidget({ super.key, this.child });
  final Widget? child;

  
  SingleChildRenderObjectElement createElement() => SingleChildRenderObjectElement(this);
}

これによりElementツリーに挿入されるElementが生成されます。この例ではSingleChildRenderObjectWidgetに対応したElementであるSingleChildRenderObjectElementが生成されます

今度はElementが生成されたタイミングでElementが継承しているRenderObjectElementmountedメソッドが実行され、このメソッド内でElementの生成元であるWidgetが実装するcreateRenderObjectメソッドが実行されます

createRenderObjectの呼び出し
RenderObjectElement
abstract class RenderObjectElement extends Element {
  /// Creates an element that uses the given widget as its configuration.
  RenderObjectElement(RenderObjectWidget super.widget);

  
  void mount(Element? parent, Object? newSlot) {
    super.mount(parent, newSlot);
    assert(() {
      _debugDoingBuild = true;
      return true;
    }());
    _renderObject = (widget as RenderObjectWidget).createRenderObject(this);
    assert(!_renderObject!.debugDisposed!);
    assert(() {
      _debugDoingBuild = false;
      return true;
    }());
    assert(() {
      _debugUpdateRenderObjectOwner();
      return true;
    }());
    assert(_slot == newSlot);
    attachRenderObject(newSlot);
    _dirty = false;
  }
}
Renderオブジェクトを生成
createRenderObject
class ConstrainedBox extends SingleChildRenderObjectWidget {

  ConstrainedBox({
    super.key,
    required this.constraints,
    super.child,
  }) : assert(constraints != null),
       assert(constraints.debugAssertIsValid());

  final BoxConstraints constraints;

  
  RenderConstrainedBox createRenderObject(BuildContext context) {
    return RenderConstrainedBox(additionalConstraints: constraints);
  }

これによりRenderツリーに挿入されるRenderオブジェクトが生成されました。この例ではConstrainedBoxWidgetに対応したRenderConstrainedBoxクラスが生成されました。

これでWidgetからElementが生成され、ElementによってRender Objectが生成され、それぞれのツリーに挿入される事でWidgetツリー、Elementツリー、Renderツリーの3つのツリーがセットされました

3つのツリーが生成された後はいよいよ実際のレンダリング処理が走っていくわけですが、そちらの処理を頭から追い始めるととんでもない事になるので、すっ飛ばして実際のサイズ計算を行う処理だけピックアップしましょう

その処理はズバリRender Objectが持つperformLayoutというメソッドになります

performLayoutメソッド
class RenderConstrainedBox extends RenderProxyBox {
  ...
  
  void performLayout() {
    final BoxConstraints constraints = this.constraints;
    if (child != null) {
      child!.layout(_additionalConstraints.enforce(constraints), parentUsesSize: true);
      size = child!.size;
    } else {
      size = _additionalConstraints.enforce(constraints).constrain(Size.zero);
    }
  }
  ...
}

レンダリングパイプラインについてはこちらの動画や記事が参考になるのでより深く知りたい方はご覧になってみてください

https://www.youtube.com/watch?v=gBfHYHvojvk&t=10722s
https://developers.cyberagent.co.jp/blog/archives/36869/

Widgetサイズを決める 3 x 5 の組み合わせ

BoxConstraintsperformLayoutメソッド

さてここまでで親から子に渡されるconstraints、そしてperformLayoutメソッドによってWidgetサイズが決められているという事が分かりました

ではもう少し詳しくそれぞれについて見てみましょう

BoxConstraints

この各Render Objectが持つcostraintsは子ノードが取って良い高さ(height)と幅(width)の最大、最小値を持つ非常にシンプルなクラスです

class BoxConstraints extends Constraints {
  /// Creates box constraints with the given constraints.
  const BoxConstraints({
    this.minWidth = 0.0,
    this.maxWidth = double.infinity,
    this.minHeight = 0.0,
    this.maxHeight = double.infinity,
  }) : assert(minWidth != null),
       assert(maxWidth != null),
       assert(minHeight != null),
       assert(maxHeight != null);
}

子に渡されるBoxConstraintsには以下3つのパターンしかありません

1. tightなconstraints
特定のサイズを強要するconstraintsです。幅、もしくは高さのminとmaxが同じ値となっており、子はその特定のサイズしか取る事はできません。

2. looseなconstraints
ある一定の幅のあるconstraintsです。幅、もしくは高さのminとmaxに乖離があり、子はその中で自由にサイズを取る事ができます。

3. unboundedなconstraints もしくは null
幅、もしくは高さのmaxがdouble.infinityなconstraints、もしくはそもそもconstraintsを渡しません。この場合、子はその方向(幅、高さ)についてどんな値を取る事も可能です。SingleChildScrollView, CustomScrollView, Flex, OverflowBoxなどのWidgetがこの制約を子に渡しています

performLayoutメソッド

performLayoutメソッドは前述の通り、Renderオブジェクトが保持し、自身のサイズを決める処理を担います

親から受け取ったconstraintsやその他の引数を元に自身のサイズを計算したり、自分の子のperformLayoutメソッドを呼び出したりと中身はWidget(厳密にはそれぞれのWidgetに対応するRenderオブジェクト)によって異なります

その挙動は大まかには下記の5つのパターンに集約されます

  1. 制約内でなるべく大きくなろうとする ( Center, ListView )
  2. なるべく自分の子と同じサイズになろうとする ( Transform, Opacity )
  3. 制約に関係なく特定のサイズになろうとする ( Image, Text )
  4. コンストラクタに渡された引数に応じて制約に対する反応を変える ( Container )
  5. 与えられた制約に応じて反応を変える ( Row, Column )

4,5について補足すると、例えば Container はデフォルトでは1の様に制約内でなるべく大きくなろうとしますが、widthが渡されると3の様にその特定のサイズになろうとします。

また RowColumn は自身の伸びる向き(Rowならwidth、Columnならheight)について、デフォルトでは子が存在すれば2の様になるべく子のサイズになろうとしますが、その向きにconstraintsを与えられた場合、その制約内でなるべく大きくなろうとします。

UIが破綻する組み合わせ

以上からWidgetのサイズの決まり方は3パターンの BoxConstraints と5パターンの performLayout の組み合わせのどれかになるという事が分かります。

しかしここで実はUIが破綻する組み合わせがあります。それが今回の本題となる RenderFlex overflowed やその他のUI関連エラーの原因です

その組み合わせは下記の2つです

  1. 「unboundedなconstraints もしくは null」 x 「制約に関係なく特定のサイズになろうとする」のサイズが更に上位(ex. ディスプレイサイズ)の制約を超えてしまう場合
  2. 「unboundedなconstraints もしくは null」 x 「制約内でなるべく大きくなろうとする」

言葉だけだと分かりづらいので具体例を見ていきましょう

破綻するパターン

1. Column x サイズ指定したContainer

このパターンではunboundedなconstraintsを子に渡す Column の子に
画面サイズを超える100 x 100の高さのContainer群が配置されています。画面サイズを超えるWidget群が配置された事で RenderFlex overflowed のエラーが発生します

コード
/// 適合するパターン:unboundedな制約を子に渡す x 特定のサイズになろうとする

  body: Center(
    child: Column(
      children: [
        for (int i = 1; i < 100; i++)
          Container(
            margin: const EdgeInsets.all(5),
            width: 100,
            height: 100,
            color: Colors.yellow,
          ),
      ],
    ),
  ),

2. Row x Text, Image

このパターンでは、unboundedなconstraintsを子に渡すColumnの子に、画面サイズを超えるTextImageが渡されています

パターン1同様、TextImageはconstraintsに関係なく自身のサイズになろうとする為、画面サイズを超えてしまいRenderFlex overflowedのエラーが発生します

コード
/// 適合するパターン:unboundedな制約を子に渡す x 特定のサイズになろうとする

/// Text
  body: Center(
    child: Row(
      children: const [
        Text("But even now, I can't shake the memory of that cat. "
            "It's not that I think about it all the time. It's just that "
            " every once in a while, the image of that cat surfaces in my "
            "mind, like an old photograph that I've filed away in some box."
            " And when it does, I find myself wondering where that cat is now, "
            "and what it's doing. It's a strange feeling, like a half-forgotten "
            "dream that comes back to you briefly in the morning."),
      ],
    ),
  ),

/// Image
  body: Center(
    child: Row(
      children: [
        Image.asset('assets/images/claw_machine.png'),
      ],
    ),
  ),

3. Column x ListView

このパターンでは、unboundedなconstraintsを子に渡すColumnの子に、画面サイズを超えるListViewが渡されています

こちらも画面サイズを超えてしまいRenderFlex overflowedのエラーが発生します

コード
/// 適合するパターン:unboundedな制約を子に渡す x 特定のサイズになろうとする
  body: Center(
    child: Column(
      children: [
        ListView.builder(
          itemCount: 50,
          shrinkWrap: true,
          itemBuilder: (context, index) {
            return ListTile(
              title: Text(
                index.toString(),
              ),
            );
          },
        ),
      ],
    ),
  ),

4. Column x TextFormField

このパターンは少し特殊ですが、よく遭遇するパターンでもあります

このパターンではColumnの子に、画面サイズを超えない高さのWidgetが配置されており、通常は問題ありません

しかしTextFormFieldfocusされキーボードが画面に出現すると画面サイズが小さくなってしまい、子Widgetが画面サイズを超えてしまいRenderFlex overflowedのエラーが発生します

コード
/// 適合するパターン:unboundedな制約を子に渡す x 特定のサイズになろうとする
  child: Center(
    child: Column(
      children: <Widget>[
        SizedBox(
          height: MediaQuery.of(context).size.height * 0.3,
        ),
        Padding(
          padding: const EdgeInsets.all(32),
          child: TextFormField(
            decoration: const InputDecoration(
              border: OutlineInputBorder(),
            ),
          ),
        ),
        SizedBox(
          height: MediaQuery.of(context).size.height * 0.3,
          child: const Placeholder(),
        ),
      ],
    ),
  ),

5. SingleChildScrollView x Expanded (x Column)

スクロールUIを実現する際によく使うSingleChildScrollView x Columnの組み合わせでも注意が必要です

Columnの子をExpandedで囲う実装はよく見ると思いますが、そのColumnを更にSingleChildScrollViewでラップする事で、Columnの子に対しunboundedなconstraintsを渡す事になります

その結果、制約がない中、Columnの子が無限に大きくなろうとしてしまう為、RenderFlex children have non-zero flex but incoming height constraints are unbounded.というエラーを見る事になります

コード
/// 適合するパターン:unboundedな制約を子に渡す x なるべく大きくなろうとする
  body: SingleChildScrollView(
    child: Column(
      children: [
        const Expanded(child: Placeholder()),
        Expanded(
          child: Image.asset('assets/images/dash_game.jpeg'),
        ),
        const Expanded(child: Placeholder()),
      ],
    ),
  ),

6. ListView x ListView

このパターンではunboundedな制約を子に渡すListViewの子に、なるべく大きくなろうとするListViewを配置しています

この組み合わせでは子であるListViewが無限に大きくなろうとする為、レイアウトが破綻してしまい、Vertical viewport was given unbounded height.のエラーが発生してしまいます

コード
/// 適合するパターン:unboundedな制約を子に渡す x なるべく大きくなろうとする
  body: Center(
    child: ListView(
      children: [
        ListView(
          children: [
            for (int i = 1; i < 3; i++)
              Container(
                margin: const EdgeInsets.all(10),
                height: 100,
                width: double.infinity,
                color: Colors.yellow,
              )
          ],
        )
      ],
    ),
  ),

7. ListView x TextField

このパターンでは横軸方向にunboundedな制約を子に渡すListViewの子に、なるべく大きくなろうとするTextFieldを配置しています

結果、子であるTextFieldが無限に大きくなろうとする為、レイアウトが破綻してエラーとなります

この例ではTextField特有のエラーであるAn InputDecorator, which is typically created by a TextField, cannot have an unbounded width.という表示がされます

コード
/// 適合するパターン:unboundedな制約を子に渡す x なるべく大きくなろうとする
  body: Center(
    child: ListView(
      scrollDirection: Axis.horizontal,
      children: const [
        TextField(),
      ],
    ),
  ),

気をつけるべきWidgetは Flex, CustomScrollView

以上、UIが破綻しエラーとなってしまう組み合わせを見てきました

具体例を見て気づいた方もいるかもしれませんが、このUIの破綻が起きるのは RowColumnListView という縦軸、横軸方向に複数のWidgetを持つWidgetを扱う時です

厳密には RowColumn は 「Flex」 を、ListViewGridView は 「CustomScrollView」 を継承しており、気を付けなければならないのはこれらを継承するクラスを使う際です

ちなみにタイトルにもなっている RederFlex overflowedRenderFlex とはこのFlexクラスの Render オブジェクトの事です

つまり我々を苦しめ続けてきた RenderFlex overflowed とはこの Flex クラスを継承した RowColumn が誤った子Widgetと組み合わさった際に遭遇するエラーです

解決法

大体のUIエラーがFlexCustomScrollViewを扱う際に発生すると分かった所で、その解決法を見ていきましょう

結論から言ってしまうと以下の通りとなります

  1. 「unboundedなconstraints もしくは null」 x 「制約に関係なく特定のサイズになろうとする」のサイズが更に上位の制約を超えてしまう場合

    • 解決法①: Scrollable widgetでラップし、スクロール可能にする
    • 解決法②: 子Widgetを Expanded もしくは Flexibleでラップする
  2. 「unboundedなconstraints もしくは null」 x 「制約内でなるべく大きくなろうとする」

    • 解決法: 子Widgetに SizedBoxConstrainedBox で制約を課す

組み合わせ 1 の解決法

1の組み合わせでは子Widgetが画面サイズを超えてしまう事が問題でした

その為、以下のどちらかのアプローチで解決する事ができます

  • 画面サイズを超えた場合、スクロール可能にしてしまう
  • 画面サイズに合うように子Widgetのサイズを変えてしまう

解決後の挙動をどのようにしたいかでどちらのアプローチを取るかが変わるかと思います

先ほどの具体例1を解決してみましょう

BEFORE
  body: Center(
    child: Column(
      children: [
        for (int i = 1; i < 100; i++)
          Container(
            margin: const EdgeInsets.all(5),
            width: 100,
            height: 100,
            color: Colors.yellow,
          ),
      ],
    ),
  ),
AFTER 1: スクロール可能にする
  body: Center(
    child: SingleChildScrollView( // <---Scrollableなwidgetでラップする
      child: Column(
        children: [
          for (int i = 1; i < 10; i++)
            Container(
              margin: const EdgeInsets.all(5),
              width: 100,
              height: 100,
              color: Colors.yellow,
            ),
        ],
      ),
    ),
  ),
AFTER 2: 画面サイズに合わせてサイズ変更する
  body: Center(
    child: Column(
      children: [
        for (int i = 1; i < 10; i++)
          Expanded( // <--- `Expanded`でラップする
            child: Container(
              margin: const EdgeInsets.all(5),
              width: 100,
              height: 100,
              color: Colors.yellow,
            ),
          ),
      ],
    ),
  ),

組み合わせ 2 の解決法

2の組み合わせでは制約がないのに、子Widgetが無制限に大きくなろうとする事が問題でした

その為、シンプルに「子Widgetに制限を課す」事で解決する事ができます

先ほどの具体例6を解決してみましょう

BEFORE
  body: Center(
    child: ListView(
      children: [
        ListView(
          shrinkWrap: true,
          children: [
            for (int i = 1; i < 5; i++)
              Container(
                margin: const EdgeInsets.all(10),
                height: 100,
                width: double.infinity,
                color: Colors.yellow,
              ),
          ],
        ),
      ],
    ),
  ),
AFTER
  body: Center(
    child: ListView(
      children: [
        SizedBox( // <--- `SizedBox`で制約を課す
          height: 300, 
          child: ListView(
            shrinkWrap: true,
            children: [
              for (int i = 1; i < 5; i++)
                Container(
                  margin: const EdgeInsets.all(10),
                  height: 100,
                  width: double.infinity,
                  color: Colors.yellow,
                ),
            ],
          ),
        ),
      ],
    ),
  ),

以上の通り、非常にシンプルに解決出来てしまいました。具体例7も同様のアプローチで解決する事ができます

ただ具体例5だけは少し解決方法が特殊なので注意してください

BEFORE
  body: SingleChildScrollView(
    child: Column(
      children: [
        const Expanded(child: Placeholder()),
        Expanded(
          child: Image.asset('assets/images/dash_game.jpeg'),
        ),
        const Expanded(child: Placeholder()),
      ],
    ),
  ),
AFTER
  body: SingleChildScrollView(
    child: Column(
      mainAxisSize: MainAxisSize.min, // <-- `MainAxisSize.min`に設定
      children: [
        // `Expanded`を`Flexible`に置き換える
        const Flexible(child: Placeholder()), 
        Flexible(
          child: Image.asset('assets/images/dash_game.jpeg'),
        ),
        const Flexible(child: Placeholder()),
      ],
    ),
  ),

具体例5では Columnの子Widgetがサイズ制約を受ける様に2段階のサイズ制約を課しています

まず Column 自身が子Widgetのサイズになろうとする様に mainAxisSize: MainAxisSize.min を設定します

その上で、Column の子Widgetをなるべく大きくなろうとする Expanded ではなく、viewportの範囲内で可能な限り大きくなると言う特性を持った Flexible でラップする事でサイズ制約をかけています

この辺りのFlexFlexibleの関係性やアルゴリズムについては下記の動画で詳しく解説してくれています

https://www.youtube.com/watch?v=_jlXS8chb7g

先に挙げた具体例の解決方法は下記のサンプルレポジトリにコメントしているので、気になる方は答え合わせしてみてください

https://github.com/heyhey1028/flutter_samples/tree/main/samples/goodbye_renderflex_overflowed

以上

以上で RenderFlex overflowed の駆逐を目指す長い旅は終わりです

正直長すぎてここまで読んで頂いた方はいらっしゃらないと思いますが、これをキッカケにずっと気になっていたFlutterの低レイヤー世界を冒険する事ができ、読まれなかろうが自分にとって非常に大きな学びとなりました。

もしここまで読んでくれた方がいたら、数多くの誤字脱字や表記揺れ、間違いで目眩を催しているかと思いますので目に余る部分があれば、生暖かくご指摘いただければ幸いです。

また先人のエンジニアの方々が残してくれた貴重な資料なくしてはこれほど深掘りする事はできませんでした。改めて貴重な資料や情報を、登壇や執筆してくださった先人の方々に感謝したいと思います。自分もいつかそんな誰かの為になる情報を発信できるよう日々精進していきたいと思います。

それでは、皆様...

良いお年を!!!

参考

公式

登壇・記事

Flutter大学

Discussion