8月31日を過ぎると、API レベル 36 未満のアプリは Google Play で更新を受け付けてもらえなくなります。手元の Android アプリは6本あり、期限はその6本すべてに同じ日付で来ました。
締切が全部同じということは、決められるのは順番だけです。そして順番は、公式のドキュメントには書いてありません。
最初は「よく使われている1本から」と考えていました。実際に棚卸ししてみると、その基準は逆でした。
期限は締切を決めますが、順番は決めてくれません
Google Play の対象 API レベル要件は、新規アプリと既存アプリの更新の双方に適用されます。要件そのものはGoogle Play の対象 API レベルの要件に書かれているとおりで、読めば5分で終わります。
難しいのはその先です。6本を同時に上げると、何かが壊れたときに切り分けが同時に6つ来ます。かといって1本ずつ順に片付けると、最後の1本に着手する頃には期限が目の前にあります。
個人開発で複数本を抱えていると、この「同時にやると切り分けが増え、順にやると時間が足りない」の板挟みが毎年来ます。私が変えたのは、順番の決め方そのものでした。
何が変わるかより、どれが壊れやすいか
挙動変更の一覧を読んで「自分に影響があるか」を判断する方法は、1本なら成立します。6本ぶんやると、同じ一覧を6回読み直すことになります。読む対象が同じなのに判断が毎回ぶれるのは、判断の材料が頭の中にしかないからです。
逆から入りました。OS の変更点から自分のアプリを見るのではなく、自分のアプリが触っている面(表面積)を先に列挙して、そこに変更が来ているかを見るという順序です。
壁紙アプリの場合、触っている面ははっきりしています。端末のギャラリーへの画像保存、通知、ロック画面や壁紙の設定、広告 SDK、課金。このうち画像保存と通知は、Android の各バージョンでもっとも頻繁に条件が変わってきた領域です。
面が広いアプリほど、変更が刺さる確率が上がります。よく使われているかどうかとは別の軸です。
6本を横断で棚卸しする
頭の中の判断を表に落とすために、プロジェクトを横断で走査するスクリプトを書きました。Gradle の設定とマニフェスト、バージョンカタログをまとめて読み、targetSdk と「触っている面」を1行にします。
#!/usr/bin/env python3
"""複数の Android プロジェクトを横断し、targetSdk と「触っている面」を1表にする。
使い方:
python3 surface_audit.py ~/apps/*/
"""
import re
import sys
from pathlib import Path
# 挙動変更の影響を受けやすい面。左が表示名、右が (検出用の手がかり, 重み)。
SURFACES = {
"media-write": (r"WRITE_EXTERNAL_STORAGE|MediaStore\.", 3),
"fg-service": (r"FOREGROUND_SERVICE|startForeground", 3),
"exact-alarm": (r"SCHEDULE_EXACT_ALARM|setExactAndAllowWhileIdle", 2),
"notification": (r"POST_NOTIFICATIONS|NotificationManagerCompat", 1),
"edge-to-edge": (r"enableEdgeToEdge|WindowCompat\.setDecorFitsSystemWindows", 2),
"native-lib": (r"externalNativeBuild|\.so\b|ndkVersion", 3),
"billing": (r"com\.android\.billingclient", 2),
"ads": (r"play-services-ads|applovin|unity-ads|pangle", 2),
}
SDK_RE = re.compile(r"(targetSdk(?:Version)?|compileSdk(?:Version)?|minSdk(?:Version)?)"
r"\s*(?:=|\s)\s*['\"]?(\d{2})['\"]?")
def read_text(p: Path) -> str:
try:
return p.read_text(encoding="utf-8", errors="ignore")
except OSError:
return ""
def scan(root: Path) -> dict:
blob = []
sdk = {}
for pattern in ("**/build.gradle", "**/build.gradle.kts",
"**/AndroidManifest.xml", "**/gradle/libs.versions.toml"):
for f in root.glob(pattern):
if "/build/" in str(f): # ビルド生成物は読まない
continue
text = read_text(f)
blob.append(text)
for key, val in SDK_RE.findall(text):
sdk.setdefault(key.replace("Version", ""), val)
joined = "\n".join(blob)
hits, score = [], 0
for name, (rx, weight) in SURFACES.items():
if re.search(rx, joined):
hits.append(name)
score += weight
return {"name": root.name, "sdk": sdk, "hits": hits, "score": score}
def main() -> None:
roots = [Path(a).expanduser() for a in sys.argv[1:]]
if not roots:
print("プロジェクトのルートを1つ以上渡してください", file=sys.stderr)
raise SystemExit(2)
rows = [scan(r) for r in roots if r.is_dir()]
rows.sort(key=lambda r: -r["score"])
print(f"{'app':<22}{'target':>7}{'min':>5}{'risk':>6} surfaces")
for r in rows:
print(f"{r['name']:<22}{r['sdk'].get('targetSdk', '-'):>7}"
f"{r['sdk'].get('minSdk', '-'):>5}{r['score']:>6} {','.join(r['hits'])}")
if __name__ == "__main__":
main()
出力はこうなります。
app target min risk surfaces
wallpaper-a 34 24 8 media-write,notification,billing,ads
relax-b 35 26 5 fg-service,ads
tiny-c 36 26 0
重みは厳密なものではありません。私は「過去に自分が実際に手戻りを食らった面」を 3、公式の移行手順どおりで済んだ面を 1〜2 に置いています。ここは各自の履歴で調整する前提の数字です。
このスクリプトには注意点が2つあります。ひとつは、バージョンカタログを使っているプロジェクトでは依存の文字列が build.gradle.kts 側に現れないことです。走査対象に gradle/libs.versions.toml を含めているのはそのためで、ここを外すと広告 SDK が検出されずに score が 0 と出ます。もうひとつは \.so\b が Gradle のコメントや無関係な文字列にも当たることで、こちらは検出後に一覧を目で確認して回避しています。自動化の結果を鵜呑みにすると、静かに間違った順位ができあがります。
私自身、最初の実行では1本だけ score が 0 になり、慌てて中身を見たらバージョンカタログの読み落としでした。落とし穴が最初の1回で出てくれたのは運が良かったと思っています。
大事なのは点数の絶対値ではなく、6本の順位が固定されることです。順位が表にあれば、翌日に見直しても同じ結論になります。
広告 SDK を載せている本数が、順番を変えました
棚卸しで一番効いたのは、広告 SDK の有無でした。
広告を載せているアプリは、OS 側の変更と広告 SDK 側の更新が、ほぼ同じ時期に重なって来ます。AdMob のメディエーションを複数社で組んでいる場合、アダプタの更新が連鎖して依存が動きます。この状態で targetSdk も上げると、クラッシュが出たときに「OS の挙動変更か、広告 SDK の更新か」の切り分けが二重になります。
| 構成 | 更新後に疑う対象 | 切り分けの手数 |
| 広告なし・課金なし | OS の挙動変更のみ | 1系統 |
| 広告あり | OS + 広告 SDK + メディエーション各社 | 3系統以上 |
| 広告あり・課金あり | 上記 + 課金ライブラリ | 4系統以上 |
そこで、広告 SDK の更新と targetSdk の引き上げを、同じリリースに載せないことにしました。先に広告 SDK だけを上げて安定を確認し、次のリリースで targetSdk を上げます。リリースが1回増えますが、クラッシュが出たときに疑う対象が半分になります。
edge-to-edge の強制についても、同じ理由で先に片付けてあります。この作業の詳細はtargetSdk 36 で edge-to-edge が必須になった壁紙アプリを Antigravity で移行した運用メモに書きました。表示の崩れは目視で確認できるので、統計を待たずに判断できる種類の変更です。ここを分離しておくと、期限直前のリリースで見るべきものがクラッシュだけになります。
段階公開の1%は、何を教えてくれるのか
順番が決まったので、次は段階公開の刻み幅です。ここで前提が崩れました。
私はずっと「1% で数日様子を見て、問題なければ広げる」という運用をしてきました。これを数字で検算したことがありませんでした。
以下は実測ではなく計算です。現行版のクラッシュ率を 0.5%、悪化を見逃したくない水準を 1.0%(2倍)とし、片側 5%・検出力 80% で必要なセッション数を求めます。
| 悪化の水準 | 必要セッション数(片群) |
| 0.500% → 0.550% | 258,303 |
| 0.500% → 0.600% | 67,633 |
| 0.500% → 0.750% | 12,287 |
| 0.500% → 1.000% | 3,681 |
| 0.500% → 1.500% | 1,223 |
| 0.500% → 3.000% | 339 |
逆から見ると、もっとはっきりします。手元に 200 セッションしかない状態で統計的に区別できるのは、0.5% から 4.29% への悪化です。8倍以上に壊れて、ようやく「偶然ではない」と言えます。
| 観測セッション数 | 検出できる最小のクラッシュ率 |
| 200 | 4.293% |
| 1,000 | 1.645% |
| 5,000 | 0.917% |
| 20,000 | 0.691% |
つまり、1% 配信で数日待つという運用は、統計的な判断としてはほとんど何もしていません。段階公開は「悪化を統計で見つける仕組み」ではなく、「壊れたときの被害の上限を決める仕組み」でした。
この違いは運用に直結します。前者だと思っていると「もう少し様子を見る」が正当化されますが、後者だと分かれば「様子を見ても分からないので、被害の上限を決めて次に進む」という判断になります。私は後者のほうが誠実だと感じています。
0件でも安心と言い切れない範囲
もうひとつ、見落としていたことがあります。クラッシュが 0 件だった場合の解釈です。
n 件観測して 0 件だったとき、真のクラッシュ率の 95% 上限はおよそ 3/n です(rule of three)。
| 観測セッション数 | 0件だったときの95%上限 |
| 100 | 3.000% |
| 300 | 1.000% |
| 1,000 | 0.300% |
| 5,000 | 0.060% |
300 セッションで 0 件というのは、「クラッシュ率は 1% 以下」としか言っていません。現行版が 0.5% なら、2倍に悪化していても矛盾しない範囲です。
「0 件だったので広げます」という判断は、n が小さいうちは根拠になりません。ここを意識してからは、報告に必ず観測数を併記するようにしました。件数だけの報告は、良い知らせのように見えて中身がありません。
刻み幅を、必要観測数から逆算する
計算を毎回手でやるのは続かないので、スクリプトにしました。1日あたりのセッション数を入れると、各配信率で判定に何日かかるかと、その段階を保持した場合の 0 件時の上限を出します。
#!/usr/bin/env python3
"""段階公開の刻み幅を、判断に必要な観測数から逆算する。
使い方:
python3 rollout_sizing.py --dau 12000 --baseline 0.005 --tolerate 2.0
"""
import argparse
from statistics import NormalDist
ND = NormalDist()
def sessions_needed(p0: float, p1: float, alpha: float = 0.05, power: float = 0.80) -> float:
"""p0 から p1 への悪化を検出するのに必要な、片群あたりのセッション数。"""
if p1 <= p0:
raise ValueError("p1 は p0 より大きい必要があります")
za = ND.inv_cdf(1 - alpha) # 片側検定
zb = ND.inv_cdf(power)
pbar = (p0 + p1) / 2
num = (za * (2 * pbar * (1 - pbar)) ** 0.5
+ zb * (p0 * (1 - p0) + p1 * (1 - p1)) ** 0.5) ** 2
return num / (p1 - p0) ** 2
def zero_event_upper_bound(n: int) -> float:
"""n 件観測して 0 件だったときのクラッシュ率の 95% 上限(rule of three)。"""
return 3.0 / n if n > 0 else 1.0
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--dau", type=float, required=True, help="そのアプリの1日あたりセッション数")
ap.add_argument("--baseline", type=float, default=0.005, help="現行版のクラッシュ率")
ap.add_argument("--tolerate", type=float, default=2.0,
help="見逃したくない悪化の倍率(2.0 なら 2 倍まで)")
ap.add_argument("--max-days", type=float, default=3.0, help="1段階に使える最大日数")
args = ap.parse_args()
p0 = args.baseline
p1 = p0 * args.tolerate
need = sessions_needed(p0, p1)
print(f"現行クラッシュ率 : {p0 * 100:.3f}%")
print(f"見逃したくない悪化 : {p1 * 100:.3f}%({args.tolerate:.1f} 倍)")
print(f"必要セッション数/群 : {need:,.0f}")
for pct in (1, 5, 10, 20, 50, 100):
per_day = args.dau * pct / 100
days = need / per_day if per_day else float("inf")
verdict = "◯" if days <= args.max_days else "×"
bound = zero_event_upper_bound(int(per_day * args.max_days))
print(f" {pct:3d}% 配信 → {per_day:9,.0f} セッション/日 / "
f"判定まで {days:6.1f} 日 {verdict} / "
f"{args.max_days:.0f}日で0件なら上限 {bound * 100:.2f}%")
if __name__ == "__main__":
main()
1日 12,000 セッションのアプリを 2 倍の悪化まで見たい場合、出力はこうなります。
現行クラッシュ率 : 0.500%
見逃したくない悪化 : 1.000%(2.0 倍)
必要セッション数/群 : 3,681
1% 配信 → 120 セッション/日 / 判定まで 30.7 日 × / 3日で0件なら上限 0.83%
5% 配信 → 600 セッション/日 / 判定まで 6.1 日 × / 3日で0件なら上限 0.17%
10% 配信 → 1,200 セッション/日 / 判定まで 3.1 日 × / 3日で0件なら上限 0.08%
20% 配信 → 2,400 セッション/日 / 判定まで 1.5 日 ◯ / 3日で0件なら上限 0.04%
50% 配信 → 6,000 セッション/日 / 判定まで 0.6 日 ◯ / 3日で0件なら上限 0.02%
100% 配信 → 12,000 セッション/日 / 判定まで 0.3 日 ◯ / 3日で0件なら上限 0.01%
期限まで2週間という条件では、1% と 5% は選択肢から外れます。判定が終わる前に期限が来るからです。私は 10% から始めて 3 日、次に 50%、という刻みに変えました。
規模の小さいアプリでは、もっと厳しい結果が出ます。1日 900 セッションのアプリだと、100% 配信でも 2 倍の悪化を検出するのに 5.1 日かかります。この場合、統計で判断するのは最初から諦めて、クラッシュの中身を1件ずつ読む方針に切り替えるほうが早いです。件数が少ないからこそ、全件読めます。
巻き戻す条件は、公開前に文章で決めておく
刻み幅が決まると、次に決めるべきものが自動的に出てきます。何が起きたら止めるか、です。
私は各リリースの直前に、次の3行をメモに書いてから公開ボタンを押すようにしています。
- 止める条件: 新規のクラッシュシグネチャが 1 件でも出たら、件数にかかわらず次の段階へ進めない
- 戻す条件: クラッシュ率が現行版の 2 倍を超え、かつ観測が 1,000 セッションを超えたら、その日のうちに前バージョンへ戻す
- 進める条件: 上記のいずれにも触れず、観測が必要セッション数に達したら次の段階へ
リリースの規模が小さい場合は、2 番目の観測数を 1,000 より下げても構いません。ただしその場合は、下げた数字で何が言えるのかを rule of three で確かめてから採用することを推奨します。数字を緩めること自体より、緩めた自覚がないまま進むほうが危険です。
公開してから決めようとすると、必ず希望的な方向に曲がります。「たぶん端末固有だろう」「もう少し見れば落ち着くかもしれない」と考えてしまうのは、判断する側が同時に作った側でもあるからです。先に文章にしておくと、その揺れが減ります。
エージェントに渡した範囲と、渡さなかった範囲
この一連の作業のうち、Antigravity のエージェントに任せたのは調査と証拠集めだけです。
任せたもの:
- 6本のプロジェクトに対する横断走査の実行と、結果の表への整形
- 該当しそうな API の呼び出し箇所の列挙(ファイル名と行番号つき)
- 変更点ごとの公式ドキュメントの該当箇所の収集
- リリースノートの下書き
任せなかったもの:
- 配信率の決定
- 公開ボタンの操作
- 巻き戻すかどうかの判断
線引きの基準は「間違いが安く見つかるか」です。走査結果は、間違っていればすぐ分かります。存在しないファイルが挙がっていれば開いた瞬間に気づきますし、行番号がずれていても数秒で確かめられます。一方、配信率の決定が間違っていたことは、被害が出てからしか分かりません。
CLI 1.1.13 では、宣言的なカスタムエージェントからも manage_task が使えるようになり、バックグラウンドタスクの一覧と停止が扱えるようになりました。走査のような長めの調査を投げて、別の作業をしながら様子を見る運用がやりやすくなっています。停止できることが分かっている作業だけを投げる、という使い分けにしています。
順番は、壊れやすいものから
最初の想定に戻ります。「よく使われている1本から」は逆でした。
壊れやすい1本を先に上げると、そこで見つかった問題は残りの5本にそのまま適用できます。よく使われている1本を先に上げると、被害だけが大きくて、学べることは少ないままです。学習を先に、被害を後ろに置くほうが、同じ期限でも余裕が生まれます。
まず手元のプロジェクトで、surface_audit.py を6本まとめて走らせてみてください。順位が表になった瞬間に、迷っていた順番は勝手に決まります。