ntaoo blog

主にDart, Flutter, AngularDartについて書いていきます。ときどき長文。日本語のみ。Twitter: @ntaoo

Dart 2.1の概要と所感

Dart 2.1がリリースされた。

https://medium.com/dartlang/announcing-dart-2-1-improved-performance-usability-9f55fca6f31a

Flutterからのユーザーフィードバックからの改善が中心という印象。 Dart 2.0において、数年に渡り開発チームがかかりきりになっていた、型安全性を強めた型システムの安定版がリリースされたため、これからはよりマイナーな改善に注力していくと思われる。

Dart 2ではクライアントサイドのユースケースに注力していくとアナウンスされたとおり、WebアプリとNativeアプリ両方の生産性を高めていくことが強調されている。 Flutterは無事離陸できた感があるので、WebアプリとしてAngularDartのパフォーマンスと生産性の魅力がもう少し知られたら良いし、そしてもし取り組まれるならば年単位の大仕事になるだろうが、Web用Flutterの動きに期待したい。

数値リテラルの改善

FlutterユーザーからのFBベースの改善とのこと。

TextStyle(fontSize: 18.0) のdouble型リテラルとして18.0と書かなければstatic errorになっていたが、TextStyle(fontSize: 18)と、18と書いてもdouble型と認識してくれるようになった。

Mixinサポートの改善

Mixinの専用構文として、classの代わりに使用するmixinキーワードが導入された。以前は、classキーワードを用いていくつかの制約のもと、mixin applyできるclassを作成していたが、mixin用に構文が分離された。

mixin SingleTickerProviderStateMixin<T extends StatefulWidget> on State<T> implements TickerProvider {}

extends句の代わりに、on句を使用する。上記の例では、FlutterのState classのみがこのmixinを適用する事ができる制約となる。(mixin bodyでは、superでsuper classのmethodを起動できる。super.dispose();など。)

FutureStreamdart:coreから利用可能に

FutureStreamを解決するために、大多数のファイルにいちいちimport 'dart:async';と書かなくてもよくなった。 ただし、dart:asyncがすべてexportされたわけではないので、StreamControllerCompleterを使いたい場合は、いままでどおりdart:asyncのimportが必要。

パフォーマンス向上

In a few edge cases, though, the comprehensive checks added by the new type system caused an undesirable overhead of 20–40%. In Dart 2.1 we’ve greatly reduced the cost of the type checks, both for AOT-compiled code and for code run in the VM with JIT (just-in-time) compilation.

たぶん、Reified Genericsを導入した際のパフォーマンス劣化なのだろうなと想像するが、いくつかのエッジケースでパフォーマンス劣化をかなり緩和したとのこと。 また、dart2jsでもコードサイズを17%、コンパイル時間を15%削減できたとのこと。すでにかなりコンパクトなコードサイズだったので満足していたが、そこからさらにコードサイズを17%削減はすごい改善。

Protocol Buffers

Dartサポートが紹介されている。

https://developers.google.com/protocol-buffers/docs/darttutorial https://github.com/knative/docs/tree/master/serving/samples/helloworld-dart

GoやPythonなみの第一級言語としてのサポートと普及が期待できる。 はやくGCP全体でDartサポートが進んでほしい。

その他バグフィックスなどの改善

  • Flutterにおいて、コンパイル時にエラーとなるべきコードがそうならなかった不具合の修正や、いくつかのうまく動いていなかったLinterがちゃんと動くようになったなど、バグフィックスがされている。
  • dart:htmlの新し目のWebAPI、たとえばServiceWorker APIにかなりのfixが入った。あとで試してみたい。

検討中

https://github.com/dart-lang/language にて言語の改善の議論が公開されている。

普通の型安全な型システムがデフォルトになってしまったし、仕様が膨らんでいって、良くも悪くも普通の言語になっていく印象。

Optional Semicolon

KotlinのようなOptional Semicolonを検討中とのこと。 https://github.com/dart-lang/sdk/issues/30347

かなり議論が紛糾した経緯がある。 https://github.com/dart-lang/sdk/issues/30347

個人的にはStatement separatorに曖昧なルールを導入してしまうのは反対なのだが...非採用になってほしい。 そんな危険なものよりも、finalとvarをoptionalにして、そのかわりにassignemntに、finalには=を、varには:=を使う記法を採用してほしい。

AngularDartでHot Reloadが使えるようになっていた

FlutterのウリのひとつであるHot Reload、この体験がAngularDartでも可能となっていた。WebでもHot Reloading。

dartファイルの編集

f:id:ntaoo:20181020054841g:plain

htmlファイル(angular template)の編集

f:id:ntaoo:20181020054942g:plain f:id:ntaoo:20181020055032g:plain

cssの編集

f:id:ntaoo:20181020055119g:plain

これで、とくにHTMLとCSSの編集がかなり捗るようになる。

--hot-reloadオプションをつけて起動するだけで、その他の設定を変えずに有効になった。

webdev serve --hot-reload

従来のLive Reloadでは、コードの編集からブラウザのリフレッシュを経てのコードの反映までに5秒から10秒かかり、ブラウザのタブがリロードされることでランタイムの状態が一新されていたが、Hot Reloadでは0.5秒から2秒くらいで差分更新され、その他のランタイムの状態が維持される。

ただしまだ不安定な印象。手元のかなり大きなコードベースでは、ランタイムエラーがでたり、なぜかhot-reloadの動作の直後にlive-reloadの動作になりブラウザのタブがリロードされてしまう。

