先月、Android 版の壁紙アプリでクラッシュの報告が続きました。
原因を追うと、RecyclerView の更新中にリストが書き換わり、IndexOutOfBoundsException が出ている箇所でした。28日間で50人以上のユーザーが影響を受けていた計算です。
直し方は分かっていました。ただ、同じ構造の画面が複数のアプリにあり、一本ずつ手で直す気力が先に削れていきました。
そこで保守作業の一部を Antigravity に渡してみました。実際に渡した作業、渡さなかった作業、そしてその境目で私が何を考えたかを書き残します。
まず、修正そのものは任せられました
RecyclerView の件は、更新時に渡すリストを防御的にコピーする修正で根治しました。この修正を一つのアプリで確定させてから、エージェントに「同じ差分を、似た構造の画面に、各アプリの命名規則を尊重して当てはめてほしい」と依頼しました。
// 解決したいこと: 更新中に元リストが書き換わっても、描画側が落ちないようにする
// Before: 呼び出し元のリストをそのまま保持していた
class WallpaperAdapter(private var items: MutableList<Wallpaper>) :
RecyclerView.Adapter<WallpaperHolder>() {
fun update(newItems: MutableList<Wallpaper>) {
items = newItems // 呼び出し元が後から変更すると、描画中に範囲外アクセスが起きる
notifyDataSetChanged()
}
}
// After: 受け取った時点で防御的にコピーし、描画側は自分のコピーだけを見る
class WallpaperAdapter(items: List<Wallpaper>) :
RecyclerView.Adapter<WallpaperHolder>() {
private var items: List<Wallpaper> = items.toList()
fun update(newItems: List<Wallpaper>) {
items = newItems.toList() // なぜ: 所有権をアダプタ側に閉じ、外からの変更を遮断する
notifyDataSetChanged()
}
}
エージェントは「アプリごとに AdManager と AdvertisingService で名前が違うので、それぞれ合わせて変換した」と報告してきました。手で行えば、必ず1〜2本は取り違えていたと思います。
この種の作業は、同じ形が三回以上現れて、結果が数値で測れるものです。クラッシュ数が下がったかどうかは、リリース後のダッシュボードが答えてくれます。
一行で消えたクラッシュが、いちばん手強かった
もう一件、別種のクラッシュがありました。
Glide を 5.0.5 に上げ、Android Gradle Plugin も 9 系に揃えたところ、Android 6.0.1 の端末だけが NoClassDefFoundError で落ちました。Java 8 の Supplier が見つからない、というものです。
エージェントに原因を尋ねると、候補をいくつも挙げてきました。ライブラリの固定、minSdk の引き上げ、問題の呼び出しの回避。どれももっともらしく、どれも広く影響が出る案です。
最終的な解決は、coreLibraryDesugaringEnabled true の一行でした。古い端末でも新しい API が使えるよう、ビルド時に変換する設定です。
ここで学んだのは、エージェントは「直す案」を網羅的に出すのが得意で、「何を変えずに済ませるか」の選択は苦手だということです。minSdk を上げれば、古い端末のユーザーを手放すことになります。それはコードの問題ではなく、運営の判断です。
私は一行の案を選び、他の案は却下の理由と一緒にメモへ残しました。
手元に残した三つの判断
保守を何週間か回して、手元に残す判断が三つに絞られました。
第一に、「なぜ」を背負ったコードには触らせないこと。壁紙アプリの中に、冗長に見えるけれど消してはいけない処理があります。ストア審査でリジェクトされた経緯への回避策です。
エージェントに整理を頼むと、当然のようにそこをきれいにしようとします。外から見れば不自然なコードなので、善意の整理です。
第二に、ユーザーの体感を決める数値は自分の手で決めること。癒し系アプリの効果音のフェードイン時間は、技術的には一行の変更です。ただ、その数字は何度も聴き直して決めたものでした。提案を受けるのは構いません。決めるのは私です。
第三に、ユーザーに向けた文章の最終稿は自分で書くこと。クラッシュ修正の説明文や、過去の不具合に遭った方へのレビュー返信は、下書きまでを任せます。
回避策コードを守る、文脈メモの書き方
三つの判断のうち、いちばん効果があったのは第一の対策でした。各リポジトリに、3〜4文の小さなメモを置いています。
設計書ではありません。次の形式だけです。
このメモを置いてからは、エージェントが回避策を整理しようとする場面がほぼなくなりました。「触らない」と書くだけでなく、理由と解除条件まで添えるのが肝です。理由がないメモは、エージェントに「古い指示」として読み飛ばされやすいように感じます。
実際に置いているメモの一例です。リポジトリ直下の AGENT_NOTES.md に、そのまま書いています。
## 広告ゲートの判定(BackGate.kt)
- 触らないこと: 判定を入れ子へ統合しない。並列で独立した分岐のまま保つ。
- 理由: 入れ子にすると優先順位が暗黙になり、不具合の切り分けに時間がかかった。
- 解除条件: 広告 SDK を差し替えるとき、私が立ち会う場合のみ。
渡す前の手順は、次の三段階に固定しました。
- 触ってほしくない処理を洗い出し、上のメモを先に置きます
- 一つのアプリで修正を確定させ、差分をエージェントへ渡します
- エージェントが出した変更が、メモの「触らないこと」に反していないか、私が目で確認します
私のお勧めは、三番目の確認だけは省かないことです。メモを置いても、守られているかを見るのは人間の仕事だからです。
段階公開で、止める基準を先に決めておく
Android の更新は、段階公開で出しました。5%、25%、50%、100% と進める運用です。
エージェントに渡さなかった部分が、ここにあります。
各段階で見るのは二つです。Crash-free users が 99.7% 以上であること、ANR が 0.20% 未満であること。どちらかを割ったら、次の段階へ進めずに止めます。
この基準は、リリースの前に自分で決めておきます。エージェントに「問題なさそうなら進めて」と任せると、「問題なさそう」の定義が曖昧なまま進んでしまうからです。数値で測れる作業は任せられますが、測った結果で止めるかどうかの閾値は、人間が先に置くものだと考えています。
止める基準は、言葉で決めるだけでは当日に揺らぎます。そこで、基準を一つのスクリプトに閉じ込めました。Play Console の画面で読み取った数値を引数で渡すと、進めてよいかどうかだけを返す小さな門番です。
#!/usr/bin/env python3
"""rollout_gate.py — 段階公開を次へ進めてよいかを判定する
解決したいこと: 「問題なさそう」という気分で段階を進めない
使い方: python3 rollout_gate.py --stage 25 --crash-free 99.82 --anr 0.14
"""
import argparse
import sys
# なぜ段階ごとに分けるか: 母数が小さい 5% 段階の数値は揺れやすく、
# 100% 直前ほど厳しく見たいため。閾値は公開前に自分で決めて、ここに固定する。
GATES = {
5: {"crash_free_min": 99.5, "anr_max": 0.30},
25: {"crash_free_min": 99.7, "anr_max": 0.20},
50: {"crash_free_min": 99.7, "anr_max": 0.20},
}
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--stage", type=int, required=True, choices=sorted(GATES))
p.add_argument("--crash-free", type=float, required=True)
p.add_argument("--anr", type=float, required=True)
a = p.parse_args()
g = GATES[a.stage]
problems = []
if a.crash_free < g["crash_free_min"]:
problems.append(f"Crash-free users {a.crash_free}% < {g['crash_free_min']}%")
if a.anr >= g["anr_max"]:
problems.append(f"ANR {a.anr}% >= {g['anr_max']}%")
if problems:
print("STOP: " + " / ".join(problems))
return 1 # 非ゼロで終わらせ、次の段階へ進む操作を止める
print(f"GO: stage {a.stage}% は基準内です。次の段階へ進めます。")
return 0
if __name__ == "__main__":
sys.exit(main())
動かしてみると、次のようになります。
$ python3 rollout_gate.py --stage 25 --crash-free 99.82 --anr 0.14
GO: stage 25% は基準内です。次の段階へ進めます。
$ python3 rollout_gate.py --stage 25 --crash-free 99.61 --anr 0.14
STOP: Crash-free users 99.61% < 99.7%
このスクリプトの閾値は、エージェントには書き換えさせません。実行して結果を読むところまでは任せますが、GATES の数字を変えてよいのは私だけ、と決めています。基準を緩めたくなる日は、たいてい公開を急ぎたい日だからです。
もう一件、画面の同時表示の問題がありました。購入案内、レビュー依頼、リワード広告の案内という三つのダイアログが、同じ瞬間に出てしまう事故です。中央のモーダル管理を一箇所にまとめて対処しました。この設計も、エージェントには「三つのダイアログの競合ルール」を私が言葉にして渡してから、実装を任せました。
仕分けの三つの問い
今は、作業を渡す前に次の三つを自分に聞いています。
一つ目、同じ形の作業が三回以上あるか。二つ目、結果が数値で測れて、止める閾値を自分が先に置けるか。三つ目、ユーザーの感情や体感に触れる場所ではないか。
一つ目と二つ目が「はい」で、三つ目が「いいえ」なら、渡します。それ以外は、下書きまで頼んで最終判断は手元に置きます。
まずは一つのアプリで、三回以上繰り返している作業を一つ選んでみてください。それと同時に、触ってほしくない処理の脇に、理由と解除条件を添えた3〜4文のメモを置いておくと、渡したあとの安心感がまるで違います。
私もまだ運用の途中です。古いコードに新しい道具を持ち込む対話から、毎回何かを教わっております。