iOS 27 外接屏踩坑记:`windowExternalDisplayNonInteractive` 不再自动提供

0 阅读5分钟

一个 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, windowExternalDisplayNonInteractive scenes are no longer offered automatically by the system. Use UIViewController.registerSceneAccessory(_:) with a UISceneAccessory.externalNonInteractive instance 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 the windowExternalDisplayNonInteractive role scene, and then unhide it.

也就是说,必须真的在外接屏场景上创建并显示一个 UIWindow,镜像才会被替换成你的内容:


let window = UIWindow(windowScene: externalWindowScene)

window.rootViewController = yourViewController

window.isHidden = false // 关键:挂窗口 + 显示

七、验证清单

排查这类问题时,按这个顺序看,能少走很多弯路:

  1. 先看系统有没有给场景:connectedScenes 的个数与 role,UIScreen.screens.count。
  • 只有 1 个场景 → 场景压根没创建,别怀疑检测逻辑。
  1. 看构建产物而不是源码:plutil -p YourApp.app/Info.plist,确认 UISceneConfigurations 真的写进去了。

  2. 看 SDK 版本:DTSDKName / DTXcode。跨大版本 SDK 时,Apple 常常改这类「默认行为」。

  3. 区分场景存在与镜像解除:场景存在 ≠ 镜像解除,还要真的 attach + unhide 一个 window。

  4. 别把环境因素当结论:台前调度、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 决定运行时行为有没有变;论坛决定文档没写清楚的部分到底是什么意思。

参考