WWDC26:升级改造你的 UIKit App(Objective-C 实践版)
本文根据 Apple WWDC26 Session 278:Modernize your UIKit app 的演讲稿重新整理,并将原演讲中的 Swift 示例改写为 Objective-C + UIKit。
文中的 iOS 27 API 来自 WWDC26 首个测试版 SDK。它们只能使用 Xcode 27 及对应 SDK 编译,正式版发布前仍可能调整命名。本文重点是理解迁移思路,接入时应以当前 SDK 头文件为准。
很多旧 UIKit 项目的“适配”逻辑,本质上是在猜测设备:
- 是 iPhone 还是 iPad?
- 当前是横屏还是竖屏?
- 主屏幕宽度是多少?
- App 是否一定占满整个屏幕?
这些假设过去已经不够可靠。到 iOS 27,iPhone App 可以在 Mac 的 iPhone Mirroring 中自由缩放;仅支持 iPhone 的 App 在 iPad 上运行时,也会像普通 iPad App 一样参与窗口缩放。App 面对的不再是几个固定设备尺寸,而是一个随时可能变化的 Scene 可用空间。
这场演讲可以浓缩为一句话:
现代 UIKit App 不应该根据“设备是什么”决定布局,而应该根据“当前场景实际拥有多少空间”动态调整。
先建立正确的适配模型
Apple 在演讲中要求重点审查四类旧代码:
- 仍然使用 App 生命周期,没有迁移到 Scene 生命周期;
- 仍然引用
UIScreen.mainScreen; - 使用
userInterfaceIdiom决定布局; - 使用界面方向决定布局。
这里的关键不是简单替换几个 API,而是改变数据来源:
| 旧思路 | 问题 | 新思路 |
|---|---|---|
| 读取全局主屏幕 | Scene 可能运行在另一块屏幕 | 从当前 Window Scene 获取屏幕 |
| 使用屏幕 Bounds 布局 | App 不一定占满屏幕 | 使用 view.bounds 或 Scene Geometry |
| 判断 Phone / Pad | Phone idiom 也可能拥有宽窗口 | 使用 Size Class 和实际尺寸 |
| 判断横屏 / 竖屏 | 可缩放环境中的方向不再可靠 | 将旋转视为一次 Bounds 变化 |
第一优先级:迁移到 UIScene 生命周期
UIScene 是所有自适应工作的基础。Apple 明确说明:使用最新 SDK 构建时,UIScene 生命周期已经成为要求;没有采用 Scene 生命周期的 App 将无法启动。
AppDelegate 只负责进程级事件
// AppDelegate.m
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary<UIApplicationLaunchOptionsKey, id> *)launchOptions {
// 初始化进程级服务,例如日志、数据库、依赖容器。
// 不要再在这里创建主窗口。
return YES;
}
- (UISceneConfiguration *)application:(UIApplication *)application
configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession
options:(UISceneConnectionOptions *)options {
return [[UISceneConfiguration alloc] initWithName:@"Default Configuration"
sessionRole:connectingSceneSession.role];
}SceneDelegate 管理每个窗口
// SceneDelegate.h
@interface SceneDelegate : UIResponder <UIWindowSceneDelegate>
@property (nonatomic, strong) UIWindow *window;
@end// SceneDelegate.m
- (void)scene:(UIScene *)scene
willConnectToSession:(UISceneSession *)session
options:(UISceneConnectionOptions *)connectionOptions {
if (![scene isKindOfClass:UIWindowScene.class]) {
return;
}
UIWindowScene *windowScene = (UIWindowScene *)scene;
UIWindow *window = [[UIWindow alloc] initWithWindowScene:windowScene];
window.rootViewController = [self buildRootViewController];
self.window = window;
[window makeKeyAndVisible];
}迁移后的职责边界应当非常清晰:
AppDelegate:整个进程共享的服务;SceneDelegate:某个场景与窗口的状态;UIViewController/UIView:局部 UI 与布局。
如果项目把当前页面、当前窗口或方向等状态都存在 AppDelegate 单例中,迁移 Scene 生命周期时也应该顺手拆除这些全局假设。
移除 UIScreen.mainScreen
场景真正所在的屏幕
当 App 被镜像到 Mac,或被移动到 iPad 外接显示器时,当前 Scene 所在屏幕可能不是设备的主屏幕。此时 UIScreen.mainScreen 返回的信息会与 App 实际环境不一致。
UIWindowScene *windowScene = self.view.window.windowScene;
UIScreen *screen = windowScene.screen;如果当前方法没有 View、View Controller 或 Window 上下文,最好把 UIScreen、Scale 或 Size 作为参数传入,而不是在深层工具类中重新读取全局状态。
- (UIImage *)thumbnailForImage:(UIImage *)image screen:(UIScreen *)screen {
CGFloat scale = screen.scale;
// 使用调用方明确传入的运行环境生成缩略图。
// ...
return image;
}只需要 Scale 时,优先使用 Trait Collection
很多代码读取 UIScreen.mainScreen.scale,其实只是为了获得当前显示 Scale。现代 UIKit 中,更合适的数据源是当前视图的 Trait Collection:
- (void)layoutSubviews {
[super layoutSubviews];
CGFloat displayScale = self.traitCollection.displayScale;
// 根据当前环境更新绘制或缓存。
}UIKit 会自动追踪常见布局与绘制方法中访问的 Trait。当相关 Trait 改变时,系统会再次调用这些方法。只有自动追踪无法覆盖的缓存或数据,才需要手动注册 Trait 变化:
- (void)setupTraitObservation {
[self registerForTraitChanges:@[UITraitDisplayScale.class]
withAction:@selector(displayScaleDidChange:previousTraitCollection:)];
}
- (void)displayScaleDidChange:(id<UITraitEnvironment>)environment
previousTraitCollection:(UITraitCollection *)previousTraitCollection {
[self.thumbnailCache removeAllObjects];
[self setNeedsDisplay];
}迁移时不要机械地把每一个 UIScreen.mainScreen 都替换为 windowScene.screen。先问清楚代码真正需要的是什么:
- 需要 Scale:使用
traitCollection.displayScale; - 需要组件可用尺寸:使用
view.bounds; - 需要 Scene 可用区域:使用
windowScene.effectiveGeometry; - 确实需要物理屏幕信息:使用当前
windowScene.screen。
不要再使用屏幕 Bounds 决定布局
在可缩放窗口中,屏幕尺寸与 App 可用尺寸是两回事。
对 View Controller 来说,最直接、最可复用的依据通常就是自己的 View:
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
CGSize availableSize = self.view.bounds.size;
BOOL shouldUseTwoColumns = availableSize.width >= 760.0;
[self updateLayoutForTwoColumns:shouldUseTwoColumns];
}这样做还有一个额外好处:同一个 View Controller 被放入 UISplitViewController、Sheet 或其他容器时,仍然能根据自身空间正确布局。
如果 SceneDelegate 需要观察整个场景的有效 Geometry,可以实现 iOS 27 新回调:
// iOS 27 SDK
- (void)windowScene:(UIWindowScene *)windowScene
didUpdateEffectiveGeometry:(UIWindowSceneGeometry *)previousEffectiveGeometry {
CGRect availableBounds = windowScene.effectiveGeometry.coordinateSpace.bounds;
[self updateSceneServicesForBounds:availableBounds];
}这里要避免一个常见错误:把 Scene 的 Geometry 再次保存为新的全局常量。Geometry 会变化,使用方要么在需要时读取,要么订阅变化并使相关缓存失效。
用 Size Class 替代设备类型与界面方向
为什么 userInterfaceIdiom 不再适合控制布局
一个仅支持 iPhone 的 App,在 iPad 或 Mac 镜像中依然可能报告 Phone Idiom,但它的窗口可以变得非常宽。因此下面这种逻辑已经失去可靠性:
// 不推荐:设备类型不能代表当前可用空间。
if (self.traitCollection.userInterfaceIdiom == UIUserInterfaceIdiomPad) {
[self showSidebar];
}改为表达真正的布局意图:
- (void)updateLayoutForTraitCollection:(UITraitCollection *)traits {
BOOL hasRegularWidth =
traits.horizontalSizeClass == UIUserInterfaceSizeClassRegular;
self.sidebarView.hidden = !hasRegularWidth;
self.compactMenuButton.hidden = hasRegularWidth;
}
- (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection {
[super traitCollectionDidChange:previousTraitCollection];
[self updateLayoutForTraitCollection:self.traitCollection];
}在新项目中可使用 Trait 变化注册 API,避免重写已经逐步淡出的 traitCollectionDidChange:。但无论采用哪种回调,业务判断都应该基于 Size Class 或实际 Bounds。
为什么界面方向也不再适合控制布局
iOS 27 中,App 声明的支持方向在可缩放环境里只是系统参考;iPhone Mirroring 甚至可能始终报告 Portrait,而窗口本身已经是横向比例。
因此不要再写:
// 不推荐
if (UIInterfaceOrientationIsLandscape(self.view.window.windowScene.interfaceOrientation)) {
[self useWideLayout];
}应该写成:
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
CGFloat width = CGRectGetWidth(self.view.bounds);
CGFloat height = CGRectGetHeight(self.view.bounds);
BOOL hasEnoughWidthForInspector = width >= 820.0 && width > height * 1.1;
[self setInspectorVisible:hasEnoughWidthForInspector];
}Apple 再次引用了 WWDC 2014 的观点:设备旋转只是一次带动画的 Bounds 改变。 在窗口可自由缩放的今天,这个模型比“横屏/竖屏”更加准确。
游戏与 UIRequiresFullscreen
对游戏或重渲染应用来说,任意连续尺寸变化可能带来高昂成本。iOS 27 在可缩放环境中会尊重 iPhone App 的 UIRequiresFullscreen,但它不再表示彻底退出窗口缩放。
开启后,系统会使用 离散缩放:用户调整窗口尺寸时,系统在符合支持方向的屏幕配置之间切换,使游戏能够以完整质量重新渲染。
这是一种针对特殊渲染场景的兼容方案,不应该被普通业务 App 当作逃避自适应布局的开关。
iOS 27 的系统 UI 更新
完成基础适配之后,再考虑新的系统 UI 能力。
Tab Bar 与 Sidebar
iPad 上的 Tab Bar 可以展开为 Sidebar。iOS 27 进一步允许 iPhone App 主动选择 Sidebar 表现形式,系统再根据当前空间判断是否能够显示。
// iOS 27 SDK;测试版期间请以 UIKit 头文件中的枚举名为准。
if (@available(iOS 27.0, *)) {
self.tabBarController.sidebar.preferredPlacement =
UITabBarControllerSidebarPlacementSidebar;
BOOL sidebarAvailable = self.tabBarController.sidebar.isAvailable;
[self updateCompactNavigationFallback:!sidebarAvailable];
// 即使 Tab Bar 滚动折叠,也让关键 Tab 保持突出显示。
self.tabBarController.prominentTabIdentifier = @"cart";
}需要注意:
- iPhone App 选择 Sidebar 后,UI 中没有 Tab Bar / Sidebar 手动切换器;
- 系统根据可用空间决定 Sidebar 是否可显示;
- 当
isAvailable == NO时,必须为嵌套 Tab 中的功能提供其他入口。
Navigation Bar 最小化
导航栏现在可以在滚动时交互式滑走,为内容释放空间。默认情况下由系统决定何时最小化,也可以显式控制:
// iOS 27 SDK
if (@available(iOS 27.0, *)) {
self.navigationItem.barMinimizationBehavior =
UINavigationItemBarMinimizationBehaviorAlways;
// 只有自己负责安全区域避让时才禁用系统自动调整。
self.navigationItem.barMinimizationSafeAreaAdjustment =
UIBarMinimizationSafeAreaAdjustmentDisabled;
}如果项目过去覆盖了滚动边缘效果,尤其强制使用 .soft 风格,需要重新检查。iOS 27 的 .automatic 已经拥有新的默认视觉,不再只是从已有的 Soft / Hard 风格中二选一。
Menu 图标
随着 Liquid Glass 视觉继续调整,菜单元素上的图片在部分场景中默认不会显示,例如 iPadOS 和 macOS 的菜单栏。
如果图标对识别功能确实必要,可以通过 preferredImageVisibility 覆盖默认行为;但优先重新审视设计,而不是让所有菜单项都强制显示图标。
// iOS 27 SDK;仅在图标有明确语义价值时使用。
if (@available(iOS 27.0, *)) {
action.preferredImageVisibility = UIMenuElementImageVisibilityVisible;
}为 Apple Intelligence 准备上下文
iOS 27 的菜单会在存在相关内容时自动显示 Ask Siri 入口。App 可以使用新的 View Annotations API,把特定 View 与 AppEntity 关联,从而向 Siri 提供更准确的上下文。
如果 App 已支持 Drag & Drop,Siri 也可以从拖拽处理器加载资源。这带来一个容易忽略的行为变化:Drag Session 不一定由用户手势启动。
因此在 sessionWillBegin 中不要:
- 播放只针对拖拽手势的动画;
- 弹出模态页面;
- 假设用户正在触摸屏幕;
- 修改会干扰 Siri 加载资源的界面状态。
需要在用户真正移动拖拽内容后才出现的 UI,应放到 sessionDidMove 等更合适的回调中。
使用 Xcode 27 的代理技能辅助迁移
Xcode 27 提供 App Modernization Skill,可以让编码代理结合项目上下文处理常见迁移任务,包括:
- 将
mainScreen调用替换为 Trait Collection 或 Scene Bounds; - 在需要时添加缓存失效逻辑;
- 把方向判断替换为 Size Class 判断;
- 将 App 迁移到 Scene 生命周期;
- 对复杂改造提出澄清问题;
- 对单次会话无法完成的任务留下待办说明。
Xcode 使用的 Skill 还可以导出给其他工具:
xcrun agent skills export代理适合处理重复、规则明确的迁移,但不能替代布局设计决策。比如旧代码写着“iPad 显示 Sidebar”,代理可以替换判断条件,却无法自动知道业务在 700pt、820pt 或 1000pt 宽度下应该呈现什么信息层级。
一套可执行的升级顺序
保证 App 能启动
- 迁移到
UISceneDelegate; - 将 Window 创建逻辑移出
AppDelegate; - 清理依赖“唯一窗口”的全局状态。
消除布局假设
全项目搜索以下关键词:
UIScreen.mainScreen
mainScreen.bounds
userInterfaceIdiom
interfaceOrientation
UIInterfaceOrientationIsLandscape
UIInterfaceOrientationIsPortrait逐个判断真实需求,并迁移到:
window.windowScene.screentraitCollection.displayScaleview.boundswindowScene.effectiveGeometryhorizontalSizeClassverticalSizeClass
检查系统组件
- Tab Bar 是否适合在宽空间展开为 Sidebar;
- 关键 Tab 是否需要保持突出;
- Navigation Bar 最小化是否影响内容与 Safe Area;
- Menu 图标是否仍然必要;
- 自定义滚动边缘效果是否与新系统视觉冲突。
接入新能力
- 用 View Annotations 提供 Siri 上下文;
- 审计 Drag & Drop 回调中的 UI 副作用;
- 使用 Xcode Modernization Skill 辅助处理机械迁移。
测试矩阵
仅在几个固定模拟器上旋转屏幕,已经无法覆盖现代 UIKit App 的适配风险。建议至少验证:
| 环境 | 重点检查 |
|---|---|
| Device Hub 自由缩放 | 连续拖动时约束、缓存与绘制是否及时更新 |
| Xcode Previews 多尺寸 | 关键断点附近的信息层级是否合理 |
| iPhone Mirroring 真机 | 宽窗口下是否仍错误使用 Phone 布局 |
| iPhone-only App 运行于 iPad | 是否能利用额外空间,是否出现巨大留白 |
| iPad 分屏与外接显示器 | Scene 切换屏幕后 Scale、Geometry 是否正确 |
| Dynamic Type 与多语言 | 可缩放布局叠加大字体、长文案后是否仍可用 |
不要只测试最终几个尺寸,还要测试尺寸变化过程。常见问题往往发生在拖动过程中:缓存没有失效、约束冲突、滚动位置跳变、导航栏 Insets 重复调整等。
结语:现代化不等于重写
这场 WWDC26 演讲并没有要求 UIKit 项目改写为 SwiftUI,也没有要求一次性重构所有页面。Apple 给出的路线很务实:
- 用 Scene 生命周期建立正确基础;
- 移除主屏幕、设备类型与方向假设;
- 让每个组件根据附近的 View、Trait 与 Scene 做决定;
- 在布局稳定后,再采用 Sidebar、Navigation Bar、菜单和 Apple Intelligence 新能力;
- 使用 Device Hub、镜像与真机形成验证闭环。
真正需要升级的不是 UIKit 本身,而是旧项目对“App 永远占满某一台设备屏幕”的假设。只要布局能够忠实响应当前可用空间,Objective-C + UIKit 项目同样可以自然地运行在新的窗口化环境中。