2014年から個人開発で iOS/Android アプリを公開してきて、累計5,000万ダウンロードを超えた壁紙アプリ群を1人で保守しています。先日 Beautiful 4K/HDR Wallpapers の v2.1.0(versionCode 49)と Ukiyo-e Wallpapers の v1.8.0(versionCode 41)が Google Play 審査を通り、段階公開フェーズに入りました。公開してからの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();
}アプリを8年運用してきて分かったことは、RecyclerView のクラッシュは大抵このデータソース共有参照が原因という、地味な結論です。
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 に頼みますが、修正パッチを書くかどうかは、影響範囲と修正コストを見て自分で決めています。
ハマりどころとしては、Firebase Console の UI が時々レイアウト変更されるため、Claude in Chrome 側のプロンプトで「いまどのフィルタが効いているか」を毎回確認させるようにしました。これがないと、フィルタが意図せず外れていて、過去のクラッシュも新規扱いされる事故が起きます。
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週間にする予定です。