Highlight
三个层次构建灵活的 UIKit 应用。
核心内容
很多 UIKit 老项目都还在用 AppDelegate 生命周期。一开始这套设计够用:一个 App 一个窗口,启动、进入后台、退出,三件事处理完就行。但是 iPad 多窗口、Mac Catalyst、AirPlay 外接显示、visionOS 多场景出现之后,AppDelegate 那套全局状态就开始打架——窗口 A 进后台时,窗口 B 还在前台,timer 该不该暂停?外接显示器接上时,要不要弹出主界面?这些问题在 AppDelegate 里都得自己写胶水代码。
iOS 26 之后的下一个 major 版本,UIScene 生命周期将成为用最新 SDK 构建的强制要求(02:19)。Apple 在这个 Session 里把灵活性拆成三层:底层是 Scene,每个 Scene 是 App UI 的独立实例,有自己的视图控制器、状态保存、窗口几何信息;中间层是容器视图控制器,UISplitViewController 和 UITabBarController 负责跨 iPhone/iPad/Mac/visionOS 的导航适配;顶层是自适应 API,Safe Area、Layout Margins Guide、窗口控制按钮的避让,让你的 UI 在任何尺寸下都不被遮挡。
详细内容
Scene 配置由 AppDelegate 决定(03:02)。一个 App 可以有多种 Scene 类型,比如普通主界面和 AirPlay 外接显示。AppDelegate 在 configurationForConnectingSceneSession 里根据 sessionRole 返回不同配置:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
configurationForConnecting sceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
if sceneSession.role == .windowExternalDisplayNonInteractive {
return UISceneConfiguration(name: "Timer Scene",
sessionRole: sceneSession.role)
} else {
return UISceneConfiguration(name: "Main Scene",
sessionRole: sceneSession.role)
}
}
}
关键点:
sceneSession.role区分场景用途,.windowExternalDisplayNonInteractive表示 AirPlay 这类只读外接显示。- 返回的
UISceneConfiguration名字必须和 Info.plist 里声明的 Scene 配置匹配。 - 同一个 App 可以注册多份配置,按 role 路由。
Scene 的窗口由 SceneDelegate 创建(03:30)。在 sceneWillConnectTo 里关联窗口和 Scene,并把模型注入到根视图控制器:
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
var timerModel = TimerModel()
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
let windowScene = scene as! UIWindowScene
let window = UIWindow(windowScene: windowScene)
window.rootViewController = TimerViewController(model: timerModel)
window.makeKeyAndVisible()
self.window = window
}
}
关键点:
timerModel是 Scene 级别的状态,每个窗口有自己的 timer,不再是全局单例。- 如果 Scene 配置指定了 storyboard,窗口会自动创建,不用手写。
UIWindow(windowScene:)把窗口绑定到当前 Scene,多窗口下不会串。
状态恢复用 NSUserActivity(04:09)。Scene 进入后台时,系统会调用 stateRestorationActivity(for:) 拿到 userActivity 并持久化;Scene 重新连接时,通过 restoreInteractionStateWith 把状态还原回来:
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
let userActivity = NSUserActivity(activityType: "com.example.timer.ui-state")
userActivity.userInfo = ["selectedTimeFormat": timerModel.selectedTimeFormat]
return userActivity
}
func scene(_ scene: UIScene, restoreInteractionStateWith userActivity: NSUserActivity) {
if let selectedTimeFormat = userActivity.userInfo?["selectedTimeFormat"] as? String {
timerModel.selectedTimeFormat = selectedTimeFormat
}
}
关键点:
activityType必须和 Info.plist 里声明的一致,否则系统不会持久化。userInfo只放可序列化的 UI 状态,比如选中项、滚动位置、文本输入。- 模型层的数据(任务、文档)应该走自己的存储,userActivity 只管 UI。
SplitView 的列宽和布局环境(04:46)。新增的 splitViewControllerLayoutEnvironment trait 告诉你当前 SplitView 是展开还是折叠状态,可以据此切换 cell 的样式:
override func updateConfiguration(using state: UICellConfigurationState) {
if state.traitCollection.splitViewControllerLayoutEnvironment == .collapsed {
accessories = [.disclosureIndicator()]
} else {
accessories = []
}
}
关键点:
.collapsed表示 SplitView 列被压成单栏导航栈,需要 disclosure 提示用户还能往下钻。.expanded时是多列并排显示,cell 选中后内容直接显示在 secondary 列,不需要箭头。- 这个 trait 是 trait collection 的一部分,trait 改变时 cell 会自动重新配置。
避让窗口控制按钮(13:04)。iPadOS 26 新增了关闭/最小化/排列三个窗口控制按钮。layoutGuide(for: .margins(cornerAdaptation:)) 拿到一个能避开窗口控制的布局指南:
let contentGuide = containerView.layoutGuide(for: .margins(cornerAdaptation: .horizontal))
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: contentGuide.topAnchor),
contentView.leadingAnchor.constraint(equalTo: contentGuide.leadingAnchor),
contentView.bottomAnchor.constraint(equalTo: contentGuide.bottomAnchor),
contentView.trailingAnchor.constraint(equalTo: contentGuide.trailingAnchor)
])
关键点:
.horizontal角落适配适合顶部 bar 类内容,会从 trailing 边避让窗口控制。- 系统组件如
UINavigationBar已经自动适配,只有自定义顶部 UI 才需要手动处理。 - 不写这个适配,自定义按钮就会被窗口控制盖住。
调整大小期间暂停昂贵渲染(14:44)。游戏或重渲染场景下,用户拖拽窗口边缘时不必每帧都重生成资源:
func windowScene(
_ windowScene: UIWindowScene,
didUpdateEffectiveGeometry previousGeometry: UIWindowScene.Geometry) {
let geometry = windowScene.effectiveGeometry
let sceneSize = geometry.coordinateSpace.bounds.size
if !geometry.isInteractivelyResizing && sceneSize != previousSceneSize {
previousSceneSize = sceneSize
gameAssetManager.updateAssets(sceneSize: sceneSize)
}
}
关键点:
isInteractivelyResizing在用户拖拽边缘时为 true,松手后为 false。- 只在松手后更新一次资源,避免拖拽过程中反复重建纹理、重排布局。
- 适合游戏、视频播放器、自定义渲染界面。
核心启发
-
做什么:把 AppDelegate 生命周期迁移到 Scene 生命周期。
- 为什么值得做:iOS 26 之后的下一个 major 版本会强制要求,提前迁移避免被赶 deadline;多窗口、状态恢复、AirPlay 外接显示这些能力都依赖 Scene。
- 怎么开始:读 TN3187 技术文档,先做最小改动——把窗口创建和生命周期事件从 AppDelegate 搬到 SceneDelegate,UI 代码一个不动;跑通后再考虑多窗口、stateRestorationActivity 这些进阶能力。
-
做什么:给 SplitView 加 Inspector 列。
- 为什么值得做:iPadOS 26 的 Preview、Mail、Files 都用 Inspector 展示选中项的元数据;展开时在 secondary 列旁边,折叠时自动变 Sheet,不用自己写适配。
- 怎么开始:调
splitViewController.setViewController(_:for: .inspector)注册 inspector view controller,然后splitViewController.show(.inspector)显示;配合splitViewControllerLayoutEnvironmenttrait 在 collapsed 时调整 inspector 内容的紧凑程度。
-
做什么:给重渲染场景加
isInteractivelyResizing检查。- 为什么值得做:iPad 多窗口和 Mac 上用户经常拖拽窗口大小,每次 geometry 变化都重建资源会卡顿;松手后再更新一次就够了。
- 怎么开始:在 SceneDelegate 实现
windowScene(_:didUpdateEffectiveGeometry:),判断geometry.isInteractivelyResizing == false再触发昂贵操作;游戏纹理、视频解码、PDF 渲染都适用。
-
做什么:用 Layout Margins Guide 替代手写 margin 数字。
- 为什么值得做:每个平台的标准 margin 不同,硬编码 16/20 在 visionOS 或 Mac Catalyst 下会错位;Layout Margins Guide 自动跟随 Safe Area 和窗口控制调整。
- 怎么开始:把
view.layoutMarginsGuide当作约束的锚点,所有内容向它对齐;窗口控制场景额外用view.layoutGuide(for: .margins(cornerAdaptation: .horizontal))。
关联 Session
- Elevate the design of your iPad app — iPad 26 设计语言更新,Tab Bar、SplitView、窗口控制的设计规范。
- Build an AppKit app with the new design — AppKit 端的同源更新,Mac Catalyst 项目可以参考。
- Create icons with Icon Composer — 多平台图标的统一制作工具,配合多 Scene App 的窗口图标。
- Bring Swift Charts to the third dimension — Chart3D 在 iPad 多窗口和 visionOS 下的展示,灵活布局的另一个场景。
评论
GitHub Issues · utterances