パルカワ2

最近はFlutterをやっています

_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にメリットはあるが大変なことのほうが多いなという感じです。

Flutter for Web で WebView を使う

native_webviewは、WebViewの機能をWebで提供するのは難しいのでWebには今の所対応していない。だが、対応していないプラットフォームでも自前で実装すれば一応動かせるようにはなっている。(というか0.28.0から動かせるようにした)

// Webのときのみsetする
WebView.platform = AppWebView();

HtmlElementView を使って AppWebView() をつくる。

import 'dart:html';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:native_webview/native_webview.dart';
import 'package:native_webview/platform_interface.dart';

class AppWebView extends PlatformWebView {
  @override
  Widget build({
    @required BuildContext context,
    @required CreationParams creationParams,
    @required String viewType,
    @required PlatformViewCreatedCallback onPlatformViewCreated,
    @required Set<Factory<OneSequenceGestureRecognizer>> gestureRecognizers,
    bool useHybridComposition = true,
  }) {
    if (creationParams.widget.initialData.data.isNotEmpty) {
      return _DataWebView(data: creationParams.widget.initialData.data);
    }
    return _UrlWebView(url: creationParams.widget.initialUrl);
  }
}

class _UrlWebView extends StatefulWidget {
  final String url;

  const _UrlWebView({Key key, this.url}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _UrlWebViewState();
}

class _UrlWebViewState extends State<_UrlWebView> {
  @override
  void initState() {
    ui.platformViewRegistry.registerViewFactory(
      "${WebView.viewType}-iframe-${widget.url}",
      (int viewId) {
        // ignore: unsafe_html
        return IFrameElement()..src = widget.url;
      },
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return HtmlElementView(
      viewType: "${WebView.viewType}-iframe-${widget.url}",
    );
  }
}

class _DataWebView extends StatefulWidget {
  final String data;

  const _DataWebView({Key key, this.data}) : super(key: key);

