Flutter Advent Calendar 2019の22日目の記事です。
はじめに
私は今年の5月にウィッシュリスト(やりたいことリスト)を作ったり、共有したりできるアプリをFlutter + Firebaseで作り、リリースしました。この記事では、そのアプリを開発し、約半年間運営する中で見えてきた失敗や後悔をまとめ、七つの大罪として皆様に共有したいと思います。同じ過ちを繰り返す人が一人でも減れば幸いです...
七つの大罪
嫉妬の罪:アプリ強制アップデートの仕組みを実装せずにリリースした
罪状
ゲームなどではよくある「アプリの強制アップデート」だが、これを行わず運用している多くのアプリへの**〈嫉妬〉**から、強制アップデートの仕組みを実装せずバージョン1.0.0をリリースしてしまった
詳細
ゲームアプリを起動して、まずアップデートさせられるという体験を自分がよくしていたんですね。で、これが「面倒だなぁ」「自分のアプリではさせたくないなぁ」と思っていました。実際にTwitterとかそういったゲームアプリ以外ではあまり見た記憶が無いこともあって、自分のアプリではそういった強制アップデートの運用は行わないぞ!という誓いのもと、1.0.0ではその仕組を実装せずにリリースしました。
しかし、、、今ではやはりやっておけばよかったと思います。まずそもそも、強制アップデートの仕組みを実装したとしても、毎回のアップデートを必ず強制にしなければいけない訳ではないですよね。本当に必要なときだけ、低い頻度でやれば良いのです。だからデメリットは工数以外何も無いといっても良いと思います。
ちなみに、Wishesはこれまで5回のアップデートを行っています。リリースから7ヶ月以上経った現時点でのAndroidのみの統計情報ですが、現在インストールされているアクティブなデバイスは134台ありますが、その内1台はいまだにバージョンが1.0.0です。
実際に自分はバージョン1.0.0のときはデータの読み取りをFirestoreの料金のことは何も考えずに実装していたり、あとはそもそもデータの持ち方がいけてなかったりしたので、やはりガッツリ変えたい欲がかなりあります。最初ほど致命的な不具合や実装があるもんなんです。
もう最終的には強制的にfirestoreのルール等を変更してしまって、古いバージョンはエラーが出て動かなくなる... ということをせざるを得ないときが来るのかなぁと思っています。😢(今はまだ1.0.0は動く状態です)
実装サンプル
結局1.0.0より後のバージョンで強制アップデートの仕組みを実装しました。参考程度ですが、どのように強制アップデートの仕組みを実装したかをまとめておきます。
基本方針は、アプリ起動時にアプリのインストールされているバージョン情報を端末で取得し、その後Firestoreに保存されているバージョンのマスタ情報と照合し、もしアップデートが必要なバージョンだと判定された場合には「アップデートお願いしますページ」に遷移させます。ソースコード全部載せると長いので、適宜割愛してイメージが伝わるくらいに留めます。
import 'package:package_info/package_info.dart';
// インストールされているアプリのバージョンを取得
PackageInfo packageInfo = await PackageInfo.fromPlatform();
Version appVersion = Version.of(packageInfo.version);
// 強制アップデートが必要な最新バージョンの取得
Version needUpdateVersion = await getLatestNeedUpdateVersion();
bool toBeUpdated = needUpdateVersion?.isAfterThan(appVersion) ?? false
PackageInfo
クラスを使うためにimport宣言を最初にします。Version
クラスやgetLatestNeedUpdateVersion
、isAfterThan
は自分で定義したものです。
Firestoreのマスタ情報は以下のような作りです
作ってみればそこまで工数はかかりません。これからアプリを作る人は実装することを検討してみてください🙏
怠惰の罪:FirebaseとFlutterのコードを同じリポジトリにて管理してしまった
罪状
リポジトリ分けたほうがいいのか少し悩んだが、**〈怠惰〉**により思考を放棄、とりあえずモノリポで、としたがFirebaseとアプリのリリースタイミングが常に同じではないことから不健全な状態となった
詳細
Wishesでは、ユーザーが自由にコンテンツを投稿できます。言ってみればエログロコンテンツも投稿させてしまい、それがタイムラインに流れる危険性があるわけです。
私は、タイムラインにエログロコンテンツを表示しない仕組みの1つとして、以下のような機能を実装しました。
ユーザーが何か投稿すると、Cloud Functionsがトリガーされ、その投稿内容をチェック、センシティブな内容でなければis_sensitiveフラグをfalseにします。
まずはCloud Functionsの実装から始めました。そして、その実装が完了しました。firestore.rulesとFunctionsをデプロイしました。うまく動いています😁良かったです😁
リポジトリでtagを切ろうとしました。。。が、、、あれ?これバージョンいくつだ???となりました。今まではアプリのリリースバージョンでタグを切っていたからです。まだアプリの方は全然開発できていません。。
ここから私は「憤怒の罪」にある通りリリースまで非常に長い間開発をすることになりました。
私はWIPの状態のタスクを持つのは嫌いです。Functionsが実装できたならそれはそれだけで出荷して、その開発のことは忘れたいです。
今回のケースではFunctionsがデプロイされてから、アプリがリリースされるまでの数ヶ月間、Gitにおける最新のtagと違うバージョンのソースコードがproduction環境にて動くことになりました。これは健全ではないと思っています。
場合によっては今回のケースに当てはまらない状況もあるかとは思いますが、こういった状況が起きうるということを知っていただき、リポジトリ戦略決定の一助になればと思います🙏
暴食の罪:Firestoreの料金を全く意識せずアプリを実装してしまった
罪状
Firestoreの料金を意識せず実装した結果、無料枠を**〈暴食〉**し使い切り、その結果アプリを停止させた。
詳細
正直個人のアプリなので、無料枠とかどうやっても超えないでしょって思ってました。なので、最初開発するときは全くお金の観点は考えていませんでした😇
今回超えたのはFirestoreの1日の読み取り上限です。Firestoreの無料プラン(Spark)では1日の読み取り上限は5万ドキュメントです。
私が開発しているアプリには、「トレンド」という画面があります。簡単に言えばタイムラインのようなもので、他人が投稿した内容が新着順にずら〜っと並びます。Wishesでは「posts」のようなコレクションがあり、それ以下にユーザー全員の投稿内容を保存しています。「トレンド」画面において、初めは以下のようなソースでFirestoreのデータを監視していました
Stream<QuerySnapshot> getLatestPosts() {
return Firestore.instance
.collection('posts')
.orderBy('created_at', descending: true)
.snapshots();
}
わかります?これ全ユーザーの投稿内容、全件を監視しているんですね😇
最初はただただ感動します。友達と2人でα版をインストールして、一方が投稿するとリアルタイムに他方のタイムラインに表示される。Flutter+Firebaseスゲー!ってなりました。
そしていざリリースしてみると、嬉しいことに結構使ってくださる方がいらっしゃって、リリースから3ヶ月経たない内に1000件以上の投稿がありました。そうするとアプリを起動してトレンドを見るだけで1000件読み取りが走るわけです。
しかもこの場合、監視しているわけですから、何かドキュメントに変更がある場合、その更新ドキュメント個分またお金がかかります。
私のアプリは投稿内容一覧を見ているときにいいね!ができます。そしていいねをするともちろんPostドキュメント中のlikeというフィールドの値がインクリメントされるわけです。ドキュメントの構成はこんな感じです。
post {
title: "アメリカに行きたい",
author: "Nobunaga Oda",
like: 2
}
つまり、アプリを開いていいね!をするごとに1件ずつ読み取り料金がかかることになります。もちろんこれ以外にもユーザー情報など様々なデータをFirestoreより取得します。
1日の読み取り上限は無料プランだと50,000ドキュメントでした。アプリを開いたら必ずトレンドを見るという前提にたてば1日にアプリを起動できる回数は全ユーザー合計50回未満ということになります😇なんじゃこりゃ😅
というわけで、急いで修正を入れました。とりあえず表示する行数を減らしました
Stream<QuerySnapshot> getLatestPosts() {
return Firestore.instance
.collection('posts')
.orderBy('created_at', descending: true)
.limit(200) // <- これ
.snapshots();
}
一旦応急処置としてこの修正をリリースしました。その後、現在はLazy Loadの仕組みを実装しトレンド画面を見たときには最初30ドキュメント程度の読み取りしか発生しなくなりました。(もちろん、スクロールすれば続きを見られます。ただその場合でも最大200件程度しか見せないようにしました。)
現在では、1日に1000回アプリを起動しトレンドを見ても無料プランの枠内に収まるくらいにはなっていると思います。
読み取り料金はドキュメント数に応じてかかるので、1ドキュメント内の配列にたくさんデータを入れれば安く済む、といった方法もちょくちょく見られますが、ドキュメントにはサイズの制限(1MiB)があるのでやはり数が多くなる場合はコレクションとして持つのが良いでしょう。もしそうなった場合は意外と無料枠は少ないのでご利用は計画的に、というお話でした🙏
憤怒の罪:アップル様の審査通過にとんでもなく時間を要した
罪状
iOSのアプリ審査担当者の対応に**〈憤怒〉**してしまい開発が手につかず審査通過までとんでもない時間を要した
詳細
この罪は、要約すれば自分が開発が遅くて時間かかってすみません、という話です😇
しかし、その過程でアップルの審査基準?というか方針?が少し見えたのでそれを共有したいと思います。
私のアプリは、ユーザーが自由に投稿でき、それらを表示するため以下の「ユーザー生成コンテンツ」規約が適用されます。
しかし、バージョン1.0.0のリリース時にはこの規約について全く触れられませんでした。フィルタ機能やブロック機能が無いままリリースできたということです。そして、投稿件数も増えてきたバージョン1.3.0のリリースで突如この規約によって指摘をされました。審査落ちです。規約で指定されたフィルタ機能やブロック機能等を全て実装するように言われました。
この指摘はもちろん正しいです。しかし、この1.3.0には「暴食の罪」でも触れたデータ大量読み取り問題の修正(.limit(200)を追加した修正)が含まれていました。早くリリースしないと料金が膨れ上がります。私は審査担当者に
「次の1.4.0にてフィルタ機能やブロック機能は実装するから、まずはお金が大量にかかる致命的なバグを修正したこのバージョンをリリースさせてください😭😭😭」
という旨のメッセージを送りました。
すると、なんと、**審査を通過したのです!!**アップル様も人間だ!融通聞いてくださる!!ありがとう!!!俺頑張るよ!!!!
そして一ヶ月ほど経ち、フィルタ機能ができました。ここで私は思いました。
「ブロック機能とかまだ無いけど、機能ができ次第順次リリースした方が、ユーザーもHAPPY!俺もHAPPY!ストアに並ぶアプリの品質も上がり**AppleもHAPPY!**やん!!」
そして審査に提出しました。落ちました。まぁこれは予想通り。そこで
- 継続して開発は進めていること
- 少しずつリリースした方が皆幸せになれること
- 今リリースしてもしなくても最終的に規約を満たすための全ての機能が実装される時期は変わらないこと
を伝えました。すると、なんと!落ちました😇
これには納得できず、ここから3往復くらいメッセージのやりとりをしましたが全く許可してもらえず、遂にAppleから電話がかかってくることになりました。(メッセージのやりとりは英語でしたが、電話は日本語でした)
ア 「もしもし、審査の件で電話しました。」
私 「はい、承知してます」
ア 「これこれこういう理由でダメで、さらにこういう理由で・・・」
私 「はい...(ダメな理由は分かっているんだがなぁ)」
ア 「それからこれも実装されてないしこれも実装されてないし・・・」
私 「はい...(それでも皆幸せになるはずだから出荷させてほしいんだけどなぁ)」
ア 「以上から、ダメです。」
私 「あ、はい。それは理解しているんですが、それでも一部ずつでもリリースした方が皆がハッピーになれると思うんです。」
ア 「いいえ、実装が基準を満たしていないのでリリースできません。」
私 「もし今リリースしても、最終的に機能が出揃う時期は変わりませんし、悪いことはないと思うのですが…?」
ア 「いいえ、実装が基準を満たしていないのでリリースできません。」
私 「でも、アップルさんにもメリットがあると思いませんか??」
ア 「・・・というかそもそもこの電話はネゴシエーションのためではありません。」
私 「はい・・・?というと...?」
ア 「どういう理由でリリースできないかを説明するための電話です。」
私 「え、あ、、、そういう意味では理由を理解はしていま...す。。」
ア 「はい、それでは。」 ガチャッ ツー ツー
厳しい😭 というわけで私は諦めて(私が無能故非常に長い時間をかけて)全ての機能を実装し、まとめてリリースしたのでした...
アップルの審査について自分の経験をまとめると
- ひと目でわかるような指摘が漏れることも全然あるぞ!
- お金的に厳しい等の理由であれば大目に見てくれるかもしれないぞ!
- でもやっぱりとっても厳しいぞ!!!
ということでした。もし指摘漏れがあっても、急にガッツリ指摘されます。最初からしっかり対応しておきましょう🙏
傲慢の罪:Firestoreセキュリティルールの作成を後回しにしてしまった
罪状
Firestoreのセキュリティルールなんて最後に書いてもどうにかなるっしょ!という**〈傲慢〉**さによって最後にテーブル設計の変更やソースの大幅変更などの改修が発生した
詳細
聡明な皆さんならばやらないとは思いますが私はFirestoreのセキュリティルール作成を最後に回しました。どうにかなるっしょ、と思っていたしそれ以上に動くものを作るのが楽しくて熱中するように開発していました。が、やはりそのツケはエグかったです😇
新たなコレクションが必要になりましたし、ドキュメントのID設計も変更することになりました。
といっても、アプリ側を作りきらずにセキュリティルールを作るのって結構難しくないですか?そんな方のためにアプリ作成時でも最低限意識しておくと後の戻りが少なくなるであろう観点を2つ挙げておきます。
1. 違うセキュリティレベルのフィールドを同じドキュメントに入れていないか
2. 不整合を検出したいドキュメントを一意に特定できるか
とりあえずこれだけ意識しておけば、後からの改修量をだいぶ抑えることができると思います。もう少し詳しくお伝えします。
違うセキュリティレベルのフィールドを同じドキュメントに入れていないか
以下の例では他のユーザーに公開していいdisplayNameフィールドと公開してはいけないemailが同じドキュメントに存在します。
/users/{userId} {
displayName: "HIMIKO"
email: "uranai_suki@ymtkk.com"
}
セキュリティルール上、ドキュメント内は全て同じスコープになるのでこれはいけません。別のコレクションを作成して、公開レベルによって使い分けましょう。
不整合を検出したいドキュメントを一意に特定できるか
こちらは少し分かりにくいかもしれませんが、Firestoreでは「あるデータAを作成する際にBというデータが存在するか」をセキュリティルールでチェックすることが可能です。
例えば、あるポストを投稿するときに、投稿主となるユーザーが存在しているかの不整合チェックを行う場合以下のように書きます。
(postのデータのauthor_uidフィールドにユーザーIDが格納されているとします)
match /posts/{postId} {
allow create: if exists(/databases/$(database)/documents/users/$(incomingData().author_uid));
}
このとき、exists
句はdocumentのIDを指定する必要があるので、不整合チェックの際にIDが不明なドキュメントの存在確認はできません。シンプルな構造だとこの問題は発生しにくいですが、発見してからだとかなり面倒ですので頭の片隅にでも入れておくと良いかもしれません。
サーバーサイドの要となるセキュリティルールをいい加減にやって良いわけがありません。傲慢にならず、真摯に向き合いましょう🙏
強欲の罪:Twitterログインでかなりの時間を無駄にした
罪状
Twitterログインは何が何でもflutter_twitter_loginを使って実装するんだという**〈強欲〉**により実装時間を無駄にした
詳細
アプリ開発を始めたときに、ログイン方法はGoogleとTwitterをサポートしようと決めました。ハマりどころはありながらも、Googleは実装できました。嬉しいです😁
次にTwitterのライブラリを検索しました。ありました😁
*flutter_twitter_login*というもののようです🧐実装方法も似ているから楽勝そうです!😁
開発を進めます。
あれ、なんだかAndroidでうまく動きません😢iOSでも一部挙動がおかしいです😢
そもそも、Twitterのアプリがインストールされていないとうまく動かないようです😢
アプリがインストールされていないときの解決策を見つけました😁
が、それもうまく動きません😢色んなケースでテストするとなんだか動きが怪しいところがいっぱい…😢
というか、そもそも
flutter_twitter_loginライブラリ内で使っている、TwitterKitがもう開発終了となっています…😭
これは使わないほうが良いやつだ…しかももうリリースまで時間が無い…
泣く泣く私はTwitterログインを閉じたのでした…😭
これからTwitterログインを実装する場合は、TwitterのWebAPIを使うのが良さそうです。今ではそういったものを実装してみた系記事もたくさんあるようなのでそちらを参考にしてみてください🧐
以上、flutter_twitter_loginで工数を無駄にしたお話でした🙏
色欲の罪:収益化についてあまり深く考えず開発を開始した
罪状
奥さんからの「そのアプリリリースされたら絶対使うよ!」という言葉に甘え**〈色欲〉**から収益化を深く考えず開発を開始してしまった。
詳細
まずは月の収支を晒しましょう😇年単位でかかるものは÷12しています。
思いっきり赤字ですね😅正直今だとFirebaseは無料プランでもいけるときが多いですが、止まるのが怖いので月25ドル課金しています。
しばらく運用してみて感じるのは、やはり黒字化は難しいということです。開発時は
- これいきなりめっちゃダウンロードされたらどうしよう😁
- めっちゃ流行って電車とかでアプリ使ってる人見つけちゃったりして😁
- 企業からの広告をつければ黒字とか余裕ちゃうか😁
なんて思ってましたが、無駄な妄想でした笑 そもそも開発が追いついておらず、機能も足りないし、現状のアプリで黒字化できるイメージが全くありません。
そしてもう1つ最近気づいたことがあり、それは開発時に黒字化までの推定所要時間を全く考えていなかったということです。かけたコストをいつまでに回収するか、という観点が開発時には全くありませんでした。
開発を始めた当初は、以下のような収入源を考えていました。
- 広告収入
- 定額課金
- テーマやフォントの販売
- 企業広告
これらを全て行えたら黒字になって運用していけるかなぁ〜となんとなく考えていました。しかし、そもそも今の自分の開発スピードではこれらの機能を揃えるのにとてつもない時間がかかってしまいそうです。
さらに自分の場合は、アプリをクローズする前に、できる施策は全て打つつもりでいます。それはつまり、アプリを切り捨てる判断をするまでに非常に長い時間がかかってしまうということになります。
つまり、収益化を考える上では単に「儲かるかな?🤪」だけではなく、
- いつまでにコストを回収する見込みか
- アプリをクローズする条件は何か
を考えることは個人開発でも大事な観点だなと思うようになりました。
個人開発では、その体験自体が勉強になり資産になるので一概にお金だけで判断すべきものでは無いと思いますが、お金も時間も無限では無いので、やはりシビアに考えなければいけないなぁと再確認しました。
ちなみに、次やるならコストを下げたローカルのみで完結するアプリか、しっかりお金を払ってでも使いたくなるような業務改善系のものをやりたいなと思ってます😅
お金と経験のバランスを取りつつ頑張っていきましょう🙏
二つの余罪
惜しくも大罪から溢れてしまった罪を2つほど紹介します。余罪はサクッと短くお伝えします。
罪状:Firebaseコンソールで作業をしてアプリを停止させた
ある日、アプリにて40件ほどのデータ不整合が発生しました!大変です!!
しかし自分は管理画面をまだ実装していなかったので、Firebaseのコンソールから作業をしました。幸い、不整合が発生したドキュメントのIDは分かっていたので、URLに直打ちしてドキュメントを表示してデータを書き換えました。これを40回ほど繰り返したのです。
その後、アプリの表示を確認すると。。。アプリの画面が表示されません😇そうです、無料枠を超過し、Firebaseが停止したのです😇
皆さんはFirestoreのデータをコンソールに表示するだけでもお金がかかるのはご存知ですか?私の検証結果では(公式ドキュメントの記述があったわけではなく、実測値です)大量にドキュメントの存在するコレクションをwebコンソールで表示するだけで613ドキュメントの読み取りが走りました。単純計算ではこれを40回は繰り返したので約24000件の読み取りが走ったことになります。これだけで無料枠の半分くらいを使ってしまっています。
Firebaseコンソールでは、Databaseというメニューを選択すると、一番上のコレクションの内容が勝手に表示されます。コレクションはアルファベット順にならぶようですので、場合によってはドキュメントが1つしかない「a」という名前のコレクションを作っておくと費用を抑えられるかもしれませんね🤔
あまり気軽にコンソールは使うものではないなぁという教訓を得ました🙏
罪状:バージョンを振り間違えた
これは紛うことなき凡ミスです。最初の「嫉妬の罪」中のスクリーンショットにて、1.0.1の次が1.1.1になっていることにお気づきだったでしょうか?😇手作業をするとこういうことがありえるんですねぇ... まともに使ったことないんですが、Codemagicとかならこういうミスはなくなるんでしょうか?🤔
自動化したいなぁと思った話でした🙏
おわりに
長文・駄文失礼しました。
簡単で当たり前の内容も含まれていたかもしれませんが、それでも少しは誰かのためになるだろうと思って書きました。普段はよくTwitterにいますので、「なんやこいつおもろそうやな!😁」と思った方はぜひ絡みにきてくださいm(_ _)m
筆者のTwitter
おまけ
- Qiitaのdart用シンタックスハイライト初めて使いましたが、awaitには色をつけてほしかったなぁ〜という、そんな気持ち👶
- Flutter Advent Calendar だけど Firebaseが結構多くなってしまいました。許してください。お願いしますm(_ _)m
- 七つの大罪、実は全く読んだことがなくて、友人との会話で「憤怒」って言ったら「それは七つの大罪の内の1つやね」とか言われて、????ってなって、ああそんな漫画聞いたことあるぞ!と思って。その後この記事を書くときに、内容が失敗集だしまさに大罪かもと思ってめっちゃWikipedia見ながら記事書いてたんですが、なんだか面白そうな漫画だから興味もってきました🤔ライジングインパクトはうっすら読んでた記憶があって、どっかのタイミングで七つの大罪も読めたらなぁと思ってます😁
- ほんとセキュリティルールを書くと無意識的にサーバーサイドが担ってた役割がわかる。サーバーサイド書いてたらあるDBのあるフィールド(カラム)にstring入ったりint入ったり普通しないもんなぁ
- firestore.rulesのシンタックスハイライト、何の言語にするか悩んだんですが、
shell
使うことにしました。そうすると$もワーニング出ずに済むので。迷ったらおすすめです✋ - 個人開発は資産になると言いましたが、自分の場合は特に転職活動にてその話を出来たのが大きかったです。何せ自分で技術選定から全てやってますので、より具体的な話ができ良い結果が得られました。