私は廣川政樹といいます。2014 年からひとりで iOS / Android のアプリ開発を続けてきて、現在は壁紙系・癒し系・引き寄せ系を中心とした合計 6 本のアプリを並行して運用しています。累計 5,000 万ダウンロードを越えたあたりから、リリース週の作業量が「ひとりで直列に回す」では明らかに足りなくなり、Antigravity のサブエージェントに QA の前段をどこまで任せられるかを半年ほど試してきました。本稿はその設計ノートで、結論だけ先に書くと、リリース直前のチェックを 4 経路に分けて並列に走らせる構成に落ち着いています。
直列で進めていた頃は、静的解析が終わってから Simulator を立ち上げ、それからアセット差分、最後にストア提出物の確認、という順番でやっていました。1 本あたり 90 分前後かかり、6 本だと正味 9 時間です。実際には深夜帯にやることが多く、判断疲れで終盤のチェックが甘くなるという問題もありました。サブエージェントに細切れの責務を渡し、合流点を明示的に設計することで、所要時間は 6 本トータルで 2 時間半ほど、深夜の集中力依存も解消しています。
4 経路に分けた根拠
最初は「全部 1 つのエージェントに渡して、Plan Mode で順番に処理させる」やり方を試しました。Antigravity の Plan Mode は計画立案として優秀なのですが、QA のように「同じ手順を 6 本のアプリに繰り返し適用する」用途では、コンテキストが膨らみすぎて途中で判断が雑になる瞬間があります。私の手元では、3 本目あたりからエージェントが「以前のアプリと同じだから省略」と判断を始め、実際には差分があった項目を見落としたケースが二度ほどありました。
そこで、責務の境界を「読み取るリソースの種類」で切ることにしました。読み取り対象を限定すれば、各サブエージェントのコンテキストは小さく保てます。最終的に落ち着いた 4 経路はこうです。
- 経路 1:ソースコードと git diff(静的解析と差分レビュー)
- 経路 2:ビルド済みアプリのランタイム挙動(Simulator / Emulator スモーク)
- 経路 3:アセットとローカライズリソース(差分検査)
- 経路 4:ストア提出物と外部設定(メタデータ・AdMob・Crashlytics 等)
この 4 経路は、参照するファイル群がほぼ重複しません。経路 1 は src/ や app/src/main/ を読み、経路 3 は Assets.xcassets/ や res/drawable*/ を読みます。コンテキストの干渉が少ないので、Antigravity のサブエージェントを 4 並列で走らせても、それぞれが自分の責務だけに集中できます。
逆に、責務を切るときに気をつけたのは「判断の重なり」です。たとえば「アイコンが正しく差し替わったか」は経路 3 の責務ですが、「Info.plist の CFBundleIconName が正しく書き換えられているか」は経路 1 にも経路 3 にも関係します。こういうグレーゾーンは経路 1 を優先する、と決めておくと、最終合流時に矛盾が起きません。グレーゾーンを「両方で見る」にしてしまうと、片方の警告だけが残った場合の判断が難しくなり、結局メインエージェントの合流処理を複雑にしてしまいます。境界を明示するときは、片方を主担当にし、もう片方は「主担当が出した結果を信用する」という規約を最初に書いておくのが安定します。
経路 1:静的解析と差分レビュー
経路 1 のサブエージェントには、前回のリリースタグから今回の候補コミットまでの差分を渡し、リスクのあるパターンが入り込んでいないかをチェックさせます。私の場合、Swift / Kotlin / TypeScript の 3 言語が混ざっているので、言語別に確認項目を分けています。
代表的な引っかかりやすいパターンはこういう類です。
- Swift で
try! や as! が増えていないか(クラッシュ要因になりやすい)
- Kotlin で
!! が増えていないか(同上)
console.log / print / Log.d のデバッグ出力が残っていないか
- Feature flag の初期値が
true で残っていないか
UserDefaults / SharedPreferences のキー名が前バージョンと衝突していないか
これらは Lint で拾える項目もありますが、Lint の警告レベル運用は私のプロジェクトごとにバラバラで、「リリース直前にだけ厳しくしたい」という要望に Lint だけだと応えられません。サブエージェントなら、テンポラリな閾値を自然言語で指示できるのが強みです。
経路 1 サブエージェントの定義はこんな形にしています。Antigravity のサブエージェント定義は YAML で書くこともできますが、私はプロジェクトごとに微調整するので Markdown 風のプロンプトテンプレートに揃えています。
# .antigravity/subagents/path1-static-diff.yaml
name: path1-static-diff
role: 静的解析と差分レビュー
read_paths:
- src/**
- app/src/main/**
- ios/**/*.swift
- android/**/*.kt
write_paths: [] # 経路 1 は read-only
termination:
on_finding_count_exceeds: 30 # ノイズが多すぎたら手動確認に切り替え
on_critical_severity: true # critical 1 件で即終了
output_format: json_schema_v1
このサブエージェントを Plan Mode から呼び出すときのプロンプト雛形を載せておきます。プロンプト本文に書く制約は、出力フォーマットを厳密に固定する部分が肝です。後段で 4 経路の結果をマージするので、フォーマットが揺れると合流処理が複雑になります。
あなたは経路 1 のサブエージェントです。{REPO_NAME} の {PREV_TAG}...HEAD の差分について、
以下の観点だけを評価してください。それ以外の判断や提案は不要です。
評価観点:
- 強制アンラップ / 強制キャストの新規追加 (Swift: try!/as!, Kotlin: !!)
- デバッグ出力の取り残し (console.log, print, Log.d)
- Feature flag の初期値が true で commit に残っているか
- UserDefaults/SharedPreferences キー名の前バージョンとの衝突
- 公開 API の breaking change (関数シグネチャ・enum の case 変更)
出力は次の JSON だけを返してください:
{
"path": "path1-static-diff",
"status": "pass" | "warn" | "fail",
"findings": [
{ "severity": "info|warn|critical", "file": "...", "line": 0, "summary": "..." }
],
"took_seconds": 0
}
status の決め方は明示的に渡しています。critical が 1 件以上で fail、warn が 5 件以上で warn、それ以下は pass、というルールです。サブエージェント側に判断を任せると、同じプロジェクトでも実行ごとに warn と pass が揺れる現象が起きたので、閾値は外から決め打ちした方が安定します。
経路 1 だけで拾える典型例として、私の壁紙アプリで実際にあったのは「アイコンキャッシュをクリアするコードが、デバッグビルドだけのつもりで書かれていたのに、#if DEBUG の閉じ忘れで本番ビルドにも入っていた」というケースでした。git diff レベルでは #endif の位置がずれているだけだったので、人間の目では追いきれなかった部分です。
もう一つ経路 1 でやらせているのは、ライブラリのバージョンピン留めの検証です。Package.resolved や Gemfile.lock や package-lock.json が、意図せぬマイナーアップデートを巻き込んでいないかを差分で見せます。これは厳密には「静的解析」ではなく依存関係の検査ですが、コードを読むサブエージェントに渡すと文脈を共有できるので便利でした。
経路 2:Simulator / Emulator スモークと Deeplink 検証
経路 2 のサブエージェントは、ビルド済みのアプリを Simulator / Emulator に流して、最低限の動線を確認します。これも 6 本ぶん人手でやると消耗するので、AI に渡せるところは渡したい領域です。
ただし、ここは経路 1 ほどスッキリ自動化できません。Simulator は UI の状態がスナップショットで返ってくるだけなので、「画面が崩れていないか」を判定するには、視覚的な比較が必要になります。私は Antigravity のサブエージェントから xcrun simctl と adb を呼び出して、起動直後・ホーム画面・設定画面の 3 枚をスクリーンショットさせ、前回リリース版とのピクセル差分を計算する方式に落ち着いています。
# .antigravity/subagents/path2/smoke_runner.py
import subprocess, json, os
from pathlib import Path
PLATFORMS = [
{"name": "ios-iphone15", "udid": "BOOTED", "cmd_prefix": ["xcrun", "simctl"]},
{"name": "android-pixel7", "udid": "emulator-5554", "cmd_prefix": ["adb", "-s"]},
]
def boot_and_capture(platform: dict, app_id: str, out_dir: Path) -> dict:
out_dir.mkdir(parents=True, exist_ok=True)
if platform["name"].startswith("ios"):
subprocess.run(["xcrun", "simctl", "launch", "booted", app_id], check=True)
screen = out_dir / "01_launch.png"
subprocess.run(["xcrun", "simctl", "io", "booted", "screenshot", str(screen)], check=True)
else:
subprocess.run(["adb", "-s", platform["udid"], "shell", "monkey", "-p", app_id,
"-c", "android.intent.category.LAUNCHER", "1"], check=True)
screen = out_dir / "01_launch.png"
subprocess.run(["adb", "-s", platform["udid"], "exec-out", "screencap", "-p"],
stdout=open(screen, "wb"), check=True)
return {"platform": platform["name"], "screen": str(screen)}
Deeplink の検証は、私の場合は固定のテストケースを deeplinks.yaml に書き出してあり、サブエージェントにはこの YAML を読ませて 1 件ずつ起動させる、という素朴な作りです。失敗しやすいのは、新しい iOS や Android のメジャーバージョンで Universal Links / App Links の仕様が静かに変わるパターンで、これは Lint や静的解析では絶対に拾えません。
経路 2 のサブエージェントが返す JSON は経路 1 と形を揃えてあります。findings の中身は、たとえばこんな粒度です。
severity: critical:起動 3 秒以内にクラッシュ(Crashlytics に同一スタックトレースが既出)
severity: warn:起動はするが、メイン画面の表示までに 5 秒以上かかる
severity: warn:Deeplink で開く画面の上半分が前回と 8% 以上違うピクセル差
ピクセル差分の閾値は数字遊びになりがちなので、私のプロジェクトでは「8% 超え」を warn、「20% 超え」を critical と決めて固定しています。経験的に、デザイン変更の意図がある変更は 8% 程度では収まらず、逆に 8% 未満で差が出るのは大抵レイアウト崩れです。
iPhone の新しい解像度がリリースされた直後は、この経路 2 が最も貢献します。私のアプリは Storyboard を捨てて SwiftUI 中心に書き換えていますが、それでも safeAreaInsets の前提が静かに変わるバージョンがあり、ストア審査直前まで気づかなかったケースが過去に 2 件ありました。今は Simulator で新解像度を 1 台ぶん起動するだけで、上下のセーフエリアに収まっていないテキストを差分で拾えています。
経路 3:アセットとローカライズリソースの差分検査
経路 3 はアセット差分を見るサブエージェントです。Assets.xcassets、Contents.json、res/drawable-*/、res/values-*/strings.xml、それから React Native 系プロジェクトであれば assets/ 配下を対象にしています。
ここで力を入れているのは「アイコンと OGP の整合性」と「ローカライズキーの欠落」の 2 点です。前者はストア側で弾かれることがあり、後者は実機で初めて UI に「missing.translation」のような文字列が出てしまうので、リリース後にしか気づけない種類のバグです。
私の運用では 8 言語に対応していて、英語と日本語を基準言語にしています。サブエージェントには次の指示を渡しています。
あなたは経路 3 のサブエージェントです。{REPO_NAME} のローカライズリソースとアセットを
基準言語 (ja, en) と比較し、欠落と整合性違反を列挙してください。
評価観点:
- ja, en に存在するが他言語に欠落しているキー
- 値が英語のままで翻訳されていないキー (heuristic: ASCII のみ・3 単語以上)
- Assets.xcassets / drawable-* / mipmap-* に存在するファイルの解像度違反
- AppIcon の各サイズが揃っているか (iOS: 20pt~1024pt, Android: mdpi~xxxhdpi)
- OG 画像のアスペクト比 (1.91:1) と最小サイズ (1200x630) を満たすか
出力フォーマット: 経路 1 と同じ JSON。
「値が英語のままで翻訳されていない」のチェックは厳密にやると過検出になります。固有名詞やプロダクト名はそのままにしたいことも多いので、私は heuristic として「ASCII 文字のみで構成され、3 単語以上、かつ大文字始まりでない」を warn の対象にしています。完璧ではないですが、リリース直前に「ここ翻訳忘れていそう」というアラートとしては十分機能しています。
ローカライズ作業をサブエージェントに任せきりにすると、文体がブレるという別の問題が出てきます。これについては別の機会に書きますが、リリース直前の QA としては「翻訳の質」までは見ない、「欠落と取り残し」だけを見る、と割り切るのが効率的です。
実例として、ある引き寄せ系アプリで「ホーム画面のフォントが、特定のローカライズで指定したカスタムフォントの太字バリアントが存在せず、Regular にフォールバックしていた」という見落としを、経路 3 が拾ったことがあります。UIFont(name:) が nil を返すと Helvetica になるという iOS の挙動を知っていれば人間でも気づけるのですが、6 本ぶん全部の言語で確認するのは現実的ではなく、こういう作業こそサブエージェントに向いています。
経路 4:ストア提出物と外部設定
経路 4 は、コードよりもむしろ運用ドキュメントとダッシュボードを扱うサブエージェントです。具体的には、App Store Connect のメタデータ、Google Play Console のリリース note、AdMob メディエーション設定、Firebase Crashlytics の閾値、dSYM のアップロード状態などを横断的に確認します。
ここに来て、テキストやスクリーンショットだけでは情報が足りないことが多いので、Antigravity のブラウザツールとの併用を前提にしています。私は AdMob ダッシュボードと Crashlytics ダッシュボードを Claude in Chrome で読み取らせる仕組みを別途運用していて、その出力(最新の eCPM、fill rate、クラッシュ件数)を JSON で経路 4 のサブエージェントに渡しています。
経路 4 が見るべき項目はこんな粒度です。
- App Store Connect のタイトル長(30 文字以内)と キーワード(100 文字以内)の超過
- スクリーンショットの解像度違反(iPhone 新解像度の追加忘れが頻出)
- リリースノートの 8 言語ぶん揃っているか
- AdMob メディエーションの中で priority 0 のネットワークが死んでいないか
- 直近 7 日の Crashlytics クラッシュ件数が、前バージョン比で 1.5 倍以上に上昇していないか
- dSYM が Crashlytics にアップロード済みか(iOS Bitcode 廃止以降は手動アップロードが必要なケースが多い)
サブエージェントには、これらをひとまとめにした JSON を返させています。重要なのは、severity: critical の項目が 1 つでも残った状態では、リリースを進めないというルールを決め打ちしておくことです。
# .antigravity/subagents/path4-store-ops.yaml
name: path4-store-ops
role: ストア提出物と外部設定の検証
read_paths:
- fastlane/metadata/**
- .antigravity/ops/admob_snapshot.json
- .antigravity/ops/crashlytics_snapshot.json
write_paths: []
termination:
on_critical_severity: true
on_timeout_seconds: 600
output_format: json_schema_v1
critical_rules:
- "app_store_title_length > 30"
- "missing_release_notes_locale_count > 0"
- "dsym_upload_missing == true"
- "crashlytics_7d_increase_ratio > 1.5"
AdMob メディエーションについては、私は 6 本のアプリで AdMob / Facebook Audience Network / Unity Ads / AppLovin / Pangle の 5 系統を組み合わせています。リリース時に意外と落とし穴になるのは、新しいバージョンの SDK と既存ネットワークの相性で、テスト広告は表示されるのに本番広告が空表示になる、というケースです。経路 4 のサブエージェントは、最新 24 時間の fill rate が 30% 以下のネットワークを critical 扱いにします。閾値の根拠は単純で、私のアプリの平均 fill rate が 75% 前後なので、その 4 割を切ったら何かが壊れている、という設計です。
Crashlytics の閾値「前バージョン比 1.5 倍」は、私のアプリ群の DAU 規模に合わせて決めた数字です。母集団が小さいアプリだと、新規ユーザー 10 人ぶんの偶発的なクラッシュで 1.5 倍を超えてしまうので、アプリごとに閾値を上書きできるようにもしてあります。経路 4 のサブエージェントには、アプリ ID から最適な閾値を引くテーブルを渡しておき、判定に使ってもらっています。
4 経路の合流点と差し戻し基準
4 経路のサブエージェントが並列に走り終わったら、メインエージェントが結果を合流させます。Antigravity のメインエージェントには、合流のときに使う「最終判定プロンプト」を渡しています。これも JSON を出力させて、後段の git tag 自動化スクリプトに食わせる前提です。
以下の 4 経路からの結果 JSON を受け取り、リリース可否を判定してください。
判定ルール:
1. いずれかの経路で status == "fail" がある場合 → release_decision = "block"
2. status == "warn" が 2 経路以上ある場合 → release_decision = "review"
3. それ以外 → release_decision = "go"
4. critical_findings 配列にはすべての経路から severity=="critical" を集約する
出力フォーマット:
{
"release_decision": "go" | "review" | "block",
"block_reason": "...", // block の場合のみ
"critical_findings": [{ "path": "...", "summary": "..." }],
"warn_count_per_path": { "path1-...": 0, "path2-...": 0, "path3-...": 0, "path4-...": 0 },
"decided_at": "ISO8601"
}
このプロンプトの設計で気をつけたのは、「メインエージェントが自分で結論を変えてしまわないようにする」ことです。Antigravity のメインエージェントは、サブエージェントよりも判断能力が高いぶん、合流点で「経路 1 は fail と言っているけれど、コミットメッセージを読むと意図的な変更らしいから pass に書き換える」のような気を利かせ過ぎる挙動を取ることがあります。これは個人開発者にとっては便利な場面もあるのですが、リリース判定としては危ういので、判定ルールは固定し、書き換えを許さない構成にしました。
実運用では、release_decision == "block" の場合は git tag 自動化スクリプトが完全停止し、私の手元に通知が来ます。review の場合は通知だけで止まらないようにしてあり、人間が判断する余地を残しています。go の場合は、そのまま fastlane のリリースパイプラインに引き継がれます。
半年ほどこの構成で運用してみて、go の判定が出たのに本番でクラッシュが急増した、というケースは今のところゼロです。逆に block で止まった案件のうち、3 割ほどは経路 4 の AdMob 関連でした。リリース直前にメディエーションの設定が壊れていることを検出してくれるだけでも、サブエージェントを 4 並列で動かす価値があると感じています。
差し戻しの履歴は、.antigravity/release-decisions/YYYY-MM-DD.json に時系列で保存しています。半年で 70 件ほど蓄積していて、これを集計すると「私のチーム(ひとりですが)が壊しやすい箇所」が見えてきます。集計ロジックはサブエージェントとは独立した別の運用スクリプトに任せていて、月初にレポートが Slack に流れてくる、というささやかな改善ループも回しています。
半年運用しての所感と次の改善
この設計に落ち着くまでに、いくつかの中間案を試しました。最初は経路を 6 つに切っていましたが、責務が細かすぎてサブエージェント間の通信コストが嵩み、結局 4 つに集約しています。逆に 3 つまで減らした時期もあったのですが、その場合は経路 1 が肥大化して、コンテキストオーバーフローを起こす頻度が高くなりました。4 経路という数字は、私の手元では局所最適に見えています。
宮大工だった両家の祖父の影響もあって、「人が見ても理解できる構造で組む」ことを大切にしています。サブエージェントの責務を 4 つに切ったのは、図にしたときに 1 枚で収まるかどうか、という基準も影響しています。アート活動で国際芸術賞をいただいたときの審査評にも「全体構造の明快さ」が言及されていて、ソフトウェアの設計でも同じ感覚で組むようにしています。
次に改善したいのは、経路 2 のスクリーンショット比較で偽陽性が出る頻度です。OS バージョンが上がるとシステムフォントの hinting がわずかに変わって、UI に意図しない差分が乗ることがあります。これは VLM 系のサブエージェントに「意味的な差分」かどうかを判定させる方が筋がよさそうで、今ちょうど試行錯誤している段階です。
もう一つ、経路 4 のストア提出物検証で、各国のローカル法規(ヨーロッパの DSA、米国のキッズプライバシー法、日本の特商法など)への準拠チェックを段階的に組み込んでいきたいと考えています。ここは私が法律家ではないので、AI に丸投げするより、各国の規制変更を週次で監視する別のサブエージェントを 1 本立てて、変更検知だけしてもらう構成のほうが現実的でしょう。
ひとりで個人開発を続けてきた身として、これくらいの規模の運用を回せるようになったのは、Antigravity のサブエージェントが「責務を切ったとおりに動く」という設計思想を持っているからだと感じています。逆に、何でもできるエージェントを 1 本だけ持つ構成では、ここまで踏み込めなかったはずです。
リリース週の負荷は、半年前と比べてかなり軽くなりました。深夜帯に判断疲れで失敗するパターンが消えただけでも、個人開発の持続可能性は大きく変わります。同じ構成で困っている方がいれば、4 経路という数字より、「責務を読み取るリソースの種類で切る」という考え方の方を持って帰ってもらえると嬉しいです。