従来のLive Reloadでも便利なので、安定するのを気長に待ちたい。

webdev serve --live-reload

安定したら公式が大々的に宣伝をしそう。

AngularDart - UI Componentのアニメーションを維持したままページ遷移する

やりたいことは以下の動画のとおり。

f:id:ntaoo:20181018064021g:plain

ページ遷移してURLは変わるが、アニメーションは維持する。

モチベーション

Routerでページ遷移すると、対応するComponentが新たに起動する。しかし、上記の動画のように、たとえばTabの遷移に合わせてURLを変更したい場合、Component instanceが新たに起動して状態が一新されると、UI上はアニメーションがぶつりと中断してしまい不格好なUIになってしまう。アニメーションを維持したままページ遷移したい。

ソリューション

Tab1 page, Tab2 pageに対応するpathに紐つけるPageのComponentを、同一のものにする。ここでは、ATabCyclePageとする。 そして、Routerのlife cycle interfaceであるCanReuseを、Componentにimplementする。CanReuseは、Component instanceを再利用するかどうかを指定する。

class ATabCyclePage implements OnActivate, CanReuse {

  int activeTabIndex;

  @override
  void onActivate(RouterState previous, RouterState current) {
    activeTabIndex = _isTab1Page(current.path) ? 0 : 1;
  }

  @override
  Future<bool> canReuse(RouterState current, RouterState next) async {
    // Componentを再利用する条件を記述。たとえばcurrentとnextのpathを比較してUIのTab遷移に対応する状態ならばtrueを返す。
  }
}

Templateのサンプル

<material-tab-panel class="tab-panel" [activeTabIndex]="activeTabIndex" (tabChange)="handleTabChange($event)">
  <material-tab label="Tab 1">
    <template deferredContent>
      <ng-container *ngIf="activeTabIndex != null">
        <material-button raised (trigger)="navigateToTab2Page()">click to Tab 2</material-button>
      </ng-container>
    </template>
  </material-tab>
  <material-tab label="Tab 2">
    <template deferredContent>
      <!--Tab 2 Page-->
    </template>
  </material-tab>
</material-tab-panel>

Tabが変わった際のハンドラ

  void handleTabChange(TabChangeEvent event) {
    if (event.newIndex == 0) {
      _navigateToTab1Page();
    } else if (event.newIndex == 1) {
      navigateToTab2Page();
    } else {
      // throw
    }
  }

  void _navigateToTab1Page() => _router.navigate(RoutePaths.tab1
      .toUrl(parameters: {'id': _router.current.parameters['id']}));
  void navigateToTab2Page() => _router.navigate(RoutePaths.tab2
      .toUrl(parameters: {'id': _router.current.parameters['id']}));

これで、アニメーションを維持したままページ遷移が可能となった。

f:id:ntaoo:20181018064021g:plain

Firebaseでチーム開発の際のプロジェクト作成、運用について

  • ローカルコンピュータで開発する際も常時Firebaseと通信できる環境が必要。
  • ひとつのアプリに対して、(Dev x チームメンバー数) + Staging + ProductionのFirebase Projectを作る。チームメンバーが3人ならば5プロジェクト。加えて、CI用のプロジェクトも必要かもしれない。
  • Dev用プロジェクトは、可能な状況ならばチームメンバー数よりも少なくしてもよいかもしれない。しばらく使用しないならば開放して他のチームメンバーに割り当てる。

Configure Multiple Projects  |  Firebase

Dartのプロジェクトジェネレーター Stagehandを使おう

Stagehand = 舞台係。裏方。

Dartパッケージ構成の規定は詳細に定められている。

手動で作るのは辛いのでツールの力を使う。Staghandは、パッケージの雛形を生成してくれる。

https://github.com/dart-lang/stagehand

 使い方

上記のリンクにすべて書かれている。

インストール

pub global activate stagehand

パッケージ生成

mkdir package_name
cd package_name
stagehand package-simple

Angularアプリの生成

Angularアプリもパッケージである。

mkdir angular_app_name
cd angular_app_name
stagehand web-angular

コマンドラインアプリの生成

コマンドラインアプリもパッケージである。

mkdir console_name
cd console_name
stagehand console-full

サーバー、Angularを使用しないWebアプリ、StageXL(Canvas)のアプリ

すべて同じ方法で生成してくれる。

Flutter

Flutterは例外で、独自のプロジェクトジェネレーションコマンドをもつ。

flutter create a_new_project_name

Dart BuiltCollection, BuiltValueの使いかた 基本編

残念なことに、DartのコアライブラリにImmutable Collectionライブラリは無いが、BuiltCollection, BuiltValueパッケージを使えばImmutable Collectionを扱うことができる。ここでは、それらの使い方の基本を簡単に解説していく。

今回のサンプルコード

https://github.com/ntaoo/built_collection_value_practice

Mutable Collectionの問題

DartのコアライブラリのListは他のほとんどの言語と同じくmutable List。

class C {
  C(this.name);
  String name;
}

test('Standard List is mutable.', () {
  final c = C('a');
  final l1 = [c];
  final l2 = List.from(l1);

  expect(l1 == l2, isFalse, reason: 'Shallow comparison.');
  expect(l1.first == l2.first, isTrue,
      reason: 'They share the same element.');

  c.name = 'b';

  expect(l1.first == l2.first, isTrue,
      reason: 'They share the same element.');
  expect(l2.first.name, 'b', reason: 'They share the same element.');
});

