在 iOS 15 公开推出后, 我们开始从用户端收到反馈报告:在打开我们的应用程序(Cookpad) 时他们被莫名其妙的反复退出到登录页。非常令人惊讶的是,这并不是我们在测试 iOS 15 beta 版的时候发现的问题。
如果你是来找修复方法的,那就直接向下滚动到结论,但如果你想了解更多关于我们如何调试这个特定问题,那就开始吧。
复现反馈的问题
用户报告中的具体信息有限,我们唯一知道的是:从 iOS 15 开始,用户打开程序后会发现自己已经退出登录。
我们没有视频,也没有具体的步骤来重现这个问题,所以我努力尝试以各种方式启动应用程序,希望能亲眼看到它。我试着重新安装应用程序,我试着在有网络连接和没有网络连接的情况下启动,我试着强制退出,经过30分钟的努力,我放弃了,我开始回复用户说我没找到具体问题。
直到我再次解锁手机,没有做任何操作,就启动了 Cookpad,我发现APP就像我们的用户所反馈的那样,直接退出到了登录界面!
在那之后,我无法准确的复现该问题,但似乎与暂停使用手机一段时间后再次使用它有关。
缩小问题范围
我担心从 Xcode 重新安装应用程序可能会影响问题的复现,所以在这样做之前,是时候查看代码并试图缩小问题的范围。根据我们的实现,我想出了三个潜在的原因。
- 1、
UserDefaults
中的数据被清除。 - 2、一个意外的API调用返回HTTP 401并触发退出登录。
- 3、
Keychain
抛出了一个错误。
我能够排除前两个潜在的原因,这要归功于我在自己重现该问题后观察到的一些微妙行为。
- 登录界面没有要求我选择地区——这表明
UserDefaults
中的数据没有问题,因为我们的 “已显示地区选择 “偏好设置仍然生效。 - 主用户界面没有显示,即使是短暂的也没有——这表明没有尝试进行网络请求,所以 API 是问题原因可能还为时过早。
这就把Keychain
留给了我们,指引我进入下一个问题。是什么发生了改变以及为什么它如此难以复现?
是什么发生了改变以及为什么它如此难以复现?
我粗略地看了一下发布说明,在谷歌上快速搜索了一下,我找不到任何东西,所以我不得不继续挖掘以更好地了解这个问题。
对Keychain
数据的访问是通过 Security 框架提供的,这是一个众所周知的棘手的问题。虽然有很多第三方库来包装这个框架以使事情变得更容易,但我们还是基于一些苹果的示例代码来维护我们自己的简单封装。
看一下这段代码,我们调用 SecItemCopyMatching 方法来加载我们的访问令牌,它返回数据以及描述结果的 OSStatus 代码。然而,不幸的是,虽然我们的封装器会将不成功的结果与状态代码一起抛出,用于调试,但我们在下一层中却抛弃了这些信息,只是将错误视为 nil
。
我们实行了每周一次的发布计划,多亏了大量的自动化。此时,我们即将发布的下一个截止点(代码冻结)是在第二天。因为我们还没有完全了解这个问题有多普遍,而且我们也不确定是否能够在代码冻结前发布一个修复程序,所以我利用这个机会通过使用Crashlytics(崩溃日志记录工具) 增加一些额外的非致命性日志来解决缺乏可观察性的问题。
这个结果给了我们一些很好的观察点,然后我们可以在接下来的几周内观察。
此时,我能够捕捉到返回的确切错误代码。罪魁祸首是errSecInteractionNotAllowed:
不允许与 Security Server 交互。
这个错误告诉我们,我们正试图在数据不可用的时间点上从Keychain
中读取数据。这通常会发生在你试图读取已存储的数据,并将其可访问性设置为kSecAttrAccessibleWhenUnlocked,而设备仍处于锁定状态。
现在这完全说得通了,但唯一的问题是,在 Cookpad 中,我们只在应用启动时从Keychain
中读取信息,而我的假设是,用户一定是点击了应用图标来启动应用,因此设备在这时应该总是解锁的,对吗?
那么,究竟发生了什么变化呢?即使我能够重现这个问题,我也100%确定我的手机在我点击应用图标的时候是解锁的,所以我不明白为什么会出现这个Keychain
错误。
我决心找到原因,用一个调试工具替换了我们的应用程序的实现,该工具将尝试并记录其生命周期中不同节点的Keychain
读取。
在能够复现问题的场景中,我观察到以下结果:
main.swift
— 失败 (errSecInteractionNotAllowed)AppDelegate.init()
— 失败 (errSecInteractionNotAllowed)AppDelegate.applicationProtectedDataDidBecomeAvailable(_:)
— 成功AppDelegate.application(_:didFinishLaunchingWithOptions:)
— 成功ViewController.viewDidAppear(_:)
— 成功
所以这(一半)解释了它。为了避免在我们的AppDelegate上持有一些隐式解包的可选属性,我们在init()
方法中进行了一些设置,其中一部分涉及从Keychain
中读取访问令牌。这就是为什么读取会失败,以及最终为什么一些用户会发现自己被登出了。
我在这里学到了重要的一课,即我不应该假设受保护的数据在AppDelegate
初始化时是可用的,但说实话,我还是不高兴,因为我不明白为什么它不可用。毕竟,我们已经很多年没有改变过这部分代码了,而且它在iOS 12、13和14系统中一直运行良好,那么是什么原因呢?
寻找根本原因
我的调试界面很有用,但它缺少了一些有助于回答所有问题的重要信息:时间。
我知道在AppDelegate.application(_:didFinishLaunchingWithOptions:)
之前,“受保护的数据” 是不可用的,但它仍然没有意义,因为为了重现这个问题,我正在执行以下操作:
1、启动应用程序
2、简单使用
3、强制退出应用
4、锁定我的设备并将其放置约 30 分钟
5、解锁设备
6、再次启动应用
每当我在第 6 步中再次启动应用程序时,我 100% 确定设备已解锁,因此我坚信我应该能够从 AppDelegate.init()
中的Keychain
读取数据。
直到我看了所有这些步骤的时间,事情才开始变得有点意义。
再次仔细查看时间戳:
main.swift
— 11:38:47AppDelegate.init()
— 11:38:47AppDelegate.application(_:didFinishLaunchingWithOptions:)
— 12:03:04ViewController.viewDidAppear(_:)
— 12:03:04
在我真正解锁手机并点击应用图标之前的25分钟,应用程序本身就已经启动了!
现在,我实际上从未想过有这么大的延迟,实际上是@_saagarjha建议我检查时间戳,之后,他指给我看这条推特。
推特翻译:
有趣的iOS 15优化。Duet 现在试图先发制人地 “预热” 第三方应用程序,在你点击一个应用程序图标前几分钟,通过dyld和预主静态初始化器运行它们。然后,该应用程序被暂停,随后的 “启动”似乎更快。
现在一切都说得通了。我们最初没有测试到它,因为我们很可能没有给 iOS 15 beta 版足够的时间来 “学习” 我们的使用习惯,所以这个问题只在现实世界的场景中再现,即设备认为我很快就要启动应用程序。我仍然不知道这种预测是如何形成的,但我只想把它归结为 “Siri智能”,然后就到此为止了。
结论
从iOS 15开始,系统可能决定在用户实际尝试打开你的应用程序之前对其进行 “预热”,这可能会增加受保护的数据在你认为应该无法使用的时候的被访问概率。
通过等待application(_:didFinishLaunchingWithOptions:)
委托回调来保护自己,如果可能的话,留意UIApplication.isProtectedDataAvailable
(或对应委托的回调/通知)并相应处理。
我们仍然发现了非常少的非致命问题,在application(_:didFinishLaunchingWithOptions:)
中报告isProtectedDataAvailable
为false
,在我们可以推迟从钥匙串阅读的访问令牌之外,这将是一个大规模的任务,现在它不值得进行进一步调查。
这是一个相当难调试的bug,而且行为的变化似乎完全没有记录,这对我来说真的没有帮助。如果你也被这个问题所困扰,请考虑复制FB9780579。
我从中学到了很多东西,我希望你也一样!
更新: 自从发表这篇文章以来,实际上很多人都向我指出了苹果公司关于预热行为的相对完善的文档。然而,其他人也告诉我,他们仍然观察到与某些场景中记录的行为不同的行为,因此请谨慎行事。
关于我们
Swift社区是由 Swift 爱好者共同维护的公益组织,我们在国内以微信公众号的运营为主,我们会分享以 Swift实战、SwiftUl、Swift基础为核心的技术内容,也整理收集优秀的学习资料。
欢迎关注公众号:Swift社区,后台点击进群,可以进入我们社区的各种交流讨论群。希望我们Swift社区是大家在网络空间中的另一份共同的归属。
特别感谢 Swift社区 编辑部的每一位编辑,感谢大家的辛苦付出,为 Swift社区 提供优质内容,为 Swift 语言的发展贡献自己的力量,排名不分先后: