[iOS13] UIScene APIを使用する [Xcode11]

Xcode11では、このUIScene APIのライフサイクルを使用したものが、デフォルトテンプレートに採用されています。現時点では、UIApplicationベースでも問題ないものの、UIApplicationのUIに関するメソッドも非推奨(Deprecated)となっていることから、今後このシーンの使用が一般的になると思われます。現時点でのメモを共有したいと思います。

UISceneとは

iOS12 でのアプリケーションのプロセスは1つで、それに対するUIインスタンス(ウインドウ)も1つでした1。iOS13(UIScene)以後のアプリケーションのプロセスは1つで、それに対するUIインスタンス(ウインドウ)は複数です。マルチウインドウと言います。開発者視点では、そのウインドウをシーンと呼びます。UIScene API とは、ひとまずアプリをマルチウインドウ(マルチシーン)に対応するAPIと言えそうです。

ユーザーによるマルチウインドウ(マルチシーン)の起動方法

iOS13でマルチウインドウ対応をしたアプリは、iPadで単独のアプリから複数のウインドウを起動し表示することが可能になります。

システムにより提供された方法

iOS9から導入されているiPadのマルチタスク機能「Split View」「Slide Over」です。

Split View Slide Over

アプリにより提供する方法

アイコンのドラッグ以外のカスタムなウインドウ起動を、アプリで実装することも出来たりします。標準アプリでの例を紹介します。

  • Safari内の【タブ】や、メールappで一覧の【セル】を、画面の端にDrag&DropしSplit Viewを起動する。
  • Safariで URLリンク をロングタップすると出る「新しいウインドウで開く」のポップアップを選択し起動する。

APPスイッチャーの変化

iPadのAPPスイッチャーでは、切り替えられるUIの単位が変わっています。iOS12以前は「起動中のアプリ」を指していましたが、iOS13以降では「アプリのシーン」を切り替えることが出来るようになりました。

標準アプリでは、Safari、メッセージ、メール、カレンダー、マップなどがマルチウインドウに対応していることを確認しました。(すべてではなく未対応のものもありました。)
WWDC2019にて発表された、MacOS Catalinaでの Mac Catalyst によりiPadアプリをMacで実行可能になり「マルチウインドウ化にはこのAPIが必須だった」とのことです。

マルチウインドウに対応させる意義

  • ドキュメント系のアプリを並べて表示させられるようになる。
  • 一つ前の状態を消さずに、別の処理を行える。(例えば、地図アプリで別のルートや場所を同時に表示させたい時。)
  • Slide Over上にウインドウを並べてTo Do代わりに使用する。(例えば、メールappなどで、書きかけのメールなどを並べます。)
  • 他方を参照しなばら、もう一方を利用したい時。(例えばメッセージappなどで、他のスレッドを参照しながら会話を行うなど。)
  • アプリ内でのデータ移動に利用する。(例えば、カレンダーappでマルチウインドウ上で別の月に予定をDrag&Dropする)

シーンの採用によるView Hierarchyの変化

UIScene API.png
UIScene APIを採用すると、ビュー階層が変わりUIScreenUIWindowの間にUIWindowSceneが入ります。

ひとつのiOSデバイスのビュー階層を示したものかと思われます。
一番下からUIScreenの赤い四角が指すものは通常、端末のメイン画面 UIScreen.mainです。その次の、新たに加わったUIWindowSceneは、アプリのひとつのUIインスタンスを管理するView階層のトップレベルオブジェクトとなりました。windows: [UIWindow]プロパティを持ち、関連付けられているUIWindowの参照を取得できます。この中にはSceneDelegatevar window: UIWindow?プロパティで保持するインスタンスが含まれています。
複数のUIViewUIViewController上のインスタンスです。UIViewControllerのルートオブジェクトはwindow.rootViewControllerで保持されます。

Xcodeのビューデバッガの比較

(iOS12)ビューデバッガ 表示画面
スクリーンショット 2019-10-08 14.48.38.png Simulator Screen Shot - iPad Pro (11-inch) - 2019-10-08 at 14.42.35.png
(iOS13)ビューデバッガ 表示画面

