SleepTap

警告,iOS 26.6 之后 SwiftUI 注册的后台任务有问题

返回产品首页

最近我在 SleepTap 上遇到了一个很隐蔽的问题。问题最早不是在 iPhone 上发现的,而是在 Apple Watch 上。

我在手表上点了上床按钮,手表突然让我手动输入起床时间。我一开始还觉得奇怪,仔细一看,原来手机同步给手表的起床日期已经用完了。SleepTap 会提前生成七天的起床时间,只要后台任务正常执行,这七天就会不断向后延长。现在日期彻底用完了,也就是说,至少有一个星期没有一次成功的后台刷新走到生成日期这一步。

这件事刚好发生在我把 iPhone 升级到 iOS 26.6 之后。升级之后,我也一直没有手动打开过 SleepTap。所以我的第一反应是,系统升级后没有启动过应用,后台任务是不是就不工作了?按 API 的设计,不应该这样。已经注册和提交的后台任务由 iOS 调度,又不是靠用户每天给应用上香才能继续运行。

可我手动打开一次 SleepTap,起床日期立刻同步到了手表,一直排到 8 月 10 日。这至少证明了三件事:日期生成逻辑没坏,手机和手表的同步没坏,前台刷新也没坏。坏掉的只剩后台执行这条路。

等待、重启、重装,都没用

我没有马上改代码,而是先等了一天。结果日期还是停在 8 月 10 日。接着我重启 iPhone,又等了一天,还是不执行。后来我修改了一部分代码,重新安装并运行,结果依旧没有后台记录。

这时候很容易开始怀疑玄学。比如 iOS 会根据应用过去的执行情况分配后台额度,SleepTap 会不会因为以前注册了两个 App Refresh 任务,被系统悄悄惩罚了?以前系统睁一只眼闭一只眼,现在 iOS 26.6 不提示错误,直接一个都不给执行?这听起来有些像奇谈怪论,但后台调度本来就是黑盒,不做实验,谁也不能说它一定不可能。

SleepTap 当时有两个后台刷新任务,一个更新起床日期,一个更新天气和晒太阳通知。我先把天气任务停掉,只留下日期任务,又把应用版本升了。结果还是不执行。也就是说,就算所谓的惩罚存在,至少也不是简单地删掉一个任务就会恢复。

我还担心过另一个问题:测试时 iPhone 是通过 Xcode 安装的,如果从 Mac 断开,应用会不会也跟着“断开”?其实不会。任务提交给的是 iPhone 上的 BGTaskScheduler,Mac 和数据线只负责安装、调试和读取日志。拔掉数据线会断开调试器,不会删除手机系统保存的请求。

单独写一个应用,结果它也不执行

既然 SleepTap 身上的历史包袱太多,我干脆新建了一个测试应用。它只做一件事:注册一个 BGAppRefreshTaskRequest,后台处理器被调用之后立刻发一条本地通知,并把 handler.entered、通知添加和完成状态写进本地记录。

这已经简单到不能再简单了。没有天气,没有手表,没有 SwiftData,也没有复杂的网络请求。如果它能执行,说明 SleepTap 可能真的被系统区别对待。如果它也不能执行,那就不是 SleepTap 自己的问题。

结果,这个新应用也一直没有执行。

我手动打开过一次,通知权限是 true,提交 API 也明确返回成功。请求早就超过了 earliestBeginDate,但记录里一次 handler.entered 都没有。不是通知被系统隐藏了,而是处理器根本没有被调用。

到这里,我原以为已经证明了 iOS 26.6 的后台调度整体坏了。可我又想了想,这个所谓的“独立测试应用”,其实并不独立。它和 SleepTap 使用的是同一种注册方式:

.backgroundTask(.appRefresh(identifier)) {
  await handleAppRefresh()
}

如果坏掉的是 SwiftUI 的 .backgroundTask,那么两个应用当然会一起失败。这不叫对照实验,这叫把同一份可疑代码抄了两遍。

苹果自己的示例不是这么写的

我接着看了苹果的官方示例 Refreshing and Maintaining Your App Using Background Tasks。这个示例是 WWDC 2019 的,使用的是古法 AppDelegate:在 application(_:didFinishLaunchingWithOptions:) 中调用 BGTaskScheduler.shared.register,任务到期时取消操作,完成时显式调用 setTaskCompleted(success:)

