[Xcode/Swift] iOSアプリのライフサイクル完全理解:UIKit と SwiftUI を比較

1. はじめに:ライフサイクル?——「お店の一日」で考える

要約

  1. 起動(開店)
  2. 表示(接客)
  3. 一時停止(小休止)
  4. バックグラウンド(裏で事務)
  5. 終了(閉店)

Xcode テンプレートの“入口”の違い

  • UIKitAppDelegate(店のオーナー)+ SceneDelegate(各店舗フロア)
  • SwiftUI App@main struct MyApp: App(宣言的な入口)+ Scene(窓口)

2. UIKit 編:アプリ全体と画面のサイクル

2.1 アプリ/シーンの状態

iOS 13+ はシーン(マルチウィンドウ)前提。実務では「アプリ全体」よりUIScene の遷移を観測することが多い。

  • Not Running(未起動)
  • Inactive(操作不可だが前面)
  • Active(通常の前面動作)
  • Background(裏で動作)
  • Suspended(完全停止直前・再開は速い)

2.2 AppDelegateSceneDelegate

  • 起動時の一度きりの初期化や SDK 構成は AppDelegate
  • 前面/背景などの遷移SceneDelegate が担当(1ウィンドウ=1シーン)。
// AppDelegate.swift
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // SDK 初期化・ログ設定など(開店準備)
        return true
    }

    // シーンを使う設定
    func application(_ application: UIApplication,
                     configurationForConnecting connectingSceneSession: UISceneSession,
                     options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
}

// SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func sceneWillEnterForeground(_ scene: UIScene) { /* シャッター開ける直前 */ }
    func sceneDidBecomeActive(_ scene: UIScene) { /* 接客開始 */ }
    func sceneWillResignActive(_ scene: UIScene) { /* 一時休止・電話着信など */ }
    func sceneDidEnterBackground(_ scene: UIScene) {
        // 事務作業:軽い保存やタスク終了
        UserDefaults.standard.synchronize()
    }
}

2.3 画面単位:UIViewController ライフサイクル

  • viewDidLoad:1度だけ。ビュー生成・軽い初期化
  • viewWillAppear:表示直前。毎回
  • viewDidAppear:表示完了。アニメ開始・計測開始
  • viewWillDisappear / viewDidDisappear:退場前後。タイマー停止・監視解除

OK/NG 例

OK:

  • ✅ ネット読み込み開始:viewDidAppear(表示をブロックしない)

NG:

  • ❌ 重い計算を viewDidLoad で同期実行(初回表示が固まる)

3. SwiftUI 編:宣言的UIのサイクルを掴む

3.1 @mainApp / Scene

SwiftUI はアプリの入口も宣言的AppbodyScene を返す。

@main
struct CounterApp: App {
    @StateObject private var store = CounterStore()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(store)
        }
    }
}

3.2 View のフック:.onAppear / .onDisappear / .task

  • .onAppear:見え始めた瞬間(何度も呼ばれる可能性)
  • .task:表示時に非同期タスクを開始。id: を付けると依存の変更で再実行
  • .onDisappear:見えなくなる瞬間。キャンセルやクリーンアップ
struct ContentView: View {
    @State private var items: [String] = []

    var body: some View {
        List(items, id: \.self, rowContent: Text.init)
            .task { items = await API.fetchItems() }  // 表示時に1回
            .onDisappear { /* タイマー停止・監視解除など */ }
    }
}

3.3 アプリ状態の監視:scenePhase

お店の開店 / 休憩 / 閉店を受け取る感覚。

struct RootScene: View {
    @Environment(\.scenePhase) private var scenePhase
    @EnvironmentObject private var store: CounterStore

    var body: some View {
        ContentView()
            .onChange(of: scenePhase) { old, new in
                if new == .background { store.save() }    // 裏に回ったら保存
                if new == .active { store.refreshIfNeeded() }
            }
    }
}

3.4 データの寿命を意識する

  • @State:View 内の短命データ
  • @StateObject:View が作る長生きな参照型 (初期化は1回)
  • @ObservedObject:外から注入された参照の監視
  • @EnvironmentObject:アプリ全体に配る共有依存
  • 便利:@AppStorage(アプリ全体の簡易永続)、@SceneStorage(シーン単位)

3.5 タスクとキャンセルの勘所

  • 重複実行を避けるなら task(id:) でトリガーを管理。
  • .task は View が消えると 自動キャンセル
struct AvatarView: View {
    @State private var userID = "me"
    @State private var image: Image?