  @override
  State<StatefulWidget> createState() => _DataWebViewState();
}

class _DataWebViewState extends State<_DataWebView> {
  @override
  void initState() {
    ui.platformViewRegistry.registerViewFactory(
      "${WebView.viewType}-data-${widget.data.hashCode}",
      (int viewId) {
        // ignore: unsafe_html
        return DivElement()..innerHtml = widget.data;
      },
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return HtmlElementView(
      viewType: "${WebView.viewType}-data-${widget.data.hashCode}",
    );
  }
}

このように差し込める形にしておくと対応していないプラットフォームでもライブラリ利用者が対応しようと思えば出来るので便利。

ちなみに dart:ui の platformViewRegistry は mobile でビルドするとエラーになる。import の部分で if (dart.library.html) などを使って出し分けるとビルド時のエラーにはならない。

// app_webview.dart
import 'mobile_app_webview.dart' if (dart.library.html) "web_app_webview.dart";
// mobile_app_webview.dart
import 'package:flutter/src/foundation/basic_types.dart';
import 'package:flutter/src/gestures/recognizer.dart';
import 'package:flutter/src/widgets/framework.dart';
import 'package:native_webview/platform_interface.dart';
import 'package:native_webview/src/webview.dart';

class AppWebView extends PlatformWebView {
  @override
  Widget build({
    BuildContext context,
    CreationParams creationParams,
    String viewType,
    onPlatformViewCreated,
    Set<Factory<OneSequenceGestureRecognizer>> gestureRecognizers,
    bool useHybridComposition = true,
  }) {
    throw UnimplementedError("not support");
  }
}

しかし、これだとIDEやLintではエラーが出っぱなしなのでignoreする必要がある。Dart 2.12では unsafe_htmlやundefined_prefixed_nameは行単位のignoreができないので、全体でignoreするかファイルをlintの対象外から外す必要がある。

Dart 2.13以降ではどうにか出来るようになりそうなので、それを待ちたい。

analyzer: Introduce cannot-ignore analysis option. · dart-lang/sdk@97cd131 · GitHub

またそもそもpackageに分ける方法もあるがめんどいのでやっていない。 ちなみに universal_ui | Flutter Package というのがあるがメンテナンスされていなさそう+リポジトリが消えているので使えるかは不明。

個人的にはunivarsalではなく dart:ui の web特有のものを切り出したpackageを作るのが一番筋がいい気がしている。誰か頼む。

native_webview の integration test

ライブラリのテストは、ほとんどの場合 unit testでいいと思うのだけど native_webviewはiOSのWKWebViewやAndroid の WebViewに依存しているのでunit testではテストが出来ない。

なので、native_webview は integration test (ほとんど Widget testだけど)を書いていて、それを手元やCirrusCIで動かしている。その時に得た知見を書いていく。

ちなみにintegration test は実行時間が10〜20分くらいでかなり長くなるのだが、CirrusCIは無料でも1時間実行出来たりMacが使えたりとOSSではコスパが異常に良いので使っている。

どのように動かしているか

.cirrus.yml を見るのが早い。

https://github.com/hisaichi5518/native_webview/blob/master/.cirrus.yml

iOS, Androidともに実機でテストはしていなくて、SimulatorやEmulatorを立ち上げてflutter driveでテストを実行している。SimulatorやEmulatorを立ち上げるために行う処理が多いけど、flutter drive自体は簡単に実行できる。

AndroidiOS で動作が違う

しょうがないと言えばしょうがないのだけど、AndroidiOS のWebViewで動作が違うことがある。具体的には、loadUrlを読んだ時にAndroidはShouldOverrideUrlLoadingが呼ばれなかったりするがiOSは呼ばれる。

しょうがないのでテストでは Platfrom.isAndroid とかで分岐したりスキップしたりして対応している。

ちなみにメンテナンスしにくいなと思って一回ファイルを分けたりしていたのだけど、AndroidではテストしてiOSではテストしてなかったみたいなことがあったので、結局まとめて分岐で対応することにした。

実行するたび結果が変わる

Android で loadUrl すると onPageFinished のイベントが2, 3回呼ばれることがあり、それは毎回ではなくて時々そうなる。あまり行儀は良くないなと思いつつも anyOf()というMatcherを使って渡したリストのどれかと同じならテストを成功するということにした。

expect(context.pageFinishedEvents, anyOf(equals([...]), equals([...])));

CI上で動かす時とローカルマシンで動かす時で動作が違う

これが一番ハマった。というかiOSに関しては解決していない。自分のMacで動かすとiOS, Androidともにちゃんと動くのだけど、CIで動かすとなぜか動作が違うことになる。

AndroidだとEmulatorに入っているChromeのバージョンが古いとWebViewの動作が違うので動作が変わるのが原因だった。最新OSのEmulatorはChromeのバージョンが新しいので最新OSのEmulatorを使うのがよい。

iOSは、XcodeのバージョンもOSのバージョンも一緒なのにWebVIewの動作が違うことがあって謎。具体的には、手元ではonPageStartedにイベントが来るのにCI上では来ない。一応、CirrusCIのサポートに問い合わせているのだけど実装が悪い説もあるとは思っていて困っている。

これも anyOf() で対応するしかないかなと思いつつサポート待ち。

SIGSEGV で落ちる

Androidは時々これで落ちる。原因がわからないのでとりあえずRe-Runしている。悲しい。

テストに失敗すると途中で止まる

flutter driveはtargetを1個設定して、そこに記載されているテストを実行する。そして途中でテストが失敗すると途中でflutter drive自体が止まる。

最初は、webview_test.dart, webview_controller_test.dart などファイルごとにテストを作って、それらのテストを1つのtarget fileにまとめて実行していた。その場合、テストに失敗した時に途中で止まってしまうので、テスト実行 → テスト失敗 → 直す → テスト実行 → 別のところが失敗 → 直す → テスト実行 の繰り返しでテストを待つ時間が多かった。

なので、matrixを指定して、それぞれのテストファイルを別に実行するようにした。そのおかげで待つ事が減ったのとある程度並列で実行されるようになった事、あとはRe-Runの影響が小さくなった。

デメリットはテストファイルを追加したときに.cirrus.ymlも更新しないといけないこと。

処理が最後まで終わってからテストする

もともとは、onPageFinishedなどのイベントをStreamに流して、テスト側ではそれをlistenしてイベントが来るたびにテストをしていた。そしてあるイベントに来たタイミングで complete を呼び出して、テストを終了させていた。

この方法は、わざわざwaitする必要がないので比較的早いのだけど、処理の途中でテストが失敗することがあるので、そのあとどうなったのかわからなかったりする。integration test は1回の実行時間が長いので、実行回数をなるべく減らしたい。そのためには1回でどういうイベントが来るのかをすべて把握したかった。

なので、直近では処理が終わっているであろう時間までとりあえず待つ方式に変えた。ただやはり遅いので、waitの方法を変えるかもしれない。

iOSAndroidの両方で同時に動かせない

困っている。もう手元で動かさずにCIで動かす前提にするのがいいのかもしれない。

まとめ

大変だけど書かないといけないときは書くしかない。

ちょっと話は変わってflutter_test の話だけど、Matcherになにがあるのか把握出来ていないのでテストが書きにくい。 TypeScriptではMatcherに何があるのかわかってなくても expect(hoge). とか打つと補完でなにがあるのか一覧で表示されるのでそれっぽいのを選んで書ける。

VSCodeのlaunch.jsonからInteliJのrunConfigurationsを自動生成する

毎日のようにはてなスターのスパムがきます。こんにちは。
仕事で作っているアプリは、Flutterを使っておりかつFlavorがたくさんあってそれぞれのFlavorごとにrunConfigurationsを用意している。また今後Flavorは増える予定である。
同僚には、VSCodeを使ってる人もInteliJを使ってる人もいるので、VSCodeのlaunch.jsonとInteliJのrunConfigurations両方を管理している。
片方追加したらもう片方も自動で追加してくれ〜という気持ちが出てきたので、VSCodeのlaunch.jsonからInteliJのrunConfigurationsを自動生成するようにスクリプトを作った。

launch.json というとJSONファイルっぽいけど、コメントがあったり最後の要素なのにケツカンマがあったりとJavaScriptのObjectっぽい。しょうがないのでevalした。
nodejsでやればいいだけだけど、nodejsに依存させたくなかったのでDartです。
あとは、これをGithub actionsとかで動かしてdiffをcommitするようにすればいい感じになる。

import 'dart:convert';
import 'dart:io';
import 'dart:isolate';

Future<void> main() async {
  final mapping = await _loadLaunchJson();
  final configurations = mapping["configurations"] as List<dynamic>;

  await Directory(".idea/runConfigurations").create(recursive: true);

  for (final config in configurations) {
    final runConfiguration = '''
<component name="ProjectRunConfigurationManager">
  <configuration default="false" name="${config["name"]}" type="FlutterRunConfigurationType" factoryName="Flutter" singleton="false">
    <option name="additionalArgs" value="${(config["args"] as List<String>).join(" ")}" />
    <option name="filePath" value="\$PROJECT_DIR\$/${config["program"]}" />
    <method v="2" />
  </configuration>
</component>
''';

    final filename = (config["name"] as String).replaceAll(" ", "") + ".xml";
    final runConfigurationFile = File(".idea/runConfigurations/$filename");
    await runConfigurationFile.writeAsString(runConfiguration);
  }
}

Future<Map<String, dynamic>> _loadLaunchJson() async {
  final launchJsonFile = File("./.vscode/launch.json");
  final launchJsonBody = await launchJsonFile.readAsString();

  // コメントがあったりケツカンマがあったりとJSONではないので必殺eval
  final uri = Uri.dataFromString(
    '''
    import "dart:isolate";
    void main(_, SendPort port) {
      port.send(${launchJsonBody});
    }
    ''',
    mimeType: 'application/dart',
  );
  final port = ReceivePort();
  await Isolate.spawnUri(uri, [], port.sendPort);

  final dynamic response = await port.first;
  return response as Map<String, dynamic>;
}

1000万年ぶりくらいにはてな記法書いた気がする。