パルカワ2

最近はFlutterをやっています

FlutterのStatefulWidgetの課題と解決方法

FlutterでStateをもたせるには色々あるが、基本はStatefulWidgetを利用する。

StatefulWidgetは以下のようにStateを持ち、Stateの中で値を持ったりsetしたりできる。

StatefulWidget class - widgets library - Dart API

class Bird extends StatefulWidget {
  const Bird({
    Key? key,
    this.color = const Color(0xFFFFE306),
    this.child,
  }) : super(key: key);

  final Color color;
  final Widget? child;

  @override
  _BirdState createState() => _BirdState();
}

class _BirdState extends State<Bird> {
  double _size = 1.0;

  void grow() {
    setState(() { _size += 0.1; });
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      color: widget.color,
      transform: Matrix4.diagonal3Values(_size, _size, 1.0),
      child: widget.child,
    );
  }
}

他にもinitStateやdisposeなどWidgetが生成されるときや破棄されるタイミングに呼び出されるCallbackなどがある。

詳しくはドキュメントを読んでもらうとしてそんなのがある。

ちなみに逆にStatelessWidgetはStateを持たずinitStateなどもない。

StatefulWidgetの課題とは

initStateなどがあると言ったが、例えばテキストを入力できる画面を作りたいときにTextEditingControllerをinitStateで作って、disposeで破棄する処理を書いたりする。

TextEditingController class - widgets library - Dart API

/// Flutter code sample for TextEditingController

// This example creates a [TextField] with a [TextEditingController] whose
// change listener forces the entered text to be lower case and keeps the
// cursor at the end of the input.

import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

/// This is the main application widget.
class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  static const String _title = 'Flutter Code Sample';

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: _title,
      home: MyStatefulWidget(),
    );
  }
}

/// This is the stateful widget that the main application instantiates.
class MyStatefulWidget extends StatefulWidget {
  const MyStatefulWidget({Key? key}) : super(key: key);

  @override
  State<MyStatefulWidget> createState() => _MyStatefulWidgetState();
}

/// This is the private State class that goes with MyStatefulWidget.
class _MyStatefulWidgetState extends State<MyStatefulWidget> {
  final TextEditingController _controller = TextEditingController();

  @override
  void initState() {
    super.initState();
    _controller.addListener(() {
      final String text = _controller.text.toLowerCase();
      _controller.value = _controller.value.copyWith(
        text: text,
        selection:
            TextSelection(baseOffset: text.length, extentOffset: text.length),
        composing: TextRange.empty,
      );
    });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        alignment: Alignment.center,
        padding: const EdgeInsets.all(6),
        child: TextFormField(
          controller: _controller,
          decoration: const InputDecoration(border: OutlineInputBorder()),
        ),
      ),
    );
  }
}

他にもScrollControllerだったりなんとかControllerがよくある。

そのたびに生成して破棄する処理を書く必要がある。

つまりTextEditingControllerに関する処理をinitStateやdisposeに書く必要があり関連するコードが一箇所にまとまらず分散してしまったり、何度も同じコードを書く必要がある。

解決方法1: mixinする

mixinする方法がある。

ただしmixinはTextEditingControllerが複数ある場合に対応出来ない。

解決方法2: Builderを用意する

TextEditingControllerBuilderみたいなWidgetを作って利用すればmixinの問題は解決するが、この方法はBuilderを使うのでbuilder外でControllerを使うことが難しくなる。

解決方法3: flutter_hooks

このように use*Controller すればいいだけになる。解決方法1, 2で起きるような問題は起きない。

flutter_hooks | Flutter Package

class Example extends HookWidget {
  const Example({Key key, required this.duration})
      : super(key: key);

  final Duration duration;

  @override
  Widget build(BuildContext context) {
    final controller = useAnimationController(duration: duration);
    return Container();
  }
}

ただし、flutter_hooksはそれなりにデメリットもあると感じている。

  • HookWidgetを継承する形になり一般的なStatelessWidgetを継承する形ではない
  • useEffectなどを多用するとbuildメソッドが大きくなってしまう
  • use* は if の中で使う事はできないが今の所機械的に発見しにくい
  • Flutter公式が提供している機能ではない

