ダイナミックな動作体験を実現するための、Streamを用いたBLoC連携

この記事はnote.comからの転載です。

https://note.com/yamarkz/n/n7f9106e53179

Flutterでの開発に取り組み始めて早半年が経ちました。

初めはわからないことも多くありましたが、色々と試行錯誤を重ねたおかげで、今では勘所を掴んだ良い実装に取り組めているなという実感があります。もちろん、まだまだ理解が浅い部分もあるのですが、それでも昔とは違った見方で実装を捉えられる様になりました。

本記事ではタイトルにもある通り、"Streamを用いたBloc連携" というトピックについて紹介します。

Link

これは筆者がFlutterでの開発初期に "知っておきたかったこと" の上位にくるトピックです。内容のレベル感としては、応用の分類に入ると思います。 極めて実戦的で、有用な内容であると思っているので、Flutterでの開発に少しでも興味のある方は、是非読んでみてください。

前提

前提として、BLoC Patternを用いたFlutterアプリケーションを想定しています。BLoC Patternってなに?という方は、以前書いた下記の記事を先に読んでいただいてから内容に踏み込んでもらうと、より理解が進むと思います。

話の題材 : DroidKaigi App 2020

今回は内容の具体度が高いので、理解を進めやすくするために、 話の題材となるアプリケーションを用意しました。

来る、2/20, 2/21にDroidKaigiが開催されます。

執筆後 追記

DroidKaigi 2020はコロナウィルスの影響により、2/17時点で中止となっています。

Link

DroidKaigiでは毎年カンファレンスアプリを有志が作成していて、以前からとても良いなと、外野ながら思っていました。(筆者は今年が初参加)

また、アプリの枠を越えて、主催者の方々がオリジナルのクラフトビールを麦芽から 作っているそうで。まじか、、、やべぇな... (最高か) と思っています。🍺 ちなみに、僕はペールエールが好きです。

Link

話が逸れましたが、今回は記事内容の理解を進めやすくするために、筆者が話の題材としてDroidKaigiのアプリをFlutterで自作しました。

ちょうど内容を文章に落とそうとしたタイミングと、カンファレンス開催のタイミングが近く、半ば乗っかり(便乗) で作成した次第です。

画像3

トップ画面

この自作したアプリは非公式 であり、公式のアプリはこちらで実装が進められています。

公式では素晴らしいオーガナイザー、エンジニア、デザイナーの方々が開発を進めており、今回題材のアプリを作る上でとても参考にさせてもらいました。ありがとうございます! 🙏

一応補足しておきますと、カンファレンスの画像などを利用させていただいているのですが、商用利用はなく、改変などもしていなので問題ないという認識です。(参考) もし何か問題などあれば連絡いただきたいです。🙏

題材のアプリは、趣味レベルの開発に留めており、Google Play StoreやAppStoreなどには配信していません。なお、コード自体はGithub上にオープンソースとして公開しているので、下記リンクよりコードを読むことができます。

Link

"趣味レベル" とは言ったものの、実装自体はBLoC Patternを用いて実用性を意識した実装 にしており、先に紹介した「BLoC Patternの全体像」の記事内容の多くを踏襲しています。なので、記事内容と合わせて今回の実装を読み解いていただけると、よりBLoC Pattern自体の理解が深まると思っています。


以前のブログを公開後に、SNS上でたくさんの反応をいただきました。 公開から数ヶ月経つ今でも、シェアをしてくださったり、コメントをいただいたりと、ナレッジを公開した身としてはとても嬉しい限りです。🙌

ただ、反応をいただく中で、「概念や仕組みの良さは理解できるが、実戦レベルでの扱い方のイメージが、文章だけだといまいち掴みきれない」といった声を多くいただきました。

確かに、概念系の理解を進める話を中心にしていたので、いざ取り組むとなると具体度が足らないかもな... というのは執筆した自分でも思っていたところです。🤔

なので今回、せっかく具体的な実装の話をするなら、コードで示した方がわかりやすくなるのでは? そしてそれを公開すれば、前にもらっていた声にも応えられるし、いいよね?という話の流れになり、 簡単に実装したものをGithub上に公開することにしました。

もちろん、DroidKaigiではAndroidが主役だとは思いますが、 Flutterでももっとやっていけるぞ! ということを、これを契機にみなさんに知っていただければ嬉しいです。 😄

ダイナミックな動作体験を実現する

さて、本題の内容にいきたいと思うのですが、

ダイナミックな動作体験とは何でしょうか?

こう聞いてすぐにイメージがつく人はあまりいないと思います。ネイティブアプリ開発で多くの経験を積まれて来られた方でしたら、あれのことかな?と推測できるかもしれません。

ここで紹介する「ダイナミックな動作体験」というのは、

アプリ内で起こる動作の結果を、 アプリ全体に共有することで、 アプリの状態に矛盾が生じないことを担保すること。

です。

