前言
UIScene 是什么|在 iOS 13 之前,在功能职责上,UIApplication 负责 App 状态,UIApplicationDelegate(AppDelegate)负责 App 事件和生命周期,包括进程和 UI 的。对于单窗口的 App 来说这没有问题,但是要想开发多窗口的 iPad App 或者 Mac Catalyst App 的话,这种功能职责的划分已经不支持了。因此,Apple 于 iOS 13 引入用于构建多窗口应用的 UIScene,并对功能职责进行了拆分,将 UI 相关的状态、事件和生命周期交与 UIWindowScene 和 UIWindowSceneDelegate(SceneDelegate)负责,UISceneSession 负责持久化的 UI 状态。
接入 UIScene|可参考 iOS CarPlay|与你分享 CarPlay 音频 App 的开发过程与细节 - 兼容 UIScene。
一般来说,没有支持多窗口的需求可不接入 UIScene。我们使用 CarPlay framework 需要 UIScene 的支持,在接入 UIScene 后遇到了一些问题,写此文章记录一下。
Bug 1:iOS 15 用户日活异常增多
我们的 App 有在 application:didFinishLaunchingWithOptions:
时机统计日活。在我们的一款产品的支持 CarPlay 的版本上线后,观察到 iOS 15 用户日活异常增多,凌晨 0 - 3 点时间段日活异常增多。经排查后,首先定位到是接入 UIScene 导致。然后结合“iOS 15”这一特征,最终定位到是 UIScene 和 iOS 15 的 prewarm 机制产生了奇妙的反应。
prewarm 机制|Apple 在 iOS 15 中引入了 prewarm (预热)机制,系统可能会根据设备的情况,prewarm 你的 App —— 启动不在运行的 App 进程,以减少用户手动启动 App 等待的时间。prewarm 执行一个 App 的启动序列直到(但不包括)当 main()
调用 UIApplicationMain
。这为系统提供了一个机会来构建和缓存它需要的任何低层结构,以期待一个完整的启动。也就是说,prewarm 机制可以减少启动时间,我们甚至可以在 load 方法中做一些资源的预加载。详见 Apple|About the App Launch Sequence。
prewarm 机制的 bug
但是实际上 prewarm 机制的行为与 Apple 文档描述的有些不符。
以下是在 iOS 15 上观察到的行为:prewarm 会触发 main()
以及 UIApplicationMain
执行。之后会发生什么取决于你的 App 是否接入了 UIScene。
- 对于接入 UIScene 的 App:
application:didFinishLaunchingWithOptions:
可能会被调用(并不总是发生)scene:willConnectToSession:options:
未被调用。事实上,SceneDelegate
直到 App 打开才创建。
- 对于没有接入 UIScene 的 App:
application:didFinishLaunchingWithOptions:
不会调用。
因此,如果 App 接入了 UIScene,又没做特殊处理的话,会因为 prewarm 机制的 bug 而导致 iOS 15 用户日活异常增多。用户可能根本没有打开 App,却因为系统 prewarm 导致产生假日活。
修复
一个简单粗暴的方案是:判断如果是 prewarm 导致的启动,直接 exit App,这样当用户自己手动启动 App 时就是正常的完整的启动流程。在 Stackoverflow 上也有开发者提出过这个方案。该方案等同于我们主动放弃了 prewarm 机制。
下面附上代码。
void exitIfPrewarm(void) {
double systemVersion = [[UIDevice currentDevice] systemVersion].doubleValue;
if (systemVersion >= 15.0) {
NSDictionary* environment = [[NSProcessInfo processInfo] environment];
BOOL prewarmed = false;
for (NSString *key in environment.allKeys) {
if ([key.lowercaseString containsString:@"prewarm"]) {
prewarmed = true;
break;
}
}
if (prewarmed) {
exit(0);
}
}
}
int main(int argc, char * argv[]) {
@autoreleasepool {
exitIfPrewarm();
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
复制代码
隐患
我们目前使用了上述修复方案,暂时没有发现问题。但该方案存在隐患。
- 该方案用于修复当前 prewarm bug,这没有问题,如下左图。
- 如果没有 prewarm,用户点击 App 启动,有完整的启动流程
- 如果系统 prewarm,这时候 prewarm 会触发
main()
,那么在main()
里 exit App,接下来当用户点击 App 启动,还是完整的启动流程
- 而如果 Apple 在某个版本修复了该 prewarm bug,当前方案存在问题,如下右图。
- 如果没有 prewarm,用户点击 App 启动,有完整的启动流程(同上)
- 如果系统 prewarm,这时候按照官方文档的描述 prewarm 不会触发
main()
,接下来当用户点击 App 启动,此时触发main()
会 exit App。需要用户重新启动 App 才能正常使用。
如果 Apple 在未来的 iOS 版本中修复了该 prewarm bug,我们需要在 exitIfPrewarm
函数中限制系统版本。
Bug 2:SK2 退款和管理订阅界面关闭后 App 假死
在接入 SK2 App 内退款和管理订阅功能时,我们遇到了界面关闭时 App 会出现假死的情况。经排查这个问题又是与 UIScene 有关,因为我们测试暂未接入 UIScene 的 App 的该功能正常。
在调试时,我们发现,当管理订阅窗口(SKRemoteEngagementPresentationWindow
)关闭后,UIApplication.shared.keyWindow
还是 SKRemoteEngagementPresentationWindow
,系统没有自动将其切换为 sceneDelegate.window
,于是就出现了 App 假死的情况。我们需要针对接入 UIScene 的 App 做修复。
@MainActor
func showManageSubscriptions() async {
#if targetEnvironment(macCatalyst)
#else
guard !ProcessInfo.processInfo.isiOSAppOnMac else { return }
guard let windowScene = UIScene.main else { return }
do {
try await AppStore.showManageSubscriptions(in: windowScene)
// 如果接入了 UIScene,调用 sceneDelegate.window.makeKeyAndVisible()
} catch {
// Handle error
// 如果接入了 UIScene,调用 sceneDelegate.window.makeKeyAndVisible()
}
#endif
}
复制代码