iOSアプリの起動白画面に4種のLaunchScreenで挑んで全滅した話 — 計測して「シンプルな起動」を選んだ理由
AI主体で執筆60d Memo v1.0 をリリースした直後、「起動してからメモ一覧が表示されるまでの間、白い画面が出る」という動画付きの報告をもらった。再現してみると確かに一瞬だがはっきり白くなる。「ロゴでも表示して埋めよう」と考えたのが地獄の入口で、UILaunchScreen.UIImageName・パディング付き画像・SwiftUI擬似スプラッシュ・LaunchScreen.storyboardの4手段を試したが、iOS 26の実機では全部まともに動かなかった。最終的にpre-mainの計測データを見て、「シンプルな起動」を意図的に選ぶ結論に至った。
問題の構造を整理する
まずiOSの起動フェーズを確認する。アプリが起動されてから最初の画面が見えるまでには大きく2つの区間がある。
| 区間 | 内容 | LaunchScreenが効く区間 |
|---|---|---|
| pre-main | dyldがフレームワークをロードし、static initializerを実行する。main()が呼ばれる前。 | ◎ この間ずっとLaunchScreenが表示されている |
| post-main | @mainの初期化からSwiftUIが最初のbodyを描画するまで。 | △ SwiftUIがcommitされるとLaunchScreenと差し替わる |
報告された「白い画面」はpost-mainの末尾で起きていた。SwiftUIがbodyを描画するわずかな間、LaunchScreenBackgroundとSwiftUI描画の切り替えが見えていた。LaunchScreenの背景色とWindowGroup直下の色が一致しているため厳密には「白」ではなく「何も描かれていないシステム背景色」だが、動画で見ると白く見える。
手段1: UILaunchScreen.UIImageName
Info.plistのUILaunchScreen辞書にUIImageNameキーを追加すれば、LaunchScreen中に画像(ロゴ等)を表示できる。最も手軽な方法で、ドキュメントにも載っている。
<!-- Info.plist -->
<key>UILaunchScreen</key>
<dict>
<key>UIImageName</key>
<string>LaunchLogo</string>
<key>UIColorName</key>
<string>LaunchScreenBackground</string>
</dict>
結果:失敗。シミュレータ初回起動では画像が表示されたが、再起動すると表示されなくなった。iOS 26はLaunchScreenをキャッシュしており、アプリ更新がない限りキャッシュを使い回す。UILaunchScreen辞書への変更はキャッシュ更新のトリガーとして認識されなかった。実機の動画でも「初回だけちらっとロゴが見えてすぐ消える」という状態で、ユーザー報告の白画面とは別の問題が発生した。
LaunchScreenキャッシュはシミュレータの「デバイスを消去」で手動クリアできる。しかし本番では当然そんな操作はできない。
手段2: パディング付き画像
UILaunchScreen.UIImageNameがキャッシュを更新しないなら、画像アセット自体に変更を加えれば再評価されるかもしれない。LaunchLogoの四辺に1px透明パディングを追加して保存することで、Xcodeがアセットの変更を検知してキャッシュが更新されることを期待した。
結果:失敗。アセットの変更はキャッシュ無効化のトリガーにならなかった。また、仮に毎回キャッシュが更新されたとしても、LaunchScreenが表示されるのはpre-main区間であり、白画面が出るpost-mainの末尾には無関係だった。根本的にアプローチが間違っていた。
手段3: SwiftUI擬似スプラッシュ
LaunchScreenに頼らず、SwiftUIのWindowGroup直下でロゴ画像を重ねるオーバーレイを実装した。LaunchScreenが消えた直後も同じロゴが見えるように、タイミングをずらしてopacityフェードアウトさせる設計だ。
// 擬似スプラッシュの実装イメージ
struct ContentView: View {
@State private var splashOpacity: Double = 1.0
var body: some View {
ZStack {
MemoListView()
if splashOpacity > 0 {
Image("LaunchLogo")
.opacity(splashOpacity)
}
}
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
withAnimation(.easeOut(duration: 0.3)) {
splashOpacity = 0
}
}
}
}
}
結果:失敗。ユーザーが録画した動画に「onAppearが呼ばれるより前にpre-main区間のLaunchScreenが終わり、SwiftUIがbodyをcommitするまでの空白区間が白い」という様子がはっきり映っていた。擬似スプラッシュはonAppear以降にしか存在しない。白くなる区間はそれより前だった。ロゴがちらっと一瞬表示されてすぐ消えるというかえって不自然な動きになった。
手段4: LaunchScreen.storyboard
UILaunchScreen辞書方式ではなく、昔ながらのLaunchScreen.storyboardで制御する方式。より細かいレイアウトが書けるため、画像の位置・サイズを明示的に指定できる。
<!-- Info.plist -->
<!-- UILaunchScreen辞書を削除し、従来のキーに戻す -->
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
結果:失敗。iOS 26環境ではstoryboard版もキャッシュ挙動がUILaunchScreen辞書と同様で、初回以降は古いキャッシュが表示され続けた。また、storyboardで表示できるのはpre-main区間だけであるという制約は変わらなかった。white flash問題は解決しなかった。
pre-mainを計測する
4手段が全滅したので、方針を変えてまず「本当にどこに時間がかかっているか」を計測した。XcodeにはDYLD_PRINT_STATISTICS環境変数を使ってpre-mainの内訳を表示する機能がある。
// Xcode スキームの Arguments タブに追加
// Environment Variables:
DYLD_PRINT_STATISTICS = 1
実測すると、pre-main自体は400〜600ms程度で特別に長くはなかった。GoogleSignIn-iOSはSwift Package Managerでstaticリンクされているためdlopenコストもなく、+loadや__attribute__((constructor))によるstatic initializerも検出されなかった。pre-mainはボトルネックではなかった。
次にpost-mainを計測した。MemoApp.initからMemoListView.bodyの初回評価完了までの間にos_signpostを仕込んでInstrumentsで見ると、SwiftDataのModelContainerが初期化されるまでに一定時間があることはわかったが、これ自体はフレームワークの制約であり、短縮に限界があった。
分かったこと:white flashは「起動が遅い」のではなく「OSがLaunchScreenからSwiftUI描画に切り替わるタイミングに生じるコンポジットの空白」だった。これはアニメーションではごまかせない。
「シンプルな起動」を意図的に選ぶ
計測の結果、真っ当な解決策は「LaunchScreenの背景色とWindowGroup直下の背景色を完全に一致させ、切り替わりを知覚させない」であり、ロゴを表示する試みはすべてかえって違和感を増やしていたと結論づけた。
最終的な実装は以下だ。
// 60dMemoApp.swift — 余分な Color レイヤーを除去し MemoListView を直接マウント
@main
struct MemoApp: App {
var body: some Scene {
WindowGroup {
MemoListView() // Color(uiColor: .systemBackground) のラップを除去
}
}
}
// Info.plist — LaunchScreen は背景色だけ、画像なし
// UILaunchScreen:
// UIColorName: "LaunchScreenBackground" (= .systemBackground 相当)
LaunchScreenBackgroundはsystemBackgroundと同じ色に定義してあるため、LaunchScreenからSwiftUIへの切り替わりで色が変化しない。「白いスプラッシュ」ではなく「何もないまま一覧が出てくる」という体験になる。これは「起動が速い」アプリの自然な挙動に近い。
以前はMemoListViewの下にColor(uiColor: .systemBackground)のレイヤーを重ねていたが、これも除去した。LaunchScreenBackgroundがすでに同色なので、余分なコンポジットパスが走るだけで視覚的な効果はなかった。
残った課題と今後の方針
「シンプルな起動」を選んだことで白いちらつきはなくなったが、LaunchScreenが消えてからメモ一覧が表示されるまでのblank区間自体は解消していない。これは以下の方針で引き続き取り組む。
- ModelContainer初期化の遅延読み込み: 表示に不要なリレーションやインデックスを初回描画後に遅延ロードする
- post-mainのos_signpost常設化: リリースビルドでも「一覧表示完了」のシグポストを残し、バージョンをまたいだ回帰テストを可能にする
- Google Drive同期の非同期化: 起動時の同期チェックをバックグラウンドに逃がし、一覧表示をブロックしないようにする(現状は既に非同期だが、sync状態バッジの更新タイミングを調整できる余地がある)
「ロゴで埋める」はユーザーの時間を奪う誤魔化しに過ぎない。本当に速くする方向で引き続き改善していく。
まとめ
| 手段 | iOS 26での結果 | 根本的な問題 |
|---|---|---|
| UILaunchScreen.UIImageName | キャッシュが更新されず再起動後に消える | LaunchScreenはpre-main区間しか表示されない |
| パディング付き画像 | 同上 | 同上 + アセット変更はキャッシュ無効化トリガーではない |
| SwiftUI擬似スプラッシュ | onAppear前の空白区間を埋められない | 白画面はonAppearより前に発生している |
| LaunchScreen.storyboard | キャッシュ挙動は辞書方式と同様 | LaunchScreenはpre-main区間しか表示されない |
| シンプルな起動(採用) | ちらつきなし。切り替わりが知覚されない | — |
「ロゴを置く」前に「どこで遅延が起きているか」を計測するべきだった。DYLD_PRINT_STATISTICSとInstrumentsを使えば30分で把握できた情報を、4手段の試行錯誤で数日消費した。
関連リンク
- 60d Memo — App Store
- 60d-google-memo-ios — GitHub
- Apple Documentation: Reducing your app's launch time
- Apple Documentation: About the App Launch Sequence