iOS13(UIScene採用したもの)のキャプチャでUIWindowSceneが追加されていることがわかります。また、あえてSplit View表示にして確認したのですが、この時片側の画面(スペース)が個別のシーン(UIWindowScene)となります。冒頭のAPPスイッチャー上のイメージ図では「ウインドウが1つのシーン」と簡略化されているように思うのですが、「Split Viewのウインドウは、片側の画面ずつ2つのシーン」と捉えられそうです。

ライフサイクルの変化

iOS12 でのアプリケーションのプロセスは1つで、それに対するUIインスタンスも1つでした。「プロセスのライフサイクル(起動や終了)」「UIの状態のライフサイクル」すべてをシステムはAppDelegateに通知していました。
実際のアプリのAppDelegateでは、ワンタイムの非UIの処理(データベースへの接続やデータ構造の初期化など)を行ったあと、UIのセットアップの処理を行う、全てが行われていました。

iOS13(UIScene)以後のアプリケーションのプロセスは1つで、それに対するUIインスタンスは複数です。プロセスは複数のシーンセッションに共有されます。
それに伴いAppDelegateの責任は変わります。「プロセスのライフサイクル(起動や終了)」のみになり、新しいSceneDelegateが「UIの状態のライフサイクル」の責任を担います。(そしてAppDelegateには新しく「シーンの作成・破棄」の責任が加わります。)
UIのセットアップや、不要になったUIの取り外しの処理はSceneDelegateで行うようにします。

iOS12 / iOS13 (UIScene)
76bdb54d-905a-bc9b-15ba-bc08fac93efc.png

iOS13での、シーンライフサイクルを採用すると、「UIの状態のライフサイクル」に関するAppDelegateのデリゲートメソッドは呼びだしはされません。バックグラウンドやフォアグラウンドといったUIの状態はSceneDelegateへと通知されるようになります。移行するメソッドの内容はたいてい1対1となるためシンプルです。これらのデリゲートメソッドは移行が必要です。

iOS12 → iOS13 (UIScene)