イシューにはなっていないのか

イシューはある。

Static Metaprogrammingがあればいいみたいな話になっているがどうか

Reusing state logic is either too verbose or too difficult · Issue #51752 · flutter/flutter · GitHub

static metaprogramming自体導入されるかはまだ未定

language/intro.md at master · dart-lang/language · GitHub

hooksはFlutterに入らないのか

このイシューで色々と議論されたようですが今の所はなさそう。

Widget hooks · Issue #25280 · flutter/flutter · GitHub

まとめ

flutterh_ooksを使うのは良さげではあるのだが使う判断はまだ出来ておらずどうしたものかなーと思いながらStatefulWidgetを作っている。

なぜRiverpodのFutureProviderを推すのか

RiverpodにはFutureProviderというのがある。

FutureProvider class - riverpod library - Dart API

非常によく出来たAPIだなぁと思ったのでそれのメリット・デメリットと所感をまとめたい。間違えているとかなにかあれば教えてください。

まずFutureProviderの例

コードは、公式ドキュメントから引用している。

FutureProvider class - riverpod library - Dart API

設定ファイルからJSONを取得してDartのObjectに変換して返すという処理があるとする。設定ファイルからJSONを取得部分がasync/awaitに依存している。

Provider

FutureProviderは値を取得して返す処理が書かれている。 Configurationは1度取得したら明示的にconfigProvider に対して reloadしたりするまで設定ファイルが再度読まれることはない。

final configProvider = FutureProvider<Configuration>((ref) async {
  final content = json.decode(
    await rootBundle.loadString('assets/configurations.json'),
  ) as Map<String, Object?>;

  return Configuration.fromJson(content);
});

Widget

FutureProviderをwatchするとAsyncValueが返ってくる。Futureを使うということは、loading, error, data(データ取得成功)の状態があるので、それぞれの状態にあったWidgetを返す。

Widget build(BuildContext, ScopedReader watch) {
  AsyncValue<Configuration> config = watch(configProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (config) {
      return Text(config.host);
    },
  );
}

FutureProviderはどういうことができるのか

アプリを作るときによくある「値をアプリ外から取得する」「取得中などの状態管理をする」「取得した値をStateとして持つ」ことができる。もちろんRiverpodのProviderとして持つので、値が更新されたら再描画などができる。

具体的にはこういうの

  • 設定ファイルから読み込んでStateとして持ちたい
  • Web APIを叩いて値をStateとして持ちたい

ちなみにReact界隈では"Application Stateと呼んでいるものの多くは、実際にはサーバーにあるStateをクライアント側でキャッシュしているに過ぎない"みたいな話があるらしくまぁ確かにと思いながらこのツイートのツリーを読んでいた。

FutureProviderのメリット

  • initStateなどで最初の読み込み処理を呼ぶ必要がない
  • 依存するデータが更新されたときに値の再生成が自動的に行われる
  • AsyncValueが返ってくる形になっているのでloading, error, dataなどの状態管理が必ず行われる
  • loading, errorなどの状態管理を自分でする必要がない

FutureProviderのデメリット

  • initStateなどで明示的に呼び出す形ではないので、どのタイミングでAPIが呼び出されるか慣れていないのわからないことがある

所感

Riverpodは、値Aが更新されたら値Aに依存する画面を再描画したり、値Aに依存する値の更新が行われる。 同様にWeb APIを叩く時に値Aに依存している場合、値Aが更新されたらWeb APIも再度叩いてほしい場合などがある。具体的には、FutureProviderがAPIClientに依存していてAPIのエンドポイントが変わってAPIClientが更新された時など。

initStateなどで読み込む場合はそういうのに対応するには自前で書く必要があるが、Riverpodにまかせてしまうとそれが不要になる。

明示的にAPIを呼び出さないので混乱のもとになるような気もしたけど、他の値同様に依存している値が更新されたら更新されるという形になるので設計的にも統一感があってシンプルになると思う。

