🦁

Flutterで複数画面をまたがるイベント通知をする方法 | いいね問題について

2021/04/19に公開
2

はじめに

リスト形式にコンテンツが並んでいて、タップすると詳細画面に選択する、というモバイルアプリをよく見かけると思います。
ショッピングアプリで商品をいいねすることができ、後で一覧画面を見たときにその商品を見つけやすくするとしたらどういう画面をつくるでしょうか?
よくあるのが、一覧画面・詳細画面それぞれにいいねボタンを設置し、リポジトリからいいねのステータスも含めたデータを取得する流れです。

上記仕様で作ったアプリでいいねボタンを押してみます。

一覧画面でいいねボタンを押した場合、詳細画面でその状態が反映されています。しかし逆に、詳細画面でいいねボタンを押してもその状態が一覧画面には反映されていません。
できれば一覧画面に戻ったときにステータスが反映されてほしいところです。

課題

最初の実装では画面作成時にデータをfetchして表示しているので、戻るアクションで作成済み一覧画面に戻ったときに最新の状態が反映されない結果このような状態になっています。

画面が最前面に来たときに必ずリフレッシュすることで解消できますが、毎回リロードするのはUXを損ないますし、スクロール位置やページネーションしている場合はその状態が失われることになるます。あまり良い解決策では無さそうです。
Navigatorpush pop 実行時に専用のフラグを通知するようにすればもう少し細かく制御できそうですが、アクションパターンが増えるとフラグ管理が難しくなりそうです。これも極力避けたい。

別の画面で発生したアクションが他画面に正しく反映されないこの状況は、一部ではいいね問題と言われています。ネイティブアプリ開発経験者だとご存知の方は多いと思いますが、初めてのアプリ開発がFlutterの方は知らなかった人もいるのではないでしょうか。

ググラビリティの低さが原因なのか、いいね問題という名称の認知度はあまり高くありません(ただしみんな事象は知っている)。いいね問題 Androidなどのキーワードで調べると良い記事がたくさん見つかりますが、逆にそれで探そうとしない限りそれに関連する記事にはあまり遭遇しないと思います。

また、画面内の状態管理という文脈のアーキテクチャ議論はよく見かけますが、複数画面にまたがった状態管理の記事はそれほど多くはありません。これは小さいアプリだとそもそも課題にならないのと、ある程度習熟している人≒過去にネイティブアプリ開発で色々経験していて同様の課題に遭遇済みであることが多いので、わざわざ記事にしていないからではと想像しています。

いいね問題という名詞を認知できた時点で殆どの人は解決できるような気もしますが、以降は僕が必要十分だと思っている実装方法を紹介していきます。

方針

  1. event_busでアクションを通知する。Androidでそれなりに知名度が高い同名のライブラリがあり、やりたいことが伝わりやすいため。
  2. いいねアクションの結果は、値ではなくドメインイベントとして通知する

https://pub.dev/packages/event_bus

前提、という名の予防線

事業やアプリの特性によりますが、殆どのアプリのいいね問題は狩野モデルにおける当たり前品質に該当します。そのため、要件を正しく達成できてある程度アプリがスケールしても耐えられる実装さえできればそれ以上の最適化は不要だというスタンスで考えています。

ここで紹介する実装方法は、僕がAndroidアプリので実装していたパターンをFlutterに適用した内容です。学習コストや汎用性、可読性などを踏まえると次第点だと思っています。

Riverpodを使っていない理由

今回のトピックはRiverpodでも対応できるので、そちらを利用している人からするとイマイチに見えるかもしれません。実際Riverpodを利用するのも良い選択肢だと思います。

https://pub.dev/packages/riverpod

僕はいくつか理由があって別の実装方法にしているので、その辺りの理由も書いておきます。
ただし好みや偏見も入っていて(特に3番)、また単に知識不足の可能性もあります。あくまでイチ意見として捉えてください。

  1. event_busで行うPub/Subは一般的な設計の一つのため、Flutterの経験が浅くても理解がしやすい
    • iOS/Androidエンジニアが見てもある程度コンテキストがわかる
  2. 単に最新データを共有するのではなく、もともとストアされていたデータの取得と、特定のイベント発生結果の処理を分けておきたい
    • Riverpodを利用して画面側で透過的に最新データを取得するようにしていると、規模が大きくなったときに更新フローが追いづらくなりそう
    • Riverpodは強力な分処理のコンテキストも隠蔽してしまう印象があるため、意図がある処理では利用したくない。逆に面倒な初期描画+αで必要なデータ取得は、Riverpodでシンプルに書いたほうが良さそう
  3. 複数画面にまたがる重要な仕組みを、特定の状態管理用ライブラリに依存させたくない
    • 今回利用するeventbusもサードパーティライブラリではありますが、中身はStreamなので技術としてのライフサイクルはもっと長いと思っています

