iOS 13 Scene Delegate and multiple windows / 2019-11-06

iOS 13的一大改进就是支持multiple windows(多窗口)功能,虽然多窗口仅在iPadOS上获得支持,但这已经是一个很大到的进步,它将会大大提升一些场景的使用体验。本文将结合WWDC 2019相关topic介绍与多窗口相关的内容。

Scene Delegate

为了实现多窗口功能,苹果修改了使用多年的AppDelegate。在Xcode 11中新建工程时,会发现工程文件中包含AppDelegate.swift,SceneDelegate.swift。多出来的SceneDelegate.swift就是本文的基础内容。

回忆一下在iOS 13之前我们是怎么使用AppDelegate的?

在didFinishLaunchingWithOptions启动入口方法中,进行初始化数据库,启动必要的服务,注册通知等基础工作,然后创建UIWindow,执行UIWindow的makeKeyAndVisible方法,即将页面显示在屏幕上。

除了didFinishLaunchingWithOptions启动入口方法,UIApplicationDelegate还提供有一系列的方法以供管理APP的生命周期:

func applicationWillResignActive(_ application: UIApplication) {
	//即将变为非活动状态
}

func applicationDidEnterBackground(_ application: UIApplication) {
	//已进入后台
}

func applicationWillEnterForeground(_ application: UIApplication) {
	//即将进入前台
}

func applicationDidBecomeActive(_ application: UIApplication) {
	//已进入前台,成为活跃进程
}

func applicationWillTerminate(_ application: UIApplication) {
	//进程终止
}

从iOS 13开始,这几个从iOS 2.0起沿用至今的方法将有所变化:原有的UIApplicationDelegate UI生命周期的几个方法将拆分到UISceneDelegate中:

// 新建窗口时调用
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
// 通过app switcher关闭该窗口时调用,至此窗口生命结束。
func sceneDidDisconnect(_ scene: UIScene)
func sceneDidBecomeActive(_ scene: UIScene)
func sceneWillResignActive(_ scene: UIScene)
func sceneWillEnterForeground(_ scene: UIScene)
func sceneDidEnterBackground(_ scene: UIScene)

相应的AppDelegate中新增对scene的支持:

//新建场景,返回场景配置
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
    return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}

//场景关闭时调用
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
}

UIApplicationDelegate提供的applicationWillTerminate接口保持不变,即UIApplicationDelegate不再负责UI的生命周期,仅负责APP的生命周期,包括启动和终止。其他四个方法分别替换为UISceneDelegate提供的sceneWillResignActive等,UISceneDelegate额外新增willConnectTo,sceneDidDisconnect接口以控制场景的新建和关闭。

即iOS 13中引入了scene的概念,每一个scene对应一个UIWindow,UIWindow由UISceneDelegate管理。iOS 13之前一个APP也是可以创建多个UIWindow的,但只能有一个keyWindow。引入scene之后,每一个scene都会有一个keyWindow。

Info.plist:Application Scene Manifest

APP支持的场景需要在Info.plist中声明,由Info.plist->Application Scene Manifest->Scene Configuration->Application Session Role节点指定场景List,每一项包含以下节点:

  • Configuration Name:场景配置的唯一标识;
  • Delegate Class Name:实现UIWindowSceneDelegate代理类的名称;
  • Storyboard Name:Storyboard的名称(如果采用的是Storyboard方式实现UI),可选。

一种类型的场景对应一个Scene Configuration。以iPadOS 13原生APP为例,Safari支持多窗口,但它的每一个窗口都是一样的(一个场景),都是一个web浏览器;而Mail则支持多窗口也有多个窗口类型(邮件列表和写邮件窗口是两个不同的场景)。当然,你完全可以定义两个Configuration Name不同,但页面一样的窗口,从技术层面这样没问题,只是不符合Apple的设计规范。

iOS中适配Scene Delegate

使用Scene Delegate之后,UIApplicationDelegate将不再持有UIWindow,它将转移至UIWindowSceneDelegate代理中。由于iOS目前还不支持多窗口,所以大部分情况下iOS项目仅需要一个场景配置。典型的改造方式如下:

  • didFinishLaunchingWithOptions

AppDelegate中,didFinishLaunchingWithOptions仅负责处理除UI之外的初始化工作:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // init database,register notification,and so on.
    return true
}
  • configurationForConnecting

iOS 13系统将调用configurationForConnecting接口,返回UISceneConfiguration对象,创建scene。

func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
	
	if let activity = options.userActivities.first, activity.activityType == "com.easeapi.scene0" {
		return UISceneConfiguration(name: "scene0", sessionRole: connectingSceneSession.role)
	}
	
	return UISceneConfiguration(name: "scene1", sessionRole: connectingSceneSession.role)
}