またFutureを使うということは、loading, error, sccuessという状態管理を必ず行う必要がある。FutureProviderを使えば自分たちでisLoadingとかを管理していたのが不要になる。

このような理由から個人的には値を取得する部分ではFutureProviderを使うのがよいと感じている。

関連

この記事を書くにあたって読んだ記事など。SWRやreact-queryは関連している気がしたので読んだ。参考になりました。ありがとうございます!

1つのWidgetの役割をとにかく小さくしたい

第5回 今ならこうするシリーズ。更新を忘れていた。

自分でFlutterのWidgetを作っているときに以下のように値を受け取るWidgetを作りがちだった。

HogeButton(title: "ほげボタン", titleColor: ...);

class HogeButton extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
        return TextButton(
            style: ...,
            child: Padding(
                ...,
                child: Text(title, style: ...)
            ),
        );
    }
}

今ならこうする

Widgetを受け取る形のWidgetを作る。

HogeButton(
    child: HogeButtonRegularText(
        title: "ほげボタン",
        titleColor: ...
    ),
);

class HogeButtonRegularText extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
        return Text(title, style: ...)
    }
}

class HogeButton extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
        return TextButton(
            style: ...,
            child: Padding(..., child: child),
        );
    }
}

ここまで書いてボタンの例はあんま良くないかもなと思いつつめんどくさいのでそのまま進める。

理由

Widgetの役割を分けたい

値だけを受け取るWidgetのHogeButtonは、以下のような役割がある。

  • Buttonのスタイルを決める
  • Buttonのchildを囲むPaddingを決める
  • Buttonのchildの内容を決める
  • Buttonのchildのテキストスタイルを決める

一方でchildを受け取るWidgetのHogeButtonは、以下のような役割がある。

  • Buttonのスタイルを決める
  • Buttonのchildを囲むPaddingを決める

その代わりにHogeButtonRegularTextが以下を担う。

  • Buttonのchildのテキストスタイルを決める

またButtonのchildの内容を決めるのは、HogeButtonを呼び出すところになる。

このように役割を分けることで、密結合が避けられて影響範囲を小さくし変更に対するコストを下げることができる。

具体的には、ボタンにテキストだけではなくIconを表示したい場合、childを受け取るWidgetの場合はHogeButtonIconTextを作ればよいが、値だけを受け取るWidgetはIconがある場合は表示するなど分岐をする必要があり後々大変になってくる。

分岐を避けるためにIconHogeButtonを作ることはできるが、HogeButtonとボタンのスタイルは同じなのにWidgetは分ける必要があるため、再利用性が低い。

懸念点

コードの記述量が多くなるのでは

多くはなる。ただchildを受け取るWidgetのほうが関心の分離は出来ているし、それぞれのクラスは非常にシンプルな役割になっている。逆に値だけを受け取るWidgetだとコードの量自体は少ないが、HogeButtonが複数の責任を持つクラスになり変更に対するコストが大きくなってしまう。

自由度が高すぎるのでは

Widgetであればなんでも受け取れるのでHogeButtonRegularTextがすでにあることに気づかずに自前で作ってしまうみたいなこともありそう。それをどうやって防ぐかは命名規則をしっかり決める、デザインするときに名前をつける以外で今の所いいアイデアがない。

メソッドを分ければ役割が分かれるのでは

メソッドで分ける方法は、影響範囲が明確に分かれていないので役割も分かれていない。

hisaichi5518.hatenablog.jp

まとめ

正直なところ、こういうことは1年で消えてなくなる可能性があるサービスは、あんまり気にする必要がないと思っているけど、少なくとも数年は生き残りそうなソフトウェアは、コードの記述量が少ないといった短期的に楽なコードではなく変化しやすさを重視した中長期的に楽なコードを目指すべきではないかと思っている。

あと当たり前なんだけど値だけを受け取るWidgetを一切作らないのは無理な話なのでそれは考えてない。むずかしいな〜〜

_buildWidgetについての考えと今ならどうするか

第4回 今ならこうするシリーズ

