Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

App-based ライフサイクルからScene-based ライフサイクルへの移行対応で見つけた既存バグと学び

はじめに

技術本部 Sansan Engineering Unit Mobile Application GroupでiOSエンジニアとして開発に携わっている新卒1年目の松山( @akidon0000 )です。

今回は、iOS 27で必須対応となる「App-based ライフサイクルからScene-based ライフサイクルへの移行」の対応を終えたので、その内容についてご紹介します。

iOS 27からのScene-based ライフサイクル必須化

2025年6月に開催されたWWDC25で、Appleから重要な発表がありました。

"As scenes are vital for ensuring flexibility, adopting UIScene life cycle will soon be mandatory. In the next major release following iOS 26, UIScene life cycle will be required when building with the latest SDK."

Make your UIKit app more flexible

この発表により、UIScene ライフサイクルに対応していないアプリは iOS 27用の最新SDKでは起動できなくなることが明確に示されました。すなわち、これまで UIApplicationDelegate のみを用いた App-based ライフサイクルのアプリは、例年のOSリリーススケジュールを踏まえると 2026年9月頃までに Scene-based ライフサイクル(UISceneDelegate)へ移行する必要 があります。

そのため、本対応は単なる技術的リファクタリングではなく、プロダクトの継続的価値提供を守るための必須施策 と位置付けることで案件優先順位を上げ移行作業に着手しました。

(以前から、Xcodeのログからは移行の必要性は示されていました)

移行作業にあたり、以下の記事を参照しました。 App-basedScene-based の違いについて、以下の記事が非常に分かりやすく整理されています。

techblog.lycorp.co.jp

Sansanアプリの状態

Sansanアプリでは、これまで UIApplicationDelegate に多くの責務が集中しており、主に以下のような機能も一括して管理していました。

  • Push通知の受信および遷移制御
  • ユニバーサルリンク/URL スキームのハンドリング
  • Twilioによる着信イベント処理
  • アプリ独自のパスコードロック制御

長年にわたる機能追加や仕様変更の積み重ねにより、アプリ内部には少なからずレガシーコードが存在していました。 さらに、主要な実装を担っていた開発者が退職しているケースもあり、設計意図や依存関係が不明瞭な状態でコードが残っている箇所もあります。

このような背景を持つ既存プロダクトのライフサイクル移行について、

  • 既存挙動を壊さないこと
  • 影響範囲を正確に把握すること
  • 段階的に安全性を担保すること

といった点が特に重要となり、移行難易度を高める要因となっていました。

今回の対応方針(シングルウィンドウ前提)

今回の移行では、マルチウィンドウへの完全対応は行わず、 既存仕様(シングルウィンドウ前提)を維持したままScene-based ライフサイクルへ移行する方針を採用しました。

具体的には、UIApplicationSupportsMultipleScenesfalse とし、 アプリは常に1つの Scene / 1つの UIWindow を持つ前提で実装しています。

なぜ完全なマルチウィンドウ対応をしなかったのか

  • 現時点でプロダクトとしてマルチウィンドウ対応の予定がない
  • 既存実装はシングルウィンドウ前提で安定稼働している
  • 全面再設計を行うと影響範囲が極めて大きい
  • 今回の目的は「iOS 27必須要件への安全な移行」である

そのため、本記事で紹介する内容は「理想的なScene設計」ではなく、レガシーを抱えた既存アプリを安全に移行する実践例になります。

ただし、AppleのScene-based ライフサイクルへの移行やiPhone Airの登場を踏まえると、将来的にマルチウィンドウやフォルダブル端末への対応が必須となる可能性は十分にあります。

しかし現時点では具体的なプロダクト要件はなく、まずは必須対応であるiOS 27への安全な移行を優先しました。将来的に対応が必要になった場合は、そのタイミングで設計の見直しを行う想定です。

段階的なアプローチ