抽象度を高くした言葉で定義しているのであまりピンっと来づらいかもしれませんが、この定義に基づいた具体的な機能例は "お気に入り機能" です。

お気に入り機能は、情報を扱うサービスでは鉄板の機能で、主要なメディアサービス、特に領域特化型のメディアサービスの大半がこの機能を提供していると思います。

DroidKaigiのアプリもこの例外ではなく、気になるセッションをお気に入りに登録してMy Planというタブ画面で、瞬時に確認できるようなサービス仕様になっています。

Link

noteにgifをuploadすることができなかった。。。

セッションの詳細画面でお気に入り登録を行うと、関連するコンポーネントとMy Plan画面の状態が変わり、"特定のセッションをお気に入り登録した" という事実がアプリ内で共有され、表示される情報に差異がなくなります。

このお気に入りに登録した際の結果を、ダイナミックに表現する実装方法が、本記事で紹介するメインの内容です。

DroidKaigiのアプリではセッションをお気に入りに登録するポイントが2箇所存在します。

1つ目が、セッション一覧画面のセッションアイテム要素に存在するButton。

2つ目が、セッション詳細画面の右下にあるFloating Button。

これら2つのトリガーと、My Planという一覧画面の表示動作がどのように連携しているのかを、実装例を交えて紹介します。

余談 余談ではあるのですが、本記事でダイナミックな~と表現した内容は一般的には "画面同期問題" などと呼ばれ、既にいくつかプラクティスが確立されていました。それのFlutterでの話が今回のメインではあるのですが、過去に語られた資料なども参考になると思うので、こちらも見てみてください。Thank you for konifar-san!!

BLoCをStream処理で連携し、イベントを伝搬する

まずは下記の図を見て、連携することの概念イメージを掴んでください。 BLoC Patternを用いている場合、ダイナミックな動作連携はBLoCとStreamを用いて相互に連携させることで実現させます。

画像2

図では右側のセッション詳細画面で起きる、お気に入り登録のイベント結果を、サーバーとの通信を介しながらBLoCを経由して変化を伝え、受け取ったイベントを元に関連するWidgetに変化を加え、UIの状態を同期する関係性を示しています。

関係性の流れをより簡潔な言葉にすると、

コードでの表現を見ると以下になります。

// Local Bloc
class SessionScreenBloc {
 void favorite() async {
   _isFavorite = !_isFavorite;
   await FavoriteRepository().toggleFavorite(sessionId, user.uid, _isFavorite);
   _changeFavoriteSubject.sink.add(_isFavorite);
   // Global Blocのfunctionを呼び出す
   FavoriteBloc.shared.updateFavorite(Tuple2(sessionId, _isFavorite));
 }
}

// Global Bloc
class FavoriteBloc {
 static FavoriteBloc _shared;
 static FavoriteBloc get shared => _shared;
 factory FavoriteBloc() => _shared ??= FavoriteBloc._();
 FavoriteBloc._();

 void dispose() {
   _updateFavoritesSubject.close();
 }

 final _updateFavoritesSubject = PublishSubject<Tuple2<String, bool>>();
 Observable<Tuple2<String, bool>> get updateFavoritesStream =>
     _updateFavoritesSubject.stream;

 void updateFavorite(Tuple2<String, bool> value) {
   _updateFavoritesSubject.sink.add(value);
 }
}

// Component Bloc
class SessionItemBloc {
 SessionItemBloc({this.session}) {
   _load();
   _updateFavoritesSubscription = FavoriteBloc.shared.updateFavoritesStream
       ?.where((e) => e.value1 == session.id)
       ?.listen((e) {
       _isFavorite = e.value2;
       _changeFavoriteSubject.sink.add(_isFavorite);
   });
 }
} 

この実装の様に、BLoCの参照を互いに持つことで、イベント結果を共有できるようになり、UIの状態が同期される状況を作ることができます。 ここで注目すべきは、3つのBLoCの関係性です。

// Local Bloc
class SessionScreenBloc {
 // call from Button Widget 
 void favorite() {
   FavoriteBloc.shared.updateFavorite(Tuple2(sessionId, _isFavorite));
 }
} 

SessionScreenBlocに定義されているfavorite() 関数の中でFavoriteBlocのupdateFavorite() 関数を呼び出しています。GlobalBLoCに変更を伝える部分です。

// Global Bloc
class FavoriteBloc {
 static FavoriteBloc _shared;
 static FavoriteBloc get shared => _shared;
 factory FavoriteBloc() => _shared ??= FavoriteBloc._();
 FavoriteBloc._();

 void dispose() {
   _updateFavoritesSubject.close();
 }

 final _updateFavoritesSubject = PublishSubject<Tuple2<String, bool>>();
 Observable<Tuple2<String, bool>> get updateFavoritesStream =>
     _updateFavoritesSubject.stream;

 // call from SessionScreenBloc
 void updateFavorite(Tuple2<String, bool> value) {
   _updateFavoritesSubject.sink.add(value);
 }
} 