UIをつくるときにStatelessWidget(またはStatefulWidget)を継承してWidgetを作るのではなく、以下のようにWidgetを返すメソッドを作って切り分けることが出来る。メソッドで切り分けるとクラスを作るよりは比較的楽に作れるという理由でそうやってることが多い。僕もそう思ってやっていた。

ここでは、これを_buildWidgetと呼ぶ。今回はこの_buildWidgetについて書く。

class HogeView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        _buildHoge(context),
        _buildFuga(context),
        _buildPiyo(context),
        _buildNyan(context),
      ],
    );
  }

  Widget _buildHoge() { ... }

  ...
}

考え

高速化・コードの読みやすさ・リファクタリングの観点からやめたほうがよい。

なぜ

無駄なbuildがされやすい

上の例で _buildNyan メソッドの中で context.watch<Config>() していたとする。

Configに変更があったとき本来は _buildNyan の内容だけrebuildされてほしいが、_buildHogeなども再度buildされることになり、無駄なbuildが走る。

詳しくは10Xの社内向けに書いた「FlutterのWidgetを書く時に知ってると便利なこと」というドキュメントで書かれているので入社してください。

open.talentio.com

各メソッドの影響範囲が掴みにくい

上記の通り、メソッド内だけではなくメソッド外にもUIの再構築という影響を与えうる。

Widget build(BuildContext context) の中でしか利用していないのならまだよくて、_buildHogeの中で更に_buildFooを呼んでいるみたいなことも多々あり、_buildFooの影響範囲はコードを追わないとちゃんと掴めない。

1つのWidgetクラスにたくさんの役割が与えられてしまいコードの再利用性が低い

最初は1画面でしか利用していなかったが他の画面で利用するようになった場合など、クラスであればprivateからpublicにするだけで基本よい。そのクラスがViewModelに依存していると話は変わってくるけどそれは別の話。

メソッドにしていると再利用するにはクラス化する必要があり、クラス化にあたって依存しているメソッドやパラメータを引き剥がす必要がある。またUIの再構築という影響をメソッド外にも及ぼしていたので、クラス化することでUIの再構築が起きなくなってバグが出ないかなどリファクタリングするときに考慮することが多くなる。

今ならどうするか

Widgetクラスをつくって影響範囲と役割を狭め再利用性を高める。

まとめ

最初は高速化のためにメソッドで切り分けるのをやめたほうがいいなくらいに軽く考えていて、まあ速度面で困ったらクラスに分けるくらいでいいかなと思っていたのだが、変化のしにくさを感じるようになってからはクラスを分けるようにしている。言ってしまえば高速化のところはわりかしどうでもよくてクラスの責務をしっかり分けましょうみたいな話のほうが個人的には重要。

次回はWidgetの設計について書きたいけど、書くことが多すぎるので分けるかも。

TextStyleの共通化についての考えと今ならどうするか

第三回 今ならこうするシリーズ

FlutterはTextにstyleをあてることができる。今回はそのTextStyleの共通化について書いていく。

const Text("文字", style: ...);

TextStyleは以下のように共通化している。こうすることで基本はconstとして呼び出すことが出来るし、画面によって文字色の変更がしたいときは copyWithで上書きすることも出来る。またTextStylesをimportしてしまえば、名前の補完が効く。

class TextStyles {
  static const body = TextStyle(
    fontSize: ...,
    color: ...,
    height: ...,
  );
}

// 呼び出すとき
const Text("文字", style: TextStyles.body);

// 上書きするとき
const Text("文字", style: TextStyles.body.copyWith(color: ...));

考え

比較的いい方法だと思っていてやめる気はないけど気になることもある。

なぜ

ファイル移動しないとデフォルト値がわからない

static constで定義しているので、呼び出し元からはTextStyleが何を受け取っているのかわからない。デフォルトでは、色が何色が設定されているのかファイル移動せずに確認したい。InteliJ固有の問題かも。

BuildContextに依存して文字の大きさを変更できない

static constで定義しているので、変更されうる画面の大きさなどに基づいて文字の大きさのデフォルト値を変更できない。

今ならどうするか