実際の移行作業では、以下の順に切り出しながら進行しました。

  • SceneDelegateの作成とwindow管理の移行
  • アプリライフサイクル関連メソッドの段階的移行
  • Push通知ハンドリングの再設計
  • URL処理(ユニバーサルリンク/URLスキーム)の移行
  • その他、UIApplicationDelegate 依存箇所の整理・修正

実装詳細

1. SceneDelegateの作成とwindow管理の移行

Sansanでは、RootViewController へのアクセスを容易にするため、AppDelegaterootViewControllercomputed propertyを定義して取得する仕組みを構築しています。

まず最初に、これらのwindowプロパティの管理をAppDelegateからSceneDelegateに移行することに取り組みました。

Before: AppDelegate

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    var window: UIWindow?  // ← AppDelegateで管理
    
    // ...
}

extension AppDelegate {
    var rootViewController: RootViewController? {
        return self.window?.rootViewController as? RootViewController
    }
}

After: SceneDelegate

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?  // ← SceneDelegateで管理
    
    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene else { return }
        
        window = UIWindow(windowScene: windowScene)
        
        // Root.storyboardからRootViewControllerを読み込む
        let storyboard = UIStoryboard(name: "Root", bundle: nil)
        let rootViewController = storyboard.instantiateInitialViewController()
        
        window?.rootViewController = rootViewController
        window?.makeKeyAndVisible()
    }
}

extension SceneDelegate {
    var rootViewController: RootViewController? {
        return self.window?.rootViewController as? RootViewController
    }
}

ポイント

  • windowプロパティの管理をAppDelegateからSceneDelegateに移動させます
  • UIWindowSceneを使用してUIWindowを初期化する点が変更になります
  • scene(_:willConnectTo:options:)で初期化を行います

またマイグレーションガイドより、Info.plistにScene設定を追加する必要があります。

<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>Root</string>
            </dict>
        </array>
    </dict>
</dict>

UIApplicationSupportsMultipleScenesfalseに設定することで、シングルウィンドウ前提のアプリとして動作します。

2. アプリライフサイクル関連メソッドの段階的移行

次に、ライフサイクルメソッドをAppDelegateからSceneDelegateに移行します。

マイグレーションガイドより、以下のAPIを移行する必要があります。

developer.apple.com

移行対応表

AppDelegate SceneDelegate
applicationDidBecomeActive(_:) sceneDidBecomeActive(_:)
applicationWillResignActive(_:) sceneWillResignActive(_:)
applicationDidEnterBackground(_:) sceneDidEnterBackground(_:)
applicationWillEnterForeground(_:) sceneWillEnterForeground(_:)

Deprecate対象APIの対応

さらに、iOS 27では UIApplicationDelegate 側で受け取っていた一部APIが非推奨(Deprecated)となるため、あわせて SceneDelegate/ UIWindowSceneDelegate への移行を行いました。

AppDelegate Scene / WindowScene Reference
application(_:willContinueUserActivityWithType:) scene(_:willContinueUserActivityWithType:) https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:willcontinueuseractivitywithtype:)
application(_:didUpdate:) scene(_:didUpdate:) https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:didupdate:)
application(_:didFailToContinueUserActivityWithType:error:) scene(_:didFailToContinueUserActivityWithType:error:) https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:didfailtocontinueuseractivitywithtype:error:)
application(_:performActionFor:completionHandler:) windowScene(_:performActionFor:completionHandler:) https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:performactionfor:completionhandler:)
application(_:open:options:) scene(_:openURLContexts:) https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:open:options:)
application(_:userDidAcceptCloudKitShareWith:) windowScene(_:userDidAcceptCloudKitShareWith:) https://developer.apple.com/documentation/uikit/uiapplicationdelegate/application(_:userdidacceptcloudkitsharewith:)

ポイント

  • メソッド名が変わるだけで、ロジックはほぼ同じ
  • すでにwindowの移行が完了しているので、特段問題なく移行できるはずです