呼び出されたFavoriteBlocはSingletonとして定義し、staticな関数(shared)で呼び出せる様にしておきます。 SessionScreenBlocで呼ばれた updateFavorite() 関数の中では、引数の値をupdateFavoriteSubject.sink.add で値を流しています。

// Component Bloc
class SessionItemBloc {
 SessionItemBloc({this.session}) {
   _load();
   _updateFavoritesSubscription = FavoriteBloc.shared.updateFavoritesStream
       ?.where((e) => e.value1 == session.id)
       ?.listen((e) {
       _isFavorite = e.value2;
       _changeFavoriteSubject.sink.add(_isFavorite);
   });
 }
} 

SessionItemBlocではclassのconstructorで処理を一手間加えます。 FavoriteBlocにあるupdateFavoritesStreamをlistenして、自classが持つsessionオブジェクトのidと一致するかの判定を行い、一致していれば自身の状態をを変え、Widgetに変化を伝えるためsubjectに変更結果をaddします。

実装の動きに順序をつけると以下の図の様になります。

スクリーンショット 2020-02-06 18.32.33

これで変更を加える、伝搬する、受け取る、変化するという流れが実現できました。

この実装を見たときに、SessionItemBlocが仮に複数存在した場合、多くのlisten処理が走ることを不安視するかもしれません。確かに、多くのlisten先が存在した場合、Streamに値が流れ込むと実行される処理がlistenの数分多くなります。 しかし、前提としてFavorite自体が連続的に実行されにくい機能であるのと、値が渡った後の受取処理は実行されますが、UIの変更自体は特定のidに紐づいたItemに限定されており、またそのUIの変更箇所自体も局所的なRebuildで完結する様になっているため、パフォーマンス的には問題ないです。

例えば、これがStatefullWidgetのsetStateなどを用いた場合では、子孫のWidgetまでRebuildが走ることになったりするので、パフォーマンス的に問題があるといった話がされますが、Streamによる伝搬で部分的なUIのみを変更させることができるので問題なく利用可能になります。 (パフォーマンス系の理解はこちらの記事を参考に)

また、このアプローチでは、FavoriteBlocというBLoC classをGlobalBLoCとして定義し、他のイベントの伝搬のみを担う様にしています。 こう定義して扱うと、GlobalBLoCが増えて複雑になるのではないかと不安視されたりもしますが、単一責任化と抽象度の良い命名による実装でカバーできると思っています。GlobalBLoCは複数の機能を持たせるのではなく、単一機能、単一BLoCとして定義するようにしています。

余談ですが、筆者はまだ困るほどの数のGlobalBLoCを相手にしたことがないため絶対にとは言い切れないのですが、もし今回の内容がBad Practiceになる or なった理由などがわかれば、教えていただきたいです。

AndroidやiOSの文脈では、この連携処理をRepository層などが担っている様ですが、FlutterでBLoC Patternを用いている場合は、BLoC自体が処理を担って対応します。もちろん、BLoCを使わない方法もあるとは思いますが、基本的にはUIのビジネスロジックを吸収する責任を持つBLoCが処理を担う方が良いなと個人的には思っています。

お気に入りの処理以外の応用としては、TabBlocの部分でも類似の処理を用いています。こちらも合わせて見ていただくと、よりStream連携の応用に対するイメージが湧きやすくなると思います。

以上が、「Streamを用いたBLoC連携」の具体的な実装方法の紹介でした。

最後に

BLoC PatternでFlutter Appを作る際に、ダイナミックな動作体験を実現するための実装構造を紹介してきました。

言葉で理解しづらい内容もあったかと思いますが、実際のコードと突き合わせて見てみると理解が進むのではないでしょうか。

今回は最もわかりやすい"お気に入り機能"を例に紹介しましたが、BLoC連携の内容は他にも転用可能な有用な実装方法です。リフレッシュタイミングのハンドリングや、通知の表示を表現するなど、多岐にわたる応用が可能になります。

本記事を参考に、よりダイナミックな動作体験をユーザーに届ける開発をしてみてください 🙂

また、今回の内容以外にも"こういったやり方もあるし良いよ"というのがあれば是非教えてください。 Android方面だとCookpad社のOOSにあるViewsWaiterがこの同期問題を解決するアプローチとして存在します。

Link

筆者は2/20, 2/21のDroidKaigiに参加します。 DroidKaigiに参加される方は是非、会場で会いましょう!

執筆後 追記 完全に書き終えた後に出た情報なのですが、DroidKaigiの開催がコロナウィルスの影響などにより、中止になってしまいました。 開催までに多大な準備を重ねてきた運営の方々の判断は、大変難しいものではあったかと思いますが、一参加者としてはその判断を尊重しています。

Link

それでは、Have a good Flutter Life!

本記事が参考になりましたら、いいね・コメントなどのフィードバックをお待ちしています。もちろんシェアなども歓迎です!🚀

Link

紹介

タイムテーブルUIを作成するパッケージを作りました。 こちらも見てみてください。

SHARES