iPhone Air が 420×912、17 Pro が 402×874、17 Pro Max が 440×956 — Apple が秋に新解像度を 3 つ同時に出したのは、長く個人で iOS をやってきた私にとっても、なかなか珍しい年でした。Beautiful HD Wallpapers / Ukiyo-e Wallpapers / Relaxing Healing / Law of Attraction を含む 6 アプリを、限られた個人開発の時間でどう並行対応するかは、今期最大の運用課題でした。
旧来の DefineManager.h に積み上げた三項演算子マクロは、解像度が増えるたびに「正しい順序で読むしかない」プレーンな分岐の連続でした。今回の 3 解像度追加で、29 箇所の三項演算子を 6 アプリすべてに同期する必要があり、ここを Claude on Xcode と手作業で 2 日かけて整え直しました。崩した瞬間にレイアウトが歪むファイルなので、その経緯を残しておきます。
始まりは「セーフエリア境界の 1 ピクセル」
新解像度対応の最初の兆候は、Xcode 17 の新シミュレータでビルドした瞬間でした。Beautiful HD Wallpapers のメイン画面下部、AdMob のバナー上のセーフエリアが 1 ピクセル分だけ広く、その隙間に微妙な背景色が漏れていました。
iPhone Air は 6.6 インチで Dynamic Island 搭載、ノッチではなくダイナミックなセーフエリア管理が要求される機種です。UIScreen.main.bounds.size を直接見るのではなく、safeAreaInsets を介してレイアウトを組み直す必要がありました。
// 旧来の判定(iPhone 16 Pro Max までしか想定していなかった)
#define IS_IPHONE_PRO_MAX \
([UIScreen mainScreen ].bounds.size.height == 932.0 f )
// 17 Pro Max は 956pt 縦になったため、true / false の判定が反転していた
最初に出した v3.0.0 ビルドでは、17 Pro Max のユーザーが下部広告領域の上に 8pt の謎の余白を見続ける状態になっていました。実機を持っていない解像度のレイアウトを、シミュレータと TestFlight だけで詰め切るのは、毎年のように難しい仕事です。
DefineManager.h に三項演算子を 29 箇所足す現実
旧来のマクロは次のような形でした。
#define IS_IPHONE_AIR \
([UIScreen mainScreen ].bounds.size.width == 420.0 f && \
[UIScreen mainScreen ].bounds.size.height == 912.0 f )
#define IS_IPHONE_17_PRO \
([UIScreen mainScreen ].bounds.size.width == 402.0 f && \
[UIScreen mainScreen ].bounds.size.height == 874.0 f )
#define IS_IPHONE_17_PRO_MAX \
([UIScreen mainScreen ].bounds.size.width == 440.0 f && \
[UIScreen mainScreen ].bounds.size.height == 956.0 f )
問題は、レイアウト値を返す側のマクロです。たとえばナビゲーションバーの高さオフセットは以下のように三項演算子で連鎖していました。
#define NAV_BAR_OFFSET \
(IS_IPHONE_AIR ? 18.0 f : \
IS_IPHONE_17_PRO_MAX ? 22.0 f : \
IS_IPHONE_17_PRO ? 18.0 f : \
IS_IPHONE_16_PRO_MAX ? 20.0 f : \
IS_IPHONE_16_PRO ? 18.0 f : 16.0 f )
ここに 29 箇所の三項演算子を 1 つずつ追加するわけですが、評価順を間違えると別端末のレイアウトが壊れます。Air と 17 Pro が両方 18pt なら共有してよさそうに見えますが、私はあえて分けて書きました。理由は、来年のリビジョンで Air と 17 Pro の挙動が分岐したときに、どこを直せばよいかを今のうちに固定しておきたかったからです。
12 年間個人でアプリを書いてきて学んだのは、「目先の DRY」と「将来の差分修正のしやすさ」は同義ではないという感覚です。アート活動と並行して個人開発を続けていると、半年後の自分は今の自分とほぼ別人で、コードに書かれていないことは確実に忘れます。三項演算子の冗長性は、未来の自分への手紙のようなものだと考えています。
Claude on Xcode に渡したパッチ生成プロンプト
Xcode 上で Claude on Xcode に渡したテンプレートは、シンプルです。
## Context
- 対象: DefineManager.h(共有マクロ定義)
- 既存マクロ: 16 機種ぶんの IS_IPHONE_* 判定 + 29 箇所の三項演算子チェイン
- 追加対象: iPhone Air (420×912) / 17 Pro (402×874) / 17 Pro Max (440×956)
- ルール:
- 既存の評価順を絶対に変えない
- 新機種の枝は「最も狭いものから順」に挿入
- 旧機種の枝はインデント・コメントを温存
## Ask
- DefineManager.h の全 29 箇所に対し、上記 3 機種の三項演算子枝を追加する patch を生成
- 各 patch hunk は最大 6 行に収め、レビュー可能な粒度に分割
「評価順を変えない」「旧コメントを温存」を明示するのが、この種のレガシー定義ファイルでは効きます。再生成型の編集に切り替えられると、半年後にレビューする自分が読めなくなるからです。
Claude on Xcode はこの粒度で 29 個の hunk を返してくれ、Xcode のサイドバーで 1 つずつ確認しながら適用していきました。29 箇所すべて適用するのに、レビュー込みで 2 時間ほどでした。
並行 6 アプリへの同期 — defineManagerSync.sh
6 アプリは同じ DefineManager.h の派生を持っています。これを「1 つの正本 + パッチ」で管理する派と、「全アプリで個別に持つ」派の両方を試した結果、私は後者を採用しています。アプリごとに広告ゲートやテーマカラーの分岐が違うため、共通化すると別の場所で破綻するからです。
代わりに、リリース直前に 6 アプリの DefineManager.h を並べて差分チェックする bash スクリプトを Claude on Xcode と作りました。
#!/usr/bin/env bash
set -euo pipefail
apps = (
"BeautifulWallpapers"
"UkiyoeWallpapers"
"RelaxingHealing"
"LawOfAttraction"
"FractalGalleria"
"CoolWallpapers"
)
REFERENCE = "apps/BeautifulWallpapers/DefineManager.h"
for app in "${ apps [ @ ] : 1 }" ; do
target = "apps/${ app }/DefineManager.h"
if ! diff -q \
<( grep "IS_IPHONE_" " $REFERENCE " | sort ) \
<( grep "IS_IPHONE_" " $target " | sort ) > /dev/null ; then
echo "❌ ${ app }: IS_IPHONE_* の宣言が ${ REFERENCE } と不一致"
diff -u " $REFERENCE " " $target " | head -40
exit 1
fi
done
echo "✅ 6 アプリの端末判定マクロは同期されています"
IS_IPHONE_* の宣言だけを比較する diff です。レイアウト値(NAV_BAR_OFFSET など)はアプリごとに違うので比較対象から除いています。これで「Air と 17 Pro Max が片方のアプリにだけ漏れていた」というミスを 1 度防げました。
TestFlight だけで詰める 14 日間の段階公開設計
実機を持っていない解像度の検証は、TestFlight + 段階公開の 2 段ロケットでやっています。
最初の段階公開は App Store Connect で 1% にします。1% は数字としては心許なく見えますが、累計 5,000 万 DL のアプリポートフォリオを束ねた場合、新解像度端末を持つユーザーがすでに数万単位で含まれているため、24 時間で十分なシグナルが取れます。
Crashlytics で次のフィルタを保存しています。
app_version == "3.0.0"
AND device_model starts_with "iPhone18" -- iPhone Air / 17 Pro 系統
AND fatal == true
iPhone18 プレフィックスは 17 Pro / 17 Pro Max / Air の内部識別子です。これを 24 時間ごとに見て、解像度起因のクラッシュが立ち上がっていないかをチェックします。
公式ドキュメントには書かれていませんが、device_model ではなく device.model を見にいくと、機種が "Unknown" に丸められて出てくるケースがあります。私はそれで一度引っかかったので、Crashlytics の SDK バージョンを最新にした上で、フィルタは device.modelIdentifier も併用するようにしています。
1% → 10% → 50% → 100% で「解像度起因 0 件」に到達するまで
実際の段階公開ステップは、各段階の判断基準とセットで管理しています。
Day 1: 1% - 24h 観察、Crashlytics の解像度フィルタが 0 件であることを確認
Day 2: 10% - 24h、ANR / 起動失敗 0 件、ストアレビューに解像度の苦情なし
Day 4: 50% - 48h、安定後にプッシュ通知を全 6 アプリで再開
Day 7: 100% - 全展開、Crash-free users 99.7% を維持
Day 14: ふりかえり、29 箇所の三項演算子を「整理して残すか」「テーブル駆動に置き換えるか」を判断
Day 14 のふりかえりは個人開発を続ける上で本当に大事だと感じています。リリースしただけで満足してしまうと、3 リリース後に DefineManager.h が破綻します。
来年に向けた次の一手 — テーブル駆動への移行
来年さらに解像度が増える前提で、私は今、DefineManager.h を Swift 側のテーブル駆動レイアウト記述に移す検討をしています。Objective-C の三項演算子チェインは、過去の自分が選んだ書き方ですが、6 機種を超えたあたりから明らかに限界です。
具体的には、レイアウト値を (modelIdentifier, key) -> CGFloat の Dictionary に置き、未登録モデルはデフォルト値にフォールバックする設計です。Claude on Xcode に Swift Package として切り出してもらう準備をしています。
12 年やってきて分かったのは、リファクタリングの最適なタイミングは「壊れる前」ではなく「ちょうど次の変更が手元に来た直後」だということです。29 箇所追加した今こそ、テーブル駆動に移すための事実認識が一番揃っています。
ご自身のプロジェクトで先に確認しておくと楽になる 1 つのこと
iPhone Air / 17 Pro / 17 Pro Max を Xcode 17 のシミュレータで起動して、AppDelegate.application(_:didFinishLaunchingWithOptions:) の最初の行で UIScreen.main.bounds.size と UIScreen.main.nativeBounds.size の両方をログ出力してみることを推奨します。論理サイズと物理サイズの組み合わせは、今後の判定ロジックを書く起点になります。
同じように 6 アプリを並行で運用している個人開発者の方の参考になれば幸いです。お読みいただきありがとうございました。