3. Push 通知ハンドリングの再設計

SceneDelegate への移行作業を進める中で、思いがけず 既存の Push 通知実装に潜んでいたバグを発見しました。 結果的に、この対応は単なる移行作業にとどまらず、長年見過ごされていた不具合を修正するきっかけにもなりました。

3-1. 既存バグの発見

問題の発覚

SceneDelegate 移行後の動作確認中、Push通知をタップしても画面遷移が発生しないという不具合が発生。

当初は、 - SceneDelegate 実装の不備 - rootViewController 生成タイミング - ライフサイクルイベントの順序 などを疑い、画面生成周辺を中心にデバッグを行いましたが、調査を進めるにつれ、本番環境においても、バックグラウンド時の通知タップが一切処理されていないことが判明。

さらに、フォアグラウンド時にもPush通知が表示されず、 Android版では正常に表示・画面遷移していることが確認されたため、機能差分として修正に取り掛かりました。

3-2. コールドスタート時とバックグラウンド時の違い

Push通知の処理は、アプリの状態によって呼ばれるメソッドが異なります。

シナリオ 呼ばれるメソッド 処理タイミング
コールドスタート時 SceneDelegate.scene(_:willConnectTo:options:) アプリ起動時
バックグラウンド時 AppDelegate.userNotificationCenter(_:didReceive:) 通知タップ時に呼ばれる
フォアグラウンド時 AppDelegate.userNotificationCenter(_:willPresent:) 通知受信時に呼ばれる

このように、同じ「通知タップ」という操作でも、アプリの状態によってエントリーポイントが変わります。

そのため、単純に処理を移動するのではなく、

どの状態で・どのメソッドが呼ばれ・どのタイミングで画面遷移させるべきか

を整理したうえで移行する必要があります。特にコールドスタート時は、アプリの初期化と通知処理が同時に走るため、初期化完了前に画面遷移してしまわないよう制御することが重要です。


4. URL 処理(ユニバーサルリンク/URL スキーム)の移行

URL処理は、カスタムURLスキームとユニバーサルリンクの2つに分けて移行しました。

4-1. カスタムURLスキームの移行

Before: AppDelegate+OpenURL.swift(削除)
extension AppDelegate {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
        if url.absoluteString.contains("<URL>") {
            AppLinkStore.webPageURL = url
            return true
        }

        if GIDSignIn.sharedInstance.handle(url) {
            return true
        }

        if !CustomURLSchemeHelper.isAutoLogin(url: url) {
            return false
        }

        if AccountsUseCase().isLoggedIn {
            return true
        }

        if let email = CustomURLSchemeHelper.email(from: url),
           let token = CustomURLSchemeHelper.token(from: url) {
            let rootViewController = self.rootViewController!
            return rootViewController.showLogin(email: email, oneTimeToken: token)
        }

        return false
    }
}
After: SceneDelegate+OpenURL.swift(新規作成)
extension SceneDelegate {
    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let url = URLContexts.first?.url else { return }

        if url.absoluteString.contains("<URL>") {
            AppLinkStore.webPageURL = url
            return
        }

        if GIDSignIn.sharedInstance.handle(url) {
            return
        }

        if !CustomURLSchemeHelper.isAutoLogin(url: url) {
            return
        }

        if AccountsUseCase().isLoggedIn {
            return
        }

        if let email = CustomURLSchemeHelper.email(from: url),
           let token = CustomURLSchemeHelper.token(from: url),
           let rootViewController = self.rootViewController {
            _ = rootViewController.showLogin(email: email, oneTimeToken: token)
        }
    }
}

4-2. ユニバーサルリンクの移行

