DefineManager.h を開いて三項演算子をもう1つ足そうとしたところで、手が止まりました。
iPhone Air の 420pt、iPhone 17 Pro の 402pt、Pro Max の 440pt。幅を3つ足すだけの単純な作業のはずでした。ところが同じ形の分岐が4本のアプリに散っていて、どこを直せば全部直るのか、自分でも言い切れませんでした。
個人開発で長く続けているアプリほど、こういう分岐は静かに増えていきます。
先に結論として、書き換えるより先に分岐表を書き出しました
取り得る手は2つありました。
- 三項演算子をやめ、幅と値の対応をテーブルに移して分岐を1箇所に集約する
- 既存コードには一切触れず、散らばった分岐を表として抽出し、網羅性だけを機械で検査する
私は2番を選びました。理由は3つあります。
- 4本のアプリを同時に書き換えると、リスクが同じ週に集中します。App Store の審査待ちが重なると、切り戻しの選択肢が細ります
- 表として抽出するだけなら、既存の実装が1行も変わりません。判断を間違えても失うものがありません
- そもそも「集約すべきか」を判断する材料が、手元になかったのです。分岐の全体像を見ないまま設計を決めるのは、順番が逆でした
書き換えは、表を見てから決めればよい話でした。
29 箇所の三項演算子が、4本のアプリに散っていました
私が App Store と Google Play に出している壁紙・癒し系のアプリでは、画面幅で余白やセルの列数を切り替えています。長く運用してきたぶん、幅の判定は定数ヘッダに三項演算子として積み上がっていて、その数は 29 箇所ありました。
対象になる幅は、縦持ちのポイント値で次のとおりです。
| 区分 | 幅 (pt) | 備考 |
| 既存 | 375 / 390 / 393 | コンパクト〜標準サイズ |
| 既存 | 414 / 428 / 430 | Plus・Pro Max 系 |
| 追加 | 402 | iPhone 17 Pro |
| 追加 | 420 | iPhone Air |
| 追加 | 440 | Pro Max 系の新サイズ |
数え上げてみると、怖いのは追加そのものではありませんでした。追加を忘れてもアプリは落ちません。落ちないまま、少しずれたレイアウトが配信されます。
抽出は Gemini に、判定は決定的なコードに分けました
最初に試したのは、ヘッダをそのまま貼り付けて「新機種の追加漏れを探してください」と聞く形でした。返ってくる指摘はそれらしく読めるのですが、同じファイルで聞き直すたびに指摘の粒度が変わります。レビューの相手としては優秀でも、リリース前の合否判定を預ける相手ではありませんでした。
そこで工程を2つに割りました。
- 抽出(列挙): ソースコードという半構造のテキストから、条件式・しきい値・値の組を取り出す。表記の揺れを吸収する仕事なので、言語モデルが向いています
- 判定(比較): 取り出した表と、対象デバイスの一覧を突き合わせる。同じ入力なら必ず同じ答えが出てほしい仕事なので、決定的なコードに残します
この線引きを決めてから、迷いが減りました。揺れてよい工程と、揺れては困る工程を混ぜていたことが、最初の失敗の原因でした。
本番のリリース可否に関わる工程は、言語モデルの外に残すことを推奨します。便利さの誘惑がいちばん強いのが、まさにこの部分です。
ソースから分岐表を構造化出力で取り出す
抽出は response_schema を指定した構造化出力で行います。自由文で返させると、そのあとのパースが結局あいまいになります。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_API_KEY")
BRANCH_SCHEMA = {
"type": "object",
"properties": {
"branches": {
"type": "array",
"items": {
"type": "object",
"properties": {
"file": {"type": "string"},
"line": {"type": "integer"},
"threshold": {"type": "integer"},
"comparison": {"type": "string", "enum": [">=", ">", "==", "<=", "<"]},
"value": {"type": "string"},
},
# 欠けたまま返させない。あとの検査が静かに壊れる
"required": ["file", "line", "threshold", "comparison", "value"],
"propertyOrdering": ["file", "line", "threshold", "comparison", "value"],
},
}
},
"required": ["branches"],
}
PROMPT = """次のソースから、画面幅で分岐している箇所だけを抽出してください。
- 行番号はソース先頭を 1 行目として数えます
- 幅以外の条件(OS バージョン・言語など)は対象外です
- 判断が付かない行は出力せず、そのまま省いてください
--- source: {name} ---
{source}
"""
def extract(name: str, source: str) -> dict:
numbered = "\n".join(f"{i}: {l}" for i, l in enumerate(source.splitlines(), 1))
res = client.models.generate_content(
model="gemini-3.5-flash-lite",
contents=PROMPT.format(name=name, source=numbered),
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=BRANCH_SCHEMA,
temperature=0,
),
)
return res.parsed # {"branches": [...]}
行番号は自分で振ってから渡します
最初は行番号もモデルに数えさせていました。長いヘッダになるほど、ここがずれます。呼び出し側で 1: 2: と振ってから渡すと、ずれが消えました。数えられる情報を数えさせないのは、この手の抽出では効きます。
「判断が付かない行は省く」と明示します
網羅性の検査では、誤った行が1つ混ざるほうが、抜けているより厄介です。抜けは後段の突き合わせで「意図した割り当てが存在しない」として現れますが、誤りは正しい表のふりをします。迷ったら省いてもらう指示を入れておくほうが、後段が素直になります。
フィールドの順序が回ごとに変わって差分が読みにくくなる場合は、Gemini の構造化出力でJSONのフィールド順を固定する実装メモ が参考になります。スキーマ検証そのものを本番に組み込む形は、レスポンスを Schema 検証で守る設計 にまとめてあります。
突き合わせた結果、危なかったのは新機種ではありませんでした
抽出した表を、対象デバイスの一覧と突き合わせます。ここから先に言語モデルは登場しません。
# 既存のしきい値(ソース上の評価順)
BRANCHES = [
(430, "proMax"), (428, "plus"), (414, "legacyPlus"),
(393, "standardPro"), (390, "standard"), (0, "compact"),
]
# アプリが対象にしている画面幅(pt・縦持ち)
DEVICES = {
"compact-375": 375, "mini-375": 375, "standard-390": 390,
"standard-393": 393, "new-402": 402, "new-420": 420,
"legacy-414": 414, "plus-428": 428, "proMax-430": 430, "new-440": 440,
}
# 設計上こうなってほしい割り当て(意図)
INTENT = {
"compact-375": "compact", "mini-375": "compact", "standard-390": "standard",
"standard-393": "standardPro", "new-402": "standard402", "new-420": "air420",
"legacy-414": "legacyPlus", "plus-428": "plus", "proMax-430": "proMax",
"new-440": "proMax440",
}
def resolve(width: int) -> str:
for threshold, bucket in BRANCHES:
if width >= threshold:
return bucket
return "unreachable"
mismatch = 0
for name, w in DEVICES.items():
got, want = resolve(w), INTENT[name]
if got != want:
mismatch += 1
print(f"{name:<14}{w:>6} {got:<14}{want:<14}{'OK' if got == want else 'MISMATCH'}")
print(f"\ndevices: {len(DEVICES)} mismatch: {mismatch} ({mismatch / len(DEVICES) * 100:.0f}%)")
手元で実行した結果です。
compact-375 375 compact compact OK
mini-375 375 compact compact OK
standard-390 390 standard standard OK
standard-393 393 standardPro standardPro OK
new-402 402 standardPro standard402 MISMATCH
new-420 420 legacyPlus air420 MISMATCH
legacy-414 414 legacyPlus legacyPlus OK
plus-428 428 plus plus OK
proMax-430 430 proMax proMax OK
new-440 440 proMax proMax440 MISMATCH
devices: 10 mismatch: 3 (30%)
10 台のうち 3 台、30% が意図と違うバケットに入りました。
私が身構えていたのは「新機種の分岐を書き忘れる」ことでした。実際に危なかったのは逆で、書き忘れても既存のしきい値が新しい幅を静かに受け止めてしまうことのほうです。402pt は >= 393 に、420pt は >= 414 に吸い込まれます。エラーも警告も出ません。ビルドは通り、審査も通り、少し詰まった余白のまま配信されます。
>= で書かれた分岐は、未知の入力に対して必ず何かを返します。この性質は普段は安全弁として働きますが、端末が増えた瞬間だけ裏目に出ます。
これを回避する手立ては、分岐の書き方の側ではなく、突き合わせる表の側にありました。意図した割り当てを別ファイルに書いておけば、コードが黙っていても差分が声を上げてくれます。
評価順が壊れているケースも、同じ表で見つかります
抽出した表には、もう1つの使い道がありました。三項演算子は上から評価されるので、しきい値が降順に並んでいないと、後ろの分岐に到達しません。
import json
branches = json.loads(payload)["branches"] # 抽出結果をそのまま渡します
order_errors = []
for prev, cur in zip(branches, branches[1:]):
if cur["threshold"] > prev["threshold"]:
order_errors.append(
f'{cur["file"]}:{cur["line"]} threshold {cur["threshold"]} '
f'is above the earlier {prev["threshold"]} (unreachable)'
)
for e in order_errors:
print("ORDER:", e)
print(f"branches: {len(branches)} order errors: {len(order_errors)}")
ORDER: DefineManager.h:43 threshold 430 is above the earlier 414 (unreachable)
branches: 4 order errors: 1
到達しない分岐は、テストを書いても気づきにくい部類です。その分岐を通る入力が作れないので、カバレッジの数字だけが静かに下がります。行番号を自分で振っておいたのが、ここで効きました。
運用に載せるときに決めた4つのこと
- モデルは軽いもので十分にします。やらせているのは表記揺れの吸収だけです。推論の重いモデルを使うと、指示にない推測まで返り始めます
- 抽出結果はファイルとしてコミットします。前回の表との差分が、そのまま「今回の変更で分岐がどう動いたか」のレビュー材料になります
- 判定は CI で毎回走らせます。抽出は人が更新したときだけで足ります。落ちてほしいのは判定のほうです
- プロンプトを変えたら、既知の表で回帰を見ます。抽出の指示を触ると出力の粒度が変わります。過去に正解だと確認した1ファイルを固定の入力として持っておくと、変化に気づけます
落とし穴としていちばん多かったのは、抽出の失敗が静かなことでした。0件が返ってきても処理は正常に終わります。抽出件数が前回より大きく減ったら止める、という単純な下限チェックを1つ入れてから、見落としがなくなりました。
停止や廃止をまたぐ移行でも、着手前に呼び出し箇所を洗い出す形は共通しています。同じ考え方をモデル停止に当てはめた例は、8月17日の画像生成モデル停止に備える棚卸し手順 にまとめました。
分岐を触る前に、まず表を1枚作ってください
新しい端末に対応する作業は、コードを足すところから始めたくなります。私自身がそうでした。けれど足す前に、いま何がどこに分かれているのかを1枚の表にするほうが、結果的に早く終わりました。
まずは分岐が集中しているファイルを1つ選んで、表として書き出すところから始めてみてください。集約するかどうかは、その表を見てから決めれば間に合います。
私もまだ 29 箇所すべてを整理し終えたわけではありませんが、判断の材料が手元にある状態は、思っていたよりずっと気が楽です。お読みいただきありがとうございました。