ntaoo blog

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

Dartでrangeで繰り返し処理

Pythonrangeに相当する関数で繰り返し処理をする。package:quiverを使う。

pub.dev

使用例

import 'package:quiver/iterables.dart';

// 1から10まで出力
for (final i in range(1, 11)) {
  print(i);
}

Smalltalkでいう1 to: 10, Rubyだと1..10

地味にないと困る機能。標準ライブラリに入ってほしいくらいだが、将来、拡張メソッド相当の機能が言語仕様に追加されたら書き方が変わりそう。

Flutter Webの現状調査

Web特有の事情はどう解決するのかに興味があって内部構造などを調べていた。 開発が進むにつれて実装はどんどん進化して問題解決されていくだろうし、現段階のこの情報の正確性も保証しない。個人のメモを公開しているだけなので鵜呑みにはしないようにしてほしい。

あと、Preview版が公開された後にFAQが追加されているので読んでおくほうがいい。

https://github.com/flutter/flutter_web/blob/master/docs/faq.md

ニュース

まとめ

https://medium.com/flutter/a-roundup-of-flutter-news-at-google-i-o-453bb3249981

Flutter Webの開発体験とPreview版段階の技術的制約についての解説

https://medium.com/flutter-nyc/under-the-hood-with-flutter-for-web-bc0d5ce1c11e

Tech Preview

  • 現段階の一時的な措置として、Web用に名前空間を分けている。flutter_web/packages/flutter_webは、flutter/packages/flutter/からコードをコピーされている。flutter_web/lib/io.dartのように、dart.io環境を前提としたコードをwebで動かすための一時対応コードもある。

  • Flutter向けpackageは、たとえネイティブAPIとの通信をしていなくとも、FlutterとFlutter Webのpackage名が分かれているので、Flutter Webではまだ使えない。たとえばpackage:providerはFlutterに依存があるためpackageの依存解決ができない。

  • ChromeSafariは対応済みだがEdgeとFirefoxはテストカバレッジが低いので未対応という状態。

  • 現段階では、さまざまな制約が存在する。上記のNYTimesのゲームアプリの作成を通じたFlutter Webの解説記事から引用。

  • Web-specific code that requires the html package. Keeping separate web/mobile/desktop branches is tricky.
  • No native debugger. While Chrome DevTools is great, working with the IDE debugger is a superior experience.
  • Offline persistent storage that works across web & mobile.
  • Dart isolates are not supported in Flutter for web. Javascript is single-threaded.
  • Text input does not yet match platform conventions.
  • Selecting/copying of text is not yet supported.
  • The browser back button works just like the Android back button, but the forward button is not supported yet.
  • Making Flutter for web more SEO-friendly.
  • Printing with a @media stylesheet isn’t yet supported.

エンジン

Under the hood, Flutter for web draws most elements to the canvas directly except text, which is rendered by the browser

https://medium.com/flutter-nyc/under-the-hood-with-flutter-for-web-bc0d5ce1c11e

すべてのUIをFlutter Widgetで書き、Web用にcompileする。

https://css-tricks.com/the-css-paint-api/

  • flutter_web_ui/lib/src/ui/ <-> pkg/sky_engine/lib/ui/

  • flutter_web/packages/flutter_web_ui が、Webプラットフォームに依存したモジュール。ui/dart:uiのコードがコピーされて、一部にWeb対応のための改造がされている。engine/が、Web版Flutter Engine。ネイティブ版はC++Dartで書かれているもの。Web版はもちろんJavaScriptではなくDartですべて書かれている。

flutter_web_ui/libsrc/engine/dom_renderer.dart DOM操作に使用するサブセットを定義している?

  • flutter/engineへのflutter web用エンジンの統合作業が始まっている。

https://github.com/flutter/engine/pull/8891

レンダリング

  • テキスト以外はほとんどCanvasレンダリングしている。Custom ElementとCanvasのカタマリ。位置はabsolute positionやtransformなどで調整。

  • Chromeのdev toolでelementsタブを見ても構造の把握はほぼできない。代わりに、Dart Dev Toolsを開いてネイティブ開発と同様にFlutter専用の開発ツールを使う。

