はじめに — このアプリを作ろうと思った日
2026年の春、私はひとつの小さな不満を抱えていた。毎日ランチを食べるたびに「あの店、先月も行ったっけ?」「今月いくら使ったんだろう」と思いながらも、記録する手段がなかった。食べログやGoogleマップはお店を「探す」ためのアプリであって、自分の食事履歴を「管理する」ためのものではない。メモアプリに書いても、写真フォルダを漁っても、結局どこで何を食べたか振り返るのは面倒だった。
「だったら作ってしまえばいい」——そう思ったのが始まりだった。Androidアプリの開発経験はあったが、一からサービスとして完成させてPlay Storeに公開したことはなかった。これを機にやってみようと決めた。ただ記録するだけじゃなく、Googleドライブへのバックアップ、地図での可視化、友達との共有まで入れたら面白いんじゃないかと構想が膨らんでいった。
こうして始まったのが「Lunch Log」の開発だ。最初は「2週間もあれば完成するだろう」と思っていた。だが実際には、42件のバグを修正し、設計を根本から覆すことが何度もあり、Play Storeの審査に弾かれ、気づけば3ヶ月近くが経っていた。この記事は、そんな開発の裏側を包み隠さず書いた記録だ。技術的な詳細というよりは、何に悩み、どう判断し、どう乗り越えたか——そういう話を書きたいと思う。
第1章 — マップが赤ピンだらけになった日
最初に実装したのはメイン画面、つまりGoogleマップにランチ記録をピンで表示する機能だった。記録した店舗の位置に写真付きのカスタムマーカーを表示して、同じ店に複数回行ったら訪問回数のバッジを付ける——そういうビジョンがあった。
ところが、動かしてみると画面は赤いデフォルトピンと青い円形のクラスターアイコンで埋め尽くされた。Googleマップの標準マーカーそのままだ。Jetpack Composeの公式サンプルにあるClusteringコンポーザブルを使ったのだが、カスタマイズの余地がほとんどなかった。ドキュメントを読んでも、カスタムレンダラーをどう差し込むか書いていない。
数時間格闘した末に気づいた。Clusteringコンポーザブル自体を捨てて、Androidの伝統的なClusterManagerを直接使うしかない、と。Composeの世界で従来のViewシステムのオブジェクトを扱うにはMapEffectというAPIを使う。マップが初期化された後のタイミングでのみ呼び出せる仕組みで、ここにClusterManagerの初期化処理を書き込んだ。
カスタムレンダラー(CustomClusterRenderer)ではAndroidのCanvasAPIを使って写真付きのマーカービットマップを手描きした。丸くクリッピングした写真の下に三角形のポインターを付けて、右上に訪問回数のバッジを乗せる。ピクセル単位の計算が必要で、バッジの円がビットマップの端で切れてしまうバグにも悩まされた。最終的にビットマップのサイズにパディングを追加して解決したが、この「2pxのズレ」を直すのに半日かかった。
完成したマーカーは想像通りの出来だった。オレンジ色のポインター付き丸型マーカーに写真が入り、「3」などのバッジが右上に表示される。初めてエミュレータでそれを見たとき、思わず声が出た。あの赤ピンだらけの画面から、たった2日で別物になっていた。
第2章 — リマインダーが絶対に鳴らなかった理由
「毎日12時に通知を送る」という機能は一見シンプルに思えた。WorkManagerのPeriodicWorkRequestを使えば定期実行できる。実装して端末で試すと、通知は届いた。完璧だと思った。
しかし数日後、気づいた。通知が来ない日がある。毎日来るはずなのに、12時を過ぎても何も届かない日が週に何度かあった。調べてみると、WorkManagerのPeriodicWorkRequestはAndroidのバッテリー最適化の影響を受け、指定した時刻通りに動作することを保証しないという仕様だった。15分の誤差は「想定内」とドキュメントに書いてある。しかし実際にはもっとひどく、数時間ズレることもあれば、そもそも発火しない日もあった。
これは根本から作り直す必要があった。正確な時刻に発火させるにはAlarmManager.setExactAndAllowWhileIdle()を使うしかない。WorkManagerを完全に廃止して、AlarmManagerとBroadcastReceiverの組み合わせに切り替えた。仕組みはシンプルで、指定時刻にアラームを発火させ、ReminderReceiverで受け取ったら通知を表示し、翌日の同じ時刻に次のアラームを登録する——これを毎日繰り返す「自走型」だ。
ところが、またしても通知が来なかった。ADBで確認するとアラームは正しく登録されており、発火もしている。なのに通知が出ない。なぜだ。
原因はコルーチンにあった。BroadcastReceiverのonReceive()内でgoAsync()を使い、コルーチンを起動してDataStoreから設定を読み込んでいた。しかしAndroidはBroadcastReceiverの処理が完了したとみなすと、その直後にプロセスを強制終了することがある。コルーチンはまだ動いているのに、プロセスが先に終わってしまっていた。解決策はrunBlockingで同期的にDataStoreを読み込むことだった。コルーチンを使わず、その場でブロックして読み込む。パフォーマンス的には若干劣るが、確実に動く。
この問題を直してから、リマインダーは一度も外れることなく動き続けている。「正確な通知」という当たり前の機能のために、2つの根本的な設計変更が必要だった。
第3章 — 再インストールしたら全データが消えた
Driveバックアップ機能を実装したとき、最初はdrive.fileスコープを使った。Googleドライブの通常のフォルダに「LunchLog」というフォルダを作り、そこにバックアップJSONと写真ファイルを保存する方式だ。実装は動いた。バックアップも復元も成功した。完璧だと思って開発を続けた。
ある日、テストのためにアプリをアンインストールして再インストールした。Drive復元を実行すると——0件。データが何もない。パニックになってDriveを確認すると、バックアップファイルはちゃんと存在している。なのになぜ取れない。
調査に数時間かかった。原因はGoogleのdrive.fileスコープの仕様にあった。このスコープでは「現在のOAuthセッションでアップロードしたファイル」しかfiles.listのAPIで返ってこないのだ。アンインストールすると新しいOAuthグラントが発行される。すると旧グラントでアップロードしたファイルは検索結果に含まれなくなる。ファイルはDriveに存在しているのに、APIからは「見えない」状態になる。
これは致命的な問題だった。機種変更や再インストールのたびにデータが消えるなら、バックアップ機能として成立しない。解決策はdrive.appdataスコープへの完全移行だった。appDataFolderはGoogleドライブのアプリ専用隠しストレージで、OAuthグラントをまたいでも同一Googleアカウントであれば常にアクセスできる。ユーザーのドライブには見えないが、確実にデータを保持してくれる。
移行作業は大掛かりだった。DriveRepositoryのほぼ全メソッドを書き直し、既存ユーザーのデータ移行も考慮が必要だった。それだけでなく、共有機能(友達にQRコードで公開する)は公開URLが必要なのでdrive.fileスコープも残さなければならない。結果的にOAuth認証で両方のスコープを同時に取得する形に落ち着いた。
この問題に気づかずリリースしていたら、機種変更したユーザーは全員データを失っていただろう。テスト中に発見できてよかったと心から思った。
第4章 — 友達機能を作ったら自分のデータが消えた
友達とランチ記録を共有する機能を設計するとき、最大の課題はデータの区別だった。「自分の記録」と「友達から取り込んだ記録」を同じデータベースに混在させるには、どう区別するかを決める必要がある。
考えた末にownerTagというフィールドをLunchEntryに追加することにした。自分の記録は"self"、友達の記録はその友達の表示名(例:"田中")を入れる。シンプルな設計だ。
実装が完成してテストしてみると、友達の記録を取り込めた。マップに友達のマーカーが表示された。順調だと思っていた。ところが翌日、バックアップして復元したら自分の記録が一部消えていた。
原因を調べると、いくつかの場所でownerTagフィルタが漏れていた。バックアップ処理が全エントリを対象にしていたため、友達の記録もバックアップJSONに含まれていた。ZIPエクスポートも同様だった。復元時に「全件削除して全件insert」という処理をしていたため、エクスポートしたZIPには友達データが含まれないので、インポートすると友達データが消える。さらに、Driveバックアップを復元すると友達データも上書きされる——という連鎖が起きていた。
修正は地道だった。バックアップ・復元・エクスポート・インポート・Drive同期のすべての処理にfilter { it.ownerTag == "self" }を追加していった。また「全件削除して全件insert」という危険な処理を廃止し、「自分の記録だけ削除してから自分の記録をinsert」するように変えた。友達データには手を触れない。
この経験から、データの所有者を示すownerTagは単なるフィールドではなく、あらゆるデータ操作に影響するアーキテクチャ上の設計判断だと気づいた。最初からもっと慎重に設計すべきだったと反省している。
第5章 — Places APIの請求額を見て青ざめた話
行きたいリスト機能を実装した当初、詳細シートを開くたびにPlaces API(New)を呼んで営業時間・評価・価格帯を取得していた。1回の取得が約0.04ドル。100人が1日に10回開いたら40ドル。月に換算すると1200ドル。もちろんそんなにユーザーがいるわけではないが、GCPのコスト試算をしていてぞっとした。
さらに調べると、Places APIのToS(利用規約)に「取得したデータをキャッシュしてはならない」という条項があった。評価や営業時間はvolatile data(変動するデータ)として、DBへの保存が禁止されている。つまり「毎回APIを呼ぶ必要があるが、呼ぶたびに課金される」というジレンマだ。
解決策はシンプルだった。詳細シートの自動取得をやめた。代わりに「詳細を見る」ボタンをタップしたときだけAPIを呼ぶ方式にした。ユーザーが能動的にアクションを起こさない限り課金されない。これでコストは劇的に下がった。
同様に、記録追加画面で店名を入力するたびに周辺店舗を自動検索していた機能も見直した。以前は入力中に500msのデバウンスをかけて自動でNearby Searchを呼んでいたが、これも「周辺のお店を表示」ボタンを押したときだけ実行するよう変更した。
GCPには月の請求が一定額を超えたら通知するバジェットアラートを設定した。¥500を超えたら即通知。今のところアラートは一度も来ていない。コスト意識を持った設計というのは、機能を削ることではなく、「本当に必要なタイミング」だけAPIを呼ぶ設計にすることだと学んだ。
第6章 — ProGuardで全機能が消えた日
リリースビルドに向けて、コードの難読化・圧縮を担うProGuard(正確にはR8)を有効化した。isMinifyEnabled = trueにするだけでいい——と思っていた。
リリースビルドを実行して端末にインストールした。アプリは起動した。しかしマップを開くとクラッシュ。設定画面を開くとフリーズ。バックアップを実行するとサイレントに失敗する。デバッグビルドでは動くのに、リリースビルドでは何もかもが壊れていた。
R8は使われていないと判断したクラスやメソッドを削除し、残ったものは名前を難読化する。問題は、R8が「使われていない」と判断したものの中に、実際には実行時にリフレクション経由で呼ばれるクラスが含まれていたことだ。GsonはJSON⇔Kotlinデータクラスの変換にリフレクションを使う。Roomのクエリ生成も同様。HiltのDIコンテナ生成も。これらのクラスが削除されると、実行時に例外が発生して機能が壊れる。
proguard-rules.proに各ライブラリのkeepルールを追加していく作業が続いた。Gson用、Room用、WorkManager用、Coroutines用、Hilt用、ML Kit用、CameraX用——それぞれのライブラリが独自のルールを要求する。公式ドキュメントに書いてあるものもあれば、エラーメッセージから逆引きしないとわからないものもある。
半日かけてすべてのルールを整備し、リリースビルドが正常に動くようになった。isMinifyEnabledとisShrinkResourcesの両方を有効にしてもクラッシュしないことを確認したとき、本当に安堵した。ProGuardは敵ではなく、正しく設定すれば強力な味方になる。ただしルールの整備はとにかく面倒だ。
第7章 — 友達のマーカーを色分けしたかった
友達のランチ記録をマップに表示するとき、「誰の記録か」を色で区別したかった。友達Aはブルー、友達Bはパープル、自分はオレンジ——そういうビジュアルだ。
最初は1つのClusterManagerに自分と友達の記録を全部入れていた。これだと色分けはできたが、新たな問題が生じた。自分の記録と友達の記録が同じクラスターにグループ化されてしまうのだ。「田中のランチ2件と自分のランチ1件」が1つのクラスターになる。これは直感的でない。
解決策はClusterManagerを2つ用意することだった。ownClusterManager(自分専用)とfriendClusterManager(友達専用)を独立して管理する。自分の記録はownClusterManagerに、友達の記録はfriendClusterManagerに入れる。それぞれ独自のCustomClusterRendererを持ち、色を制御する。
ただし2つのClusterManagerを使うとマップのタップイベントが複雑になる。どちらのClusterManagerのマーカーがタップされたか判定しなければならない。map.setOnMarkerClickListenerを両方のCMに委譲するカスタムリスナーを実装し、先にownClusterManagerが処理を試み、処理できなければfriendClusterManagerに回すという形にした。
友達の色分けは6色パレットを用意し、ownerTag(友達の表示名)を昇順ソートした際のインデックスを使って決定論的に色を割り当てた。同じ友達は常に同じ色になる。マップ左上に凡例(レジェンド)を表示し、「自分 / 田中(ブルー)/ 山田(パープル)」のように色の意味を示した。
完成したマップは思った以上に見やすかった。自分のオレンジのマーカーと友達の青いマーカーが混在しながらも、誰がどこで何を食べているかが一目でわかる。機能的な要件に加えてビジュアル的な達成感もあった実装の一つだ。
第8章 — Play Store審査で権限を削られた
v2.0のリリースに向けてPlay Consoleで製品版リリースを申請しようとしたとき、審査前のチェックで2つの権限についての警告が出た。
1つ目はUSE_EXACT_ALARM。これは「正確なアラームを使用する権限」で、Googleは「カレンダーアプリや目覚まし時計アプリだけが使用できる」と明記していた。Lunch Logは昼食記録アプリであってカレンダーや目覚まし時計ではない。リマインダー通知のために正確なアラームを使いたかったのだが、用途として認められていなかった。
2つ目はREAD_MEDIA_IMAGESとREAD_EXTERNAL_STORAGE。これらは写真へのアクセス権限だが、Lunch Logではシステムの標準フォトピッカー(ActivityResultContracts.GetContent())を使っているため、そもそもこの権限は不要だった。フォトピッカーを使う場合、システムがユーザーの代わりに写真を選択して渡してくれるため、アプリ側がギャラリーに直接アクセスする権限を持つ必要がない。コードを確認すると確かに実行時にこれらの権限を要求する処理がどこにも存在していなかった。
つまり当初から不要な権限をManifestに書いていただけだった。Androidのリマインダー機能を実装したとき「念のため」と思ってUSE_EXACT_ALARMとUSE_EXACT_ALARMを両方書いてしまったのが原因だ。すべてManifestから削除した。
権限の削除はバイナリ互換性に影響するため、新しいAABをビルドし直す必要があった。しかしビルドのたびにPlay ConsoleにアップロードしてはエラーになってversionCodeを消費してしまい、最終的にversionCode=29でv2.0のリリースが完成した。本来は25で済んだはずが、試行錯誤で4つのversionCodeを無駄にした。Play Consoleでは一度使ったversionCodeは破棄しても再利用できないため、今後は事前確認を徹底しようと誓った。
また、広告IDの申告、正確なアラームの用途申告、カメラ・位置情報の使用申告など、コード以外にPlay Consoleのフォームへの記入が多いことも初めて知った。「アプリを作れば公開できる」という認識は甘かった。審査のプロセス自体がひとつの作業工程だった。
第9章 — 42件のバグと向き合った日々
DEVLOGを振り返ると、バグ番号が42まで付いている。3ヶ月の開発期間で42件。単純計算すると2日に1件のペースでバグを発見して修正していたことになる。
バグにはいくつかの種類があった。まず「実装し忘れ」系。カレンダー画面に削除ボタンがない、ギャラリーから削除できない——これらは単純な追加実装で解決した。次に「Android固有の仕様との衝突」系。通知チャンネルの未作成、権限リクエストの漏れ、BroadcastReceiverのライフサイクル問題——これらはAndroidの仕様を知らなければ起きないバグで、調査に時間がかかった。
最も手強かったのは「タイミング系」のバグだ。カレンダーで月を素早く切り替えると前月のデータが混入する問題がそれだ。CalendarViewModel.loadMonth()が呼ばれるたびに新しいコルーチンを起動するが、前のコルーチンをキャンセルしていなかった。月を連続して切り替えると複数のコルーチンが同時に走り、最後に完了した月のデータで上書きされる競合状態が発生していた。loadJob?.cancel()を追加して前のコルーチンを明示的にキャンセルするだけで直ったが、発見まで時間がかかった。
面白かったのは「バグを直したら別のバグが出る」という連鎖だ。友達のマーカーをClusterManagerに統合したら友達記録が少ないエリアでクラスタ化されなくなった(バグ8)。ClusterManagerを2分割したらタップイベントが競合した。修正すると今度は別の画面でイベントが拾えなくなった。こういう連鎖は一歩引いてアーキテクチャを俯瞰しないと解決できない。
バグ番号を付けて管理していてよかったと思う。DEVLOGに「バグN:原因・修正」を記録していくことで、同じパターンのバグに二度ハマらずに済んだ。また、複雑な修正を加えた後に「あのとき直したバグ34の件、今回の変更で再発していないか」と確認する習慣もできた。ソロ開発でもドキュメントは書くべきだ、というのがこの経験から得た教訓のひとつだ。
第10章 — 審査に出して待つ夜
2026年6月7日、Play ConsoleでversionCode=29のAABを製品版トラックにアップロードし、審査に送信した。「レビューに送信する」ボタンを押した瞬間、不思議な達成感と不安が同時に押し寄せた。
3ヶ月弱で42件のバグを直し、設計を3回大きく変えた。Drive appDataFolderへの移行、ownerTagによるデータ所有者管理、ClusterManagerの2分割——どれも最初の設計には含まれていなかった。アプリとは、作り始めた時点の想像を超えて育っていくものだと思った。
一番印象に残っているのは、友達に初めてQRコードを読み取ってもらったときだ。自分の記録を友達のマップ上に表示させて「おお、ここ行ったんだ」と言ってもらえた瞬間、なんのために作っているのかが実感できた。バグを直している時間は孤独な作業だが、使われている場面を見ると全てが報われる気がした。
技術的に学んだことは多い。Composeのライフサイクル、DriveAPIのスコープ仕様、AlarmManagerの正確な動作、ProGuardのルール設定、Play Storeの審査プロセス——どれも「やってみないとわからなかった」ことばかりだ。ドキュメントを読んでわかった気になっていても、実際に動かしてみて初めて見えてくる仕様がある。
審査結果は数日以内に届く。承認されればPlay Storeに掲載される。そのとき「管理対象の公開」を使い、自分で公開ボタンを押してリリースする予定だ。自分が毎日使うために作ったアプリが、見知らぬ誰かのスマホにインストールされる日が来るかもしれない。その想像だけで、また次の機能を作りたくなっている。
おわりに
この記事を書きながら、開発中に悩んでいた時間が思った以上に多かったことに気づいた。ClusterManagerの赤ピン、消えたバックアップデータ、鳴らないリマインダー——どれも当時は「詰んだ」と思った問題だが、今振り返ると全部解決できている。
開発において「詰んだ」は一時的な状態でしかない。原因がわかれば必ず解決できる。原因がわからなければ、わかる方法を探せばいい。その繰り返しがソフトウェア開発の本質だと、42件のバグを通じて改めて思った。
Lunch Logはまだ完成ではない。GCPのクォータ設定、UIテストの充実、そして次のバージョンで追加したい機能もある。このアプリと一緒に、開発者としても少しずつ成長していきたいと思っている。
サラリーマンの相場道 