SceneDelegateで様々なアプリの起動経路に対応する

## はじめに

iOS13でSceneDelegateが導入されて久しいですが、
この度既存プロジェクトにSceneDelegateを導入する必要があったので、
AppDelegateに書かれていた処理を適宜SceneDelegateに書きScene対応しました。

今回は以下の起動経路に対応する必要があったので、それについて記載しています。

| 起動経路                   | アプリの状態                                                                 | 
| -------------------------- | ---------------------------------------------------------------------------- | 
| リモートプッシュ通知               | アプリ未起動時に受信<br>フォアグラウンド時に受信<br>バックグラウンド時に受信 | 
| URLスキームによる起動      | アプリ未起動時に受信<br>バックグラウンド時に受信                             | 
| Quick Action（3D Touch） | アプリ未起動時に受信<br>バックグラウンド時に受信                             | 
| Spotlight検索              | アプリ未起動時に受信<br>バックグラウンド時に受信                             | 

## SceneDelegateとは

iOS13から`UIScene`というクラスが追加され、
マルチウィンドウに対応できるようになりました。

iOS12まではアプリのプロセス（起動や終了などを指す）は1つで、
それに対するUIインスタンスも1つでした。

これがiOS13からはアプリのプロセスは1つで、
それに対するUIインスタンスは複数になりました。

このUIインスタンスをSceneといいます。

これまではアプリのプロセス、UIの状態管理を全てAppDelegateで行っていましたが、
このSceneの概念が登場したことに伴い、
AppDelegateではアプリ全体のプロセスのみを管理して、
新たに追加されたSceneDelegateで画面の表示などのUIの状態やライフサイクル管理を担うようになりました。