https://github.com/ntaoo/built_collection_value_practice/blob/master/test/built_collection_test.dart

上記のコードのように、うっかりとmutable elementの参照値を複数のListで共有して、意図せぬ挙動に悩まされた経験はだれもがしているだろう。 Immutable CollectionとValueを組み合わせると、意図せずそのようなコードを書いてしまう心配がなくなる。

Built CollectionとBuiltValueの特徴

Built Collectionは、Immutable Collectionを提供するパッケージ。BuiltValueは、Valueを提供する。

Built Collectionは、Collectionのmutationではなく、elementの追加、変更、削除のたびに新しいCollectionを生成するため、意図せずCollectionをmutationするありがちな不具合を防げる。

また、BuiltValueと組み合わせれば、Collectionのelementまで含めたdeep comparisonが容易になり、そのパフォーマンスが良い。

さらに、変更したelement以外は変更前のListのelementの参照を引き継ぐため、効率的なコードを簡単に書くことができる。

他の選択肢

準コアライブラリとも言えるpackage:collectionにはUnmodifiableListViewがあり、生成後のmutation操作ができなくなる。また、コアライブラリのList.unmodifiableコンストラクタを使えば同様になる。しかしImmutable collectionからその一部のelementを変更したImmutable collectionを作るといった操作は提供されていない。

また、package:collectionには、elementがValueでなくともコアライブラリのCollectionの同一性を確かめるライブラリが提供されている。

BuiltCollection, BuiltValueのようなImmutable Collection / Valueを必要としない、またはコアライブラリのCollectionを使用するべき理由があれば、こちらの選択肢を検討する。

名前の由来

Builder patternで表現しているため、Built Collection, Built Value

他言語におけるImmutable Collection

Mutable collectionに加えてimmutable collectionも提供している言語は、Scala, C#(.NET), Java(Guava), Racket、等々。

Haskellはimmutable collectionしか提供していない。PHP, Python, Ruby, JavaScriptなどのスクリプト言語では自分が知る限り提供されていない。

BuiltCollection, BuiltValueの基本的な使い方

Built CollectionにおけるImmutable ListであるBuiltListを例に、BuiltCollection, BuiltValueの使い方を紹介する。

class C {
  C(this.name);
  String name;
}

test(
    'BuiltList provides deep comparison, but the non BuiltValue elements can still be mutable.',
    () {
  final c = C('a');
  final l = [c];
  final l1 = BuiltList<C>(l);
  final l2 = BuiltList<C>.from(l);

  expect(l1 == l2, isTrue, reason: 'thanks to the deep comparison.');
  expect(l1.first == l2.first, isTrue, reason: 'they share same element.');

  c.name = 'b';

  expect(l1.first == l2.first, isTrue, reason: 'they share same element.');
  expect(l2.first.name, 'b',
      reason:
          'mutated, because they share the same mutable element. NOT recommended.');
});

https://github.com/ntaoo/built_collection_value_practice/blob/master/test/built_collection_test.dart

生成

final c = C('a');
final l = [c];
final l1 = BuiltList<C>(l);
final l2 = BuiltList<C>.from(l);

コンストラクタが用意されている。

比較

expect(l1 == l2, isTrue, reason: 'thanks to the deep comparison.');
expect(l1.first == l2.first, isTrue, reason: 'they share same element.');

Mutable Listとは異なり、==でdeep comparisonができている。ただし、

c.name = 'b';

expect(l1 == l2, isTrue, reason: 'they share the same element.');
expect(l1.first == l2.first, isTrue, reason: 'they share the same element.');
expect(l2.first.name, 'b',
    reason:
        'mutated, because they share the same mutable element. NOT recommended.');

cがmutableなelementである場合、BuiltListでも同一判定されるにもかかわらず、実際はmutationしてしまう。そこで、BuiltValueと組み合わせることが推奨されている。

BuiltCollectionをBuiltValueと組み合わせる

test('BuiltList can be deeply immutable with built values.', () {
  SimpleValue buildValue() {
    return SimpleValue((b) => b
      ..anInt = 1
      ..aString = 'a'
      ..aListOfStrings = ListBuilder<String>(['one', 'two', 'three']));
  }

  final l1 = BuiltList<SimpleValue>.from([buildValue()]);
  final l2 = BuiltList<SimpleValue>.from([buildValue()]);

  expect(l1 == l2, isTrue, reason: 'thanks to the deep comparison.');
  expect(l1.first == l2.first, isTrue, reason: 'value comparison.');

  final l3 =
      l2.rebuild((b) => b.first = b.first.rebuild((b) => b..aString = 'b'));

  expect(l2 == l3, isTrue);
  expect(l1 == l2, isFalse, reason: 'l2 was rebuilt.');
  expect(l1 == l3, isFalse, reason: 'l3 is l2.');
  expect(l2.first.aString, 'b');
  expect(l3.first.aString, 'b');
});

SimpleValueはBuiltValue。

l2をrebuildし、そこでvalueをrebuildしている。期待通りl1 == l2がfalseとなった。

rebuildで変更

final l3 =
    l2.rebuild((b) => b.first = b.first.rebuild((b) => b..aString = 'b'));

expect(l2 == l3, isTrue);
expect(l1 == l2, isFalse, reason: 'l2 was rebuilt.');
expect(l1 == l3, isFalse, reason: 'l3 is l2.');
var newList = list.rebuild((b) => b
    ..add(4)
    ..addAll([7, 6, 5])
    ..sort()
    ..remove(1));

