はじめに
Dart/Flutter連載 の2本目です
FlutterでWidgetを開発するとき、Stateless WidgetやStateful Widgetを継承したクラスを作成することが一般的だと思います。一方でクラスを定義せずとも、Widgetを返却するFunctionを定義することで同様のことが実現できるのでは?と考えたことはないでしょうか。
本記事では前者をClass Widget, 後者をFunctional Widgetと称して以下説明をしていきます。
簡単なサンプルを示してみましょう。
1 2 3 4 5 6 7 8 9 10
| class SampleWidget extends StatelessWidget { const SampleWidget({Key? key}) : super(key: key);
@override Widget build(BuildContext context) { return Container( child: Text('hello'), ); } }
|
1 2 3 4 5
| Widget sampleWidget() { return Container( child: Text('hello'), ); }
|
恐らく多くの方がFunctional Widgetはあまり良くないと思っていると思いますが、その理由を明確に説明できるでしょうか。
本記事では、2つの違いや使い分けについて整理したいと思います。
TL;DR
Flutterが公式に公開している動画でも本件について触れられており、パフォーマンス最適化や予期せぬバグの回避、テスタビリティ(本記事では割愛しています)という観点で Class Widgetの利用が推奨されています。
2つの違い
冒頭のサンプルで記述したClass WidgetとFunctional Widgetをそれぞれ利用した場合、アプリケーションの見た目はどちらも変わりません。
2つの一番の違いは生成されるWidgetツリーの構造です。それぞれのWidgetツリーは次のようになります。
1 2 3 4
| ParentWidget └─ SampleWidget └─ Container └─ Text
|
1 2 3
| ParentWidget └─ Container └─ Text
|
Widgetツリーの構造は、FlutterがWidgetをリビルドする際の挙動に影響します。
Functional WidgetはClass Widgetに比べて、パフォーマンスが最適化されない可能性があり、また予期せぬバグが発生する可能性が高まります。
以下、具体的に説明していきましょう。
具体例
リビルドの最適化
これは紹介した動画でも述べられている例になります。
下記のようにクリック時に状態を変更するようなボタンをFunctional Widgetとして切り出した場合を考えてみます。この場合、ボタンをクリックした場合には大元のWidget全体のリビルドが実行されてしまいます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| class BigUIElementState extends State<BigUIElement> { @override Widget build(BuildContext context) { return Stack( children: [ ..., ..., sampleButton(), ], ); }
Widget sampleButton() { return ElevatedButton( onPressed: () { setState(() { }); }, child: const Text('Button'), ); } }
|
このボタンが変更する状態のスコープが限定的な場合(例えばいいねボタンの様にクリックによってボタン自身の色を変更するようなケース)は、Functional WidgetではなくStateful Widgetとして切り出した方がリビルドの範囲を限定できるため、パフォーマンスの面で優れています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| class BigUIElementState extends State<BigUIElement> { @override Widget build(BuildContext context) { return Stack( children: [ ..., ..., SampleButton(), ], ); } }
class SampleButton extends StatefulWidget { @override State<StatefulWidget> createState() { return SampleButtonState(); } }
class SampleButtonState extends State<SampleButton> { @override Widget build(BuildContext context) { return ElevatedButton( onPressed: () { setState(() { }); }, child: const Text('Button'), ); } }
|
ただこの例は、状態のスコープを最小化すべきというのが主要なポイントであって、Class WidgetとFunctional Widgetの本質的な違いの例としては少しズレているように筆者は感じてしまったので、もう一つリビルドの最適化に着目した例を示しましょう。
リビルドの最適化 その2
先ほどの例は切り出すWidgetが状態を保持する前提でしたが、下記のように状態を持たないWidgetの場合はどうでしょうか。
Functional Widgetの場合はParentElement
の状態が変わるたびにsampleWidget()
が呼び出され、内部で返却しているWidgetが都度再生成されることになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class ParentElementState extends State<ParentElement> { @override Widget build(BuildContext context) { return Stack( children: [ ..., ..., sampleWidget(), ], ); }
Widget sampleWidget() { return Container( child: const Text('hello'), ); } }
|
この場合も、Functional Widgetではなく Stateless Widgetとして切り出すことで、リビルドを最適化することができます。(const constructorが利用できることが前提となります。)
下記のようにStateless Widgetとして切り出した場合はParentElement
がリビルドされた場合でもSampleWidget
のリビルドは実行されません。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| class ParentElementState extends State<ParentElement> { @override Widget build(BuildContext context) { return Stack( children: [ ..., ..., const SampleWidget(), ], ); } }
class SampleWidget extends StatelessWidget { const SampleWidget({Key? key}) : super(key: key);
@override Widget build(BuildContext context) { return Container( child: const Text('hello'), ); } }
|
誤ったBuild Contextの参照
例えば Builder
Widgetを使用するようなコードにおいて、Build Contextの1つに任意の別の名前(ここでは innerContext)を指定すると、下層のWidgetにて古いBuild Contextを参照することができてしまいます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class ParentWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Builder(builder: (innerContext) { return sampleWidget(context); }); }
Widget sampleWidget(BuildContext context) { Theme.of(context)... return Container( child: const Text('hello'), ); } }
|
Class Widgetとして切り出すことでこのような予期せぬバグを防ぐことができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class ParentWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Builder(builder: (innerContext) { return const SampleWidget(); }); } }
class SampleWidget extends StatelessWidget { const SampleWidget({Key? key}) : super(key: key);
@override Widget build(BuildContext context) { Theme.of(context)... return Container( child: const Text('hello'), ); } }
|
少し無理やりな例ですが、下記のようにボタンクリックによって、四角のコンテナが円形にアニメーションする例を考えてみましょう。
circle()
メソッドとsquare()
メソッドで返却されるWidgetはどちらも Container
Widgetであるため、RuntimeTypeが同じであり、アニメーションがうまく機能しません。
https://dartpad.dev/?id=ab9ef6401c4687811ea59f44adfa8ee7
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| class ParentWidgetState extends State<ParentWidget> { bool showCircle = false;
@override Widget build(BuildContext context) { return Column( children: [ AnimatedSwitcher( duration: const Duration(seconds: 1), child: showCircle ? circle() : square(), ), ElevatedButton( onPressed: () { setState(() { showCircle = !showCircle; }); }, child: const Text('Click'), ) ], ); }
Widget square() { return Container( width: 50, height: 50, color: Colors.red, ); }
Widget circle() { return Container( width: 50, height: 50, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.red, ), ); } }
|
それぞれの Container
WidgetにKeyを指定すればうまく機能します。
Widget Keyの詳細は割愛しますが、気になる方は下記の記事などを参考にすると良いでしょう。
https://qiita.com/kurun_pan/items/f91228cf5c793ec3f3cc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| Widget square() { return Container( key: UniqueKey(), width: 50, height: 50, color: Colors.red, ); }
Widget circle() { return Container( key: UniqueKey(), width: 50, height: 50, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.red, ), ); } }
|
このような予期せぬ不具合もClass Widgetとして切り出しておけば WidgetのKeyを意識せずとも未然に防ぐことが可能です。
https://dartpad.dev/?id=a69d57ea09802753676a46efc8390d15
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| class ParentWidgetState extends State<ParentWidget> { bool showCircle = false;
@override Widget build(BuildContext context) { return Column( children: [ AnimatedSwitcher( duration: const Duration(seconds: 1), child: showCircle ? const Circle() : const Square(), ), ElevatedButton( onPressed: () { setState(() { showCircle = !showCircle; }); }, child: const Text('Click'), ) ], ); } }
class Square extends StatelessWidget { const Square({Key? key}) : super(key: key);
@override Widget build(BuildContext context) { return Container( width: 50, height: 50, color: Colors.red, ); } }
class Circle extends StatelessWidget { const Circle({Key? key}) : super(key: key);
@override Widget build(BuildContext context) { return Container( width: 50, height: 50, decoration: const BoxDecoration( shape: BoxShape.circle, color: Colors.red, ), ); } }
|
使い分け
ここまでみてきた通り、基本的には原則Class Widgetを利用する形が良いでしょう。
ただしFunctional Widgetそれ自体が問題を引き起こすものではなく、リファクタを目的としたプライベートなFunctional Widgetであれば、Functional Widgetの方がスマートに記述できるシーンがあると考えています。
下記のように、Widget自体が分岐によって切り替わるようなケースにおいて、build
メソッド内が肥大化しているため、Switchのロジックを切り出したくなったとします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| class ParentWidget extends StatelessWidget { const ParentWidget({Key? key, required this.someType}) : super(key: key);
final String someType;
@override Widget build(BuildContext context) { return Column( children: [ ..., ..., () { switch (someType) { case 'A': return WidgetA(); case 'B': return WidgetB(); case 'C': return WidgetC(); default: return const SizedBox.shrink(); } }() ], ); } }
|
このような場合は、Class Widgetではなく Functional Widgetの方がより簡潔にかつ分かりやすく記述できるのではないでしょうか。
Class Widget
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| class ParentWidget extends StatelessWidget { const ParentWidget({Key? key, required this.someType}) : super(key: key);
final String someType;
@override Widget build(BuildContext context) { return Column( children: [ ..., ..., SwitchWidget(someType: someType), ], ); } }
class SwitchWidget extends StatelessWidget { const ParentWidget({Key? key, required this.someType}) : super(key: key);
final String someType;
@override Widget build(BuildContext context) { switch (someType) { case 'A': return WidgetA(); case 'B': return WidgetB(); case 'C': return WidgetC(); default: return const SizedBox.shrink(); } } }
|
Functional Widget
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| class ParentWidget extends StatelessWidget { const ParentWidget({Key? key, required this.someType}) : super(key: key);
final String someType;
@override Widget build(BuildContext context) { return Column( children: [ ..., ..., _switchWidget(), ], ); }
Widget _switchWidget() { switch (someType) { case 'A': return WidgetA(); case 'B': return WidgetB(); case 'C': return WidgetC(); default: return const SizedBox.shrink(); } } }
|
このようにSwitchや三項演算子などにより、既にClass Widgetとして定義されているWidgetを返却するためのロジックのみを切り出したいような場合(言い換えればFunuctional Widget自体が構造化されたWidgetを定義せず、Privateな関数として広く再利用されないような場合)は Functional Widgetの利用を許容しても良い気がしています。
おわりに
原則Class Widgetの利用が推奨されるべきであり、開発時のルールとしてFunctional Widgetは禁止にして問題ないと思います。
ただ最後に記述したとおり、Functional Widgetを使いたくなるようなシーンがいくつかあるような気がしており、(筆者もうまく明文化ができていないですが)そのようなケースが他にもあればコメントいただけますと幸いです。
参考記事