この記事の注意事項です
それぞれ設計思想や構造が異なると思いますので、詳細な実装方法は省いています。(※希望があれば書くかも)
実現したかった仕様について
- ユーザー間でアプリのデータをリアルタイムで共有できる
- 共有するユーザーに招待リンクを送れる
- 招待されたユーザーは招待リンクからログインするとグループに参加できる
- 各端末はローカルDBのデータを参照する。
- グループから抜けることができる
- グループから抜けた時点でのデータはそのままローカルで使えること。
まずは結論から
特段ローカルDBと同期する必要がない、もしくは同期しなくても良い状況で作れるなら、やめておいた方がいいです。考慮しなければいけないことも多岐にわたるからです。
特に今回は、画像のアップロード・ダウンロードなど、時間のかかる処理を間に挟むので、非同期の処理対応が大変でした。
もし最初から招待機能を用いたシェア機能を作りたいならば、直接Firestoreを参照する仕様にするか、もしくはローカルDBから参照を切り替えるような対応がベストではないでしょうか?
使用した技術
- Firebase Authentication (以下 FA)
- Firestore (以下 FS)
- Firestrage(画像等保存するものがなければ不要)
- DynamicLinks (招待リンク生成)
- ローカルDB Moor
なぜローカルDBとFirestoreを同期する必要があったのか?
ユリウスという絵本の読み聞かせ記録するアプリをリリースしていますが、ユーザーから一番要望があったのが「タイムツリー」のようなシェア機能でした。しかし、すでにローカルDBで運用しており、またユーザーの中にはシェア機能を必要としないユーザーやログインしないでも使えることが競合との差別化になっており、完全にFSに移行することもできません。
そのため、ローカルDBを残しままの運用することにしました。
ママさん・パパさんからアプリへの要望で多かった
— 絵本の案内人|絵本の読み聞かせ記録アプリ ユリウス【公式】♪ (@PictureBook_app) May 17, 2021
記録の「共有機能」を追加しました📕
絵本版の「みてね」の感じで、じぃじ・ばぁばともシェア可能です。
是非使ってみてください!
■iPhonehttps://t.co/H5b5FY5Umm
■Androidhttps://t.co/RAtNrlFBh2 pic.twitter.com/spmhsfaanW
パーフォマンスの観点から、ローカルから読み出した方が良さそうですし、ドキュメントの読み込み・編集の回数も減らせるので、Firebaseの節約になるのでは?という考えもありました。
どうやって設計すればよいのか? → 同期のルール作り
まず初めにローカルDBとFSの同期機能について設計開始しましたが、create、edit、update、deleteを考えていくと頭がこんがらがってきます。正直の最初の設計でかなり時間を潰ました。
まずは、同期に関するルールを決めることで解決しました。
- Firestore側のデータが絶対的である
- データを書くのするコレクションのドキュメントIDはローカルIDと同じにする
当たり前ですが、複数人の同時書き込みが想定されますので、いくらFSに都度保存するとしても、かならずローカルDBに差異が発生します。そのため、データは必ずFS上にあるデータが正しいものとして機能するようにしました。
また各データモデルに紐づくコレクションは、ローカルDBのIDとドキュメントIDを合わせることで2重登録を防ぎながら、検索時にどちらもIDで探せるように設計をしました。
補足ですが、各データモデルに紐づくコレクションはサブコレクションとして 共通の親(groupコレクション)の配下にあります。
そのため、groupコレクションは固有のIDを持っているので、他のグループとはIDがバッティングしません。
Firestoreの設計
//Firestoreのコレクション構成
- users //外部キーでgroupのドキュメントID)
- group
- groupUsers //userのドキュメントIDで保存
- 各データモデルに紐づくサブコレクション
Firestoreの設計は悩んだところですが、大きくわけてusersとgroupにコレクションを分けました。
groupのサブコレクションgroupUsersに参加しているユーザーのuserコレクションのドキュメントIDを保存します。
ユーザー作成の流れ
- FAでログイン処理、 FSのusersコレクションに保存
- userがgroupIDを持っていない場合は、groupを作成
- userをサブコレクションのgroupUsersに、同一ドキュメントIDで保存
- userのgroupIDを追加
- groupIDを付帯して、招待リンクを生成
招待者された側は、招待リンクのパラメーターからgroupIDを取得して、引数渡しながら
- FAでログイン処理、 FSのusersコレクションにgroupIDを追加して保存
- userをサブコレクションのgroupUsersに、同一ドキュメントIDで保存
という風に分岐させています。
初期のデータ同期
初期のデータ同期は、以下のようにしました。
//groupUserが存在するか?処理の例
if(existsGroupUser == false){
//ローカルDBデータのアップロード処理
}else{
//FSのデータをダウンロード処理
}
というロジックにしています。(groupUserが全員いなくなった場合は、保存しているコレクションは全て削除される仕様になっています。)
同期するデータの流れ ローカル→FireStore
- ローカルにデータを保存
- 非同期処理でFSへの保存処理を開始
- FSに同一のIDを持っているドキュメントがないかを確認
- ドキュメントがない場合 → create: 処理が終了、update:ドキュメント新規作成
- ドキュメントがある場合 → create: ドキュメントを保存、update: ドキュメントをアップデート
- 画像があれば、画像保存の非同期処理スタート
状態管理はProviderを使ってますが、同期あり・なしに関わらず、ローカル側の処理は変わりません。
同期時にの場合、非同期処理でFSへの処理を流して、処理完了を待ちません。
些細な時間ですが、FSの処理を待ってしまうと、少しもたつきを感じたからです。
手間になりますが一度、Firestoreへの問い合わせを入れて、同一IDをチェックすることで、FSと各ローカルDB内でIDの衝突が起こらないようにしています。
複数のデータを同時に更新する
一つのデータをやり取りするのであれば、さほど問題はないんですが、複数データを同時に更新する場合に少しだけ工夫が必要です。
例えばデータを並び替えするために、各データのインデックスを更新する場合などです。
普通にeach文で処理をすると、受信側の更新処理が忙しくなるので、まとめて処理するようにバッチを使いました。
https://firebase.google.com/docs/firestore/manage-data/transactions?hl=ja
var batch = firestore.batch();
await Future.forEach(dataList, (Map<String, dynamic> data) {
var document = firestore
.collection("コレクション名")
.doc("ドキュメントID")
.collection("コレクション名")
.doc("${data["id"]}");
batch.update(document, data);
});
await batch.commit();
バッチ処理で一括で変更を反映するようにしています。ただし一度に処理可能なドキュメントが最大500とのことなので、ドキュメントが500を超える可能性がある場合は、リストを分割して処理させる必要があります。その方法については、今回は省かせてもらいます。
同期するデータの流れ Firestore → ローカル
- FSのSnapShot()でリアルタイムに変更を感知できるようになるので、streamで各データモデルに紐づくコレクション・サブコレクションをリッスンしておきます
- 変更があった場合に、2秒〜3秒処理の開始を遅らせる。(画像がある場合は5秒〜7秒遅らせてます。)
- 画像の保存を非同期でスタート
- 受診したFSデータの中に存在しないが、ローカルDBに存在するデータを抽出して削除
-
受診したFSデータが存在し、ローカルDBにも存在する場合 → データ変更なし:何しない データ変更あり:ローカルDBをアップデート
6 受診したFSデータが存在し、ローカルDBに存在しない場合 → ローカルDBに保存
FirebaseFirestore firestore = FirebaseFirestore.instance;
Future<void> realTimeUpdateData({String collectionId}) async {
Stream<QuerySnapshot> querySnapshot;
querySnapshot = firestore
.collection("collectionId")
.doc(collectionId)
.collection("サブコレクション")
.snapshots();
dataStream = querySnapshot.listen((snapShot) async {
await Future.delayed(Duration(milliseconds: 3000));
//画像のダウンロードやデータ保存処理を開始。
}catch(e){
//エラー処理
}
});
試行錯誤した結果、本当のリアルタイムではなく、少しだけ遅延して反応させることで、各処理がスムーズに行きました。(ちなみに呪術廻戦みて思いつきました笑)
今回のアプリでは刹那的リアルタイム性は求められず、さらに更新頻度もそこまで高くないので、これで処理できましたが、たとえばSNSのようなもので作くろうとすると処理の難易度がかなり高まると思います。
というのも、FSの反応がは早すぎるため、稀にローカルのその他処理・Viewの描写が完了する前に、変更処理が走ってしまうことで処理が止まってしまう不具合があったためです。少しだけ遅らせた方が取り回しが良かったです。
基本的にFSのデータが絶対なので、FSに存在しないローカルDBは削除されるので、常にFSの状態を保つように自己治癒するようになっています。
たとえば、ローカルでcreateが成功しても、FSで落ちた場合は、FSにデータがないため、ローカルのデータが削除されます。
ユーザーの退会などでFSのすべてのデータを削除する
ユーザー退会なので、FS上のすべてのデータを削除する場合ですが、以下の使用にしました。
もしgroupUserが自分以外に・・・
- いる場合 → groupUserとUserを削除
- いない場合 → groupと配下のサブコレクション、userを削除
という処理になります。
この時に注意点ですが、「いない場合」でサブコレクションを削除を開始前に、かならず退会するユーザー側では、変更のStreamが走っているので、かならずdispose()をしてあげる必要があります。
///ログアウト処理前に、このdispose処理を呼ぶ。
Future<void> disposeFireStore() async {
try {
if (dataStream != null) {
await dataStream.cancel();
}
}catch(e){
print("disposeFireStore:$e");
}
///すでに実装しているStream
FirebaseFirestore firestore = FirebaseFirestore.instance;
Future<void> realTimeUpdateData({String collectionId}) async {
Stream<QuerySnapshot> querySnapshot;
querySnapshot = firestore
.collection("collection名")
.doc(collectionId)
.collection("サブコレクション")
.snapshots();
dataStream = querySnapshot.listen((snapShot) async {
await Future.delayed(Duration(milliseconds: 3000));
//画像のダウンロードやデータ保存処理を開始。
}catch(e){
//エラー処理
}
});
}
というのも、リッスンをとめない状態で、削除処理を始めると、
退会処理中にすべてのローカルデータがFS上にないと判断されて、サードインパクトみたいにデータが全て消えてしまいます。
この場合Stream自体を、グローバル変数に格納しておいて、FSの処理スタート前に、dispose()をかけています。
同一のAuthアカウントの処理
これは実装中に気づいたのですが、同一のFAで別の端末でログインした時の挙動を考える必要があります。
userのCreate時に、FSのusersのフィールドにログインの有無をつけて、拒否すればいいのですが、今回は別の端末でも同アカウントで共有できるようにしました。
以下の処理をしないと、退会処理がされた場合に、別の端末は感知できないので、リッスン がそのまま継続しています。
仮に同一のAuthが最後のgroupUserだった場合に、別端末のデータは全て削除されてしまいます。
これはまずいので、
まずドキュメントにboolのフィールドを追加して、退会処理をした場合に追加したフィールド(delete)を変更するようにしました。
あとは考え方が同じなので受ける側は・・・
- FSの自分のuserドキュメントをSnapShop()でStreamでリッスンする
- 変更を感知、deleteフィールドがtrueだったら、全てのリッスンをdispouseさせる
- authからログアウト
- ローカルので設定を変更
という流れになります。逆に退会する側には以下の処理を追加
- すべてのStreamをdisposeする。
- 自身のuserデータのdeleteフィールドをtrueに変更
という風に処理します。
招待リンクの作成
招待リンクはFirebaseのDyanmicLinkを使えば招待リンクを作成が可能です
[https://firebase.google.com/docs/dynamic-links?hl=ja]("Firebase Dynamic Links公式")
Flutter用のPUBもあるので実装としては問題ないのですが、いくつかわかりづらいので補足すると。
https://pub.dev/packages/firebase_dynamic_links
まずブラウザ上とコード上どちらでURLを作るかですが、コード上で作ります。ただし、ベースのドメインはブラウザ上で作っておきましょう。
iOS、Androidともにネイティブ側に設定を追加する必要がありますが、PUBの公式を見れば問題ないと思います。
このあたりの記事が分かりやすかったです。
https://betterprogramming.pub/deep-linking-in-flutter-with-firebase-dynamic-links-8a4b1981e1eb
基本的にきっちりと設定ができていれば、TestFlight経由でも、Androidは直接インストールでも、ディープリンクが機能するはずです。
基本的な流れは、ユーザーがログインした後に、個別でgroupIdなどを取得しておきます。
それをDynamicLinkを作成時にパラメーターとして渡してあげるだけです。
Future<Uri> createPartnerInviteLink({@required String groupId, @required DateTime limitTime}) async {
int time = limitTime.millisecondsSinceEpoch;
//LINKに有効期限を付帯します。
final DynamicLinkParameters parameters = DynamicLinkParameters(
uriPrefix: 'https://独自設定した内容.page.link',
link: Uri.parse("PCの場合の推移先URL?
groupId=$groupId&limitTIme=${time.toString()}"),
//パラメーターでgroupIDなどを渡しています。
navigationInfoParameters: NavigationInfoParameters(
forcedRedirectEnabled: true,
),
androidParameters: AndroidParameters(
packageName: "パッケージネーム",
minimumVersion: 12,
),
iosParameters: IosParameters(
bundleId: "ビルドID",
appStoreId: 'ストアID',
minimumVersion: "1.4.0",
),
);
var dynamicUrl = await parameters.buildUrl();
final link = await parameters.buildUrl();
final ShortDynamicLink shortenedLink = await DynamicLinkParameters.shortenUrl(
link,
DynamicLinkParametersOptions(shortDynamicLinkPathLength: ShortDynamicLinkPathLength.unguessable),
);
return shortenedLink.shortUrl;
}
shareInviteLink(BuildContext context) async {
final DynamicLinkService _dynamicLinkService = DynamicLinkService();
var user = currentUser
String groupId = "${user.groupId}";
DateTime now = getDateTimeNow();
DateTime limitTime = now.add(Duration(days: 1));
Uri dynamicLinkUrl = await _dynamicLinkService.createPartnerInviteLink(groupId: groupId, limitTime: limitTime);
String url = dynamicLinkUrl.toString();
//このタイミングじゃなくてもいいんだろうけど、"openExternalBrowser=1"を追加
Share.share("$url?openExternalBrowser=1");
}
- iOSで一度ブラウザ開いて変なボタンが表示されるのは → efr=1をパラメーターにつけると回避できたはず。なぜかコードから消えててても動いてる笑
https://logmi.jp/tech/articles/320720 - 一部LINEでは、ブラウザが開いてしまうという症状 → openExternalBrowser=1"をつけると解消されます。
まとめ
今回ユーザーからの要望が一番多かった、招待およびシェア機能の実装について行ったことを書いていきました。
最初は2週間もあればできるかな?と侮っていましたが、仕様や設計、実装、バグの修正、各種セキュリティルールやテストの記述などで、トータルで2ヶ月ほどかかってしまいました。
正直、特段の理由がなければ、ローカルDBとの同期ではなく、同期する場合は参照元をローカルからFSに切り替えるような処理にしておいた方が、バグ対策や保守性からも良いと思います。
当初はローカルでのドキュメント取得数などを減らせるのでは?と思っておりましたが、なんだかんだで、チェック等でアクセスは何度かする必要があり、そんなに節約になっていないかもしれません。
とにかく大変でしたのでお勧めしません笑。