実装

ベースとなるItemクラスと、データを保持しているStorageクラスです。必要最低限のフィールドを定義しています。

class Item {
  Item(
    this.id,
    this.name,
    this.description,
    this.like,
  );

  final int id;
  final String name;
  final String description;
  final bool like;
}

import 'package:like_state_sync/domain/item/item.dart';

class Storage {
  static List<Item> get items => _items;

  static List<Item> _items = List<int>.generate(10, (i) => i + 1)
      .map((e) => Item(e, 'item-name-$e', "item-description-$e", false))
      .toList();

  void changeLikeState(int id, bool state) {
    _items = _items
        .map(
          (e) => Item(e.id, e.name, e.description, e.id == id ? state : e.like),
        )
        .toList();
  }
}

通知するEvent用のクラスを作ります。

class ItemLikeEvent {
  ItemLikeEvent(
    this.id,
    this.state,
  );

  final int id;
  final bool state;
}

eventbusのインスタンスはglobalオブジェクトとして保持します。
ライブラリのコンストラクタ側でシングルトンになっているのでそのまま利用していますが、気になる方はラッパークラスを作成すると良いかと思います。

import 'package:event_bus/event_bus.dart';

var eventBus = EventBus();

データ更新だけでなく通知をすることになったのでServiceクラスを作成し、repositoryの更新とメッセージのpublishを行うようにします

import 'package:like_state_sync/domain/item/Item_repository.dart';
import 'package:like_state_sync/domain/item_like/item_like_event.dart';
import 'package:like_state_sync/inflastructure/event_bus.dart';

class ItemLikeService {
  ItemLikeService._();

  static void likeAction(ItemRepository itemRepository, int id) {
    itemRepository.like(id);
    eventBus.fire(ItemLikeEvent(id, true));
  }

  static void unlikeAction(ItemRepository itemRepository, int id) {
    itemRepository.unlike(id);
    eventBus.fire(ItemLikeEvent(id, false));
  }
}

いいねボタンをタップしたときに、ServiceクラスのMethodを実行するとイベントが発行されるので、そのイベントを監視してデータリロードするように宣言します

class _ItemsScreenState extends State<ItemsScreen> {
  final ItemRepository _itemRepository = ItemRepository();
  List<ItemLikeEvent> itemLikeEvents = [];

  
  void initState() {
    super.initState();
    eventBus.on<ItemLikeEvent>().listen((event) {
      // eventから対象itemIdを取得できるので
      // ちゃんと実装すれば必要なデータのみ更新することも可能
      setState(() {});
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('items screen'),
      ),
      body: # 省略
    );
  }
}

ここまでの実装を反映すると、、、

変更監視の仕組みを導入することで、詳細画面側で実行したいいねアクションの結果が一覧画面にもすぐ反映されるようになりました。※今回は利用していませんが、イベント設定したidや変更後の状態を利用することで細かく制御することも可能です。

まとめ

  • いいね問題について簡単に説明しました
  • Riverpodもいいけど、イベント発生を通知するならPub/Subがいいんじゃないか?ということを書きました
  • event_busを使った実装例を紹介しました

今回紹介したサンプルコードはこちらです

https://github.com/ham-burger/like_state_sync

Discussion

monomono

buildメソッドは何回も呼ばれ得るため、以下のようにそこでlisten系の処理を書くのはアンチパターンです。多重購読になってしまいます。

  
  Widget build(BuildContext context) {
    eventBus.on<ItemLikeEvent>().listen((event) {
      // eventから対象itemIdを取得できるので
      // ちゃんと実装すれば必要なデータのみ更新することも可能
      setState(() {});
    });
    ...
  }
kudokudo

確かにそうでした。initState()で宣言すべきですね。
ご指摘ありがとうございます!