BuiltCollectionのインターフェース

test('BuiltList is not a List, but an Iterable', () {
  expect(BuiltList<int>([1]), isNot(isList));
  expect(BuiltList<int>([1]) is Iterable, isTrue);
});

BuiltListにList Interfaceがない。add, addAllなどのListの変更のためのメソッドは提供されていない。 BuiltListにInterable Interfaceはある。

test('BuiltList can return self as standard Immutable List', () {
  final list = BuiltList<int>([1, 2, 3]).asList();
  expect(() => list.add(4), throwsA(TypeMatcher<UnsupportedError>()));
  expect(() => list.first = 4, throwsA(TypeMatcher<UnsupportedError>()));
});

ただし、コアライブラリListを返すことはできるが、変更を試みるとランタイムエラーが発生する。

BuiltValue

生成

SimpleValue build() {
  return SimpleValue((b) => b
    ..anInt = 1
    ..aString = 'a'
    ..aListOfStrings = ListBuilder<String>(['one', 'two', 'three']));
}

コンストラクタでクロージャを使って、メソッドカスケーディングで値を組み立てる。 各fieldの指定漏れはランタイムエラーとなる。標準のfinal field指定では静的解析の支援を受けられたが、BuiltValueでは受けられない。 Filedの数が10以上に増えてきた場合は、別途factory named constructorを用意して静的解析できるようにすることも検討したらよい。

BuiltValueのfieldにはデフォルトではnullを許容しない。Non Nullable By Default。 @nullable annotationを付与するとnullを許容する。

比較

test('It is a value.', () {
  final s1 = build();
  expect(s1.anInt, 1);
  expect(s1.aString, 'a');
  expect(s1.aListOfStrings, ['one', 'two', 'three']);
  expect(s1.aListOfStrings, BuiltList<String>(['one', 'two', 'three']));

  final s2 = build();
  expect(s1, s2);
});

BuiltValueはValueなので、deep comparisonで同一object判定される。 言うまでもなく、BuiltValueでない通常のobjectの比較では、異なるobjectと判定される。

rebuildで変更。

test('can rebuild', () {
  final s1 = build();
  final s2 = s1.rebuild((b) => b
    ..anInt = 2
    ..aString = 'b');

  expect(s1 == s2, isFalse);
  expect(s2.anInt, 2);
  expect(s2.aString, 'b');
  expect(s2.aListOfStrings, BuiltList<String>(['one', 'two', 'three']),
      reason: 'it still holds the privious value.');
});

BuiltValueのNest

group('NestedValue', () {
  SimpleValueContainer build() {
    return SimpleValueContainer((b) => b
      ..simpleValue.aString = 'a'
      ..simpleValue.anInt = 1
      ..anInt = 1);
  }

  test('can build nested values.', () {
    final c1 = build();
    expect(c1.anInt, 1);
    expect(c1.simpleValue.anInt, 1);
    expect(c1.simpleValue.aString, 'a');
  });

  test('can rebuild nested values.', () {
    final c1 = build();
    final c2 = c1.rebuild((b) => b
      ..simpleValue.anInt = 2
      ..simpleValue.aString = 'b');
    expect(c1 == c2, isFalse);
    expect(c2.anInt, 1, reason: 'it still holds the previous value.');
    expect(c2.simpleValue.anInt, 2);
    expect(c2.simpleValue.aString, 'b');
  });
});

BuiltValueのfieldがBuiltValueの場合、コンストラクタ引数のクロージャの中では、またクロージャを指定せずとも、

SimpleValueContainer((b) => b
  ..simpleValue.aString = 'a'
  ..simpleValue.anInt = 1
  ..anInt = 1);

のように書ける。rebuild()でも同様。

Serialization, Deserialization.

BuiltValueにはSerialization, Deserializationのストラテジが用意されており、StandardJsonPluginを組み合わせるとJSON string互換のMapと変換できる。

https://github.com/ntaoo/built_collection_value_practice/blob/master/lib/serializers.dart

test('can serialize to json Map.', () {
  final s1 = build();
  final result = standardSerializers.serialize(s1);
  expect(result is String, isFalse);
  expect(result is Map, isTrue);
  expect((result as Map)['\$'], 'SimpleValue');
  expect((result as Map).keys.toSet(),
      Set.from(['\$', 'aString', 'anInt', 'aListOfStrings']));
  expect((result as Map)['aListOfStrings'], isList);
});

test('can serialize to json Map, with familiar format.', () {
  final s1 = build();
  final result = standardSerializers.serializeWith<SimpleValue>(
      SimpleValue.serializer, s1);

  expect(result is String, isFalse);
  expect(result is Map, isTrue);
  expect((result as Map).containsKey('\$'), isFalse);
  expect((result as Map).keys.toSet(),
      Set.from(['aString', 'anInt', 'aListOfStrings']));
  expect((result as Map)['aListOfStrings'], isList);
});

メンテナンス

BuiltCollection, BuiltValueはsource_genでソースコードジェネレーションしているので、使用の際にはbuildが必要となる。

pub run build_runner build

ファイルの変更を検知して自動的に再ビルドする場合は、watchを使用する。

pub run build_runner build watch

Flutterの場合は、flutter packagesを付加する。

flutter packages pub run build_runner build

ユースケース

  • RPCのデータ送受信
    • Http request/response
    • Firestore request/response
  • UIでの表示のためのModelからのデータ送信
  • 並列プログラミング
  • その他、高頻度でListをmutationしパフォーマンスが必要なユースケース以外のすべて

