ローソンデジタルイノベーション テックブログ

ローソンデジタルイノベーション(LDI)の技術ブログです

【iOS】AppDelegateからSceneDelegateへ シングルウィンドウアプリ向けのライフサイクル変更対応

はじめに

こんにちは、iOSエンジニアの坂田です。

2025年9月にXcode 26およびiOS26がリリースされ、iOSアプリを取り巻く環境は急速に変化を遂げていきました。

そのうちの1つとして、アプリのライフサイクル変更対応が挙げられます。

しかし、取り扱うアプリによっては今まで通りのライフサイクルで十分なこともあるため、必須対応だから仕方なくやるけどなるべくコストをかけずに済ませたいと言う方もいらっしゃると思います。

なので今回はそういった方向けに必要最低限の対応を紹介できればと思います。

iOSアプリのライフサイクルについてと、移行対応の必要性

app-basedとscene-based、2つのライフサイクル

まず、iOSのライフサイクルには、app-basedライフサイクルとscene-basedライフサイクルの2つが存在します。

app-basedライフサイクルとは、AppDelegateを用いてアプリのライフサイクルを管理する方法で、すべてのiOSバージョンで使用できます。

アプリ起動~アプリ終了(タスクキル)までさまざまなイベントを取り扱い、アプリ全体の処理の根幹を担います。

対して、scene-basedライフサイクルとは、従来のライフサイクルに加えてiOS13から使用できるSceneDelegateを用いた新しいライフサイクルです。

scene-basedライフサイクル登場まではアプリのライフサイクルのすべてのイベントをAppDelegateが取り扱っていましたが、責務が広すぎてコードが肥大化していました。

それを解決するため、アプリ全体のライフサイクルイベントはAppDelegateが引き続き取り扱い、Scene関連のライフサイクルをSceneDelegateが取り扱うようにしました。

これをscene-basedライフサイクルといい、この分割が実現されることによって、iPadでのマルチウィンドウなど、1つのアプリが複数の画面を持つことが可能になりました。

(上記に記載した通り、scene-basedライフサイクルでもAppDelegateは引き続き使用することには注意が必要です。筆者は対応前AppDelegateは無くなるものだと勘違いをしていました。。。)

scene-basedライフサイクルへの移行が必須化

iOS13以降、scene-basedライフサイクルが推奨されているものの、どちらを採用するかは基本的に開発側の自由でした。

特にiOS12以前から公開されているアプリについては移行対応が必要になるため、マルチウィンドウ機能を使用するなどの必要に駆られない限りapp-basedライフサイクルを継続して使用しているアプリも少なくないと思います。筆者が開発するアプリも同じ状況でした。

しかし、WWDC 2025のセッションビデオで、iOS26の次のメジャーリリースからscene-basedライフサイクルが必須になると明言されています。

毎年9月ごろにメジャーリリースがあり、その半年後の3月~5月頃にAppStoreへの提出には最新のSDKを使用するように求められるため、iOS27以降を想定するとどんなに遅くても2027年5月ごろには対応が必要で、9月にiOSの正式版がリリースされ開発者以外のユーザーもOSをアップデートする人が増えることも踏まえると2026年の9月までには移行対応をリリースしておきたいところです。

developer.apple.com

移行方法

基本的には、公式のマイグレーションガイドに沿って対応を進めました。

developer.apple.com

注意事項として、今回はあくまで最低限、アプリがマルチウィンドウを想定していない場合の実装方法です。

マルチウィンドウ機能を使用したい場合はUIWindowの取得方法などが大きく変わってくると思いますので、別途対応が必要なことにご注意ください。

1. Info.plistに設定を追加する

Info.plistに以下の設定を行います。基本的にはマイグレーションガイドにあるものをそのまま使用して問題ないです。

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/> 
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneConfigurationName</key>
                <string>Default Configuration</string>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
                <key>UISceneStoryboardFile</key>
                <string>Main</string> 
            </dict>
        </array>
    </dict>
</dict>

それぞれのキーは以下の設定を表します。

キー 意味
UIApplicationSceneManifest scene-basedライフサイクルに対する内容の全体をまとめるキー
UIApplicationSupportsMultipleScenes 複数のシーンに対応するかどうかを表す。今回はNoを設定
UIWindowSceneSessionRoleApplication アプリ画面用のScene設定を持つキー。Configurationの配列を値にもつ
UISceneConfigurationName Sceneの名前を示す。AppDelegateからSceneに接続する際にどのSceneかを特定する材料になる。
UISceneDelegateClassName SceneDelegateとして使用するクラスを特定するための識別子
UISceneStoryboardFile sceneを使用する際に最初に表示するstoryboardの名前。シングルウィンドウ前提かつ、UILaunchStoryboardNameでアプリ起動時のstoryboardを設定していればこの値は不要。(筆者が対応した際は削除しました)

