D4653dc2e28063888a468cb3b1c9954d
iPad 上的多窗口

在本次 WWDC 发布的 iOS 13 / iPadOS 中,Apple 开始允许开发者在 iPad 上为应用创建多个窗口,从而能够让用户并排运行两个应用界面的实例。本文将介绍 iPad 上多窗口的设计、多窗口对应用生命周期的影响,以及一些常见的问题与解决办法。

设计

你的 App 是否需要多窗口?如果是,多窗口要被用来展示哪些内容?这是我们在适配前需要先考虑清楚的问题。Apple 自家的应用为我们提供了一些参考:Safari、Pages、Maps、Calendar 等应用,它们在 iOS 13 中都支持了多窗口,但这些 App 都仅有一种类型的窗口,每个窗口都能独立完成整个 App 中的所有功能。另一种类型的则是如 Mail、Messages 之类的应用,这种应用不仅支持多窗口,而且存在有多种类型的窗口,例如可以将其中的某个联系人对话独立成窗口、将邮件编辑器独立成窗口等,但这些窗口是有特定用途的专用型窗口,当用户在此窗口中完成了要做的事情,如回复邮件完成,窗口就会被关闭掉。Apple 向我们强调的一点是,应当允许用户在第一个窗口中就能完成所有的事情。如果用户认为有需要,再让用户去创建打开多个窗口。而不该是 App 要求用户必须要使用多窗口才能进行操作。

同时,Apple 提到了让 iPad 应用支持多窗口的另一个理由:对多窗口的支持能够在把 iPad App 运行在 Mac 上时,在本就支持多窗口特性的 macOS 上有更好的体验。

第二个问题则是应该通过怎样的交互方式来创建窗口。这里系统已经为我们提供了一部分交互入口,如在 SpringBoard App Exposé 界面的右上角存在有一个用于创建新窗口的按钮,以及用户可以通过拖拽 Dock 上的 Icon 到已打开的窗口上来创建多窗口等。除此之外,我们也可以自行提供显式创建窗口的方式,如拖拽列表中的一个单元格,或长按一个链接选择在新窗口中打开等。

App 生命周期的变化

为了提供多窗口支持,iOS App 的生命周期也迎来了一些变化。在开始适配工作前,我们需要先认识一下在 iOS 13 中新加入的两个类:UIWindowScene 和 UISceneSession。

它们首先带来的是 UI 层级结构上的变化。在 iOS 13 中,原本的 UIScreen 层级和 UIWindow 层级中加入了新的一级:UIWindowScene。

UI 结构变化

UI 结构变化

基本上来说,一个 UIScene 中会包含 UI,scene 会由系统按需创建,同样也会在未被使用时由系统销毁。而一个 UISceneSession 中则会包含持久化的用户界面状态。每次创建一个新窗口时,应用会在 Application Delegate 中收到通知,有一个新的 session 被创建了。当窗口被关闭时,同样也会收到 session 被销毁的通知。用一个有三个 session (即在系统中会有三个 space 窗口界面)的应用举例来说:

  • 开始时三个 session 都没有连接,应用处于后台状态。

Session 示例1

Session 示例1

  • 当激活了其中的一个 space 时,对应的 session 被启动,应用的状态也会变更为前台激活状态。

Session 示例2

Session 示例2

  • 当再次让它回到后台时,应用的状态就和它一同回落到了后台状态。

Session 示例3

Session 示例3

  • 同样,当激活了其他的 session 时,应用又会再次回到前台激活状态。

当我们把这些变化尝试映射到类的功能职责上会发现,原本由 UIApplication 负责应用状态,UIApplicationDelegate 负责应用事件与生命周期的划分已经没法很好的满足需求。因此 Apple 在此基础上做了进一步的拆分:UIApplication 仍作为一个系统进程表示系统状态,UIApplicationDelegate 负责进程的事件和生命周期,能获取到应用启动与终止等相关的通知;UIWindowScene 中封装了各类 UI 状态,如 status bar 等相关的事情现在由它负责;UIWindowSceneDelegate 负责 UI 事件和生命周期,如打开 URL,scene 回到了前台或后台等;UISceneSession 则负责持久化的 UI 状态。如图所示:

Class Role

Class Role

由于上述的这些类在功能角色上的变化,我们需要将以下的这些原本在 Application Delegate API 中的实现进行迁移到新的 Scene Delegate API 中:

Lifecycle API Changings

Lifecycle API Changings

为了能够让用户在多个 space 中切换但仍能保留住相关的状态信息,状态恢复的处理就变得非常重要。苹果在这里借用了 handoff 中 NSUserActivity 的 API 来执行数据恢复相关的操作。我们能够将各类数据都放入其中 UserActivity 中,系统会从 SceneDelegate 中获取到 UserActivity,并会在 scene 恢复的时候于 connection delegate callback 中将 UserActivity 传回。我们也可以直接通过 UISceneSession 中的 stateRestorationActivity 获取到 UserActivity 信息。

支持多窗口的具体适配过程如下:

1、在 Xcode Target Settings 中勾选上 Supports multiple windows;

Step 1

Step 1

2、在 Info.plist 中创建并完成 Application Scene Manifest 相关的配置,包括指明 Scene Delegate Class 与 Storyboard Name 等;

Step 2

Step 2

3、实现 Scene Delegate,且按照自己的需要实现状态恢复相关的逻辑。

Step 3

Step 3

Drag & Drop

Drag & Drop 能够让一些元素在不同的窗口间进行交互。实现 Drag & Drop 的方式有几种,如使用现存的 Universal Links 或是应用在 plist 中声明支持的文件就可以。但如果想要实现更自定义的效果,依然可以使用 NSUserActivity 相关的 API。关于 Drag and Drop 的部分在本 session 中没有详细展开,如果对 Drag & Drop 不太了解的话可以观看 WWDC 2017 的 session: Introducing Drag and Drop。

下一步

管理 Scene Sessions

App Switcher

App Switcher

在 iPadOS 的应用切换器中,用户能够看到 App 的多个窗口并排展示,然而作为开发者,我们需要意识到这里实际上是前面提到的 scene 和 scene sessions。在应用切换器中的窗口实际上是以快照的方式存在,而这些快照对应的 scene 在应用中并不一定被加载了。如前文中提到的 UIScene 会由系统按需创建或销毁,但我们却始终能够获取到 session。正因如此,我们可以通过 session 来按照自己的需求控制创建新窗口、更新已有窗口在应用切换器中的快照,又或是请求关闭窗口。

Apple 的示例代码如下:

```swift
// 打开新窗口
@IBAction func handleLongPress(forDocumentAt url: URL) {

// 如果已经有该文档的 session,重新激活
if let existingSession = findSession(for: url) {
// 新加入的 API
// 能够将 App 中已经存在的 scene 激活带到前台,或是创建新的 scene
UIApplication.shared.requestSceneSessionActivation(existingSession, userActivity: nil, options: nil)

} else { // 没有则创建新的 scene session
let activity = NSUserActivity(activityType: "com.example.MyApp.EditDocument")
activity.userInfo["url"] = url
UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: nil)
}
}

// 更新已有窗口在应用切换器中的快照
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable:Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
let session = findSession(for: userInfo)
// 新加入的 API
application.requestSceneSessionRefresh(session)
}

// 关闭窗口
func closeWindow(and action: DraftAction) {
let options = UIWindowScene.DestructionRequestOptions()

// 可以根据 action 来判断选择最符合当前情景的窗口关闭动画

top Created with Sketch.