審査通過の通知が届いた翌朝、Play Console の段階公開スライダーを 5% に動かしました。Beautiful 4K/HDR Wallpapers v2.1.0(versionCode 49)と、Ukiyo-e Wallpapers v1.8.0(versionCode 41)。押した瞬間から、Crashlytics のタブを閉じられなくなります。
新しい機能を書いている時間より、公開したあとに数字を見ている時間のほうが長い週があります。壊れていないことを確認し続ける作業には終わりがなく、しかも誰にも気づかれません。個人開発で複数本を並行して抱えていると、この時間が静かに一日を削っていきます。
公開から2週間で実際に踏んだ落とし穴と、毎朝の監視をどこまで Claude in Chrome に預けたか。ここでは、手元の実機で再現し、修正まで通したものだけを保守目線で書き残します。
公開直後の最初の48時間が一番怖い
Android の段階公開(Phased Rollout)は、5% → 25% → 50% → 100% の4段階で1段階あたり24〜48時間というのが私の標準です。一番神経を使うのは最初の5%が当たっている48時間で、ここで Crashlytics の Crash-free users が99.7%を下回ったり、ANR が0.20%を超えたりすると、即座に halt(段階公開比率を0%に戻す)判断をします。
v2.0.0 を公開したときは、ここで2回ほど halt して旧版に戻しました。何が起きていたかというと、WallpaperPagerAdapter と ThumbnailViewPagerAdapter のスクロール中に IndexOutOfBoundsException が頻発し、28日間で50ユーザー以上・56イベント以上のクラッシュが計上されました。
原因はリストへの参照を直接保持していたことで、データソース更新中に RecyclerView がアクセスして競合する状態でした。mList を防御的コピー化する1行修正で v2.1.0 にて根治しました。
// Before: 共有参照
public void setItems(List<Wallpaper> list) {
this.mList = list;
notifyDataSetChanged();
}
// After: 防御的コピー
public void setItems(List<Wallpaper> list) {
this.mList = new ArrayList<>(list);
notifyDataSetChanged();
}壁紙アプリを長く保守してきて手元に残った結論は、RecyclerView のクラッシュは大抵このデータソース共有参照に行き着く、という地味なものです。派手な原因はめったに出てきません。呼び出し側が渡したリストを後から clear() して詰め直す——それだけで、スクロール中のアダプタは足元を掬われます。
halt は「戻す」ではない、という誤解を解く
段階公開を止めたとき、私は最初「これで元に戻った」と思っていました。実際には違います。公開比率を 0% にしても、すでに v2.1.0 を受け取った端末が旧版へ戻ることはありません。halt でできるのは「これ以上広げない」だけです。
つまり 5% の段階で 10 万人規模のユーザーがいるアプリなら、halt した時点で数千人が壊れた版を持ったまま取り残されます。ここを取り違えると、halt して安心した数時間のあいだにレビュー欄が荒れます。私はこの誤解のせいで、v2.0.0 のとき初動を半日遅らせました。
だから halt の直後にやるのは、旧版に戻す操作ではなく、versionCode を上げた修正版を最短で出すことです。判断の閾値は次のように固定しています。数字を毎回考え直すと、深夜の自分が必ず甘い方に倒れるからです。
| 指標 | halt する線 | 次にやること |
|---|---|---|
| Crash-free users | 99.7% 未満 | 比率を 0% にし、当日中に hotfix の versionCode を用意する |
| ANR 率 | 0.20% 超 | 比率は据え置き、Play Console の ANR クラスタを先に読む(誤検知が多い) |
| 単一クラッシュの影響ユーザー | 5% 配信中に10人超 | halt。同一スタックなら再現条件を特定してから直す |
| 起動3秒以内のクラッシュ | 1件でも新規発生 | 即 halt。早期クラッシュは体感被害が最も大きい |
最後の行だけ、他より明らかに厳しくしてあります。起動直後に落ちるアプリは、ユーザーから見れば「開かないアプリ」で、原因の軽重とは無関係にアンインストールされます。Glide の desugar 漏れで踏んだのが、まさにこの種類でした。
minSdk 23 と Glide 5.0.5 の desugar 必須化
v2.0.0 を公開した直後、Android 6.0.1(API 23)を使っているユーザー全員が起動3秒以内にクラッシュするという報告が Crashlytics に上がりました。7日間で12イベント・4ユーザー、Crashlytics 側は「早期クラッシュ」タグを付けてきました。
原因は Glide 5.0.5 が内部で Java 8 の java.util.function.Supplier を使っており、minSdk 23 では desugar が必須になることでした。build.gradle の依存に coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5' は入っていたのですが、compileOptions { coreLibraryDesugaringEnabled true } のフラグが立っていませんでした。
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
coreLibraryDesugaringEnabled true // ← これが落ちていた
}
}
dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
}ライブラリの依存ツリーが Java 8 API を要求する場合、desugar の依存追加だけでは不十分で、compileOptions のフラグ有効化が必要だ、というのが地味だけど大事な学びでした。
Play Store の density split で消えたリソース
Google Pixel + Android 12 の一部ユーザーで、起動時に Resources$NotFoundException が出るというクラッシュが Crashlytics に上がってきました。MainActivity.onCreate:120 で参照している background_silver の Drawable が見つからないという内容です。
調べた結果、background_silver.jpg を drawable-xxhdpi/ と drawable-xxxhdpi/ にしか配置しておらず、Play Store の Android App Bundle が密度ごとに分割配信した際、低密度バケットの端末に届く APK にこのリソースが含まれていない、という事故でした。
修正は drawable-nodpi/ に同ファイルを配置することです。drawable-nodpi/ はどの密度の split APK にも含まれるため、確実に存在させたいリソースの置き場として安全です。
app/src/main/res/
├── drawable-xxhdpi/background_silver.jpg
├── drawable-xxxhdpi/background_silver.jpg
└── drawable-nodpi/background_silver.jpg ← これで全密度に届く
App Bundle 時代の Android では、密度別ディレクトリと nodpi のどちらに置くべきかという判断が、デザイン部門と運用部門の境界に落ちがちです。私は「確実に必要なリソースは nodpi、画質を優先したいものは密度別」というルールに揃えてからは、この種の事故は出なくなりました。
Claude in Chrome に任せている Crashlytics 監視
公開直後の48時間は人間が張り付くしかないのですが、それ以降は新規クラッシュの一次トリアージを Claude in Chrome に毎朝任せています。
具体的にやらせていることはシンプルで、Firebase Console の Crashlytics タブを開いて、前日新規発生したクラッシュを以下の条件で並べ替えてもらいます。
- Crash-free users が 99.7% を下回ったクラッシュ
- 影響ユーザー数が10人を超えたクラッシュ
- 「Active」ステータスで「Untouched」のクラッシュ
ここまでを Claude in Chrome に出してもらった後、本当に対応が必要なクラッシュかどうかを私が判断します。スタックトレースの読み取りまでは AI に頼みますが、修正パッチを書くかどうかは、影響範囲と修正コストを見て自分で決めています。
毎朝投げているプロンプトは、ほぼこの形で固定しています。指示を短く書くと Console の既定フィルタに流されるので、確認を明示的に一手挟ませているのが要点です。
Firebase Console の Crashlytics を開いてください。
アプリは Beautiful 4K/HDR Wallpapers、期間は「過去24時間」。
1. まず、いま画面で有効になっているフィルタ(期間・バージョン・
ステータス)をそのまま読み上げてください。想定と違う場合は
修正してから次へ進んでください。
2. Status = Active かつ Untouched の新規クラッシュだけを、
影響ユーザー数の多い順に列挙してください。
3. 各件について「影響ユーザー数 / イベント数 / 先頭スタックフレーム /
端末とOSの偏り」の4項目だけを表にしてください。
4. 推測での原因分析や修正案は書かないでください。3 で項目を4つに固定しているのは、出力が長くなるほど私が読まなくなるからです。4 でわざわざ原因分析を禁じているのは、もっともらしい推測が混ざると、スタックトレースを自分で読む手間を省いてしまうためでした。トリアージで欲しいのは解釈ではなく、素の観測値です。
ハマりどころとしては、Firebase Console の UI が時々レイアウト変更されるため、上の 1 番——「いまどのフィルタが効いているか」の読み上げ——を省けません。これがないと、期間フィルタが「過去7日間」のまま残っていて、既知のクラッシュが毎朝「新規」として再登場します。二度ほど同じ件を追いかけ直して、この一手を足しました。
ad-free 状態の合成判定という設計の話
v2.0.0 公開後の保守で、一番神経を使ったのが ad-free 状態の合成判定でした。Globals.SHOW_AD の真実は、2つの独立した永続ストアから合成されます。
BillingManager— 永久課金やサブスクリプションの状態AdFreeManager— リワード広告視聴で得た期間限定 ad-free の状態
ここで setAdFree(false) を直接呼ぶコードを1箇所でも書くと、BillingManager 側で ad-free を持っているユーザーが意図せず広告ありに戻る事故が起きます。鉄則は、常に合成判定(isAdFree || isRewardAdFree)を経由することです。
過去には BillingManager.restorePurchases() に論理逆転バグがあり、ad-free のままサブスク失効しても false に戻すパスが存在しないという事故もありました。inapp と subs の両クエリを並行実行し、両方完了してから合成判定する finalEvaluation パターンに刷新し、Activity.onResume() で restorePurchases() を呼ぶように変更したことで、起動中の失効も復帰時に検出できるようになりました。
個人開発で課金フローを触るときは、「Single Source of Truth が複数ある」という現実を最初に認めるところからスタートするのが、結局は近道だと思っています。
ユーザー設定 vs API デフォルトの上書きパターン
サーバー API が起動時に返す値(シャッフル、パーソナライズ広告の有効・無効など)と、ユーザーが Settings 画面で変更した値が衝突する問題があります。
私は各設定に *_USER_OVERRIDE フラグを別キーで持たせる方針で揃えました。
- ユーザー操作からの保存は専用の
*ByUser()メソッドを通す。このとき override も同時に立てる - システム由来の保存は override が立っていればスキップする
これによって、サーバー側でデフォルト値を変更してもユーザー設定が上書きされず、かつユーザー設定をリセットしたい場合は override を消すだけで API デフォルトに戻る、という運用が成立します。
多言語ユーザーレビュー返信のレート制限
最後に、保守の中で意外と重い作業がユーザーレビューへの返信です。4アプリ × 主要6カ国分で App Store だけで72件の代理返信を1セッションで処理したことがありますが、ここにはいくつか罠があります。
- 1セッションで30〜40件を超える連続送信は、Google からスパム判定されるリスクがある
- App Store 側もレート制限があり、各送信の間に約8秒の待機を入れた方が安全
- 自動翻訳のベタ貼りは Google ポリシー違反として弾かれる可能性があるため、手書きトーンで返す
- アフィリエイトリンク、他アプリ宣伝、SNS リンクは入れない
私は日本語・英語・繁体字中国語・イタリア語・ロシア語・韓国語・ペルシャ語・ウクライナ語・タイ語・ポーランド語・ポルトガル語(ブラジル)の11言語で返信しています。Claude in Chrome に手書きトーンで翻訳させ、各送信の間に8秒のディレイをプロンプトに明記しています。
公開後の保守は「監視 → 一次トリアージ → 修正判断」の3層
v2.0.0 から v2.1.0 までの保守を振り返って、結局やっていることは3層の繰り返しでした。
- 監視 — Crashlytics と Play Console の品質メトリクスを毎朝確認(Claude in Chrome に大部分を任せる)
- 一次トリアージ — 新規クラッシュのスタックトレースを読み、影響範囲とパターンを言語化する(AI と人間の混成)
- 修正判断 — 影響範囲と修正コストを見て、修正するか・段階公開を halt するか・無視するかを決める(人間が決める)
個人開発で6アプリを並行運用するためには、1の監視を人間がやるのは時間的に成立しないと感じています。AI に任せられる部分を機械的に任せて、3の修正判断に集中する時間を作る、というのが現状の落としどころです。
明日から、メディエーション設定への Liftoff・Unity Ads・InMobi の3社追加と、iOS 版のスライドショー逆移植が並行で動きます。v2.1.0 の保守は AI に任せつつ、新規開発に集中する1週間にする予定です。