TextStylesのままでいく。

ただ以下のようにWidgetを作成するのもよいかもと思っている。

class TextBody extends StatelessWidget {
  final String data;
  final Color color;

  const TextBody(
    this.data, {
    Key? key,
    this.color = AppColors.black,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final breakpoint = context.watch<Breakpoint>();

    return Text(
      data,
      style: TextStyle(
        fontSize: breakpoint.map(
          mobile: (_) => 14,
          tablet: (_) => 15,
          desktop: (_) => 16,
        ),
        color: color,
      ),
    );
  }
}

ただしデメリットもあると感じている。なのでTextStylesで定義するのがよいと思っている。

  • 値を変更するためには受け取るパラメータを自分で書く必要がある
  • Widgetを作成する必要があり、書くコード量が多い
  • SelectableTextなどもあるのでそれぞれに対応が必要になる

まとめ

一長一短で悩ましい。例えばクラスにする方法は値を変更するためには自分でパラメータを受け取る必要がある。画面ごとに変わるパラメータが多い場合はめんどくさく感じそう。またWidgetを作成する必要があるので比較的書くコードが多くなる。

しかし、今回あげたデフォルト値がわからない問題はインスタンス作成時の引数として受け取るときにデフォルト引数に設定すればわかるようになるし、画面の大きさなどBuildContextに依存する値によってデフォルトの文字の大きさを変更したい場合には有効だと思う。

余談: Flutter folioでは同様にTextStylesが定義されていた。

github.com

次回は_buildWidgetについて。

providerについての考えと今ならどうするか

「今ならこうする」をシリーズ化しようと思ってカテゴリを作った。「今ならこうする」は僕基準であって全員そうすべきという話ではない。今回もそう。

Flutterにはprovider packageというのがあり、公式でもprovider + ChangeNotifierの構成がドキュメントにあったりするし、なんなら A recommended approach. として紹介されている。 それくらいprovider packageはよく出来ていて僕もFlutterをやり始めた当初から使っている。

Simple app state management - Flutter

考え

provider packageは便利だけど問題や足りないこともあって困る。

なぜ

DIがめんどくさい

provider packageはBuildContextに依存している。BuildContextに依存していないViewModelのインスタンスを作るときは以下のようにcontext.read()で値を取得しインスタンスを作成して下位のWidgetで利用できるようにしている。

class HogeScreen extends StatelessWidget {
  static Widget _newScreen() {
    return ChangeNotifierProvider<HogeViewModel>(
      lazy: false,
      create: (context) {
        return HogeViewModel(
          rpcClient: context.read(),
          logger: context.read(),
          firebaseAuth: context.read(),
        );
      },
      child: HogeScreen._(isInitialRoute),
    );
  }
}

このやり方には以下のような問題がある。

  • インスタンスの作成するたびに毎回自分でcontext.read()する必要がありめんどくさい
  • 単体テスト時にBuildContextには依存できないので別のライブラリに依存する必要があり値の2重管理になる

またぼくたちは利用していないけど、providerにはLocatorという型がある。 インスタンスを作るときにLocatorを渡して、ViewModelの中で値を取得しちゃおうというやり方である。この方法だとインスタンスを作成するたび毎回自分でcontext.read()しなくていいので楽ではある。

Locator typedef - provider library - Dart API

しかしLocatorを利用すると以下のような問題が起きる。