欠点

  • 構文の簡潔さはList literalにはもちろん及ばない。
  • 一般的ではない(not familiar)ため、習得コストがかかる。
  • コアライブラリではない。したがって、開発したライブラリの公開APIには代わりにIterableを指定できないか検討する。BuiltListはIterableインターフェースを実装している.
  • 高頻度でCollectionの中身を書き換えたい場合はMutable Collectionよりもパフォーマンスが悪い。

したがって、初心者向けではないかもしれないかもしれない。それに、我々はMutable Listの動作に慣れきっているので、いきなりImmutable Listを強制すれば混乱して生産性が落ちそうだ。 しかしImmutable Listを第一の選択肢とすれば、つまらない不具合に悩ませられる機会が大幅に減るだろう。

いつかはImmutable Listがコアライブラリに取り込まれてほしい。

BLoCパターンとはなにか - FlutterとAngularの間でModelのコードを再利用する実践を通じての考察

ここでは、BLoCパターンのガイドラインを参照し、それに基づきデモアプリを作成し、その解説を通じてBLoCパターンの考察を行う。 今までも何度かMeetup等でBLoCパターンの解説を行ってきたが、あらためてこのブログ記事にまとめておく。

BLoCパターンとは

BLoCは、Business Logic Componentの頭字語(acronym)で、状態管理に関するアーキテクチャパターン

f:id:ntaoo:20181008064919p:plain

以下、https://www.youtube.com/watch?v=PLHln7wHgPEより、BLoCガイドラインを引用する。

Business Logic Component(BLoC)のガイドライン

状態を管理するBLoCに以下の制約を課す。

  1. インプットとアウトプットは、単純なStreamとSinkに限定する。(Inputs and outputs are simple Streams/Sinks only.)
  2. 依存性は、必ず注入可能でプラットフォームに依存しないものとする。(Dependencies must be injectable and platform agnostic.)
  3. プラットフォームごとの条件分岐は、許可しない。(No platform branching allowed.)

上記の制約を守れば、どのような実装でも構わない。(Implementation can be whatever you want if you follow the previous rules.) ただし、reactive programmingを推奨したい。(But may I suggest reactive programming?).

UIのガイドライン

BLoCのインプット、アウトプットを利用するUIクライアントは以下のガイドラインに従うべきである。

  1. 「十分に複雑な」UIコンポーネントひとつひとつが、対応するBLoCをもつ。(Each "complex enough" component has a corresponding BLoC.)
  2. UIコンポーネントは、インプットをBLoCに「そのまま」送るべきである。(Components should send inputs "as is".)
  3. UIコンポーネントは、BLoCからのアウトプットをできるだけ「そのまま」表示するべきである。(Components should show outputs as close as posssible to "as is".)
  4. すべての条件分岐は、単なるBLoCからの真偽値のアウトプットを元にするべきである。(All branching should be based on simple BLoC boolean outputs.)

今年の1月のDart Confで初出。

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

そして、今年6月のGoogle I/Oで紹介されたことで広く知られるようになった。

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

AdWordsAdSenseで実戦投入されている

Googleの利益の大部分を稼ぎ出すミッションクリティカルなサービスであるAdWordsAdsenseのアプリは、AngularDartおよびFlutterで構築されており、それらのアプリで採用されているBattle Testedなアーキテクチャパターンであるとして権威づけがされている。 従来は、Webアプリ、Androidアプリ、iOSアプリそれぞれで、異なる言語で三回も同じロジックを書かざるをえなかったが、このパターンによりAngularDartとFlutterでロジックを共有することで一回ですむようになったとのこと。

Angular (Dart)

以下、AngularDartを単にAngularと呼称する。Angularは複雑な歴史を経て現在はTypeScript版とDart版がそれぞれ別のフレームワークとしてメンテナンスさている。だが、もともとは同一のフレームワークとして開発されてきたため、両者はいまだに多くの概念を共有している。そのため、どちらか一方を使用した開発経験があれば、その知識を活かしてもう一方にもさほど苦労せずに慣れることができるだろう。

TypeScript版Angularについてはここでは言及しないが、BLoCパターン自体は、TypeScript版Angularはもちろん、Streamに依存できるならばどのような環境でも適用可能だ。

BLoCパターンの実践

上記BLoCパターンのガイドラインに基づき、デモアプリとしてごく簡単なチャットアプリをFlutterとAngularの両方で作成した。その解説を通じてBLoCパターンを考察していく。

サンプルアプリのソースコード

https://github.com/ntaoo/bloc_chat

仕様

  • ユーザーは、アプリの起動時にAnonymousUserとして自動サインインする。
  • ユーザーは、チャットルームを作成できる。
  • ユーザーは、チャットルーム一覧から特定のチャットルームに入室できる。
  • ユーザーは、チャットルームでメッセージを追加できる。また、自らのメッセージのみ編集と削除ができる。
  • メッセージ表示は、内容、および作成日時をフォーマットしたものから構成される。作成日時は、本日のものならば時刻のみ表示する。
  • メッセージは、それらの追加/編集/削除の結果がリアルタイムで各クライアントに通知され、UIが書き換わる。

バックエンドとしてFirestore, Firebase authを採用している。 デモアプリのため、やや乱暴だがアプリ起動時にAnonymousUserとして自動サインインする仕様としている。

f:id:ntaoo:20181008070005p:plain

f:id:ntaoo:20181008070037p:plain

Model