f:id:ntaoo:20190522151648p:plain

  • CSS Paint APIレンダリングするオプションもあるが、ブラウザーのサポート率がまだ低いので、まだ先の話だろう。

  • UIに関しては完全にFlutter frameworkによってDOM APIが隠蔽されている。AngularのDOM APIを拡張する思想とは対照的。

ジェスチャー

JavaScriptのGestureライブラリを使っているわけではなく、あくまでFlutter frameworkのGestureコード(flutter/lib/gestures.dart)をWeb用にコンパイルするようだ(実装は追っていない)。

Widgets

(プラットフォーム依存コードが含まれていなければ)既存のWidgetをそのまま再利用できる。HTML, CSS, JavaScriptのDOM APIを意識することはなく、完全に隠蔽されている。

ルーティング

Flutter WebはSPAとして動作する。History API操作を抽象化するモジュールを内部で使用している。現在は、AngularDartのルーティング関連モジュールをコピーしている。Flutterのnamed routingでコードを書くと、WebではUrlにpathがつく。paramとquery paramをハンドリングする方法は分からなかった。自分のFlutter力が足りないだけの可能性もある。

  • 最悪、Flutter Web起動時の処理としてdart:htmlを使ってparamとquery paramを取得してFlutter Webに渡せばいけるだろうが、その前にquery paramsがあるとhomeにredirectされてしまう動作を観測している。

  • History push時にquery paramsを指定したい。

  • もしparamsとquery paramsをハンドリングする仕組みがまだないならば、さすがに現段階で実用的なWebアプリを作るには制約が強すぎる。

  • 404ページにする方法もまだ調べていない。

  • 当然、<a></a>tagを書くことはできない。外部リンクを開く方法は調べていない。url_launcherのような仕組みを使うのだろうか。

  • その他、Matrix Paramsのような慣習的な記法をFlutter Webはサポートするのだろうか。

アクセシビリティ

src/engine/semantics/でFlutterのSemanticsARIAコンパイルしている。まだ発展途上のようだ。

ペイロードサイズ

  • Flutter Web Hello World サンプルアプリで146KB zip。これから開発が進むにつれて削減されていくだろう。
  • アプリが大規模になれば、AngularDartのDeferred Loadingのような起動時のペイロードサイズを削減する仕組みがWebアプリでは必要になるが、いまのところそういったものはないようだ。
  • AngularDartと同様、普通のアプリならば全体で300KBから500KBくらいになるはず。

アニメーション

まだカクカクする。改善作業中とのこと。もちろん、CSSやWeb AnimationといったWeb APIを直接操作することはない。

JavaScriptライブラリの利用

  • プラグインシステムはまだ設計段階。
  • 現段階では、dart:htmlpackage:jsを使って直接操作する。

未実装API

一部のAPIは未実装のようだ。たとえばGradient APIが未実装で、実行時にUnimplemented Errorを観測した。

SEO

  • SSRも技術的には可能そうだが、アプリケーションコードをSSR対応にするために条件分岐したりstateの引き継ぎをしたりするのはとてもダルいので、可能でもやりたくないなという感想。
  • DartをJSにコンパイルすると結局EcmaScript 5になるし、Google Search Botは最新版Chromeになることが発表されたので、レンダリングに問題は起きないだろう。とはいえ、レンダリング方法がかなりアグレッシブなので、Google Botからどう見えているのか、実際にGoogle Search Consoleなどで動作テストしてみないと怖い。
  • OGPなどのhtml headに配置するメタデータは当然サーバーでレンダリングしておく必要がある(SSRではなく。ややこしい)。
  • CDNレンダリングなど、状況は変わりそう。

構造化データ

HTMLを書けないので、Schema.orgMicrodataをどうやって指定するのか。HTML埋め込み機能が必要になるのだろうか。

所感

歴史的事情が積み重なって複雑で扱いづらいHTML, CSS, JavaScriptを、Flutter Frameworkが隠蔽して古層にしてくれる。

iOS, AndroidのネイティブアプリをFlutterで開発する際でも、プラットフォームの事情を知らなければハマることもあるので、Webでも同じようにプラットフォームの知識は必要になるが、これまでのようにWeb APIの進化に精通する必要性はだいぶ薄れていくはず。Extensible Webの思想的には喜ばしいことでは。