  • ViewModelが何に依存しているか外からみて明確ではないので実装を見る必要がある。また依存に変更があったときに変更漏れが起きやすい
  • インスタンス作成時ではなく値が必要なタイミングで取得することが出来るのでその場合エラーになるタイミングが遅れて気づきにくい
  • 単体テスト時に別ライブラリに依存して値の2重管理になる問題は解決していない

なのでLocatorは使わないのがよいと思っている。

またproviderの話ではなくproviderの使い方の話だけど、readを多用してしまったのは間違いだったなと思っている。watchすべきだった。

値が未定義の場合、実行時エラーになる

Providerは値を定義していないときに実行時にエラーになる。定義されていないときはコンパイルエラーになるべき。

devtoolが見にくくなる

Providerは値を定義するときにWidgetとして定義するので、扱う値が多ければ多いほどdevtoolで見るとlayout treeがProviderで埋まってしまう。

今ならどうするか

作者が同じRiverpodを使う。

Riverpodは上記のprovider packageの問題点はすべて解決している。

ただし、APIが安定していない。APIの変更自体はmigrate toolが提供されるのである程度問題ないが、migrate toolで対応できない部分もあるかもしれないので手で直したりするコストは必要になる。またドキュメントも十分とは言えずコードやイシューを読んだりする必要があったりする。

ただ僕はそういうのは自分や人が時間をかければ解決する問題だと思っていて、provider packageの問題は時間が経っても解決しない根本的な仕様なので、解決しそうなほうをとりたいという感じ。

riverpod.dev

まとめ

1 on 1をしていたときにproviderってどうなんですかと聞かれたので書いた。

ぼくはRiverpod推しではあるが、provider packageにも当然価値はあって例えばドキュメントが充実していたり、APIが安定していたり、公式でdevtoolの対応が入っていたりする。provider packageを使うのは全然悪い選択ではないし、むしろ状況によっては非常にいい選択になりえるとも思っている。

しかし、ある程度Flutterに慣れた人はRiverpodを選択すると上記の課題は解決されるのではと思っている。

次回は、TextStyleの共通化について。

ChangeNotifierについての感想

ChangeNotifierというのがFlutterにはあり、ViewModelで継承して使っている。 StateNotifierとよくパフォーマンス面で比較されるValueNotifierというのもあり、そっちは1個のValueしか扱えないがChangeNotifierは複数扱える。なのでValueNotifierやStateNotifierのように新たに管理する値を増やすときにクラスを定義する必要がなく楽ではある。

api.flutter.dev

結論

ChangeNotifierにあまりポジティブな印象がない。 新たに設計し直すなら後述の方法か違う方法をとるとおもう。

なぜ

値を変更したあとにnotifyを忘れる

以下の例だとisLoading を更新したあとにnotifyしないといけないのだけどうっかり忘れることがある。 また途中でreturnとかしているとうっかり忘れることがある。 するとbuildされず???となる。

class HogeViewModel extends ChangeNotifier {
  Future<void> load() async {
    isLoading = false; // 本当は変更したあとにnotifyする必要がある
    // 時間のかかる処理
    isLoading = true;
    notifyListeners();
  }
}

Linterなどでどうにか出来るとよいけど、どうにか出来るかは知らない。

notifyするとすべての値が更新されたことになり影響範囲が大きくなる

ViewModel なので Viewに関するStateを持っておりそれが大きくなることがある。 またViewModel以外にもアプリ全体で値を管理するStoreクラスみたいなのを作っている。

そのStoreもChangeNotifierを継承している。例えば注文に関するStoreがあって、注文に関するStateをいれていくと多くの値を持つStoreが出来上がる。

Aという値とBという値があったとして、Aを更新したあとにnotifyした場合変更していないBも更新されたことになる。 なので、Aを更新したいだけなのにBが更新されても大丈夫か気にする必要がある。

これをどうにかするには、値をwatchする側で更新されたか確認してあげる必要があるのだが、ちゃんと更新されたか確認しているかどうかはwatchする側に依存するのでちゃんと更新されたか確認しているか確認する必要があるが、全て見るのは大変で大体見ない。

そもそもクラスをちゃんと分割できればいいのだがそううまくいかないのが現実だなと最近思っている。難しい。

今ならどうするか

RiverpodのStateProviderを使うのがよさそう

  • 値を定義するのにクラスを作らなくてもよい
  • 値を更新したあとのnotifyが不要
  • static member で定義するので注文に関するProviders みたいにまとめられる
  • StateProviderは値が1つしか持てない。複数持つにはfreezedなどでデータクラスを作るけどそれやるならselectはしなくていいと思う

まとめ

今日notifyしていないバグを見つけたので書いた。 ChangeNotifierにメリットはあるが大変なことのほうが多いなという感じです。