Modelを共通化するので、package:modelを作成し、Angular, Flutterで利用するChatModelの公開インターフェースを定義している。

https://github.com/ntaoo/bloc_chat/tree/master/model/lib

なお、modelをDartのpackageとしているので、ここで解説されている型に従っている。

Chat library

model packageにchat libraryを定義した形となる。chat libraryはおおよそ、room, message, authというビジネスロジック、そしてfirebase backendと通信するrepository層から構成されている。

BLoCを使用したパターンと使用しないパターンを比較するために、roomに関してはBLoCを作成せず、逆にmessageに関しては作成している。

MessagesBloc

UIクライアントとのインターフェースとなり、状態を管理するfacadeとなる。

*ソースコード

インプットSink、アウトプットStream

  // Input signals
  Sink<String> get addMessage => _addMessageController.sink;
  Sink<String> get startEditingMessage => _startEditingMessageController.sink;
  Sink<Null> get cancelEditingMessage => _cancelEditingMessageController.sink;
  Sink<String> get updateMessage => _updateMessageController.sink;
  Sink<String> get deleteMessage => _deleteMessageController.sink;

  // Output streams
  Stream<UnmodifiableListView<MessageView>> get messages =>
      _messagesSubject.stream;
  Stream<String> get newMessageContent => _newMessageSubject.stream;
  Stream<bool> get isEditingMessage => _isEditingMessageSubject.stream;
  Stream<String> get editingMessageContent =>
      _editingMessageContentSubject.stream;

このBLoCの公開インターフェースは、ガイドラインに従い、インプットはSink、アウトプットはStreamに限定している。(Dartでは、classのmember名が"_"(アンダースコア)で始まっていればprivateとなり、それ以外はpublicとなる。)

クライアントは、Input signalsとコメントをつけているSinkにデータを追加し、BLoCはそれをlistenしてハンドラを起動し、状態変更した結果をOutput streamsに追加する。クライアントはそのstreamをlistenし、UIを更新する。

DI

  MessagesBloc(AuthService authService, MessagesCollection messagesCollection)
      : _messagesService =
            MessagesService(authService.currentUser, messagesCollection) {
    _addMessageController.stream.listen(_addMessage);
    _updateMessageController.stream.listen(_updateMessage);
    _deleteMessageController.stream.listen(_deleteMessage);
    _startEditingMessageController.stream.listen(_startEditing);
    _cancelEditingMessageController.stream.listen(_cancelEditing);

    _messagesService.onMessagesChanged.listen(_emitMessages);
  }

「依存性は、必ず注入可能でプラットフォームに依存しないものとする。」という制約に基づき、依存性をコンストラクタで注入している。

AuthService、MessagesCollectionはバックエンドのfirebase auth, firestoreとそれぞれ通信する責務をもつ。ただし、firebaseはもちろんwebとnativeで実装が異なるため、依存性逆転の原則に従い、抽象化したInterfaceを定義している。このあたりはJavaのバックグラウンドがあれば馴染み深いだろう。(*注 Dartでは各classに暗黙的にinterfaceが定義される。)

abstract class MessagesCollection extends FirestoreCollection<MessageDocument> {
  Stream<List<MessageDocument>> get onAdded;
  Stream<List<MessageDocument>> get onUpdated;
  Stream<List<MessageDocument>> get onDeleted;
  Future add(DocumentAddRequest<MessageDocument> request);
  Future update(DocumentUpdateRequest<MessageDocument> request);
  Future delete(String documentId);
}

厳密には、Firestoreの用語であるCollectionをBLoC内部に持ち込むべきではないかもしれないが、現状でFirestoreから他のインフラに移行することはまず考えられないため、妥協してCollectionという用語を採用している。また、将来、ユースケースの追加によってより多様なQuery APIなどが必要になれば、Repository classを追加してCollectionに依存する形でユースケースごとのメソッドを追加していけば良い。

「プラットフォームごとの条件分岐は、許可しない。」を守るため、Repositoryパターン等を活用し、プラットフォームに依存するコードがBLoC内部に入り込まないように注意する。

DIライブラリ

FlutterとAngularで共通で使用できる信頼できるライブラリがあればよいが、現状では選択肢に乏しいため、このデモアプリでは以下の選択をしている。

  • Angular: AnguarのDIの仕組みをそのまま使用する。
  • Flutter: InheritedWidgetを使用してService locator patternで依存性を管理し、手動でコンストラクタインジェクションコードを書く。

Flutterにおける他の可能性としては、

があるが、プロジェクトが若い、またはExperimental扱いであるため、これらのライブラリに依存するにはリスクがあることを認識しておく。

なお、Angular 5からmodel calssに@Injectable()アノテーションが必要がなくなり、Angularのimport文を通じた静的な依存関係がなくなったため、プラットフォームから独立したpackageだけに依存したmodel componentの作成が用意になった。

インプット、アウトプットのデータはas isで

2. UIコンポーネントは、インプットをBLoCに「そのまま」送るべきである。(Components should send inputs "as is".)
3. UIコンポーネントは、BLoCからのアウトプットをできるだけ「そのまま」表示するべきである。(Components should show outputs as close as posssible to "as is".)

上記ガイドラインに基づき、アウトプットデータをそのままUIで表示するため、MessageのView Modelを定義する。

ここにおけるView Modelは、MVVMアーキテクチャにおけるView Modelとは定義が異なり、ただのimmutableなデータ構造にすぎない。この用語はClean Architectureから借用している。クラス名の接尾辞としてViewをつけると良いだろう。

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