Flutter Frameworkが隠蔽してくれる代償として、Frameworkの抽象から漏れたWebの機能を利用しづらくなりそう。(たとえば、いま話題のPortalとか。)Web Standardへの対応は進んでいくだろうけど、それまでは生のHTMLやAngularDartなどの従来型のソリューションとの併用が必要になるはず。Flutterがすべてのユースケースに対応するかについてはまだ懐疑的だけど、長期的にはなんとかしてくれそうという期待感はある。

AngularDartは、DOMを拡張する思想のフレームワークなので、Webに特有の要求仕様に対応した詳細な制御が可能。DOMを完全に隠蔽するFlutterとは対照的。詳細な制御ができるが、HTMLやCSSを扱う必要があるので、Flutterに慣れるとそれがとても面倒に感じる。UI以外はAngularDartとFlutterで共有可能なので、AngularDartでWebアプリを書きつつ、Flutter Webの適用可能範囲が広がってくればFlutterでUIの置き換えを検討する戦略。

WebにはAndroidiOSにあるような統一されたデザインガイドラインがあるわけではない。マテリアルデザインiOSデザインの採用を拒否したり理解度が低いプロジェクトではFlutterの高い生産性を発揮しにくそう。現段階では、ペライチ用途やWebサイト用途には向いていない。まずはSPA Webアプリ向け。