iOS13で、マルチウインドウを採用しても、iOS12以前のサポートは可能です。単に両方のメソッドを保持して、実行時にUIKitが適切な方を呼び出します。
iOS12以下にターゲットを変更し、試したところコンパイラのエラーFixサジェスト通りに@available()のAttributesまたはPreprocessor Macros`を使って、修正すると問題なく出来そうです。

UIScene API の採用手順

Supporting Multiple Windows on iPadのサンプルコードを参考に、具体的な手順をみていきます。

  • App TARGETS の General > [Development Info]の[Supports multiple windows]チェックボックスを有効にする
  • チェックボックスを有効にすると、XcodeによりInfo.plistファイルにUIApplicationSceneManifestキーが追加される。

スクリーンショット 2019-09-29 23.14.58.png

このキーが追加されると、UIScene APIを用いた新しいライフサイクルメソッドが呼ばれるようになります。

次に、シーンの構成(UISceneConfiguration)データを、次の2つの方法でシステムに提供します。

(A) Info.plistにシーン構成を定義して提供。
(B) UIApplicationのデリゲートメソッドを実装してシーン構成を提供。

(A) Info.plistにシーン構成を定義

Info.plist
<key>UIApplicationSceneManifest</key>
    <dict>
        <key>UIApplicationSupportsMultipleScenes</key>
        <true/>
        <key>UISceneConfigurations</key>
        <dict>
            <key>UIWindowSceneSessionRoleApplication</key>
            <array>
                <dict>
                    <key>UISceneConfigurationName</key>
                    <string>Default Configuration</string>
                    <key>UISceneDelegateClassName</key>
                    <string>$(PRODUCT_NAME).SceneDelegate</string>
                    <key>UISceneStoryboardFile</key>
                    <string>Main</string>
                </dict>
            </array>
        </dict>
    </dict>

(B) UIApplicationのデリゲートメソッドを実装してシーン構成を提供。

動的に行う必要がある場合は application(_:configurationForConnecting:options:) で UISceneConfiguration オブジェクトを返します。(A)のplist相当のコンフィグレーションは以下のようになります。

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let configuration = UISceneConfiguration(name: "Default Configuration", sessionRole: .windowApplication)
        configuration.delegateClass = SceneDelegate.self
        configuration.storyboard = UIStoryboard(name: "Main", bundle: .main)

SceneDelegate.swiftファイルの作成

Info.plist内でデリゲート先クラスとして指定したSceneDelegate.swiftファイルを以下のように作成します。

UIの状態が復元される最小限のSceneDelegate
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {   
    // (1) windowインスタンスはシーンごとに保持する
    var window: UIWindow? 
    // (2) シーン切断時に呼ばれます。保存するシーンuserActivityを返します
    func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
        return scene.userActivity
    }
    // (3) シーン接続時に呼ばれます。保存されたuserActivityを元にUIを復元します
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        if let userActivity = connectionOptions.userActivities.first ?? session.stateRestorationActivity {
            // 得られたuserActivityから具体的なUI復元(画面遷移など)を書く
            // 〜省略〜 Enable Multiple Windowsサンプルを参考
        }
        // ユーザーのアクティビティ情報が無い場合は何もする必要がない。(デフォルトStoryboardの初期VCが起動)
    }
}
PhotoDetailViewController
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // (4)
        view.window?.windowScene?.userActivity = photo?.openDetailUserActivity
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        // (4)
        view.window?.windowScene?.userActivity = nil
    }

(1) UIWindow?プロパティを宣言。iOS12までは通常、AppDelegateで保持していたところ、windowインスタンスはシーンごとにSceneDelegate で保持します。指定したMain.storyboardのイニシャルVC呼び出し時に自動的に代入されます。

(2)アプリがバックグラウンドに入り非アクティブになった時に呼ばれます。UI状態の復元が必要なシーンは、このメソッドでscene.userActivityを返します。ここで渡したuserActivityはシステムにより永続化され、セッションが消されるまでUIKitにより維持されます。
ホーム画面や他のシーンへ移動、シーンが非表示になり切断されるタイミングで呼ばれるので、Xcode。この場合「全てのシーンのアクティビティを保存」となります。
シーンの切断状態が続くと、システムにより適当なタイミングでシーンは破棄されセッションだけが残ります。この時、ユーザーがAPPスイッチャに見えているのはシーンのスナップショットであるので、このメソッドの実装が無い場合、そのシーンを復元するためのアクティビティ情報がscene(willConnectTo:session:options)で得られなくなります。
状態復元が不要なsceneではnilを返せばよいと思います。

(3) シーン接続時に呼ばれます。シーンがセッションと接続する時、UIScene.ConnectionOptionsUISceneSessionの参照が得られます。いずれかのuserActivityからUI復元処理(具体的には画面遷移処理など)を行います。
userActivityconnectionOptions.userActivities.firstから取得できるかチェックして、次にsession.stateRestorationActivityを取得すると良いようです。(不明点があるため後日、別途記事にする予定。)他のシステムからのUserActivityの受け渡しがない、または、アプリで(前回)保存したUserActivityがない場合は、どちらもnilになるでしょう。

(4) UIの状態を復元したいポイントでwindowScene?.userActivityに状態復元に必要な情報を作成したNSUserActivityを入れます。システム側に(2)のメソッドで提供するアクティビティオブジェクトになります。
UIScene APIではNSUserActivityを利用し状態の保存と復元が行う手法を採用しました。Handoff APIのクラスを借りてきたとのことです。
view.window?.windowScene?.userActivity = photo?.openDetailUserActivityで保持する内容は以下のようなものです。画面のパス、記事ID、URLといった情報を持たせると良さそうです。シーンの再接続時の状態復元に必要な情報をカプセル化します。

NSUserActivityのサンプル
        let GalleryOpenDetailActivityType = "com.apple.gallery.openDetail"
        let GalleryOpenDetailPath = "openDetail"
        let GalleryOpenDetailPhotoIdKey = "photoId"

        let userActivity = NSUserActivity(activityType: GalleryOpenDetailActivityType)
        userActivity.title = GalleryOpenDetailPath
        userActivity.userInfo = [GalleryOpenDetailPhotoIdKey: name]

以上が、UISceneの採用手順です。

シーンベースでのコールスタック

イベントの概要や行うとよい処理を併記しています。

◾️ユーザーがアイコンをタップし、アプリの初回起動が行われました。デリゲートメソッドが次の順序で呼ばれます。

(1) UIApplicationDelegate#application(_:didFinishLaunchingWithOptions:)
・ワンタイムの非UIなセットアップ処理(データベース接続やデータ構造の初期化)を行う。

(2) UIApplicationDelegate#application(_:configurationForConnecting:options:)
・コンフィグレーションを指定し、シーンセッションの作成を行う。
・コンフィグレーションはコードで動的に設定をするか、Info.plistで静的に行う。
・Info.plistで静的に行った場合には、name引数で参照し、connectingSceneSession.roleを渡す。(コード参照)
・コンフィグレーションには、メインシーン、アクセサリといった種類があり、アプリに併せて正しいコンテキスト選択に使用できる。
・SceneDelegateクラスの指定、初期Storyboardの指定、作成したいシーン(のサブクラス)の指定を行う。

シーンのコンフィグレーション実装例
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    return UISceneConfiguration(name: "Default", sessionRole: connectingSceneSession.role)
}

<注意>シーンの新規作成直前にしか呼ばれません。Xcodeで実行した際に毎回呼ばれるだろうと思っていたのですが、最初のシーン新規作成時だけ呼ばれて、次回以降そのセッションが生きている間、シーンがセッションへ再接続時する時はコールされないようです(多分、セッションに既にコンフィグレーションを持っているため)。
UIApplication.shared.requestSceneSessionDestruction(session, options: nil, errorHandler: nil)を実行して、意図的にセッション破棄を実行すると、他に起動していないシーンがなければ次回起動時にシーンが新規作成されコールされました(他のシーンがあればそちらが起動する模様)。また、マルチタスク機能でのシーン追加操作を行うことでも呼ばれました。

(3) UISceneDelegate#scene(_:willConnectTo:options:)

・この時点では、UIは作成されていないが、シーンセッションは作成されSceneDelegateと接続されている。

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?
    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: .ConnectionOptions) {
        window = UIWindow(windowScene: scene as! UIWindowScene)
        if let activity = options.userActivities.first ?? session.stateRestorationActivity {
            configure(window: window, with: activity)
        }
    }
}

◾️ここでユーザーがホームバーをスワイプして、ホームに戻ったとします。

(4) UISceneDelegate#sceneWillResignActive(_:)
(5) UISceneDelegate#sceneDidEnterBackground(_:)

・使用途中のテキストの下書きのようなユーザーデータはシーンの再接続時のために削除せずに保存、または保存したままにしておきます。

この後、ある時点でシステムの判断によりシーン切断が起こりえます。メモリにはシーンに関連する多量のリソースが保持されており、割り当てを解除してリソースを回収するためです。

(5.5?) UISceneDelegate#sceneDidDisconnect(_:)

◾️ユーザーがAPPスイッチャーから、シーンを上にスワイプしキルしました。

(6) UIApplicationDelegate#application(_:didDiscardSceneSessions:)
・シーンが切断され、セッションが破棄されます。
・ユーザーにより明示的に削除が実行されたので、テキストの下書きのようなユーザーデータはこのタイミングで削除できます。

新しいアプリではベストプラクティスとして推奨

Deprecated Added

UIApplication

UIWindowScene

iOS13では、UIApplication、UIApplicationDelegate からUIの状態とプロセスのライフサイクルの責任が分離されました。
これに伴いUIStatusBarUIWindowUIApplicationの管理するところではなくなり、これらのメソッドやプロパティはUIApplicationから非推奨となっています。非推奨となったのUIの状態に関するメソッドは、UIWindowSceneを使って置き換えられます。マルチウインドウを使う予定がなくても、今後マルチウインドウにしたい時に役に立つので、新しいプロパティを採用することがお勧め、とのことです。

例えば、シーンごとに、ステータスバーの色をライトモードまたはダークモードに表示できます。
Xcode11では、このUIScene APIのライフサイクルを使用したものが、デフォルトテンプレートとなっています。

プログラムからのシーンの作成・更新・破棄

引数となるセッションは、UIViewを介しても取得することが出来ます。

UIViewからUISceneSessionを取得する
   let session = view.window?.windowScene?.session

シーンの作成です。

シーンの作成
   // (A) 既存セッションから作成
   UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)

   // (B) 新規に作成(必要に応じてアクティビティをセット)
   let activity = NSUserActivity(activityType: "com.example.MyApp.EditDocument")
   activity.userInfo["url"] = url
   UIApplication.shared.requestSceneSessionActivation(session, userActivity: activity, options: nil) 

シーンの更新。これを行うとUIが更新されたスナップショットがAPPスイッチャーに保存されます。

シーンの更新
   UIApplication.shared.requestSceneSessionRefresh(session)

シーンの破棄では、シーンを閉じる時のアニメーションを選択することが出来ます。

シーンの破棄
   let options = UIWindowScene.DestructionRequestOptions()
   options.windowDismissalAnimation = .standard // シーンを閉じる時のアニメーションを選べる
   UIApplication.shared.requestSceneSessionDestruction(session, options: options)

マルチシーンの実践・デバッグ

ステートが共有されたことによる不具合の2つの事例

その1:シーンの非同期(区別)

これらのようなオブジェクトはシングルトンとして、またはそれに近い形で利用される可能性があり、アプリの中でよく使用されます。

問題:マルチシーンでこのようなオブジェクトを扱う際、別々の処理内容として扱うべきであるのに、同時に一箇所にデータを書き込むようなことが発生しがちです。
例えば、テキストエディタアプリで、これまで編集中の内容は1つのファイルとして保存していたところ、マルチシーンでは、他方の内容がもう一方の内容を上書きしてしまい、不整合がおきました。

解決:この場合、シーン(セッション)ごとの編集中のデータが、別々のファイルに保存されるようにします。UISceneSession#persistentIdentifierを編集中のデータに加え、シーンと関連付けるIdとして利用できます。シーンごとにステートを区別出来るようにしましょう。

Before After

シーン再接続の際にscene(_:willConnectTo:options:)で、マニュアルでの状態復元データとして使用できます。シーンの状態復元が終わったら、この復元用データは忘れずにクリーンアップしましょう。
また、シーンのライフサイクルと関連づいた復元用データはapplication(_:didDiscardSceneSessions:)で削除するのが便利です。ユーザーがシーンを破棄すると呼ばれます。

その2:シーンの同期

問題:すべてのシーン間で共有されるべきUIの設定変更が、変更を実行したシーンにしか反映されていません。

解決:シーン間で共有されるべき設定値は、KVO(Key-Value Observing)で共有するのが洗練された方法です。UserDefaultsを拡張し、コンピューテッドプロパティにラップして、\UserDefaults.isInfoBarHiddenといったKVO用のKeyPathを得られるようにしておきます。vc側では変更を監視するようにします。この時、observe時のoptionには.initialを指定しておけば、変更の有無にかかわらずviewDidLoad()で一度実行されるため、UI変更を実行するコードも一箇所で済みます。

UserDefaultsの拡張
// Add a Key-Value Observable Property to UserDefaults
extension UserDefaults {
    private static let isInfoBarHiddenKey = "IsInfoBarHidden"
    @objc dynamic var isInfoBarHidden: Bool {
         get { return bool(forKey: UserDefaults.isInfoBarHiddenKey) }
         set { set(newValue, forKey: UserDefaults.isInfoBarHiddenKey) }
    }
}
vc側での変更監視
    class DocumentViewController: UIViewController {
    private var observer: NSKeyValueObservation?
    override func viewDidLoad() {
        observer = UserDefaults.standard.observe(\UserDefaults.isInfoBarHidden,
options: .initial, changeHandler: { [weak self] (_, _) in
              // 変更内容
              let controller = self?.navigationController?
              controller.isToolbarHidden = UserDefaults.standard.isInfoBarHidden
        })
     }
}

後日、書きかけ項目はまた追記予定です。


参考リンク


  1. 例外的にSafariのみ、iOS12のiPadでマルチウインドウ出来たようです。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away