😸

【Flutter】Firestoreを使ったいいねの設計について考える

2022/11/19に公開約5,900字

はじめに

SNS的なアプリを作る際に、「いいね(ブックマーク)」機能を実装したいとなることはよくあると思います。

今回はデータベースにFirestoreを使う場合に どういう設計にするのがいいのか? UXとパフォーマンスを基準に選定してみようと思います。

アプリの要件に応じてベストプラクティスは変わるかと思いますが、参考になれば嬉しいです。

また、私自身データベース周りの知見が浅いので、 もっとこうしたらいいよ! という意見があれば、コメントいただけるととても嬉しいです。

アプリの概要

本題に入る前に、今回例にするアプリの機能・要件を提示します。

今回はインスタのような画像投稿アプリを例にしてみました。
機能自体はかなり簡素で、下記の通りです。

・ 画像の投稿ができる(以下投稿画像をPOSTと呼ぶ)
・ 他ユーザーのPOSTをブックマークできる
・ ブックマークしたPOSTを一覧表示できる

UI

画面としては、SearchSavedの2つです。
Search画面で他ユーザーのPOSTを閲覧でき、画像右下のアイコンをタップすることでブックマークできます。
(未保存のPOSTのアイコンは白色、保存済みのPOSTのアイコンは緑です)

↑でブックマークしたPOSTは、Saved画面で一覧表示されます。

Search Saved

Firestoreの設計

Fiestoreには、↓のようにデータを持たせています。
(コレクション名は適当なのでスルーしてください)

サブコレクション

Search画面で表示するPOSTは、/posts-debugコレクションに保存しておきます。

ブックマークした場合、ログイン中ユーザーのサブコレクションである、/users-v1/{user}/saved-posts-debugに同じ内容(タイムスタンプのみ変更)をコピーして保存します。
Saved画面では、このサブコレクションの一覧を表示することになります。

今回は一度投稿されたPOSTは更新されない仕様なので、非正規化してサブコレクションに同じ内容を持つようにしています。

この辺はアプリの要件に応じて変わると思いますが、本題から逸れるので議論はスルーします。

ここからが本題です

上記のアプリを実現するにあたって今回焦点を当てるのが、 Search画面の各POSTのおける、ブックマーク済みかどうか?の判別方法 です。

ブックマーク済みかどうかに応じて、アイコンの色を変える必要があります。
(今回は要件として盛り込んでいませんが、ブックマーク済みの場合はアイコンタップでブックマークを解除できた方がいいかもしれません)

Search画面で表示されるPOST一つ一つに対して、 ブックマーク済みのPOSTか? を判定するにはどうするのがいいでしょうか?

少し考えて思いついたのは、以下の3つの方法です。

  1. /users-v1/{自分}/saved-posts-debug の全データを取得して比較する
  2. クライアントサイドジョインの要領で、Search画面のPOST一つ一つにおいて、/users-v1/{自分}/saved-posts-debugに対してgetしてみて存在をチェックする
  3. /posts-debug/{post}に、ブックマークしたユーザーの配列(もしくはサブコレクション)を持たせて比較する

今回はこのうち1と2について比較検証してみました。
3は以下の理由から、検証せずに不採用としています。

・ ブックマークしたユーザーの配列を持たせる場合、要素(ユーザー)数の上限がわからないので不安。
そもそも同時書き込みやセキュリティルールを考慮すると、/posts-debugの各ドキュメントを誰でも更新できるのはよくない気がする。

・ サブコレクションにする場合、結局2と同様にクライアントサイドジョインが必要なので、2を上回るパフォーマンスは期待できなさそう。

アプリのコード

検証結果の前に、イメージしやすいよう実際のコードを抜粋して載せておきます。

UI構成イメージ図

PostsView

Widget build(BuildContext context) {
  return FirestoreQueryBuilder<Post>(
     query: // クエリ
      builder: (_, snapshot, __) => 
        GridView.builder(
	  itemCount: snapshot.docs.length,
          itemBuilder: (context, index) =>
            PostTile(snapshot.docs[index].data()),
	 ),
   );
 }

PostTile

Widget build(BuildContext context) {
  final hasBookmarked = どうにかして判定する;
  
  return CachedNetworkImage(
     imageUrl: post.imageUrl, // 引数のpostを参照
     imageBuilder: (context, imageProvider) {
       return Container(
          decoration: BoxDecoration(
	    borderRadius: BorderRadius.circular(30),
	    image: DecorationImage(
               image: imageProvider,
	       fit: BoxFit.cover, 
             ),
	   ),
	   child: GestureDetector(
             onTap: () {
	       if(!hasBookmarked){
	         // ブックマークしていない場合、ブックマークする
	        }
	     },
             child: Container(
               alignment: Alignment.bottomRight,
               padding: const EdgeInsets.only(right: 12, bottom: 12),
               child: Icon(
                 Icons.bookmark,
                 size: 32,
                 color: hasBookmarked
		   ? Colors.green
		   : Colors.white,
                 ),
             ),
	   ),
	 );
      });
}

PostTilehasBookmarkedの判定方法について、これから比較検討していきます。

検証結果

今回は、10,000(1万)件のPOSTを登録、そのうち5,000件をブックマーク済みとしました。

profileモードでアプリを動かし、Dev toolでMemoryPerformanceもみてみます。
(端末はiPhone 11 Proを使用しました。)

  1. ブックマーク済みかどうかをチェックしない
  2. /users-v1/{自分}/saved-posts-debug の全データを取得して比較する場合
  3. クライアントサイドジョインの要領で、Search画面のPOST一つ一つにおいて、/users-v1/{自分}/saved-posts-debugに対してgetしてみて存在をチェックする

について、結果は以下の通りです。

画面の動き(滑らかさ)

0 1 2

Memory

0 1 2

Performance

0 1 2

まとめ

それぞれで画面の滑らかさやMemory使用量などに差が出るかと思いましたが、ほとんど差はありませんでした。

この結果を踏まえて、今回のアプリ要件であれば2を採用するのが良さそうです。

1の場合、画面描画時に/users-v1/{自分}/saved-posts-debugを全件取得することになります。
実際に画面に表示しているPOST(/posts-debug)のデータは、ページネーションによりスクロールする毎に段階的に取得されます。

そのため、/users-v1/{自分}/saved-posts-debugの全件取得だと、件数が多くなる程無駄な読み取りになる可能性が高くなります。

Firestoreは読み取り毎に課金されるので、ページネーションに合わせて個別に存在チェックをする2の方がコスト面で有利になりそうです。

最後に

FlutterとFirebaseの組み合わせは2年半ほど使っており、主にスタートアップでプロダクトでの利用にも携わったことがあるのですが、ちゃんと検証したのは今回が初めてでした。

過去の経験から、2のようなクライアントサイドジョインだと、読み込みが遅くなってUXに影響がでると思っていたのですが、少なくとも今回のケースでは問題にならなかったというのが、いい学びになったと感じます。
(結局はアプリの要件次第かもしれませんが・・・)

個人的にはRDBも採用して比較検討したいのですが、Flutterの場合はFirebase UI(旧FlutterFireUI)などが便利なのと、そもそもRDBの知見が浅いのでなかなか手を出せないでいます・・・。

その辺も含めて、知見をお持ちの方はコメントいただけると幸いです。

Discussion

ログインするとコメントできます