詳細は[[iOS13] UIScene APIを使用する [Xcode11]](https://qiita.com/snoozelag/items/fe95df7e748ad5fc3205)の記事が大変参考になりました。


## SceneDelegateのライフサイクル
[【xcode 11】新たに導入されたsceneDelegateの各メソッドが呼ばれるタイミング](https://qiita.com/ninoko1995/items/b28a712d5f620dbebb6f)の記事が具体的でわかりやすかったです。
実装ベースでライフサイクルのイメージを掴む事ができると思います。

## 導入の経緯

Widgetを入れるためにその前段としてSceneDelegateを導入しました。

## 実装

### 前提
- サポート対象: iOS10以降
    - iOS13の前後で通知先がAppDelegateとSceneDelegateで分かれるため、両方実装しています。
- リポートプッシュ通知はFirebase Cloud Messagingを利用しています
- windowの作成やInfo.plistの設定などは割愛します  

### リモートプッシュ通知（Firebase Cloud Messaging）

**各通知先**

| 起動方法                 | 通知先（~iOS12）                                                                                                         | 通知先（iOS13~）                                                                                                         | 
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | 
| アプリ未起動時に受信     | ①<br>`application(_:didFinishLaunchingWithOptions:) -> Bool`                                                            | ④<br>func scene(_:willConnectTo:options:)                                                                               | 
| フォアグラウンド時に受信 | ②<br>UNUserNotificationCenterDelegateの準拠先に通知<br>func userNotificationCenter(_:didReceive: withCompletionHandler) | ⑤<br>UNUserNotificationCenterDelegateの準拠先に通知<br>func userNotificationCenter(_:didReceive: withCompletionHandler) | 
| バックグラウンド時に受信 | ③<br>同上                                                                                                               | ⑥<br>同上                                                                                                               | 


**実装**

```swift:AppDelegate.swift
extension AppDelegate: UIApplicationDelegate, UIResponder {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        if #available(iOS 13, *) {} else {
            // iOS12以下はAppDelegateをdelegate先に設定
            UNUserNotificationCenter.current().delegate = self
        }
        
        let userInfo = launchOptions?[UIApplication.LaunchOptionsKey.remoteNotification] as? [AnyHashable: Any]
        // ① userInfoから特定のURLSchemeを埋め込んだkeyを取得して画面遷移など使用
        return true
    }
}

// MARK: - UNUserNotificationCenterDelegate
extension AppDelegate: UNUserNotificationCenterDelegate {

    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        // ②③ userInfoから特定のURLSchemeを埋め込んだkeyを取得して画面遷移など使用
        completionHandler()
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.alert, .badge, .sound])
    }
}

```

```swift:SceneDelegate.swift
@available(iOS 13.0, *)
extension SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {      

        // iOS13以上はSceneDelegateをdelegate先に設定
        UNUserNotificationCenter.current().delegate = self  

        // プッシュ通知による起動
        if let response = connectionOptions.notificationResponse {
            let userInfo = response.notification.request.content.userInfo
            // ① userInfoから特定のURLSchemeを埋め込んだkeyを取得して画面遷移など使用
        }
    }
}
    
// MARK: - UNUserNotificationCenterDelegate
@available(iOS 13.0, *)
extension SceneDelegate: UNUserNotificationCenterDelegate {

    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        let userInfo = response.notification.request.content.userInfo
        // ⑤⑥ userInfoから特定のURLSchemeを埋め込んだkeyを取得して画面遷移など使用
        completionHandler()
    }
    
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([.alert, .badge, .sound])
    }
}
```

**備考**

様々な記事を調べていたところ、
`UNUserNotificationCenterDelegate`のdelegate先はSceneDelegateを使っていたとしても、
共通でAppDelegateに準拠させるで良さそうだったのですが、
実際にiOS13以上で動かしてみるとSceneDelegateで準拠していないとプッシュを受け取れませんでした。

### URLスキームによる起動

**各通知先**

| 起動方法                 | 通知先（~iOS12）                            | 通知先（iOS13~）                                                                     | 
| ------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------ | 
| アプリ未起動時に発火     | `func application(_:open:options:) -> Bool` | `func scene(_:willConnectTo:options:)`<br>`connectionOptions.urlContexts.first?.url` | 
| バックグラウンド時に発火 | `func application(_:open:options:) -> Bool` | `scene(_:openURLContexts:)`                                                          | 

**実装**

```swift:AppDelegate.swift
extension AppDelegate: UIApplicationDelegate, UIResponder {
    func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
        // ①② urlを使用
        return true
    }
}

```

```swift:SceneDelegate.swift
@available(iOS 13.0, *)
extension SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {      

        // URLSchemeによる起動
        if let url = connectionOptions.urlContexts.first?.url {
            // ③ 
        }
    }

    func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
        guard let url = URLContexts.first?.url else {
            return
        }
        // ④
    }
}

```

### Quick Action（3D Touch）

**各通知先**

| 起動方法                 | 通知先（~iOS12）                                                | 通知先（iOS13~）                                                                 | 
| ------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------- | 
| アプリ未起動時に受信     | ①<br>`func application(_:performActionFor:completionHandler:)` | ③<br>`func scene(_:willConnectTo:options:)`<br>`connectionOptions.shortcutItem` | 
| バックグラウンド時に受信 | ②<br>同上                                                      | ④<br>`func windowScene(_:,shortcutItem:completionHandler:)`                     | 

**実装**

```swift:AppDelegate.swift
extension AppDelegate: UIApplicationDelegate, UIResponder {
    func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
        // ①②
    }
}

```

```swift:SceneDelegate.swift
@available(iOS 13.0, *)
extension SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {      

        // Quick Actionによる起動
        if let shortcutItem = connectionOptions.shortcutItem {
            // ③
        }
    }

    func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
        // ④
    }
}

```

### Spotlight検索

**各通知先**

| 起動方法                 | 通知先（~iOS12）                                                 | 通知先（iOS13~）                                                                         | 
| ------------------------ | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | 
| アプリ未起動時に発火     | ①<br>`func application(_:continue:restorationHandler:) -> Bool` | ③<br>`func scene(_:willConnectTo:options:)`<br>`connectionOptions.userActivities.first` | 
| バックグラウンド時に発火 | ②<br>同上                                                       | ④<br>`func scene(_:continue:)`                                                          | 

**実装**

```swift:AppDelegate.swift
extension AppDelegate: UIApplicationDelegate, UIResponder {
    func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
        // ①②
        return true // 受け取ったuserActivityをハンドリングするか否かを返す
    }
}

```

```swift:SceneDelegate.swift
@available(iOS 13.0, *)
extension SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {      

        // spotlight検索による起動
        if let userActivity = connectionOptions.userActivities.first {
            // ③
        }
    }

    func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
        // ④
    }
}

```

### その他SceneDelegateを扱う上での注意点
- SceneDelegateを使う場合、UIWindowはAppDelegateではなくUIWindowSceneを使う
    - UIWindow配下のrootViewControllerにアクセスする場合はSceneDelegate経由で取得する必要がある
        - `(UIApplication.shared.delegate as? AppDelegate)?.window?.rootViewController = vc`ではとれない
    - Windowを新たに作成する場合に、UIWindowSceneから作成する必要がある
        - [参考](https://ulog.sugiy.com/xcode11uiwindow/)


## 参考
- [[iOS13] UIScene APIを使用する [Xcode11]](https://qiita.com/snoozelag/items/fe95df7e748ad5fc3205)
- [【xcode 11】新たに導入されたsceneDelegateの各メソッドが呼ばれるタイミング](https://qiita.com/ninoko1995/items/b28a712d5f620dbebb6f)
- [[iOS 14]WidgetでDeep Link作成](https://blog.personal-factory.com/2020/11/30/how-to-create-deep-link-for-widget/)
- [アプリ画面のライフサイクル管理をAppDelegateからSceneDelegateへ移行した話](https://tech.studyplus.co.jp/entry/2021/07/05/100000)
- [肥大化しがちなアプリの起動経路を整理する](肥大化しがちなアプリの起動経路を整理する)