概要
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回生成してしまうというのもありかもしれません。