iOS BugFix|接入 UIScene 遇到的一些问题及解决方案

前言

UIScene 是什么|在 iOS 13 之前,在功能职责上,UIApplication 负责 App 状态,UIApplicationDelegate(AppDelegate)负责 App 事件和生命周期,包括进程和 UI 的。对于单窗口的 App 来说这没有问题,但是要想开发多窗口的 iPad App 或者 Mac Catalyst App 的话,这种功能职责的划分已经不支持了。因此,Apple 于 iOS 13 引入用于构建多窗口应用的 UIScene,并对功能职责进行了拆分,将 UI 相关的状态、事件和生命周期交与 UIWindowSceneUIWindowSceneDelegate(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

iOS-Prewarm0

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 才能正常使用。

iOS-Prewarm1

如果 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
}
复制代码

参考

猜你喜欢

转载自juejin.im/post/7111173753668632584