タオルケット体操

サツバツいんたーねっとでゲームとかガジェットのレビューとかをします

FlutterでBLoCだChangeNotifierと振り回されて消耗するまえに

Sponsored link

追記

providerとかfreezedの作者が作ってる state_notifier が当エントリとほぼほぼ同じことをやっているので依存が増えることを気にしない人はそっち使ってもいいんじゃないかとおもいます。
みんなの心はひとつでした。

まえがき

先のエントリ BLoCにおけるリモートデータの状態遷移のパターンをくくりだす方法 - タオルケット体操 の書き方からもわかるように、そもそも僕はBLoCが嫌いです。
というか10年前にC#がRXをはじめたときからわかっていたはずですが、ObservableStreamは超かっこいいけど使い道の少ない技術です。フレームワークの裏側で使う分には便利ですが、表に出てくるべきではないでしょう。普通のGUIアプリケーションであれば99%のユースケースはただのコールバックで満たせます。

しかもBLoCはViewModelのパターン*1です。ViewModelとViewの接続にStreamが必須になるようなケースをどれだけおもいつけますか? コナミコマンド? せめてFlutterのTextFieldやControllerなんかがStreamをストレートにサポートしていればいいのでしょうが、もちろんそんなものはありません。
さらにもうちょっと高度なことをやろうにも、Dartの貧弱な型システムでは安全性の担保が面倒です。

というわけで、 例によって界隈はBLoCだるくてかなわんしシンプルなChangeNotifier(要はEventEmitterです)つかおーぜww という空気になっているようです。

ただし、ChangeNotifierはある意味でBLoCよりも退化しているパターンです。公式を鵜呑みにすると消耗します。

プレーンなChangeNotifierパターンの問題

まず公式で出しているSimple Apps Statementの例(scoped modelの単純な置き換え)が世の中のベーシックであると前提します。

https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple

これは事実上のActiveRecordパターン(バックエンドがDBではなくJSONを返すHTTPリクエストであるという違いはある)です。
CRUDだけで完結するような極々小規模なアプリではよく動作しますが、アプリが管理するデータが少しでも複雑になると破綻します。RailsのFatModelがいい例です。

ちなみに僕がmobx(あるいはそれを使って"シンプル"を標榜しているサンプル)を使わない理由も同じです。現実世界はTODOアプリを一番綺麗に書けるコンテストではないので、見せかけのコード数の少なさで全てを判断するのはやめましょう。

上記を避ける方法

本質的にはDDDやCQRSなどのアーキテクチャを使いこなそうぜと、そういうことになりそうです。

しかし複雑で抽象度の高いDDDの導入は、メンバー全員がよほどそれに精通しているかあるいはかなり規模の大きいエンタープライズアプリじゃないとコストがペイしないんじゃないかと個人的にはおもっています*2
なのでもっとシンプルなCQRSのフレーバーのみを持ち込んで解決します。

またWriteModelとReadModelを分けるというCQRSの考え方は、QueryとMutationで使うモデルが変わることがほぼ前提となっているGraphQLとも非常に相性が良いです。

ちなみにEventSourcingはやりません。難しそうだからです。

サンプル

import 'package:provider/provider.dart';
import 'package:meta/meta.dart';

import 'package:flutter/material.dart';
import 'package:equatable/equatable.dart';

@sealed
abstract class StoreNodeConstraints implements Equatable {}

typedef ModelUpdate<T> = T Function(T prev);

mixin ModelProtocol<MT extends StoreNodeConstraints> on ChangeNotifier {
  MT get value;
  set value(MT v);

  void _update(ModelUpdate<MT> updator) {
    final prev = value;
    final next = updator(prev);
    if (next != prev) {
      value = next;
      notifyListeners();
    }
  }
}

mixin Behavior {
  List<ModelProtocol> get states;

  /// TODO: support batch update
  @protected
  void effect<MT extends StoreNodeConstraints, T extends ModelProtocol<MT>>(ModelUpdate<MT> updator) {
    states.whereType<T>().forEach((s) {
      s._update(updator);
    });
  }
}

はい。
このような形でモデルと"アプリケーションに必要な振る舞い"(ここではBehaviorという名前にしました)を分離します。

