一个 SwiftUI 应用,在 iPhone 上外接屏功能正常,换到 iPad + AirPlay 后「外接屏功能全部失效」。
排查到最后发现:不是 iPad 的问题,也不是 AirPlay 的问题,而是 iOS 27 SDK 的行为变更。
一、现象
-
应用内有多个模块会把内容投到外接屏:视频播放、网页浏览器、立体视频等。
-
iPad 通过 AirPlay 连接电视后,外接屏始终是完全镜像,App 侧所有「检测外接屏」的逻辑全部返回「没有外接屏幕」。
-
打开/关闭台前调度(Stage Manager)没有任何变化。
代码里的检测逻辑本身没问题,问题在于它从来没有机会命中:
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let externalScene = scenes.first { $0.screen !== UIScreen.main }
二、先确认「系统有没有把外接屏交给 App」
与其猜,不如打点。在检测逻辑里把现场数据采集出来:
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let data: [String: Any] = [
"uiScreenCount": UIScreen.screens.count,
"uiScreenBounds": UIScreen.screens.map { "\($0.bounds.size)" },
"windowSceneCount": scenes.count,
"sceneRoles": scenes.map { $0.session.role.rawValue },
"sceneStates": scenes.map { $0.activationState.rawValue },
]
在 iPad + AirPlay 的现场,结果是:
{
"uiScreenCount": 1,
"windowSceneCount": 1,
"sceneRoles": ["UIWindowSceneSessionRoleApplication"]
}
结论很明确:
-
系统只给了 App 1 块屏幕、1 个场景,且角色只有普通的
windowApplication。 -
外接屏根本没有作为独立的
UIScreen/UIWindowScene暴露给 App,App 再怎么检测都不可能检测到。
也就是说:这不是检测逻辑的 bug,而是场景根本没被创建。
三、第一个坑:老方案(声明场景角色)在 iOS 27 SDK 下失效
按 Apple 长期的文档做法,在 Info.plist 里声明外接屏场景角色即可:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleExternalDisplayNonInteractive</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>External Display</string>
<key>UISceneDelegateClassName</key>
<string>ExternalDisplaySceneDelegate</string>
</dict>
</array>
</dict>
</dict>
这一步本身是必要的(对 iOS 26 及更早系统有效),但改完重新跑,日志依旧是 1 屏 1 场景。
顺带一个非常隐蔽的坑
如果工程开了这个构建设置:
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES
Xcode 会在构建时自动生成并覆盖 UIApplicationSceneManifest,你在 Info.plist 里手写的 UISceneConfigurations 会被丢掉,产物里只剩:
UIApplicationSceneManifest = {
UIApplicationSupportsMultipleScenes = true
UISceneConfigurations = {} // 空的
}
所以手写场景配置时必须同时改成:
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO
排查时可以顺手核对构建产物,而不是只看源码:
plutil -p "…/Build/Products/Debug-iphoneos/YourApp.app/Info.plist" | grep -A12 UIApplicationSceneManifest
四、真正的根因:iOS 27 SDK 的变更
核对产物后发现,这次构建用的是 iOS 27.0 SDK:
DTXcode = 2700
DTSDKName = iphoneos27.0
而 Apple 在 iOS 27 的 release note(177015874)里写得很清楚:
In apps built with the iOS 27.0 SDK,
windowExternalDisplayNonInteractivescenes are no longer offered automatically by the system. UseUIViewController.registerSceneAccessory(_:)with aUISceneAccessory.externalNonInteractiveinstance to display non-interactive content on external display scenes.
翻译一下:
-
iOS 26 及更早:系统会自动把外接屏场景交给 App,App 「不提供内容」就等于放弃;
-
iOS 27 SDK 构建的 App:系统不再自动提供该场景,必须由 App 主动注册一个 scene accessory。
这解释了全部现象:不管你怎么声明角色,系统都不会再主动把外接屏场景交给你,所以永远只有 1 个场景,外接屏只能停在系统级镜像。
五、解决方案
1. 在视图控制器上注册 scene accessory(iOS 27+)
@available(iOS 27.0, *)
private final class ExternalDisplayAccessoryViewController: UIViewController {
private var registration: UISceneAccessoryRegistration?
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
view.isUserInteractionEnabled = false
let configuration = UISceneConfiguration()
configuration.delegateClass = ExternalDisplaySceneDelegate.self
registration = registerSceneAccessory(
UISceneAccessory.externalNonInteractive(sceneConfiguration: configuration)
)
}
}
要点:
-
UISceneConfiguration必须带上delegateClass,系统会用它实例化外接屏场景的代理; -
registerSceneAccessory(_:)是UIViewController的实例方法,注册位置会影响系统挑选:
「The system will pick the most relevant registration based on view controller depth and presentation state」,
所以把承载它的控制器挂到主界面视图层级深处即可;
-
返回的
UISceneAccessoryRegistration要持有住,它提供isAvailable/isEnabled(isAvailable只在updateProperties、layoutSubviews生命周期内可观察)。
2. 在 SwiftUI 里挂载注册器
SwiftUI 没有「视图控制器」的概念,用一个不可见的 representable 桥接:
struct ExternalDisplayAccessoryRegistrar: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
if #available(iOS 27.0, *) {
return ExternalDisplayAccessoryViewController()
}
return UIViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
TabView { … }
.background(
ExternalDisplayAccessoryRegistrar()
.allowsHitTesting(false)
)
3. 场景代理:真正把画面挂上去
@objc(ExternalDisplaySceneDelegate)
final class ExternalDisplaySceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
// 在这里把窗口挂到 windowScene 上
}
}
注意:UISceneConfiguration 的 delegateClass 用类引用,Info.plist 里用类名。
Info.plist 是用字符串 NSClassFromString 解析的,如果模块名非 ASCII,$(PRODUCT_MODULE_NAME).XXX
这种写法容易解析不到,用 @objc(XXX) 固定 Objective-C 名字更稳。
4. 兼容矩阵
| 系统 | 需要做的事 |
| --- | --- |
| iOS 26 及更早 | Info.plist 声明 UIWindowSceneSessionRoleExternalDisplayNonInteractive 角色(并把 manifest 自动生成关掉) |
| iOS 27+ | 在视图控制器上 registerSceneAccessory(UISceneAccessory.externalNonInteractive(sceneConfiguration:)) |
两套都保留即可:老系统走 Info.plist,新系统走 accessory,代码里用 if #available(iOS 27.0, *) 分支。
六、还有一个容易忽略的点:镜像不会自动解除
即使场景已经拿到,系统也不会主动停止镜像。
Apple 工程师在开发者论坛里的原话是:
If you wish to kick the system out of mirroring, create a
UIWindow, attach it to thewindowExternalDisplayNonInteractiverole scene, and then unhide it.
也就是说,必须真的在外接屏场景上创建并显示一个 UIWindow,镜像才会被替换成你的内容:
let window = UIWindow(windowScene: externalWindowScene)
window.rootViewController = yourViewController
window.isHidden = false // 关键:挂窗口 + 显示
七、验证清单
排查这类问题时,按这个顺序看,能少走很多弯路:
- 先看系统有没有给场景:
connectedScenes的个数与role,UIScreen.screens.count。
- 只有 1 个场景 → 场景压根没创建,别怀疑检测逻辑。
-
看构建产物而不是源码:
plutil -p YourApp.app/Info.plist,确认UISceneConfigurations真的写进去了。 -
看 SDK 版本:
DTSDKName/DTXcode。跨大版本 SDK 时,Apple 常常改这类「默认行为」。 -
区分场景存在与镜像解除:场景存在 ≠ 镜像解除,还要真的 attach + unhide 一个 window。
-
别把环境因素当结论:台前调度、AirPlay 还是线缆,都可能影响,但先用数据确认系统是否提供场景,再谈环境差异。
八、小结
-
症状「外接屏功能失效」的本质是:App 从来没拿到外接屏场景。
-
用 iOS 27 SDK 构建后,
windowExternalDisplayNonInteractive场景不再自动提供,必须注册UISceneAccessory。 -
顺带的坑:
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES会覆盖手写场景配置。 -
拿到场景后,还要 attach 一个
UIWindow,系统才会解除镜像。
九、附:如何快速、准确地查一个 API
文档和博客经常滞后于 SDK,最权威的答案永远在你本机的 SDK 里。以下是本次定位 UISceneAccessory 的完整方法。
1. 先定位 SDK 路径
# 如果命令行默认指向 CommandLineTools,需要显式指定 Xcode
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
xcrun --sdk iphoneos --show-sdk-path
# /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS27.0.sdk
xcrun --sdk iphoneos --show-sdk-version
# 27.0
拿到 SDK 路径后,所有头文件、模块接口都能直接读。
2. 在 SDK 头文件里搜符号(Objective-C / UIKit 等)
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
grep -rn "registerSceneAccessory\|UISceneAccessory" \
"$SDK/System/Library/Frameworks/UIKit.framework/Headers/" | head -n 40
输出直接给出定义位置与可用性:
UIKit.framework/Headers/UISceneAccessory.h:36: + (instancetype)externalNonInteractiveSceneAccessoryWithConfiguration:(UISceneConfiguration *)sceneConfiguration NS_SWIFT_NAME(externalNonInteractive(sceneConfiguration:));
UIKit.framework/Headers/UIViewController.h:366: - (UISceneAccessoryRegistration *)registerSceneAccessory:(UISceneAccessory *)accessory API_AVAILABLE(ios(27.0)) ...
3. 读懂头文件里的三个关键信息
对着上面两行就能得到写代码需要的一切:
| 头文件里的写法 | 含义 |
| --- | --- |
| NS_SWIFT_NAME(externalNonInteractive(sceneConfiguration:)) | Swift 里的真实调用名,不是 Objective-C 的名字 |
| API_AVAILABLE(ios(27.0)) | 需要可用性判断,Swift 里写 if #available(iOS 27.0, *) |
| API_UNAVAILABLE(macCatalyst, tvos, visionos, watchos) | 其他平台不可用,跨平台工程要额外包一层 |
| NS_SWIFT_UI_ACTOR | 该类在 Swift 中标注为 @MainActor,只能在主线程调用 |
然后直接 cat 头文件看完整语义(比搜索片段更靠谱):
sed -n '1,60p' "$SDK/System/Library/Frameworks/UIKit.framework/Headers/UISceneAccessory.h"
4. 纯 Swift 框架看 .swiftinterface
SwiftUI、SwiftData 这类框架没有可读头文件,看模块接口文件:
ls "$SDK/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/"
# arm64-apple-ios.swiftinterface arm64-apple-ios.private.swiftinterface …
grep -n "struct WindowGroup" \
"$SDK/System/Library/Frameworks/SwiftUI.framework/Modules/SwiftUI.swiftmodule/arm64-apple-ios.swiftinterface"
.swiftinterface 就是编译器实际使用的接口,签名、泛型约束、available 标注一应俱全。
5. 全 SDK 找「新 API」
想快速知道新 SDK 加了什么,可以直接在主头文件目录里搜可用性标注:
SDK=$(xcrun --sdk iphoneos --show-sdk-path)
grep -rn "API_AVAILABLE(ios(27" "$SDK/System/Library/Frameworks/" --include="*.h" | head -n 50
排查「升级 SDK 后行为变了」这类问题时,这一步常常直接命中。
6. 反查构建产物的真实配置
代码写对了不代表产物生效,二进制里的配置才是事实:
plutil -p "…/Build/Products/Debug-iphoneos/YourApp.app/Info.plist"
本次就是靠这一步发现 DTXcode = 2700 / DTSDKName = iphoneos27.0,从而把方向从「iPad 环境问题」修正到「SDK 行为变更」。
7. 配合 Xcode 图形界面(更快)
-
Cmd + Shift + O:按符号名跳转,包括系统框架符号; -
Cmd + 点击符号:直接跳到生成的头文件 / swiftinterface; -
头文件顶部通常有
API_AVAILABLE列表和一句语义说明,比看文档快; -
需要背景说明、跨版本差异时再去 developer.apple.com/documentati… 和 Release Notes;
-
官方文档没写的行为变更,去开发者论坛搜:DTS 工程师的回复经常比文档更新(本次「iOS 27 不再自动提供外接屏场景」就是在论坛答复里拿到确认的)。
8. 一条经验
先查 SDK,再看文档,最后看论坛。
SDK 决定编译期能不能通过、怎么调用;Release Notes 决定运行时行为有没有变;论坛决定文档没写清楚的部分到底是什么意思。