Before: AppDelegate
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    if userActivity.activityType == NSUserActivityTypeBrowsingWeb {
        if let unwrapWebPageURL = userActivity.webpageURL,
           unwrapWebPageURL.absoluteString.contains("<URL>") ||
           unwrapWebPageURL.absoluteString.contains("<URL>") {
            NotificationCenter.default.post(name: .signInWithUniversalLinksKey, object: unwrapWebPageURL)
            return true
        }

        if let resetPasswordURL = userActivity.webpageURL, resetPasswordURL.absoluteString.contains("<URL>") {
            NotificationCenter.default.post(name: .resetPasswordNotificationKey, object: resetPasswordURL)
            return true
        }

        AppLinkStore.webPageURL = userActivity.webpageURL
        AppLinkStore.shouldShowSplash = AccountsUseCase().isLoggedIn
    }
    return true
}
After: SceneDelegate
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    handleUniversalLink(userActivity: userActivity)
}

private func handleUniversalLink(userActivity: NSUserActivity) {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb else { return }

    if let unwrapWebPageURL = userActivity.webpageURL,
       unwrapWebPageURL.absoluteString.contains("<URL>") ||
       unwrapWebPageURL.absoluteString.contains("<URL>") {
        NotificationCenter.default.post(name: .signInWithUniversalLinksKey, object: unwrapWebPageURL)
        return
    }

    if let resetPasswordURL = userActivity.webpageURL, resetPasswordURL.absoluteString.contains("<URL>") {
        NotificationCenter.default.post(name: .resetPasswordNotificationKey, object: resetPasswordURL)
        return
    }

    AppLinkStore.webPageURL = userActivity.webpageURL
    AppLinkStore.shouldShowSplash = AccountsUseCase().isLoggedIn
}

また、これらも同様にコールドスタート状態に対応する必要があります。

func scene(
    _ scene: UIScene,
    willConnectTo session: UISceneSession,
    options connectionOptions: UIScene.ConnectionOptions
) {
    // ... window初期化 ...

    // コールドスタート時のユニバーサルリンク処理
    if let userActivity = connectionOptions.userActivities.first {
        handleUniversalLink(userActivity: userActivity)
    }
}
ポイント
  • 戻り値がBoolからVoidに変更(OSが自動判断するため、戻り値が不要になりました)
  • Sansanはマルチウィンドウに対応していないため、URLはSet<UIOpenURLContext>からfirstを取得する対応で決定
  • コールドスタート時とアプリ起動中で同じ処理が必要

5. その他、UIApplicationDelegate依存箇所の整理・修正

Sansanアプリでは、UIApplication.willEnterForegroundNotification などのアプリ単位の NotificationCenterイベントに依存しているコードが12箇所存在していました。

これらはアプリ全体のライフサイクルに基づく実装であり、Scene単位でイベントが管理される設計とは完全には一致しません。

対応方法

今回は設計の見直しは行わず、既存実装を維持する方針としました。影響範囲が広く、iOS 27必須対応のスコープを超えるためです。

将来的な設計見直しが必要であることは認識したうえで、該当箇所にはFIXMEコメントを追加し、社内チケットとして管理することにしました。

// FIXME: - <社内チケット番号> マルチウィンドウに対応できていない。修正推奨だが、優先度とリソースの都合により今回は対応を見送る。
NotificationCenter.default.rx.notification(UIApplication.willEnterForegroundNotification).subscribe(onNext: { [weak self] _ in
    guard let self else { return }
    executePollingJob()
})

移行で直面した課題と対応

1.パスコードロック画面の表示タイミングの問題

Sansanアプリでは、所属企業のセキュリティレベルに応じて、アプリを開くたびにパスコードロック画面を表示し、セキュアに利用できる機能を提供しています。

Scene-based ライフサイクルへ移行後、このロック画面の表示ロジックを sceneDidEnterBackground(_:) で実行する実装にしていました。

func sceneDidEnterBackground(_ scene: UIScene) {
    presentLockScreenIfNeededWithAnimation() 
}

この実装により、アプリ起動後にバックグラウンドへ遷移し、再びフォアグラウンドに戻った際にはパスコードが表示されるようになりました。

