個人開発で壁紙系アプリを4本並行で更新していて、いちばん削りたかった作業が「新しい iPhone が出るたびの解像度対応」でした。iPhone Air(420×912 pt)や 17 Pro(402×874 pt)が増えたとき、画面サイズで背景画像を出し分ける if / 三項演算子が、DefineManager という1ファイルの中に29箇所も散らばっていたのです。1箇所直すたびに、残り28箇所のうちどこを直し忘れているかを探す作業になっていました。
この記事は、その「端末が増えるたびに分岐が増殖する」構造そのものを、テーブル駆動の小さな設計に置き換えた記録です。コードはそのまま自分のアプリに移植できる形にしてあります。
分岐が散らばると、新モデルが牙をむく
最初に問題のコードをそのまま見ていただくのがいちばん早いと思います。当時の判定はこういう形でした。
// Before: 同じ高さ判定が DefineManager 内に29箇所散在していた
let bgName: String
let h = UIScreen.main.bounds.height
if h == 956 { // Pro Max
bgName = "bg@3x-max"
} else if h == 912 { // iPhone Air ← 新モデルのたびにここを足す
bgName = "bg@3x-air"
} else if h == 874 { // 17 Pro ← ここも足す
bgName = "bg@3x-pro"
} else if h == 852 { // 15 / 16 標準
bgName = "bg@3x-standard"
} else {
bgName = "bg@2x-classic"
}
この形の何が苦しいかというと、判定ロジックそのものは正しいのに、同じ判定が散在しているせいで「変更の単位」がコードの形と一致していないことです。新端末を1つ足すという1つの意思決定が、29箇所のコード変更に化けます。書き足しの過程で1箇所抜ければ、その端末だけ背景がぼやけたり、別解像度の画像が引き伸ばされて表示されます。実際に私はこの抜けで、特定機種だけアセットが取り違わるクラッシュ報告を受けたことがありました。
落とし穴は「==」での厳密一致にもあります。Apple が次に出す端末の論理解像度は事前にはわかりません。else 句に落ちた未知の端末は、いちばん解像度の低い bg@2x-classic を掴んでしまい、新しい大画面ほど低品質な画像が出るという、最も避けたい挙動になっていました。
「どの端末か」ではなく「どのプロファイルか」で考える
設計を変える起点は、判定の主語を変えることでした。これまでは「この端末は Pro Max か?」と端末を主語にしていました。これを「この画面サイズは、どの配信プロファイルに属するか?」という問いに置き換えます。
アプリが本当に知りたいのは端末の商品名ではありません。どのサフィックスのアセットを読み込み、どの安全領域を前提にレイアウトすればよいか、という運用上の属性だけです。であれば、端末ごとの属性を1つの値オブジェクトにまとめ、それを並べた表(テーブル)を唯一の真実の場所にすればよい、という発想に行き着きます。
この方針に切り替えると、分岐の数は端末数に依存しなくなります。判定コードは1つだけになり、端末が増えるのは「表に行が1つ増える」だけになります。
デバイスプロファイルをテーブルにまとめる
まず、アプリが必要とする属性だけを持った値オブジェクトを定義します。商品名のような曖昧な情報は持たせず、アセット選択とレイアウトに直結する情報に絞るのがコツです。
import CoreGraphics
/// アプリが実際に必要とする「端末の属性」だけを持つ値オブジェクト
struct DeviceProfile : Equatable {
let id: String // 内部識別子(ログとアセット選択に使う)
let pointSize: CGSize // 論理解像度(pt)。向きは縦基準で正規化して持つ
let assetSuffix: String // 配信アセットのサフィックス
let hasDynamicIsland: Bool
}
/// 唯一の真実の場所。新端末はここに1行足すだけで完結する
enum DeviceCatalog {
static let profiles: [DeviceProfile] = [
DeviceProfile ( id : "se_classic" , pointSize : CGSize ( width : 375 , height : 667 ),
assetSuffix : "2x-classic" , hasDynamicIsland : false ),
DeviceProfile ( id : "standard" , pointSize : CGSize ( width : 393 , height : 852 ),
assetSuffix : "3x-standard" , hasDynamicIsland : true ),
DeviceProfile ( id : "pro_17" , pointSize : CGSize ( width : 402 , height : 874 ),
assetSuffix : "3x-pro" , hasDynamicIsland : true ),
DeviceProfile ( id : "air" , pointSize : CGSize ( width : 420 , height : 912 ),
assetSuffix : "3x-air" , hasDynamicIsland : true ),
DeviceProfile ( id : "pro_max" , pointSize : CGSize ( width : 440 , height : 956 ),
assetSuffix : "3x-max" , hasDynamicIsland : true ),
]
/// 万一どの行にも寄せられなかった場合の保険(中庸なプロファイル)
static let fallback = profiles[ 1 ] // standard
}
表が縦に伸びても、増えるのはデータだけです。ロジックは一切増えません。新しい iPhone が発表されたら、論理解像度を1行追記し、対応するアセットを Assets に置けば、それで対応は完了します。これが、29箇所の書き足しを1行に変える核心です。
起動時に一度だけ解決して使い回す
次に、画面サイズからプロファイルを引く解決器を用意します。ここで2つの落とし穴を回避しておきます。ひとつは向き(縦横)の違いで一致判定が崩れること、もうひとつは未知の端末を else で最低解像度に落とさないことです。
import UIKit
enum DeviceProfileResolver {
private static var cached: DeviceProfile ?
/// 起動時に一度だけ解決し、以降はキャッシュを返す
static func current ( screenSize : CGSize) -> DeviceProfile {
if let cached { return cached }
let resolved = resolve ( for : screenSize)
cached = resolved
return resolved
}
static func resolve ( for size: CGSize) -> DeviceProfile {
let target = normalized (size)
// 1. 完全一致を最優先
if let exact = DeviceCatalog.profiles. first ( where : { normalized ( $0 .pointSize) == target }) {
return exact
}
// 2. 一致しなければ「最も面積が近い」プロファイルへ寄せる
// → 未知の新大画面が最低解像度に落ちる事故を防ぐ
return DeviceCatalog.profiles. min ( by : {
abs ( area ( $0 .pointSize) - area (target)) < abs ( area ( $1 .pointSize) - area (target))
}) ?? DeviceCatalog.fallback
}
/// 向きに依存しないよう短辺×長辺へ正規化する
private static func normalized ( _ s: CGSize) -> CGSize {
CGSize ( width : min (s.width, s.height), height : max (s.width, s.height))
}
private static func area ( _ s: CGSize) -> CGFloat { s.width * s.height }
}
呼び出し側は、これだけになります。画面サイズの取得は、UIScreen.main への依存を避けて、表示中のウインドウシーンから取るのを私は推奨します。マルチウインドウや外部ディスプレイ接続時に、UIScreen.main が意図しない画面を指す場合があるためです。
// 呼び出し側(View や ViewController から)
let screenSize = view.window ? .windowScene ? .screen.bounds. size
?? UIScreen.main.bounds. size // 旧経路のフォールバック
let profile = DeviceProfileResolver. current ( screenSize : screenSize)
imageView. image = UIImage ( named : "bg_ \( profile. assetSuffix ) " )
topInset = profile.hasDynamicIsland ? 59 : 47
ここまでで、アプリ全体に散らばっていた解像度判定は「プロファイルを1回引く」という1行に集約されます。アセット名も安全領域も、すべてプロファイルが一元的に答えてくれます。
既存の三項演算子を安全に置き換える手順
一気に全置換するとデグレが怖いので、私はこの場合は次の順番で段階的に移し替えました。各ステップで挙動を確認しながら進めるのが、本番アプリでの安全策になります。
新しい DeviceProfile / DeviceCatalog / DeviceProfileResolver を追加する。この時点では既存コードは一切触りません。
既存の三項演算子のうち、最も呼び出し回数の多い1箇所だけを profile.assetSuffix 参照に置き換え、全機種で表示が変わらないことを確認します。
残りの箇所を、意味の近いものからまとめて置換します。UIScreen.main.bounds.height == NNN を機械的に grep し、置換漏れがないかを確認します。
旧 DefineManager の高さ定数をすべて削除し、ビルドエラーになった箇所を潰します。コンパイラが「まだ残っている参照」を教えてくれるので、ここは積極的に削るのが安全です。
3 の機械的な grep と置換、4 のビルドエラー潰しは、Antigravity のエージェントに任せると速い工程でした。「UIScreen.main.bounds.height == 956 のような厳密一致による解像度分岐をすべて列挙して、DeviceProfileResolver 経由に書き換える計画を出して」と頼むと、変更計画と差分を提示してくれます。
この移行で、重複していた分岐コードを約90%削減できました(29箇所 → 解決器の1箇所と呼び出しに集約)。体感としても、新端末対応の心理的なハードルが一気に下がりました。
Antigravity に任せた範囲と、人間が確認すべき範囲
正直に書くと、ここでエージェントに丸投げできるのは「機械的な置換」までです。判断が要る部分は人間に残ります。
任せて良かったのは、分岐箇所の網羅的な洗い出し、定型的な置換、削除後のビルドエラー追従でした。これらは見落としが起きやすい単純作業なので、エージェントの正確さが効きます。
一方で、テーブルに入れる論理解像度の数値そのものは、私自身がシミュレータと実機で1つずつ確認しました。公称スペックの数値をそのまま信じると、Safe Area や scale の扱いで実測とずれることがあるためです。プロファイルの粒度(どこまで端末をまとめ、どこから分けるか)も設計判断なので、ここは人間が決めます。テスト系の設計思想についてはSwift Testing と AI 駆動テスト設計の記事 でも触れています。
全シミュレータで一括検証する
テーブル駆動にした最大の利点は、検証が書きやすくなることです。表に並んだ全プロファイルが「自分自身に解決されること」、そして未知サイズが「決め打ちで最も近い行に寄ること」を、起動せずにユニットテストで担保できます。
import XCTest
@testable import WallpaperApp
final class DeviceProfileResolverTests : XCTestCase {
/// カタログ上の全サイズが、自分自身のプロファイルに解決される
func testEveryCatalogedSizeResolvesToItself () {
for profile in DeviceCatalog.profiles {
let resolved = DeviceProfileResolver. resolve ( for : profile.pointSize)
XCTAssertEqual (resolved.id, profile.id,
" \( profile. id ) が別プロファイルに解決されました" )
}
}
/// 向きを変えても同じプロファイルに解決される(横向き対策)
func testLandscapeResolvesSameProfile () {
let air = CGSize ( width : 420 , height : 912 )
let airLandscape = CGSize ( width : 912 , height : 420 )
XCTAssertEqual (
DeviceProfileResolver. resolve ( for : air).id,
DeviceProfileResolver. resolve ( for : airLandscape).id
)
}
/// 未知の新端末(408×884 想定)は最も近い 17 Pro に寄る
func testUnknownSizeFallsBackToNearest () {
let resolved = DeviceProfileResolver. resolve ( for : CGSize ( width : 408 , height : 884 ))
XCTAssertEqual (resolved.id, "pro_17" )
}
}
3つ目のテストは、まさに過去にクラッシュの原因だった「未知端末が最低解像度に落ちる」挙動への回帰テストです。新しい端末プロファイルを足したときに、既存端末の解決先がうっかり変わっていないかも、1つ目のテストが守ってくれます。テーブルとテストが対になっているので、安心して行を足せるようになりました。
なお、解像度バケットそのものの配信側の組み方は壁紙アプリの画像配信を解像度バケット方式で組み直した運用設計 に、新解像度対応で29箇所ハマった当時の生々しい記録は新 iPhone の解像度対応を乗り越えた話 にまとめてあります。
次の端末追加を1行で終わらせるために
もし今、似たような画面サイズ分岐がアプリ全体に散らばっているなら、最初の一歩は「すべての置換」ではありません。DeviceProfile の表を1つ作り、いちばん呼ばれている1箇所だけを置き換えて、全機種で表示が変わらないことを確認する——そこから始めるのが、本番アプリで安全に着地させるコツです。Google Play 側の密度分割と違って、App Store 側はアセットの取り違えがレビューで弾かれにくいぶん、検証は自分で握っておく必要があります。
表とテストが揃えば、次に Apple が新しい iPhone を出したとき、対応は1行のデータ追加とアセット配置だけで終わります。私はこの形にしてから、新端末のニュースを身構えずに読めるようになりました。同じ作業を毎年繰り返している方の参考になれば嬉しいです。