UISceneSession是管理scene的实例,一个scene对应一个UISceneSession,UISceneSession包含唯一标识和场景的配置细节。UISceneSession的生命周期由UIKit维护,当scene关闭时UISceneSession销毁。开发者无法直接创建UISceneSession,除了在configurationForConnecting接口由系统传入UISceneSession的实例外,开发者也可以通过UIApplication.shared.requestSceneSessionActivation的方式动态创建新的场景。

UIScene.ConnectionOptions则提供了创建场景时携带的额外信息,以便启动不同的场景配置。

  • willConnectTo

接着系统将在场景配置list中找到Configuration Name(scene0)指定的Delegate Class Name(Scene0Delegate),控制权交由Scene Delegate,将执行willConnectTo方法,在这个方法中完成创建UIWindow的操作。

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
	if let windowScene = scene as? UIWindowScene {
		let window = UIWindow(windowScene: windowScene)
		window.rootViewController = ViewController()
		window.backgroundColor = .white
		self.window = window
		window.makeKeyAndVisible()
	}
}

之后,页面进入前后台的事件全由Scene Delegate接管。在上述示例中,configurationForConnecting方法中有区分是走scene0场景还是scene1场景,options的参数是调用时传递的:当直接打开APP时,options中数据为空;通过UIApplication.shared.requestSceneSessionActivation方式切换场景时,options将包含携带的用户数据。

iOS各个版本的适配

由于Scene Delegate仅在iOS 13获得支持,iOS 13之前的系统还是需要走UIApplicationDelegate相关的方法。两个方案可供适配:

  • 不支持Scene Delegate:直接删除Info.plist的Application Scene Manifest节点,回到使用UIApplicationDelegate的方式;
  • 支持Scene Delegate:通过#available(iOS 13.0, *)等方式区分版本,iOS 13之前系统走原始的UIApplicationDelegate,iOS 13之后走Scene Delegate。

iPadOS 多窗口

自iOS 13开始,苹果推出了iPad专用的iPadOS系统,表明了苹果将iPad打造成生产力工具的野心。期待已久的multiple windows功能终于在iPadOS 13上得以实现。

上面讲到的iOS适配Scene Delegate是非常简单的,就是将UIWindow的创建及UI生命周期管理放置在Scene Delegate中,其它的比如页面栈管理等和之前完全一样。Scene Delegate的更大的用途是用来支持iPadOS的多窗口。

想要iPad App获得多窗口的能力非常简单,在Xcode项目中勾选“Supports multiple windows”即可,运行打开APP->选中APP长按->Show All Windows->点击屏幕右上角+号,即可新建一个窗口。

就是这么简单,你甚至不需要添加任何代码就可实现多窗口的能力。但我们的需求肯定不止于此,我们还需要在代码中控制窗口的新建,关闭以及用户数据交互。苹果提供了简洁的接口方便我们编程控制。

新建scene

let activity = NSUserActivity(activityType: "com.easeapi.scene0")
activity.userInfo = ["website": "http://easeapi.com/blog"]
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil, errorHandler: nil)

为了达到各个场景之间切换时状态保留的目的,用户数据的传递就变得非常关键。苹果使用NSUserActivity(iOS 8加入的,很多地方都用到了)来存储用户数据。NSUserActivity亦可从UISceneSession的stateRestorationActivity方法获取,典型的使用场景如下:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
	let vc: ViewController
	if let activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity,
	let identifier = activity.targetContentIdentifier {
		vc = ViewController(catName: identifier)
	} else {
		vc = ViewController(catName: "default")
	}
		
	if let windowScene = scene as? UIWindowScene {
		let window = UIWindow(windowScene: windowScene)
		window.rootViewController = vc
		window.backgroundColor = .white
		self.window = window
		window.makeKeyAndVisible()
	}
}

这样就可以在载入场景时获得暂存的用户数据,以便UI平滑过渡。关于NSUserActivity的内容可以参考文档

关闭scene

if let session = self.view.window?.windowScene?.session {
	let options = UIWindowSceneDestructionRequestOptions()
	options.windowDismissalAnimation = .commit
	UIApplication.shared.requestSceneSessionDestruction(session, options: options, errorHandler: nil)
}

上面讲解了Scene Delegate和multiple windows的一般知识,更酷的高级用法比如拖拽多窗口交互等后续有时间精力在研究。

其它文章

WWDC2019 258
Understanding the iOS 13 Scene Delegate
iOS Sign With Apple实践
iOS 13 适配