我原本以为这只是 UIKit 和 SwiftUI 的写法差异。SwiftUI 的 .backgroundTask 本来就是官方 API,凭什么旧写法可以,新写法不行?可苹果开发者论坛里早已有一串相关讨论。在 BGTaskScheduler crashes on iOS 18.4 这个帖子里,苹果 DTS 工程师确认 SwiftUI 的后台任务集成存在注册时序问题,并建议用 UIApplicationDelegateAdaptor 回到 AppDelegate,在 didFinishLaunchingWithOptions 中尽早注册。帖子讨论的是 iOS 18.4 上的崩溃,而我遇到的是 iOS 26.6 上悄悄不执行,表现并不完全一样。但它们指向的是同一个危险点:SwiftUI 可能太晚才替应用注册处理器,而任务没有正确注册,就不可能被执行。

还有一个很容易误判的地方。earliestBeginDate 不是预约时间,只是“不早于这个时间”。苹果的文档写得很清楚,系统不保证在指定时间启动。所以等一小时没执行,不能立刻说有 bug。可连续多天不执行、七天日期全部耗尽,再加上测试应用完全没有 handler.entered,这就不能只用“系统会择机调度”来搪塞了。

五分钟之后,通知来了

我把测试应用改成 AppDelegate 注册,保留原来的 identifier、通知和事件记录,只替换注册路径。为了不用再傻等一天,我把第一次请求的最早时间改成五分钟后。任务真正执行之后,再续订到一小时后。五分钟并不是要求系统五分钟准时执行,只是让它尽快进入可执行状态。

手机里的记录很清楚:14:00:53 提交请求,14:05:53 开始具备执行资格,14:09:09 系统调用 handler。同一秒,应用提交了下一次请求,加入本地通知,最后记录 handler.completed。整个链条走完,没有过期,也没有失败。

macOS 随后通过 iPhone Mirroring 显示了这条来自 iPhone 的通知:后台任务已执行,时间 14:09:09。当时 iPhone 并不依赖 Xcode 的调试连接。这也顺手排除了我之前关于“断开 Mac 会不会导致应用断开”的担心。

严格地说,这还不是完美的单变量实验,因为我同时改变了注册方式和第一次的等待时间。但原来的 SwiftUI 请求已经超过最早时间几个小时,甚至几天,也从未进入处理器;AppDelegate 版本第一次自然调度就完整执行。对于工程判断来说,证据已经足够了。继续等,不会让问题变得更正确。

我最后怎样修改 SleepTap

我把 SleepTap 也改成了 AppDelegate 早期注册,版本升到 1.6.3。现在应用只保留一个系统后台任务,最早时间仍然按照用户的起床时间加一小时计算。处理器进入之后先续订下一次任务,再更新睡眠通知和手表上的起床日期,最后尝试刷新天气和晒太阳通知。

天气原来有自己的 App Refresh 任务,现在不再单独注册。它仍保留原来的业务逻辑:只在 6:00 到 18:00 之间处理,检查六小时缓存和位置变化,确实需要时才请求天气。用户手动打开 SleepTap 时也会检查天气,缓存过期就刷新,缓存有效就直接重新计算晒太阳计划。

这样做不是因为两个后台任务在 API 上一定不允许,而是我已经没有理由继续让两个不确定因素同时存在。一个任务负责唤醒,里面顺序执行日期和天气逻辑,出了问题也更容易看日志。这叫减少变量,不叫架构升级。

从 7 月 27 日到 8 月 8 日

回头看,这个问题的时间线其实很完整。不是某一天突然灵光一现找到了答案,而是每天排除一点,最后才发现,最值得怀疑的恰恰是看起来最不值得怀疑的那一层。

从升级到发现问题,刚好七天。从发现问题到真正解决,又用了五天。期间等待过、重启过、重装过、升级过版本、减少过任务,也怀疑过系统惩罚和 Xcode 连接。结果真正的答案不在这些地方,而在苹果自己的 Demo 里:SwiftUI 提供了更漂亮的注册方式,但至少在我的 iOS 26.6 设备上,真正可靠的仍然是 AppDelegate 里的古法注册。

总结

我现在能确认的是:在我的 iPhone SE 3、iOS 26.6 上,两个使用 SwiftUI .backgroundTask(.appRefresh(...)) 注册的应用都没有自然进入处理器;换成 AppDelegate 中直接调用 BGTaskScheduler.register 后,测试应用第一次就成功执行。

我不能据此宣布所有 iOS 26.6 设备都会中招,也不能证明它和 iOS 18.4 的论坛问题是同一个 bug。但如果你的后台任务在升级之后突然消失,提交又不报错,别只盯着 earliestBeginDate,也别急着怪系统在“惩罚”应用。先记录 handler.entered,再写一个最小测试应用,最后把 SwiftUI 注册换成 AppDelegate 注册做对照。

结论:iOS 26.6 之后,SwiftUI 注册的 App Refresh 后台任务不值得信任。至少在苹果修复并解释这个问题之前,我会使用 AppDelegate 的古法注册。