@Immutable('View model should be immutable')
class MessageView {
  factory MessageView(
      String id, String content, bool isEditable, DateTime createdTime) {
    return MessageView._(
        id, content, isEditable, _formatCreatedTime(createdTime));
  }

  MessageView._(this.id, this.content, this.isEditable, this.createdTime);

  final String id;
  final String content;
  final bool isEditable;
  final String createdTime;

  static String _formatCreatedTime(DateTime createdTime) {
    if (createdTime == null) return '';

    if (createdTime.day == DateTime.now().day) {
      return DateFormat.Hm().format(createdTime);
    } else {
      return DateFormat.yMMMMEEEEd().format(createdTime);
    }
  }
}

"createdTime"はBLoC内部ではDateTimeとして扱うが、UIにわたす際はas isで表示させるため、Stringにformatしている。もし国際化する場合もBLoC内部で国際化モジュールを用意することになる。

UIで、メッセージが編集可能かどうかの表示を条件分岐させるため、"isEditable"を定義している。ビジネスロジックとしては、ログインユーザーのidがmessageのuidと一致しているかで判定するが、このロジックをUI側に露出させないように注意し、結果だけisEditableに格納して返すようにする。

  MessageView _toViewModel(MessageDocument message) => MessageView(
      message.id,
      message.content,
      _messagesService.isEditableMessage(message),
      message.createdTime);

今回は単純な仕様なので、プライベートメソッドでMessageDocumentをMessageViewに変換しているが、仕様が複雑になればBLoC内部にViewModelを生成する責務をもつPresenterを抱えることになるだろう。

Angularのpipe、たとえばビルドインされたdate pipe等の使用については、BLoC内部に閉じ込めるべきロジックがAngular側に露出していることを意味するため、そのような用途では使用するべきはない。 BLoCパターンを抜きにしても、Pipeはささやかなデータ変換を手軽に実行する用途には便利だが、代償としてmodelと比較してテストによりコストがかかるviewがさらに複雑化してコストが高まるため、使用にはかなり慎重になるべきだと思う。Async pipeを除いて、通常はpipeを使用することはあまりないのではないか。

(* 本筋から離れるが、これでViewModelをBLoC内部の状態を表すModelと峻別できたが、開発が進めば、そのModelとFirestore DocumentのEntityも峻別することになるだろう)

BLoCへのインプットについて

MessagesBLoCのインプットを再掲する。

  // Input signals
  Sink<String> get addMessage => _addMessageController.sink;
  Sink<String> get startEditingMessage => _startEditingMessageController.sink;
  Sink<Null> get cancelEditingMessage => _cancelEditingMessageController.sink;
  Sink<String> get updateMessage => _updateMessageController.sink;
  Sink<String> get deleteMessage => _deleteMessageController.sink;

インプットデータのハンドリングもBLoCの責務である。たとえば、今回の仕様からは外れるが、入力値の検証、NGワード判定、自動翻訳、アノテーションの付与などが考えられる。

void function(args)の誘惑

アウトプットをStreamに限定するべきなのは自明だが、インプットをSinkに限定するべきかは異論があるかもしれない。

MessagesBLoCのインプットを見ると、Sinkでなくvoid functionにしてそれを呼び出すほうが簡潔になりそうだ。voidであればUIがBLoCAPIと密結合になる事態も防げそうだ。そしてSinkを管理するStreamControllerも削減できる。実際、このデモアプリの仕様ではなんの問題もでなさそうだ。

では、なぜBLoCパターンではインプットをSinkに限定することに議論の余地はないと言い切っているのか。

それは、UI、そしてUIに限らず環境からはStreamを通じてデータが渡されるため、BLoCのインプットもSinkにしておくのが自然なこと、そしてインターフェースの統一によるcomposabilityの維持が理由だと考える。

Dartではかなり初期からコアライブラリでStreamをサポートしており、あらゆるライブラリがそれに依存しているため、データをStreamで扱うのにとても都合が良い。マウスクリックやテキストインプットなどのユーザー操作イベント、HTTPレスポンス等あらゆるイベントがStreamに依存している。したがって、それをBLoCにpipeするのもとても自然な操作になる。つまり、主にユーザーのUI操作のイベントStreamを直接BLoCに流し込むことを想定しているのだろう。

また、BLoCをネストしてインプットStreamを別のBLoCに流し込む、または同じBLoCの別のSinkに流し込むなどが考えられる。そういったユースケースでstreamの取りあつかいが単純になるため、composabilityを維持できる。

BLoCをDCIアーキテクチャのContextのようなものと捉えれば、複雑なビジネスロジックを処理するにはBLoCのネストが有効であると考えられ、インプットとアウトプットをSink/Streamに限定する単純さは大きなメリットになりそうだ。

つまり、この制約を遵守することで、BLoCがコミュニケーションするUI、インフラ、バックエンド、そして他のBLoCなどの各コンポーネント間のデータの受け渡しをStreamに統一したアーキテクチャにできる。

また、将来、BLoCパターンをサポートするライブラリがでてきた場合も障害なく導入できるだろう。

したがって、現在の仕様から見えている範囲で判断して、UIからvoid function callするほうがひとつのstreamを管理するコストが減らせたとしても、BLoC設計の単純さ(Simplicity)によるComposabilityを損なうため、誘惑に抵抗し、インプットにvoid functionを混ぜるのは止めておくべきだと考える。

複雑なフォームをどう取り扱うか?