Google I/O 19 Dart関連セッションの視聴メモ その2: Pragmatic State Management in Flutter (Google I/O'19)

Pragmatic State Management in Flutter (Google I/O'19)

Flutterで状態管理をする方法についてのセッション。去年も同じペアで同じテーマのセッションがあった。

https://www.youtube.com/watch?v=d_m5csmrf7I&list=PLjxrf2q8roU2no7yROrcQSVtwbYyxAGZV&index=4&t=0s

ひとつのWidget Treeしかないなどの単純なアプリの場合をのぞき、アプリの状態管理は重要な問題となる。

Agenda

  • 状態管理の重要性
  • さまざまな取り組みの推移
  • Flutterでの実践的な状態管理方法

論点

  • 理解しやすく読みやすく保守しやすいこと
  • テストしやすいこと
  • 実行性能(パフォーマンス)が高いこと

残念ながら万能の解決策はないので、あなたのユースケースのなかでエッジケースを考慮するべき。

案1: グローバル変数で状態共有

動くが、ユニットテストも保守も難しい。Widgetが強結合してしまっている。本当に単純なアプリ以外ではするべきではない。

UI = f(state)

  • UIの状態は、アプリケーションの状態を反映したもの
  • UIは他のUIの状態を直接管理しない。
  • なんらかのNotifierで状態をUIに通知する

案2: package:scoped_model

ScopedModel WidgetWidget Treeの先祖(ancestor)に配置し、その子孫(descendant)達に通知する。(現在は、後述のpackage:providerで代替できる。)

案3: BLoC

複雑なアプリ向け。Streamを活用。かなり複雑なアプローチ。

案4: package:provider

package:provideでなくpackage:provider。

package:provideは、ScopedModel version 2とでもいうべきもので、Googleからオープンソース化されたが、同時期にCommunityにより開発されたpackage:providerのほうが優れた選択肢という結論になり、package:providerが公式に推奨された。package:provideは非推奨に。

複雑なアプリを書くためには、

  • Dispose callbackでリソースの後始末をする
  • ChangeNotifierProvider、StreamProviderなどの機能を活用してレンダリングコストを抑える
  • MultiProviderで複数の依存を管理する

Single State Objectを避ける

ただ一つの巨大なState Objectは作りたくない。通常は、精緻な状態管理のためには種別ごとにComponent, Classを分ける。 Providerによってそうすることができる。 (筆者注: これはReduxの一枚岩stateを批判をする文脈ではなく、package:providerで状態を分割管理して更新する文脈)

Performance, Measure

  • Widget Treeの更新範囲をできるだけ小さくする
  • Flutter Driverでパフォーマンス測定してコードの最適化のための根拠にする。(測定なしに当てずっぽうでコードの最適化をしない)

https://medium.com/flutter-io/performance-testing-of-flutter-apps-df7669bb7df7

package:providerが万能、というわけではない

以下のような場合は単純にStateful WidgetsetState()で状態管理すれば十分で、package:providerは過剰設計。

  • とてもシンプルなアプリの場合の状態管理
  • Animation Widgetのような、ひとつのWidgetカプセル化された内部のWidget Treeの状態管理

Testing

Flutterにはheadless test frameworkが同梱されている。ひとつのテストを数ミリ秒で実行できる。

まとめ

  • 状態管理においてScoped Modelはそんなに精巧な手法ではないが、申し分なく良い手法。(筆者注: ここでいうScoped Modelは、Scoped Model version 2であるpackage:provide、そしてそれを非推奨にして代わりに推奨しているpackage:providerを指すと解釈した)

  • 詳細な状態管理が必要になりStreamとRxDartを好む場合は、BLoCパターンが素晴らしい手法。

  • Reduxのパターンに慣れている人には、Flutter向けReduxライブラリが申し分ない手法。しかし、Flutterをこれから始めるならば、package:providerで状態管理を始めることが本当に信頼できる良い選択肢である。

所感

  • 詳細な状態の制御が必要ならばBLoCが最適
  • FlutterでのReduxの採用は、Reduxに慣れていてこだわりがある人以外にはあまり推奨しない
  • いまからFlutterを始めるならば、Providerで状態管理を始めてみてBLoCパターンにも挑戦すると良い

結局はProvider + BLoCに落ち着きそう。最初からProvider + BLoCの決め打ちで状態管理をするのも十分アリだと思う。BLoCの難点は初学者にとってはStreamの学習コストが重いことだと思う。

ProviderはAngularでいう階層型Injectorと解釈できる。昔はDagger2ベースのユニバーサルDIパッケージの開発の構想があったが、実現はせずに代わりにFlutterではProviderを、AngularではAngularのDIの仕組みを使い続けることになるようだ。

UIとModelの通信には、ProviderかBLoCを状況に応じて選択という結論。Model自体の内部の設計についてはこのセッションではなにも語られていないが、主にJavaで培われてよく知られているオブジェクト指向デザインパターンおよびクリーンアーキテクチャ的な設計パターンを適用していけばよく、BLoCパターンについてもデザインパターンへの理解があるならばあとはStreamの操作方法を理解すれば習得できる。

FlutterとMVC

ここで述べられているUI=f(state)は、結局、MVCアーキテクチャを関数スタイルで言い換えたもの。Controllerを通じて状態変更のためのメッセージをModelに送り、状態変更したModelの状態をViewに反映する。PDSしてModel Driven View。WidgetView + Controller

Google I/O 19 Dart関連セッションの視聴メモ : Dart: Productive, Fast, Multi-Platform - Pick 3 (Google I/O'19)

Google I/O 19には数種類Dart関連セッションがあり、Youtubeに公開されている。その視聴メモ。まずはひとつめ。

Dart: Productive, Fast, Multi-Platform - Pick 3 (Google I/O'19)

https://www.youtube.com/watch?v=J5DQRPRBiFI&list=PLjxrf2q8roU2no7yROrcQSVtwbYyxAGZV&index=6&t=0s

Dart入門者向けののSessionだった。

  • Dart 1時代から一貫して、総合的な生産性にフォーカスして言語とライブラリ、エコシステムに投資

  • 3つの側面

    • Productive
    • Fast
    • Multi Platform

Productive

Dart - ライトウェイトなOOP言語で、関数スタイルと静的型付けをサポートした言語。

Dart 2.3。UI-as-code。ソースコードで視覚的にUIの構造を把握しやすくするための進化。Listのなかでif forを使用可能に。Spread operator。UIをより宣言的に記述可能に。

DartVM

  • CFE (Common Frond End)

    • Parser, Lexer
    • -- Dart Kernel: 型推論、型チェック、最適化
    • -- Analyzer : IDE向けの静的解析", "Analysis Server": "解析サーバー
  • Backend

    • JIT Compiler
    • RunTime
    • Debug Service

Hot reloadはDartVM前提

Hot reloadはRuntimeのStateを維持したままコードの変更をRunTimeに反映する。

Flutter CLI -> FE Server (on CFE) -> DartVM (JIT Compiler, Runtime, DebugService)

AoT compile

開発中は生産性向上のためにDartVMで動くが、デプロイ時はコードサイズの最小化、パフォーマンスの最大化のために、AoTコンパイルを行う。ネイティブコードで動作。

Dart for Web

DartはFlutter以前からWeb向けに投資を続けてきた。Google Adsなどの多くのWebアプリがDartで動作している。Google AdsGoogleの最も重要なビジネスのひとつ。何百万行ものDartコードで動いている。

  • 開発中は生産性優先のためにDevCompilerでコンパイル
  • デプロイ時はコードサイズとパフォーマンス優先のためにdart2jsでコンパイル

Web向けHot reloadは安定化にむけ作業中。

Flutter Web

DartVMのサポートが手厚いので、Flutter WebはWeb Browser向けに一部の基盤を差し替えるだけで動作。

f:id:ntaoo:20190511144100p:plain f:id:ntaoo:20190511144125p:plain

Dartの他の様々な動作環境

Non-nullable Types (NNBD)

  • 長い間ペンディングになっていた仕様。
  • Null safety。Runtime ErrorになっていたNull関連エラーをコンパイルエラーにする。
  • この破壊的変更の痛みを最小限にするための移行施策として自動migrationなどを計画中。
  • コンパイル時にnull checkが要らなくなるので、パフォーマンスとコードサイズにも大きな効果がある。

その他言語仕様の進化の計画

  • 新しいConcurrency Primitive (!!)
  • CとC++のコードをより再利用しやすくするための新しいFFI

所感

  • Dart入門者向けにDartとDartVMの魅力をプレゼンしたセッションだった。
  • 宣伝を控えてDart 2にむけた基盤整備に集中するフェーズが終わり、言語仕様の進化に再びフォーカスできるフェーズに入った
  • クライアンサイド開発のブランディングもいいけど、Google Cloud向けサポートもはやく充実させてほしい

Flutter Webが公開された / Flutter Native アプリをFlutter Webアプリに移植してみた

Flutter Frameworkの上でDartでUIを書いていくので、HTML, CSS, JavaScriptの各APIはそのframeworkに隠蔽されてそれらを直接書く必要がない。

現状の制約

段階としては、まだテクニカルプレビューなので、制約や未実装のAPIがある。


Limitations We intend to completely support all of Flutter's API and functionality across modern browsers. However, during this preview, there are a number of exceptions:

flutter_web does not have a plugin system yet. Temporarily, we provide access to dart:html, dart:js, dart:svg, dart:indexed_db and other web libraries that give you access to the vast majority of browser APIs. However, expect that these libraries will be replaced by a different plugin API. Not all Flutter APIs are implemented on Flutter for web yet. Performance work is only just beginning. The code generated by Flutter for web may run slowly, or demonstrate significant UI "jank". At this time, desktop UI interactions are not fully complete, so a UI built with flutter_web may feel like a mobile app, even when running on a desktop browser. The development workflow is only designed to work with Chrome at the moment.

https://github.com/flutter/flutter_web#

Flutter Web アプリをFlutter Native アプリから移植してみた

Flutter Create コンテスト用にHIITタイマーを1日ででっち上げてたので、それをFlutter Webに移植してみた。

  • Flutter Web用のpackageはFlutterのpackageとは(まだ)別なので、UIにそれぞれのプラットフォームごとにimportで静的な依存関係がある。だからUIコードは今のところコピペが必要になる。UIコードをそのままコピペして動いた。将来はpackage:universal_uiと切り出せるようになるだろう。

  • 一部のAPIは未実装。たとえばGradient APIについては実行時にUnimplemented Errorが出た。

  • Flutter Webでmaterial iconの設定方法をまだ調べていない。なにか方法があるはず。

  • UIにSoundPlayer関係の見苦しいビジネスロジックが散らばっているけど、これはFlutter Createコンテストに提出時に時間がなかったのでこうなっているだけで、本来はSoundPlayerをmodelにDIしてmodel内に閉じ込めるべきロジックです。あとで修正しておきたい。

  • サウンドはWebAudioで実装してHIIT Timer ModelDIしたらちゃんと鳴るはず。

  • Routingはどうやっているのか興味深い。SPAみたいだがいまのところ、History APIを操作しているわけではない?(そりゃそうかも)

  • Hot Reloadがちゃんと動いているようだ。(webdev serve auto=restart) WebでHMR体験。すごい。

もうすこし調べて内部の実装も読んでみる。

キーボードの自作欲

商業ベースのキーボードは、既存のキーボードの規格に縛られてしまい革新を期待できそうにない。 どのような技術革新が起ころうとも、おそらく一生キーボードを使い続けることになるので、キーボードを自作しても割に合う投資になるはず。自作キーボード市場が盛り上がってきているようなので、自分もいつか自作したい。 今はmacOSを前提にして入力環境をかなりカスタマイズしている。他のOSにも簡単に対応するために、そのカスタマイズをキーボード側に寄せていく。

gist.github.com

BLoCパターンにおける、AngularDartでのStreamの扱い方

BLoCパターンでModelを設計するとUIとの通信はStreamとSinkに限定される。StreamをAngularDartのComponentでlistenしてViewを更新するコードについて、迷ったりハマったりするかもしれないところを解説する。

Async Pipeを使用する際の不具合を避ける

BehaviorSubject、StreamTransformer、AsyncPipeの組み合わせで、無限ループ

問題ないケース

// Model
final aBehaviorSubject = BehaviorSubject<String>();
Stream<String> get aStream => aBehaviorSubject.stream;

// Angular Template
<div *ngFor="let element of aStream | async">{{element}}</div>

無限ループが起こるケース

BehaviorSubjectからのObservableまたはStreamStreamTransformertransformしたものを、getterAsyncPipeにパラメーターとして渡すと、無限ループが起こる。

// Model
final aBehaviorSubject = BehaviorSubject<String>();
Stream<String> get aStream => aBehaviorSubject.doOnData(print).stream;

// Angular Template
<div *ngFor="let element of model.aStream | async">{{element}}</div>

上記の例ではdoOnDataでstreamに流れる値をprintしており、これはデバッグ時によく使う手法だが、 (doOnDataに限らず、) Observableのmethodは内部でStreamTransformerで処理されている。

もちろん、package:stream_transformや、自作のStreamTransformerでも同様にこの問題が起こる。

// Model
final aBehaviorSubject = BehaviorSubject<String>();
// Stream<String> get aStream => aBehaviorSubject.stream.transform(tap(print));

// Angular Template
<div *ngFor="let element of model.aStream | async">{{element}}</div>

なぜ無限ループが起こるのか

ChangeDetectionで値を比較する際、StreamTransformerを起動することにより、毎回異なるidentityのstreamgetterで生成してしまうため。StreamTransformerは、同期的なCollection操作と同様に、副作用をおこさないために新しいStreamインスタンスを生成する。(より正確には、BehaviorSubjectからのStreamTransformerは、その内部に独自のBroadcastStreamControllerを持ち、bindによって入力Streamを元にそのBroadcastStreamController参照を保持した新たなBroadcastStreamが生成される。)

注) BehaviorSubjectBroadcastStreamControllerである。

  1. ChangeDetection -> Async Pipe transform
    1. 初回なので、そのままlisten。BehaviorSubjectのstreamなので即座に値が流れてくる
    2. _updateLatestValue -> markForCheckでChangeDetectionを起動させる
  2. ChangeDetection -> Async Pipe transform
    1. getterで新たなstreamを取得するため、以前のstreamをdisposeし、その新たなstreamでAsync Pipe transformを再帰呼び出しする
  3. Async Pipe transform(再帰
    1. getterで新たなBehaviorSubjectのstreamをlistenする。即座に値が流れてくる
    2. _updateLatestValue -> markForCheckでChangeDetectionを起動させる。そして2.1.へ戻る

どうすれば良いか

ChangeDetectionのたびにgetterによりStreamTransformerによる変換を経て新たなStreamが生成されることが問題なので、生成されたstreamをcomponentのインスタンス変数に保持しておくことで防ぐ。

// 可能ならばconstructorで処理し、finalを付ける。
Stream aStream;
ngOnInit() {
  aStream = model.aStream;
}

このほうが無駄なStreamインスタンス再生成を省略できるので、処理効率も良い。しかし、ボイラープレートコードが増えるので退屈。

Viewの更新手法が異なるFlutterのStreamBuilderでは、私が理解している限りではこのような問題は起こらず、同時により効率的な更新手法になっている。

Single Subscription Stream、StreamTransformer、AsyncPipeの組み合わせで、EXCEPTION: Bad state: Stream has already been listened to.

Single Subscription Stream Controllerから生成されたstreamををlistenできるのは一度だけである。複数回listenすると、EXCEPTION: Bad state: Stream has already been listened to.というエラーが起こる。

// Model
final aStreamController = StreamController<String>();
// Stream<String> get aStream => aStreamController.stream.transform(tap(print));

// Angular Template
<div *ngFor="let element of model.aStream | async">{{element}}</div>

なぜAsync Pipeでこのエラーが起こるのか

こちらも、上記の無限ループの挙動のように、 StreamTransformerで新たなSingle Subscription Streamが生成され、その際に入力 StreamがStreamTransformer内部でlistenされているため、上記の無限ループの問題のようにAsync Pipe内部でdispose -> listenすると、新たなStreamTransformerが以前のStreamTransformerによりlisten済みの状態のstreamをlistenしてしまい、このエラーが起こる。

どうすれば良いか

上記無限ループ問題と同様に、StreamTransformerにより変換されたStreamをcomponentのインスタンス変数に保持しておくことで防ぐ。

Component, Templateのコードをどう書くべきか

Async Pipeを使う場合と使わない場合の両方を解説する。

Async Pipeを使う場合

Async Pipeはその内部で、ComponentのonDestroy時にlistenしているstreamのstreamSubscription.cancel()が実行されるので、Componentでstreamをcancelするコードを書く必要がない。

ただ、なんとAngularDartには、*ngIfasync as構文がない。 *ngIf="aStream | async as anObject"と書けない。Objectのfieldが複数ある場合は、残念なことに、以下のような何回もlistenする冗長なコードを書かなければならない。

<div>{{(aStream | async).field1}}</div>
<div>{{(aStream | async).field2}}</div>
<div>{{(aStream | async).field3}}</div>

*ngForではAsync PipeでStreamで流れてきたListのそれぞれの要素を同期的に扱うことができるのだが、*ngForを使わない場合に複数のfieldがあるobjectをハンドリングする際は、Async Pipeを使わないほうが良いかもしれない。

もし良いやり方があれば知りたい。

Async Pipeを使わない場合

Async Pipeを使わない場合は、ComponentのDart側のコードでの対応が必要。コンストラクタやngOnInitで、BLoCのoutput streamをlistenし、そのsubscriptionをListにまとめておく。

var anObject;
final List<StreamSubscription> _subscriptions = [];

ngOnInit() {
  var aSubscription = bloc.aStream.listen((e) {
    anObject = e;
  });
  _subscriptions.add(aSubscription);
}

void ngOnDestroy() {
  print('cancel all subscriptions.');
  for (final s in subscriptions) s.cancel();
}

モリーリークを防ぐために、componentのOnDestroyですべてのStreamSubscriptioncancel()を行う。


BroadcastStreamControllerはその内部で複数のlistenerを保持できる。Listenする側がstreamSubscription.cancel()を忘れると、BroadcastStreamControllerに不要なListenerがいつまでも残ったままになる。つまり、メモリーリークする。長い時間実行される類のアプリケーションならば気にしたほうが良い。 対照的に、SingleSubscriptionStreamControllerにはメモリーリークの心配はない。


StreamはChangeDetectionの対象なので、Streamの新しい値が来てanObjectが書き換わるたびにViewが更新される。

<div>{{(anObject.field1}}</div>
<div>{{(anObject.field2}}</div>
<div>{{(anObject.field3}}</div>

このngOnDestory()でのstreamSubscription.cancel()は頻出するパターンなので、以下のようなMixinを用意すると楽ができる。

mixin CancelSubscriptionsOnDestroy implements OnDestroy {
  @protected
  final List<StreamSubscription> subscriptions = [];
  
  void ngOnDestroy() {
    // cancel all subscriptions.
    for (final s in subscriptions) s.cancel();
  }  
}

使用方法は以下。

class AComponent with CancelSubscriptionsOnDestroy implements OnInit, OnDestroy {
  AComponent(this.bloc);
  
  final ABloc bloc;
  var anObject;
  
  ngOnInit() {
    subscriptions.add(bloc.aStream.listen((e) {
      anObject = e;
    }));
  }
  
  // Override and call super only if the rest process after canceling all stream subscriptions is necessary.
  @override
  void ngOnDestroy() {
    super.ngOnDestroy();
  }
}

Async Pipeのテンプレート構文サポートが不十分なため、このようなボイラープレートコードを書くことが多くなる。また、Async Pipeを使用しても、前述のStreamTransformer使用時の注意点のように、インスタンス変数にStreamを保持するコードが必要になる。


ここまでで説明してきた配慮と使い分けが面倒に感じるならば、いっそAsync Pipeをまったく使わないという判断もありだと思う。

ComponentState Mixin

FlutterのStreamBuilderでは、私が理解している限りは、この解説でAsync Pipeについて見てきたような問題は起こらない。AngularのChangeDetectionほどの黒魔術感もなく、より効率的なView更新メカニズムだと思う。

実は、AngularDartにはComponentStateというMixinが提案されており、これはFlutterのようにsetState()を明示的に呼ぶことでViewを更新する。 ComponentStateはまだExperimental扱いなので実戦投入するべきではないが、これの発展次第ではFlutterのようなより効率的で罠の少ないView更新メカニズムが手に入るのかもしれない。

Stream Pipeを作る

いちどlistenしたstreamが入れ替わることを考慮するユースケースはおそらくないはずなので、いちどlistenしたらcomponentがdestroyされるまでstreamを入れ替えないというpipeがあればいいかもしれない。また、Async PipeはStreamとFutureの両方に対応して内部で処理が分岐しているので、Streamだけに対応したものがあればすこしだけ性能があがる。StreamPipeという名前にして、aStream | listenという構文にする。そうすれば、StreamTransformerで変換したStreamをインスタンス変数に保持するボイラープレートコードが必要なくなる。

FlutterのStreamBuilder WidgetのようなComponentまたはStructual Directiveを作る

Angular TypeScript版のようなasync as構文が無いので、pipeする回数が増えるのは退屈。

FlutterのStreamBuilder WidgetのようなComponentまたはStructural Directiveを作り、Template変数と共に使用すれば、TemplateだけでStreamを扱うことができ、ComponentのDart側のボイラープレートコードをまったく書かなくてすむようになるかもしれない。


両者とも開発コストは高くなさそうだが、今から非公式にそういうものを作るのにかなり躊躇がある。おとなしくいまのままで、ComponentState Mixinの発展やFlutter Webの成功を祈るほうが無難かもしれない。

まとめ

以上のように、Async Pipe使用時の落とし穴と、Streamを取り扱うComponentのパターンについて見てきた。

問題の根本には、Angular TemplateやWeb ComponentのHTMLといったマークアップ用外部DSLプログラミング言語のコードを連携させていく辛みがある。 Angular TypeScript版はTemplate構文が複雑化する宿命。一方、FlutterはViewがXML, HTMLのような外部DSLでなくDartコードによる内部DSLなので問題は起こらないが、Angularとは対照的に、HTML, CSSに慣れたWebデザイナーに再学習を強いる。また、もともとUIを表現することを想定されているわけではないC系の構文でUIを表現するので、Dartのシンプルな構文といえどもXMLなどよりも可読性が低くなりがち。そこで、Dart 2になってnewの記述が任意になったり、2.3でその可読性問題の痛みを緩和するための機能追加が行われた。

Angularの現在のChangeDetectionも十分にこなれており性能も良いので、このままAngularDartを使用しつつ、ComponentState、もしくはFlutterのWeb版の成功を気長に待ちたい。

Streamについての基礎知識を解説するのは負担が重いので避けたが、そういった基礎が不安な人は、以下の記事を読み、dart:asyncソースコードを読むなどしたら理解が深まると思う。

https://www.dartlang.org/articles/libraries/broadcast-streams

RxDartの実装はdart:asyncのStream関連ライブラリのラッパーとなっているので、その内部構造の理解を通じてStreamへの理解を深めていくのも面白いのではないか。

あと、UnmodifiableListView*ngForに渡すとフリーズした件、単なる私の勘違いだったか、Angularのアップデートで直ったのかはわからないが、5.2.0では問題なかった。