ただし、UIWindowSceneSessionRoleApplicationに設定された配列が1つの場合、UISceneConfigurationNameは無くても動作します。 Sceneを実際に作成するとき、名前が一致するConfigurationの1件目を使用するが、なければ配列の先頭の構成を使用するという内部動作になっているようです。

そのため、シングルウィンドウ前提で追加の設定を極限まで減らすと以下のようになります。

<key>UIApplicationSceneManifest</key>
<dict>
    <key>UIApplicationSupportsMultipleScenes</key>
    <false/> 
    <key>UISceneConfigurations</key>
    <dict>
        <key>UIWindowSceneSessionRoleApplication</key>
        <array>
            <dict>
                <key>UISceneDelegateClassName</key>
                <string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
            </dict>
        </array>
    </dict>
</dict>

しかし、UIWindowSceneSessionRoleApplicationが持つ配列の要素が増えたりすると予期せずアプリが正常動作をしない状態を引き起こす可能性があるので、個人的にUISceneConfigurationNameは残しておくのが良いと思います。

2. AppDelegateにSceneの構成を取得するデリゲートメソッドを実装する

AppDelegateに以下のメソッドを追加します。

    func application(_ application: UIApplication,
                     configurationForConnecting connectingSceneSession: UISceneSession,
                     options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

マイグレーションガイドだとoptions.userActivities.first?.activityTypeによってnameを分けていますが、今回は"Default Configuration"固定で良いと思います。

1でも触れましたが、nameが一致する設定を見つけられなかった場合は1つ目の設定を使用するため、最悪nameはなんでも良いですが、念の為”Default Configuration”にしておくと良いと思います。

3. SceneDelegateを作成する

マイグレーションガイドに沿って、SceneDelegateを作成します。

import UIKit


class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    
    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        // Confirm the scene is a window scene in iOS or iPadOS.
        guard let windowScene = scene as? UIWindowScene else { return }
                
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = YourRootViewController()
        window?.makeKeyAndVisible()
    }
}

上記の通り、scene-basedライフサイクルではルート画面の表示はSceneDelegate側で実施されます。

そのため、この時点でAppDelegateからmakeKeyAndVisible周りの処理を削除しておくと良いです。

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

          // その他処理...

-        window?.rootViewController = YourRootViewController()
-        window?.makeKeyAndVisible()

          // その他処理...

}

4. SceneDelegateに移行できるメソッドを移行する

AppDelegateに書かれていたメソッドの一部をSceneDelegateに移行します。 マイグレーションガイドにはapplicationDidBecomeActive(:), applicationWillResignActive(:), applicationDidEnterBackground(:), applicationWillEnterForeground(:)の4つのメソッドの移行先が書いてありますが、実際に移行できるメソッドは他にもあります。

UIApplicationDelegateの中でdeprecatedになっているメソッドや、ドキュメント中にImportantでSceneについて言及されているものが移行対象になりえます。

AppDelegateに実装を残しておいても、SceneDelegateがあると呼び出されなくなるメソッドもあるため、移行には注意が必要です。

置き換え対象のメソッドは以下の通りです。

AppDelegateで実装されていたメソッド名 SceneDelegateで実装する移行先メソッド名
applicationDidBecomeActive(_:) sceneDidBecomeActive(_:)
applicationWillResignActive(_:) sceneWillResignActive(_:)
applicationDidEnterBackground(_:) sceneDidEnterBackground(_:)
applicationWillEnterForeground(_:) sceneWillEnterForeground(_:)
application(_:willContinueUserActivityWithType:) scene(_:willContinueUserActivityWithType:)
application(_:continue:restorationHandler:) scene(_:continue:)
application(_:didUpdate:) scene(_:didUpdate)
application(_:didFailToContinueUserActivityWithType:error:) scene(_:didFailToContinueUserActivityWithType:error:)
application(_:performActionFor:completionHandler:) windowScene(_:performActionFor:completionHandler:)
application(_:open:options:) scene(_:openURLContexts:)
application(_:userDidAcceptCloudKitShareWith:) windowScene(_:userDidAcceptCloudKitShareWith:)

ただし、今後のAppleの仕様変更により、上記の対応表が変わる可能性があります。そのため、実装されているAppDelegateのメソッドについては、公式ドキュメントで移行対象を確認することをお勧めします。

