概要
iOS13から新たにUIWindowSceneDelegate
が導入され、プロジェクトを新規作成するとclass SceneDelegate
がテンプレートとして生成されるようになりました。この記事では、このSceneDelegate
とアプリの起動シーケンスについて説明します。まだわかっていない部分もあるので、そこは疑問の内容を書きました。
Sceneの概要
Sceneについてザックリと説明します。正確なところは公式の文書を参照してください。
SceneとはアプリのUIを表示するための窓の事です。iPadでは以前から複数のアプリを同時に分割表示する事ができましたが、同一のアプリを同時に複数表示する事はできませんでした。iOS13からはこれが可能になります。この時、分割されている画面1つがSceneに対応します。同一のアプリが複数表示されているときは、内部的にアプリケーションとしては1つのプロセスだけが起動していますが、Sceneオブジェクトは複数生成されており、ユーザはそれぞれの画面ごとに独立した画面遷移を利用できるというしくみです。
窓と書きましたが、これは説明上の概念であって、UIKit
のUIWindow
のことではありません。Sceneを表すUIScene
クラスにはUIWindowScene
サブクラスがあり、iOSではUIWindowScene
が普段使われています。(他のサブクラスが見当たらないのですが、どんな物があるのか気になります)そして、UIWindowScene
の場合は、その1つのSceneに対して1つのUIScreen
と複数のUIWindow
が紐付けられます。
Info.plistのシーンマニフェスト
Xcode11でプロジェクトを新規作成したり、アプリターゲットのGeneral > Deployment Info
のRequires full screen
やSupports multiple windows
のチェックボックスを操作すると、Info.plist
の中にApplication Scene Manifest
という項目が作られます。Raw KeyではUIApplicationSceneManifest
です。iOS13は、このエントリがあるかどうか、もしくは後述するUIApplicationDelegate
のメソッドが定義されているかどうかによって、アプリの動作モードのScene対応の有無を切り替えます。もちろん、iOS12環境ではこのエントリがあったとしても、従来と変わらない動作をします。
アプリの起動
従来はアプリが起動するとUIApplicationDelegate
を満たし、@UIApplicationMain
が付与されている型が、UIApplication
にデリゲートとして接続されました。この型のテンプレートが生成する名前はAppDelegate
です。そして、
optional func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool
と
optional func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool
が呼ばれます。iOS13でもこの部分は同一です。アプリケーションプロセスのレベルでのライフサイクル処理はここで行う事ができます。
更に、General > Deployment Info > Main Interface
としてStoryboardが設定されている場合はこれが読み込まれて、UIWindow
の生成と、window.rootViewController
の設定などの初期化が自動で行われていました。Storyboardが設定されていない場合は、下記のようにして自分でUIWindow
の生成と初期化を行う事ができました。
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]?)
-> Bool
{
let window = UIWindow(frame: UIScreen.main.bounds)
self.window = window
window.makeKeyAndVisible()
let vc = ViewController()
window.rootViewController = vc
return true
}
}
上記コードにおいて、AppDelegate.window
へのUIWindow
の登録により、メインウィンドウの指定、window.makeKeyAndVisible()
によってキーウィンドウの指定とウィンドウの表示を行っています。
ところが、iOS13からは、Scene対応が有効になっている場合にはこれらの処理が無視されるようになりました。Main Interface
として指定されているStoryboardは読み込まれません。また、コードからAppDelegate.window
を設定してもウィンドウは表示されません。ウィンドウの取り扱いはSceneの起動処理の方で対応する必要があります。
Sceneのconfiguration
Sceneの起動において、UIScene
オブジェクトの構築はiOS側が行いますが、その際にどのように構築するかを指定するconfigurationが行われます。これは、Info.plist
のApplication Scene Manifest > Scene Configuration
(Raw Key: UISceneConfigurations
)で指定するか、UIApplicationDelegate
の
optional func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
で指定します。設定は引数として与えられるUISceneSession
やUIScene.ConnectionOptions
に応じて行いますが、主要なキーとなるのはUISceneSession
のrole: UISceneSession.Role
のようです。というのも、Info.plist
ではScene Configuration
の子要素として、このロールごとに設定を行うようになっているからです。ただ、実質的に普段使用するのはApplication Session Role
(Raw Key: UIWindowSceneSessionRoleApplication
, コード: UISceneSession.Role.windowApplication
)だけのようです。
Configurationは型としてはUISceneConfiguration
であり、initializerでname: String?
とsessionRole: UISceneSession.Role
を受け取ります。ロールは引数で受けたUISceneSession
の値を転送すれば良いようです。その他に設定可能なプロパティとして、sceneClass: AnyClass?
, delegateClass: AnyClass?
, storyboard: UIStoryboard
があります。Info.plist
においてはそれぞれ、Configuration Name(UISceneConfigurationName)
, Class Name(UISceneClassName)
, Delegate Class Name(UISceneDelegateClassName)
, Storyboard Name(UISceneStoryboardFile)
として設定できます。
Xcode11でプロジェクトを新規作成すると、テンプレートとしてデフォルトでInfo.plist
にConfiguration Name = Default Configuration
, Delegate Class Name = $(PRODUCT_MODULE_NAME).SceneDelegate
, Storyboard Name = Main
が設定されています。
デリゲートメソッドとInfo.plist
の両方を設定する事ができて、その場合は両方の内容が合成されます。デリゲートメソッドで返したUISceneConfiguration
のname
とsessionRole
に基づいてInfo.plist
のエントリが検索され、sceneClass
, delegateClass
, storyboard
がnil
の場合はInfo.plist
のエントリの値が使われるようです。デリゲートメソッドがない場合は、Info.plist
のエントリはロールごとの配列の[0]
要素が採用されます。新規作成したプロジェクトでは、このデリゲートメソッドも実装されていて、UISceneConfiguration
のname
をDefault Configuration
に設定するようになっています。
もし複数のConfigurationを取り扱う場合は、Info.plist
側にエントリを静的に定義しておいて、デリゲートメソッドではそのどれを選択するかをname
で指定するだけにする構成が良さそうな気がします。
Configurationのリロード
iOS13.0
のiPadシミュレータで検証したところ、アプリケーション起動時にConfiguration
を設定するデリゲートメソッドが呼び出されない場合がありました。なんらかのキャッシュ機能があるかもしれません。
Info.plist
のConfigurationエントリが無い場合は、プロセスを再起動すると必ずデリゲートメソッドが呼ばれました。しかし、Info.plist
にエントリがある場合は、プロセスを再起動しても呼ばれない事がありました。Info.plist
に変更を与えた場合は、プロセスの再起動に関わらず必ず呼ばれました。デバッグ実行のたびにAppDelegate
のlaunch系のデリゲートメソッドは呼ばれていたのでプロセスはいずれにせよ再起動していると思うのでよくわからないですが、iPad側でアプリを明示的に終了する事で結果が変わるようでした。
あまり機会は多くないと思いますが、SceneのConfigurationをいじくるときは注意が必要かもしれません。
Sceneの起動
Configurationが決まったらそれに基づいてiOSがSceneを生成します。そして、Sceneが起動する際には、UISceneDelegate
の
optional func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
が呼ばれます。ここで接続されているデリゲートオブジェクトは、Configurationで指定したdelegateClass
の型のオブジェクトです。テンプレートではScene Delegate
になっています。このデリゲートメソッドはUISceneDelegate
のものなので、scene
の型がUIScene
になっていますが、実際に来ているのはUIWindowScene
のオブジェクトで、デリゲートの型はUIWindowSceneDelegate
にしておきます。もし、ConfigurationでStoryboardを指定していた場合は、ここで自動的にUIWindow
の生成やwindow.rootViewController
の設定などが行われます。Storyboardを設定しなかった場合は、ここでコードで初期化する事ができます。
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions)
{
guard let scene = (scene as? UIWindowScene) else {
return
}
let window = UIWindow(windowScene: scene)
self.window = window
window.makeKeyAndVisible()
let vc = ViewController()
window.rootViewController = vc
}
}
まず、冒頭でscene
の型をチェックします。UIWindow
の生成はinit(frame:)
ではなくinit(windowScene:)
という新しいメソッドを使用します。ウィンドウの生成と初期化の他、UIWindowSceneDelegate
のoptional var window: UIWindow? { get set }
プロパティに生成したウィンドウを設定する事でメインウィンドウとして登録しています。従来のUIApplicationDelegate
でのコードによる初期化の方法とよく似ており、Sceneの概念が捉えられると思います。
Sceneの再利用?
テンプレートコードのコメントの// This delegate does not imply the connecting scene or session are new (see application:configurationForConnectingSceneSession instead).
から、UIScene
とUISceneSession
のオブジェクトは再利用される事がありそうです。また、ライフサイクルの文書の図でもScene
の再利用の図があります。おそらく、初回のUnattached → Foreground Inactive
に加えて、2回目以降ではBackground → Unattached --(ここ)-> Foreground Inactive
とBackground → Unattached --(ここ)-> Background
の遷移で、willConnectTo
が同じUIScene
オブジェクトに対して呼ばれるのだと思います。しかし、しばらく試した範囲では実際に発生させる手順がわかりませんでした。
もしUIScene
が再利用される場合、紐付いているUISceneDelegate
も再利用されるはずですから、window
プロパティに以前設定したものが残っていそうです。その場合これをチェックしてウィンドウやビューツリーを再利用すべきなのか、それとも、新しくウィンドウごと再生成してしまって問題ないのかどうか、よくわからないので気になっています。
以前のiOSのサポート
テンプレートから新規作成されたプロジェクトそのままでDeployment TargetをiOS12に下げるとコンパイルエラーが多発します。これの簡単な対応としては下記の2点を行うのが良さそうです。
SceneDelegate
に@available
をつける。
@available(iOS 13.0, *)
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// 略
}
AppDelegate
のapplication(:configurationForConnecting:options:)
に@available
をつける。
class AppDelegate: UIResponder, UIApplicationDelegate {
// 略
@available(iOS 13.0, *)
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration
{
// 略
}
}
そして、Storyboardを使う場合、iOS12以前のためのMain Interface
の設定と、iOS13以降のためのApplication Scene Manifest
の設定を行います。
コードで構築する場合は、iOS12以前の場合はapplication(:willFinishLaunchingWithOptions)
の中で起動処理を行い、iOS13以降の場合はscene(:willConnectTo:options)
の中で起動処理を行う必要がありますが、たとえiOS13であってもapplication(:willFinishLaunchingWithOptions)
は呼ばれてしまうので、iOS13の場合に重複して起動処理が行われないようにする必要があります。まとめると、下記のようになるかと思います。
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication,
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]?)
-> Bool
{
if #available(iOS 13, *) {
} else {
let window = UIWindow(frame: UIScreen.main.bounds)
self.window = window
window.makeKeyAndVisible()
let vc = ViewController()
window.rootViewController = vc
}
return true
}
}
@available(iOS 13.0, *)
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions)
{
guard let scene = (scene as? UIWindowScene) else {
return
}
let window = UIWindow(windowScene: scene)
self.window = window
window.makeKeyAndVisible()
let vc = ViewController()
window.rootViewController = vc
}
}
共通部分は適宜まとめると良いでしょう。前述したとおりiOS13でもSceneが有効にならない場合があるので本当はバージョン判定だけでは不足がありますが、煩雑になるしSceneの有効判定の正確なところはわからないのでやらなくて良いでしょう。また、UIWindow
やViewControllerの無駄な生成のコストを気にしないのであれば、分岐しないで2回生成してしまうというのもありかもしれません。