    var body: some View {
        VStack {
            image ?? Image(systemName: "person.circle")
        }
        .task(id: userID) { image = await API.avatar(for: userID) } // userID 変更時だけ再実行
    }
}

4. UIKit と SwiftUI の対比&落とし穴

4.1 「どこで何をやる?」対応表(最小指針)

やりたいことUIKit の置き場SwiftUI の置き場
SDK 初期化AppDelegate.didFinishLaunchingApp.init
前面になったら更新sceneDidBecomeActivescenePhase == .active.onChange
背景へ行くと保存sceneDidEnterBackgroundscenePhase == .background.onChange
画面が見えたらフェッチviewDidAppear.task or .onAppear
タイマー停止viewWillDisappear.onDisappear

4.2 よくある落とし穴

  • 重い処理を同期で置くviewDidLoad / body に巨大計算→描画止まる (非同期に分離、スピナーは ProgressView などで。)
  • 非同期の多重起動onAppear が再実行されがち (task(id:) でトリガーを絞る or Actor/AsyncSequence で直列化。)
  • 後始末忘れ:Timer / Notification / Combine の購読解除 (→ UIKit は deinit or viewWillDisappear、SwiftUI は .onDisappearonChange でキャンセル。)

4.3 併用パターン(ブリッジ)

UIKit → SwiftUIUIHostingController(rootView:)

let vc = UIHostingController(rootView: ContentView())
window.rootViewController = vc

SwiftUI → UIKitUIViewControllerRepresentable

struct MailVC: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> MFMailComposeViewController { .init() }
    func updateUIViewController(_ uiViewController: MFMailComposeViewController, context: Context) {}
}

5. 実践:ミニアプリで往復練習(同じ仕様で UIKit / SwiftUI)

仕様

  • 画面にカウンター(+ / -)
  • バックグラウンドに入ったら保存、復帰時に復元
  • 保存先は UserDefaults(簡易)

5.1 UIKit

// モデル
final class CounterStore {
    private let key = "count"
    var count: Int = UserDefaults.standard.integer(forKey: "count")

    // 保存
    func save() {
        UserDefaults.standard.set(count, forKey: key)
    }
}

// SceneDelegate で保存フック
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    let store = CounterStore()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        let vc = CounterViewController(store: store)
        window = UIWindow(windowScene: scene as! UIWindowScene)
        window?.rootViewController = vc
        window?.makeKeyAndVisible()
    }

    func sceneDidEnterBackground(_ scene: UIScene) {
        // アプリがバックグラウンドに入るときに保存
        store.save()
    }
}

// 画面
final class CounterViewController: UIViewController {
    private let store: CounterStore
    init(store: CounterStore) {
        self.store = store; super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) {
        fatalError()
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        // + / - ボタンとラベルを配置(Auto Layout 省略)
    }

    @objc private func increment() {
        store.count += 1 /* ラベル更新 */
    }
    @objc private func decrement() {
        store.count -= 1 /* ラベル更新 */
    }
}

5.2 SwiftUI 版(scenePhase 連動)

final class CounterStore: ObservableObject {
    @Published var count: Int = UserDefaults.standard.integer(forKey: "count")
    private let key = "count"

    func save() {
        UserDefaults.standard.set(count, forKey: key)
    }
    func refreshIfNeeded() {
        /* 必要ならフェッチ等 */
    }
}

@main
struct CounterApp: App {
    @StateObject private var store = CounterStore()
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        WindowGroup {
            CounterView().environmentObject(store)
        }
        .onChange(of: scenePhase) { _, new in
            if new == .background { store.save() }
        }
    }
}

struct CounterView: View {
    @EnvironmentObject private var store: CounterStore

    var body: some View {
        VStack(spacing: 16) {
            Text("\(store.count)").font(.system(size: 48, weight: .bold))
            HStack {
                Button("-") { store.count -= 1 }.buttonStyle(.bordered)
                Button("+") { store.count += 1 }.buttonStyle(.borderedProminent)
            }
        }
        .padding()
    }
}

// さらに簡単にするなら @AppStorage("count") var count = 0 を使えば保存処理すら不要。

5.3 チェックリスト

  • 初期化は App(Delegate) に寄せる
  • 表示直後のフェッチは viewDidAppear / .task
  • 背景遷移で保存は sceneDidEnterBackground / scenePhase
  • 監視解除・タイマー停止は 退場フック
  • 重い処理は非同期化、UI スレッドを塞がない

参考リンク