新しい iPhone が発売された週末、私が個人開発で続けている壁紙アプリ「Beautiful HD Wallpapers(綺麗な壁紙)」に、ある実機でだけ壁紙の上下にうっすら余白が出る、という報告が届きました。手元のシミュレータで再現してみると、確かに新機種だけ画像が中央に寄り、上下が背景色で埋められています。原因は単純で、端末ごとの解像度を判定するコードが新しい画面サイズを知らず、「いちばん近い古い端末」にフォールバックして、縦横比の合わない画像を選んでいたのです。
壁紙アプリは、この「端末ごとに正しいピクセル解像度の画像を出す」という一点に、表示品質のほとんどが懸かっています。少しでも縦横比がずれると、引き伸ばしでぼやけるか、余白が出るかのどちらかになります。にもかかわらず、長年の更新でこの判定ロジックはアプリ内に散り散りになっていました。今回はその散らばりを Claude Code で一枚のテーブルに畳み、新機種を 1 箇所の追記だけで対応できる形に作り替えた工程を、実際のコードとともに残しておきます。
なぜ「最寄りの高さ」で選ぶと壊れるのか
まず、問題の起点を正確に押さえます。iOS の画面は論理サイズ(ポイント)と物理サイズ(ピクセル)の二層構造で、UIScreen.main.bounds が返すのはポイント、実際の画素数はそこに nativeScale(多くの最新機で 3.0)を掛けた値です。壁紙として配信する画像は、この物理ピクセルにぴったり合っていないと、システムが拡大縮小をかけて甘くなります。
私のアプリでは、端末を判定して画像サイズやレイアウト定数を切り替える分岐が、追加のたびに各画面へ少しずつ書き足され、最終的に 29 箇所の三項演算子になっていました。典型的にはこういう形です。
// ❌ Before: 画面の「高さ」だけを見て近い端末を当てる
// 新機種が出るたびに、こういう分岐が各所に増殖していった
func wallpaperPixelHeight () -> CGFloat {
let h = UIScreen.main.bounds.height
if h >= 932 { // Pro Max 系のつもり
return 2796
} else if h >= 852 { // 標準 Pro のつもり
return 2556
} else if h >= 844 {
return 2532
} else {
return 2436 // それ以前へフォールバック
}
}
このコードの弱点は二つあります。一つは、新機種の高さが既存の閾値の隙間に落ちると、意図しない分岐に吸い込まれること。もう一つは、判定軸が「高さ」だけなので、縦横比が違う端末を同じ高さの端末と同一視してしまうことです。壁紙にとって致命的なのは後者で、たとえば同じ高さでも横幅が数ポイント違えば、画像はわずかに引き伸ばされるか、レターボックス(上下の余白)になります。
新型 iPhone でだけ余白が出たのは、まさにこの「高さは近いが横幅が違う端末」を、古い端末として扱ってしまったからでした。
まず Claude Code に「分岐の全量」を出させる
修正方針は決まっています。散らばった分岐を一箇所に集め、判定軸を高さから「画面のポイントサイズ(縦横ペア)」に変え、選定ロジックを「最寄りの高さ」から「同一サイズの厳密一致+縦横比で最も近いもの」へ変えること。ただし、いきなり書き換えると 29 箇所のどれかを取りこぼします。最初にやるべきは、現状の全量を正確に把握することです。
ここで Claude Code に頼ります。手で grep するより、意味的に「端末分岐をしている箇所」を拾わせるほうが速く、抜けも少ないからです。私は次のように依頼しました。
このリポジトリ内で UIScreen.main.bounds の高さや幅、
UIDevice の機種名を条件に分岐しているコードをすべて列挙してください。
ファイルパス・行番号・その分岐が何を切り替えているか(画像サイズ/余白/
フォントなど)の3点を表にしてください。修正はまだしないでください。
「修正はまだしないでください」と明示するのが大事です。Claude Code は指示すると一気に直そうとしますが、リファクタリングは「現状把握 → 設計 → 置換 → 検証」の順で進めたほうが事故が減ります。返ってきた一覧で、29 箇所のうち 6 箇所は画像サイズ、残りは余白やセーフエリア関連の定数だと分かりました。画像サイズの判定だけを先に切り出すのが正解だと、この時点で見通せます。
Source of Truth を一枚のテーブルにする
次に、端末ごとの正しい値を一箇所へ集約します。各端末の論理サイズ(ポイント)を主キーにし、そこから配信したいピクセル解像度を引けるテーブルにしました。私のアプリで実際に採用している値を抜粋すると、次の通りです(論理サイズ × 3 倍が物理ピクセルになります)。
端末クラス 論理サイズ (pt) 配信ピクセル (px) 縦横比
iPhone Air 420 × 912 1260 × 2736 0.461
iPhone 17 Pro 402 × 874 1206 × 2622 0.460
iPhone 16 / 17 Pro Max 440 × 956 1320 × 2868 0.460
iPhone 15 / 14 Pro 393 × 852 1179 × 2556 0.461
縦横比の列を入れているのは、フォールバック時にここを使うためです。完全一致する端末がなければ、高さではなく縦横比が最も近いプロファイルを選びます。こうすると、未知の新機種が来ても「引き伸ばし」ではなく「ほぼ同じ比率の解像度」が当たり、余白や甘さが出にくくなります。
Swift では、この表を素直に構造体の配列で持ちました。
// ✅ After: 端末プロファイルを一箇所に集約する
struct DeviceProfile {
let pointSize: CGSize // 論理サイズ(判定キー)
let pixelSize: CGSize // 配信したい物理ピクセル
var aspect: CGFloat { pointSize.width / pointSize.height }
}
enum WallpaperResolution {
// 新機種が出たら、この配列に1行足すだけで全画面に反映される
static let profiles: [DeviceProfile] = [
. init ( pointSize : . init ( width : 420 , height : 912 ), pixelSize : . init ( width : 1260 , height : 2736 )),
. init ( pointSize : . init ( width : 402 , height : 874 ), pixelSize : . init ( width : 1206 , height : 2622 )),
. init ( pointSize : . init ( width : 440 , height : 956 ), pixelSize : . init ( width : 1320 , height : 2868 )),
. init ( pointSize : . init ( width : 393 , height : 852 ), pixelSize : . init ( width : 1179 , height : 2556 )),
]
/// 現在の端末に最適なピクセル解像度を返す
static func best ( for screen: UIScreen = .main) -> CGSize {
let size = screen.bounds. size
// 1) 論理サイズが厳密一致するプロファイルを最優先
if let exact = profiles. first ( where : {
$0 .pointSize.width == size.width && $0 .pointSize.height == size.height
}) {
return exact.pixelSize
}
// 2) 一致がなければ「縦横比が最も近い」プロファイルへフォールバック
// (高さで選ぶと比率違いの端末で余白が出るため)
let targetAspect = size.width / size.height
let nearest = profiles. min ( by : {
abs ( $0 .aspect - targetAspect) < abs ( $1 .aspect - targetAspect)
})
return nearest ? .pixelSize ?? . init ( width : 1179 , height : 2556 )
}
}
これで、冒頭の wallpaperPixelHeight() のような分岐は WallpaperResolution.best().height の一呼び出しに置き換わります。判定の根拠が一箇所に集まったので、「なぜこの端末でこの解像度なのか」を後から追えるのも大きな利点です。
29 箇所の置換を Claude Code に任せるときの渡し方
集約先ができたら、散らばった 29 箇所を新しい呼び出しに差し替えます。ここでも一括置換は危険です。画像サイズの分岐と、余白・セーフエリアの分岐は意味が違うのに、見た目が似ているため、Claude Code でも文脈を取り違えることがあります。私は段階を分けて依頼しました。
先ほどの一覧のうち「画像サイズを切り替えている6箇所」だけを対象に、
WallpaperResolution.best(for:) を使う形へ置き換えてください。
1ファイルずつ提案し、変更前後の差分を見せてください。
余白・セーフエリア関連の分岐には今は触れないでください。
「1ファイルずつ」「差分を見せて」と縛ると、レビューしながら進められます。
この段階置換では、次の三つを順に守ると事故が減ります。
画像サイズの分岐だけを先に対象にする
余白やセーフエリアの分岐は意味が異なるため、同じ波で触らないようにします。対象を画像サイズ 6 箇所に限定することで、レビューの観点が一つに絞れます。
1 ファイルずつ差分を確認する
まとめて受け入れず、ファイル単位で差分を読みます。表示倍率の計算など、解像度と紛らわしい例外はここで初めて見つかります。
例外は人間が採否を決める
単純置換で意味が変わる箇所は、Claude Code の提案を保留し、手で書き直します。速さは候補出しに使い、最終判断は自分が持ちます。途中、ある画面では解像度ではなく表示倍率を計算していて、単純置換だと意味が変わる箇所が見つかりました。こうした例外は、人間が一度差分を見ていないと見落とします。Claude Code が速いのは事実ですが、速さに任せて差分を読み飛ばすと、結局あとで実機で気づくことになります。私はこの工程では、Claude Code を「候補を出す側」、自分を「採否を決める側」と役割を分けるようにしています。この使い分けの感覚は、別記事のClaude on Xcode と Claude Code を 2 週間使い分けた所感 でも触れています。
取りこぼしを検知するスナップショットテスト
リファクタリングで一番怖いのは「直したつもりで、ある端末だけ古い挙動が残る」ことです。これを目視のシミュレータ確認だけに頼ると、端末が増えるほど抜けます。そこで、全プロファイルに対して期待ピクセルが返るかをテストで固定しました。
import XCTest
@testable import BeautifulWallpapers
final class WallpaperResolutionTests : XCTestCase {
// 各プロファイルが「自分自身の論理サイズ」で厳密一致すること
func testExactMatchForEveryProfile () {
for p in WallpaperResolution.profiles {
let screen = StubScreen ( bounds : CGRect ( origin : .zero, size : p.pointSize))
XCTAssertEqual (WallpaperResolution. best ( for : screen), p.pixelSize,
" \( p. pointSize ) で厳密一致しませんでした" )
}
}
// 未知サイズは「高さ最寄り」ではなく「縦横比最寄り」に落ちること
func testUnknownSizeFallsBackByAspectNotHeight () {
// 高さは Pro Max に近いが、比率は標準機に近い架空サイズ
let weird = CGSize ( width : 360 , height : 950 )
let screen = StubScreen ( bounds : CGRect ( origin : .zero, size : weird))
let got = WallpaperResolution. best ( for : screen)
// 高さ最寄り(Pro Max 2868)に吸われていないことを確認
XCTAssertNotEqual (got.height, 2868 )
}
}
StubScreen は bounds を差し替えられる薄いラッパーで、テストのために best(for:) が UIScreen を引数で受け取れるようにしてあります。テスト可能な形に整えること自体が、設計を素直にしてくれました。二つ目のテストが今回の本質で、「高さ最寄りに吸われない」ことを明示的に守っています。新機種が将来また閾値の隙間に落ちても、このテストが赤くなって教えてくれます。
スナップショットの考え方や、壁紙の見栄えを支える描画側の工夫については、SwiftUI アニメーションのプロンプト設計 や、壁紙アプリの月次アップデートを 3 か月続けた記録 にもまとめています。
この作り替えで何が変わったか
数字で言えば、画像サイズ判定は 6 箇所の三項演算子から 1 つのテーブル参照になり、新端末対応は「配列に 1 行追加してテストを 1 つ足す」だけで完了する形になりました。余白や甘さの報告は、修正版を App Store で段階公開してからは新機種起因のものが出ていません。
iOS の端末まわりの設計をもう少し体系的に押さえたい方には、Apple の Human Interface Guidelines のレイアウトとアダプティブ対応の項が、論理サイズと物理ピクセルの関係を整理するのに役立ちます。
新しい端末が出たときに慌てて 20 箇所を直す運用から、1 行追記で終わる運用へ。まず手元のプロジェクトで「端末の高さで分岐している箇所」を Claude Code に列挙させてみてください。散らばりの全量が見えた瞬間に、畳むべき場所が自然と浮かび上がってきます。