以下に、上のコードのユニットテストを提示します。察してくれや

import 'dart:async';

import 'package:equatable/equatable.dart';
import 'package:test/test.dart';
import 'package:meta/meta.dart';
import 'package:flutter/material.dart';

import 'package:sew_app/tools/more_provider.dart';

class TestAnswer extends Equatable implements StoreNodeConstraints {
  final String text;
  final int likeCount;
  const TestAnswer(this.text, this.likeCount);

  @override
  List<Object> get props => [text, likeCount];

  @override
  String toString() => '$runtimeType($text, $likeCount)';
}

class TestComment extends Equatable implements StoreNodeConstraints {
  final String id;
  final String text;
  const TestComment(this.id, this.text);

  @override
  List<Object> get props => [id, text];

  @override
  String toString() => '$runtimeType($id, $text)';
}

class TestAnswerModel with ChangeNotifier, ModelProtocol<TestAnswer> {
  @override
  TestAnswer value;

  TestAnswerModel({@required String text, @required int likeCount}) : value = TestAnswer(text, likeCount);
}

class TestCommentModel with ChangeNotifier, ModelProtocol<TestComment> {
  @override
  TestComment value;

  TestCommentModel({@required String id, @required String text}) : value = TestComment(id, text);
}

class PageService with Behavior {
  final List<ModelProtocol> _fields;
  PageTransaction(this._fields);

  @override
  List<ModelProtocol> get states => _fields;

  Future<void> applyTestDataForAns(String testText, int testLikeCount) async {
    await Future<void>.delayed(const Duration(milliseconds: 300));
    effect((TestAnswer prev) => TestAnswer(testText, testLikeCount));
  }

  void applyTestDataForComment(String testText) {
    effect((TestComment prev) => TestComment(prev.id, testText));
  }
}

void main() {
  group('Behavior can tell listeners that the model was changed', () {
    test('When model was changed.', () async {
      final testModel = TestAnswerModel(text: 'test', likeCount: 0);

      final pt = PageTransaction([testModel]);

      const newTxtForTest = 'testeteadatata';
      const newLikeCountForTest = 999;
      final c = Completer<TestAnswerModel>();
      testModel.addListener(() {
        c.complete(testModel);
      });

      await pt.applyTestDataForAns(newTxtForTest, newLikeCountForTest);
      final changedModel = await c.future;
      expect(changedModel.value, const TestAnswer(newTxtForTest, newLikeCountForTest));
    });

    test('When models were changed.', () async {
      final testModel = TestAnswerModel(text: 'test', likeCount: 0);
      final tesCModel = TestCommentModel(id: 'xxxx1', text: 'test');

      final pt = PageTransaction([testModel, tesCModel]);

      final ansStream = StreamController<TestAnswer>();
      testModel.addListener(() {
        ansStream.sink.add(testModel.value);
      });

      final commentStream = StreamController<TestComment>();
      tesCModel.addListener(() {
        commentStream.sink.add(tesCModel.value);
      });

      const newTxtForTest = 'testeteadatata';
      const newLikeCountForTest = 999;
      await pt.applyTestDataForAns(newTxtForTest, newLikeCountForTest);

      const newCommentForTest = 'hmaaaaaaaa!!!';
      pt.applyTestDataForComment(newCommentForTest);

      // To make sure each notifiers are called only once.
      expect(
        ansStream.stream,
        emitsInOrder(<TestAnswer>[const TestAnswer(newTxtForTest, newLikeCountForTest)]),
      );
      expect(
        commentStream.stream,
        emitsInOrder(<TestComment>[const TestComment('xxxx1', newCommentForTest)]),
      );
    });
  });
}

${Something} with ChangeNotifier を何と捉えるか

これはFluxでいうStoreです。
データを持ち、observableで変更をlistenerに通知することができます。データのpresentationに必要な振る舞いを持つことはありますが、基本的にはそれだけのものです。

例えばですが、バリデーションなどはここではやりません
バリデーションのようなデータ整合性に関わるロジックは往々にして個々のノードを跨いだ判断を要求します。
例えば「テスト用無料プランではPostして良いのは各自10個まで」という要件があった場合、Organizationが持つplanと、今までに投稿した数、など複数のノードに跨ったデータが必要になります。
その他、一時期ハズってた #チケット料金モデリング とかで考えてみてもいいかもしれないですね。