UI側のフレームワークがフォームのハンドリングのサポートをしている場合、バリデーションなどのロジックがBLoC内部と外部に分散してしまう事態が出現してしまいやすい。この問題への対策は、フォームのUI部分はフレームワークに任せ、入力値の検証ルールをBLoCから提供し、UIからのフォーム入力結果を同じ検証ルールであらためて検証するといったことが考えられる。

また、フォームの状態管理がUIのフォームmodelとBLoC内部のmodelの二重管理になってしまう危険も考えられるが、このハンドリングに関しては、たとえばngrxがAngular Formをどのようにバインディングして同期しているかが参考になるかもしれない。

UIにおけるアウトプットStreamのハンドリング

FlutterではStreamBuilderを、AngularではAsync Pipeを使えば良い。理想的な設計では、BLoCからのアウトプットstreamをそれらにそのまま渡すコードになっているはずだ。

BLoCパターンを使わない例: Rooms

チャットルームの一覧、作成を行う機能は、BLoCパターンを使わずにそれぞれのアプリでCollectionからのgetとViewへの反映のコードを直接記述している。

以下はAngular側のコードになる。

    _roomsCollection.onAdded.listen((e) => rooms.insertAll(0, e));
  Future addRoom() async {
    if (newRoomName.isEmpty) return;

    adding = true;

    await _roomsCollection.add(RoomDocumentAddRequest(name: newRoomName));

    newRoomName = '';
    adding = false;
  }

ごく簡単なコードなので問題にならないかもしれないが、BLoCパターンではBLoC内部に閉じ込められるコードが露出しているのは確かだ。そして、Flutter側でも同様のコードを書いて仕様を維持する必要がある。

要はSmartUIにするかModel Driven Viewにするか、という選択の問題になる。

BLoC内部の設計をどうするべきか

BLoCパターンでは、BLoC内部の設計は自由としている。(ただしリアクティブプログラミングを推奨している)

個人的な指針としては、Clean ArchitectureやDDDで広く敷衍されている設計手法に従って、BLoCをfacadeにしたコンポーネントを作成し、内部にドメインロジック(ビジネスロジック)を構築していくのが良いと考える。

BLoCパターンにライブラリやフレームワークは必要か

BLoCパターンは、Fluxのようにフレームワークが乱立する状況にはなっていないし、なるべきではない。フレームワーク化する意義も今のところは見いだせない。一般的に、アプリケーションが状態管理フレームワークに依存する事態は、たとえReduxのような大人気でエコシステムが繁栄してデファクトスタンダードに近いものであっても避けるべきだ。

BLoC内部ではもちろんReduxなどのフレームワークに依存するべきではない。たとえ内部で依存しなくとも、BLoCを採用するならば他のReduxのような状態管理フレームワークは役割が重複するため、ひとつのアプリケーションに同時に導入する意味はない。

ただし、Stream管理のボイラープレートを減らすライブラリは導入の余地がある。StreamChannelを採用してStreamの管理をまとめてBLoC内部で疑似ルーティングを構築するなどが考えられる。しかしまずはBLoCの責務を分割できないかを検討するべきだ。

BLoCパターンの利点

  • 環境に依存するUI、およびインフラと、依存しないModelの間に明確な境界線を引くことができる。
  • BLoCは環境に依存せず、代わりにインターフェースをDIする制約によりテスタビリティを維持できる。
  • いかなるフレームワークにも依存せずに状態管理する指針となる。
  • Modelが独立してコンパイルできる。

  • BLoCパターンに厳密に従うことにより、WebアプリとネイティブアプリのModelのコードを共通化することで、Webアプリ、ネイティブアプリ間のmodelの仕様と実装の不一致やひとつのプラットフォームだけでmodelのロジックのバグを作り込むリスクなくすことができる。

BLoCパターンの欠点

MVVM, Flux, DDD, Clean Architecture等々のアーキテクチャパターンと同じく、ボイラープレートが増えるため、簡単な機能要件であることが確定している場合は適さない。 簡単なアプリは、Angularのチュートリアルのように、ViewController中心にロジックを書くほうが見通しがよく、難易度もコード量も抑えられて早く完成するだろう。

結論

BLoCパターンは、Streamの制約を除けば、ソフトウェア開発において見慣れた普遍的なプラクティスばかりであると気づくはずだ。

BLoCパターンは特に目新しい概念ではなく、Clean Architectureの要点ととても似ている。異なるところは、Input, OutputをStreamに限定しており、Clean Architectureほどの詳細なルールや用語はないことだ。BLoC内部の設計については自由としている。

BLoCパターンは、複雑なシステムの開発においてあるべきアーキテクチャの輪郭について、少数の明確なルールとともに名前をつけてくれたことにより、説明しやすくなったことに大きな意義があるのではないかと思う。

このパターンは、言うまでもないが、ボイラープレートのコード量がスマートUIパターンのものと比べると増加する。そのため、簡単なアプリケーションの場合、または開発チームの経験値が低い場合には適用すべきではない。

逆に、機能要件が複雑で長期間メンテナンスしつづけることが予想される場合は最初から採用を検討する。例えば、FlutterアプリとAngularアプリで共通の振る舞いが多い場合は、第一に検討する。片方しか開発する必要がない場合でも、ある程度複雑なアプリケーションならば同様。

BLoCパターンは、Flutter, Angular以外でもStreamに依存できるならば適用可能なので、他のアーキテクチャパターンとともに検討の俎上に上げると良い。