AppDelegateについての公式ドキュメントは以下になります。この中のdeprecatedと書いてあるメソッドが主な移行対象となります。

developer.apple.com

移行中に起こった問題と対応方法

次に、移行中に起こった問題をいくつか紹介します。

1. ViewControllerから直接呼んでいたAppDelegateのメソッドをSceneDelegateに移行した時どうするか

アプリ内には、ViewControllerからUIApplication.shared.delegate as? AppDelegateでAppDelegateを取得しインスタンスメソッドを呼び出している箇所がいくつかありましたが、呼び出されているメソッドがSceneDelegateに移行され、直接呼び出せなくなる課題が発生しました。

解決策としては、UIApplication.sharedが持つconnectedScenesからSceneDelegateの特定ができたので、それを参照するcomputed propertyをAppDelegate下に用意し、それを介してメソッドを呼ぶようにしました。

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
+    var sceneDelegate: SceneDelegate? {
+        UIApplication
+           .shared
+            .connectedScenes
+           .compactMap { $0 as? UIWindowScene }
+            .last { $0.windows.count { $0.isKeyWindow } > 0 }?
+            .delegate as? SceneDelegate
+    }
    ...
}

呼び出し側は以下になります。

class SomeViewController: UIViewController {
    func hoge() {
        if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
+            appDelegate.sceneDelegate?.hoge()
-            appDelegate.hoge()
        }
    }
}

今回はAppDelegateのcomputed propertyにしましたが、AppDelegateのインスタンスに影響しないため、定義する場所はプロジェクトのルールに合わせるので良いと思います。

ただし、この取得方法もあくまでSceneDelegateがアプリ上で1つしかない場合のみ有効で、マルチウィンドウ対応などを行った際に正しく取得できなくなる可能性があることには注意が必要です。

2. 移行前後のメソッドで引数の型が違う

application(:open:options:)をscene(:openURLContexts:)に移行した際、引数のURLと[UIApplication.OpenURLOptionsKey: Any]がSet<UIOpenURLContext>に変わり、少々手間取りました。 解決策としては、まずSet<UIOpenURLContext>のfirstを取得し、UIOpenURLContext.urlを取り出すことでURLを取得できます。 オプションについては、以下のように個別に変換する処理を実装しました。

    func scene(_ scene: UIScene,
               openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let context = URLContexts.first else { return }
        // UIOpenURLContextを[UIApplication.OpenURLOptionsKey: Any]に変換する
        var options = [UIApplication.OpenURLOptionsKey: Any]()
        if let annotation = context.options.annotation {
            options.updateValue(annotation, forKey: .annotation)
        }
        if let eventAttribution = context.options.eventAttribution {
            options.updateValue(eventAttribution, forKey: .eventAttribution)
        }
        options.updateValue(context.options.openInPlace, forKey: .openInPlace)
        if let sourceApplication = context.options.sourceApplication {
            options.updateValue(sourceApplication, forKey: .sourceApplication)
        }
    }

3. PUSH通知のハンドリングができなくなった

URLのハンドリングメソッドを共通で使っている都合上、application(_:continue:restorationHandler:)メソッドの置き換え時に一緒にUNUserNotificationCenterDelegateへの準拠もAppDelegateからSceneDelegateに移行しました。

その際、ほとんどコピー&ペーストでメソッドを移したのですが、移行後にUNUserNotificationCenterDelegateのデリゲートメソッドであるuserNotificationCenter(:didReceive:withCompletionHandler:)とuserNotificationCenter(:willPresent:withCompletionHandler:)が処理されないという問題が発生しました。

結論、completionHandler引数に@Sendableをつけておらず、UNUserNotificationCenterDelegateが要求するメソッドと同一だと認識されていないことが原因でした。そのため、クロージャ引数に@Sendableをつけたところ正常に動作するようになりました。

最後に

今回はscene-basedライフサイクルへの移行方法をご紹介しました。

次のメジャーリリースで対応必須となるため、遅くても2027年5月ごろ、出来れば2026年の9月まで対応しないと今後アプリをリリースできなくなる問題ですので、とても重要な事だと思います。

この記事が皆さんの対応に役立つか、既に対応済みの方の抜け漏れ発見に繋がれば幸いです。

去年のXcode16によるSwiftのメジャーアップデートも然り、iOSアプリを取り巻く環境は変化が早いので引き続きキャッチアップを続けていこうと感じました。

今後もローソンデジタルイノベーションでは技術ブログを更新していきますので、是非「読者になる」で応援していただけますと幸いです。