ちなみにこのStoreの構造は上記でいうGraphQLのReadModelとほぼ対応する形になるとおもいます。
が、構造は正規化しておくことを推奨します

例えばTweetというNodeに紐づくLikesが持つLikeしたユーザーを表すノードは、フルのUserではなくてUserIdのみを持つということですね。
Likeしたユーザーのデータを表示するWidgetは Selector<UsersStore, User>(selector: (_, store) => store.byId(userId), builder: というように、idをキーにUserを保管しているノードからデータを引っ張ってきます。これはオブジェクトの重複を避けて消費するメモリ容量を減らすのもそうですが、データ整合性の担保やデータ更新時に無関係なWidgetへ更新が走るのを防ぐ効果も期待できるからです。Reduxなんかでも同様のやり方が推奨されています。

Behaviorは

各ノードから切り離された振る舞い、DDD風にいえばApplicationServiceとして実装します。
CommandsとQueryに分離する必要はないとおもいますが、やろうとおもえば簡単にできる構造になっています。……が、メソッドの命名規則を決める程度で問題ないでしょう。
バリデーションなどもここで行います。

また、ここで使うgqlクライアントなどは何らかの形でDIして依存性を排除し、Behaviorは常にテスト可能な状態を保ちます。

ちなみにBehaviorをどういう単位で分割していくかですが、これはケースバイケースになるでしょうね。
ただ僕としては明らかにアプリ全体に関わるようなデータを操作するものはGlobal、そうでないものはPer featureごとに存在していくのが適切なんじゃないかとおもいます(PageではなくFeatureです。最近のSPAもそうですが、UXを重視するほどPageという単位での設計は破綻しやすいように感じています。逆にシンプルなアプリであればPage = Featureなのでいずれにしろ無理はありません)。

ここで注意して欲しいのは、StoreとBehaviorは一対一ではないということです。
例えばツイッターの実装であれば、 Tweets というが ListViewBehavior に保持されたり、 TweetDetailBehavior に保持されたりしても良いということです。

想定される反論

  • Reduxじゃダメなの?

    Reduxでいいとおもいます。しかし僕は可能な限り各言語や環境が推奨するメインストリームのライブラリのみを使ってコーディングするべきだと考えています。まぁ過去のGoogleの所業を考えるとそこに意味があるのかどうかは微妙ですけどね、早くもBLoC勢の梯子外しましたし。
    もうひとつ、仕様の貧弱なDart言語で理想主義的なReduxをやってうまくワークする未来がみえないという理由もあります。そんなこんなで僕は選択肢から外しました。

  • 各要素の命名微妙では……?

    そんなこと言わせちゃってごめんな。

  • Viewでどうやって使うの?

    素直に package:provider が提供するConsumerやSelectorを使います。
    また ProLocator が実装しているように、 Behavior を取得するときはlistenをfalseにしておくことでパフォーマンスを高めることが可能です。

  • Viewのcontextにはどうやって載せるの

    素直にproviderのProviderでアレします。
    per featureで注入するのか、それともReduxのようにGlobal Topに置くか、これはアプリの性質や規模、載せるデータ量によって変わるので一概には言えません。がんばれ。
    しかしただでさえtype safetyをかなぐり捨ててる感のあるDartに乗っかっているFlutterのcontextにStoreが載ってたり載っていなかったりするのは怖いので、可能な限りGlobal Top置くのが安全なんじゃないかなーと思います。

  • 型安全性は?

    諦めろ

  • FlutterのInheritedWidget嫌いなんだけど

    わかる、わかるよ〜。諦めろ!

こちらからは以上です。


ちなみに僕はモバイル経験値が低いのでイマイチ考え方が富豪的なきらいがあるとおもいます。全部スマホが悪い。

Flutter モバイルアプリ開発バイブル

Flutter モバイルアプリ開発バイブル

*1:なのでいわゆるApplication Stateを管理する方法について、Flutter界隈にデファクトは存在しません

*2:DDD自体は言語機能がほぼJavaなDartと相性が良いとおもいます。たぶんね