しかし、App Switcher(アプリスイッチャー)上ではパスコードが表示されず、ロック前の画面が見えてしまう問題が発生しました。

原因は、ロック画面の表示にアニメーションを使用していたことでした。

iOSでは、アプリがバックグラウンドへ遷移する際に、現在の画面をスナップショットとして保存し、App Switcher(タスク一覧)に表示します。一方で、 presentLockScreenIfNeededWithAnimation はモーダル表示のアニメーションを伴うため、スナップショットが撮られるタイミングまでに表示が完了しないことがあります。

その結果、

  • 実際にはロック処理が走っている
  • しかし App Switcher にはロック前の画面がスナップショットとして残る

という状態になっていました。

この問題は、sceneDidEnterBackground(_:) から呼び出す場合は アニメーションを無効化して即座に表示する挙動に変更することで解消しました。

func sceneDidEnterBackground(_ scene: UIScene) {
    presentLockScreenIfNeeded(animated: false)
}

2. QA範囲の決定

課題

今回の変更はアプリ全体に横断的に影響する内容だったため、影響範囲の特定が難しく、どこまでテストすれば十分と言えるのかの判断が非常に難しい状況でした。

すべてを網羅的に検証することは理想ですが、現実的にはスケジュールやリソースの制約もあるため、リスクの大きさとテストコストのバランスを取りながら、合理的なQA範囲を設計する必要がありました。

実施したテスト

上記を踏まえ、私は以下の2軸でテストを実施しました。

  1. UISceneDelegate関連のテスト

    • ログイン・ログアウト
    • Push通知からの画面遷移(コールドスタート/バックグラウンド/フォアグラウンド)
    • パスコードロック画面の表示・認証・クリーンアップ
    • バックグラウンド/フォアグラウンド遷移
    • 通知バッジ
    • ユニバーサルリンク/URLスキーム
    • Twilio着信
    • 生体認証の表示非表示
  2. 簡易全件テスト

    • 主要な機能の動作確認
    • 影響範囲の広い機能を中心に実施

ライフサイクルやエントリーポイントに関わる挙動は事故リスクが高いため重点的に確認しました。一方で、簡易全件テストは明確な不具合を検出することよりも、「想定していない副作用が出ていないか」を広く確認することを目的としたものです。

今回の変更はアプリ全体に横断的に影響する内容であり、かつシステム全体の挙動を完全に把握しているエンジニアも限られていました。そのため、簡易全件テストは念のための意味合いも含めたリスクヘッジとして実施しています。

3. Push通知デバッグ

デバッグ手順

Push通知のデバッグは、以下の3つのシナリオで行う必要があります。

  1. コールドスタート時のテスト

    • アプリを完全に終了
    • Push通知を受信
    • 通知をタップしてアプリ起動
    • scene(_:willConnectTo:options:)connectionOptions.notificationResponseで処理される
  2. バックグラウンド時のテスト

    • アプリをバックグラウンドに遷移
    • Push通知を受信
    • 通知をタップしてアプリ復帰
    • userNotificationCenter(_:didReceive:)で処理される
  3. フォアグラウンド時のテスト

    • アプリを起動中
    • Push通知を受信(バナー表示)
    • バナーをタップ
    • userNotificationCenter(_:willPresent:)で処理される

この3つのシナリオを網羅することで、既存バグを発見できました。

最後に

iOS 27対応のためのUISceneDelegate移行は、必須対応でありながらも、多くの学びを得られるプロジェクトでした。 特に、Push通知の既存バグを発見できたことは、移行作業の副産物として大きな収穫です。

SansanのMobileチームでは、こうした「小さな改善を継続して積み上げる文化」を大切にしています。この記事が、同じようにiOS 27対応を進める方の一助になれば嬉しいです。

Sansan技術本部ではカジュアル面談を実施しています

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

© Sansan, Inc.