肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

苹果平台 macOS/iOS/watchOS 后台任务的条件与执行

Swift

最近我一直在处理 SleepTap 的后台刷新问题。这个问题看起来只是一个 BGAppRefreshTask 没有执行,后来却牵扯出了 AlarmKit、Apple Watch Smart Session、WatchConnectivity、天气缓存和 WidgetKit。

处理完之后我发现,苹果平台上的“后台任务”其实是一个非常混乱的说法。

Task 可以叫后台任务,BGAppRefreshTask 也叫后台任务,静默推送唤醒应用还是后台任务,AlarmKit 关闭闹钟时启动应用进程,同样可以在后台执行代码。它们听起来像是一回事,实际上完全不是一回事。

判断它们最简单的方式,不是看 API 名字里有没有 Background,也不是看代码里有没有 Task,而是看一件事:

这段代码为什么在此刻有资格运行?

前台执行的“后台任务”

最常见的就是 GCD、OperationQueue 和 Swift Concurrency 的 Task

比如:

Task {
    await updateWeather()
}

这段代码看起来异步执行,不阻塞界面,所以很多人会顺口将它叫做后台任务。但它实际上只是前台应用中的并发任务。

Task.detached 也一样。它只是脱离当前 Actor,并没有顺便向系统申请后台运行资格。应用一旦被挂起,里面的 Task、GCD 队列、Timer 和异步网络请求也会一起停下来。

这不叫系统后台任务,这叫应用进程还活着时,在另外一条执行路径上工作。

iOS

iOS 应用进入后台时,系统会先给应用很短的时间完成收尾。Apple 的文档明确写了,applicationDidEnterBackground 默认大约只有 5 秒,之后应用通常就会进入 suspended 状态。

如果当前工作确实需要多一点时间完成,可以使用:

UIApplication.shared.beginBackgroundTask(...)

但这个 API 不是让应用从明天开始在后台工作,也不是让应用长期运行。它只是说:“用户现在把应用切到了后台,请再给我一点时间,把手头这件事做完。”

Apple 没有承诺固定时长,应用只能读取 backgroundTimeRemaining,并且必须处理 expiration。用完之后不主动结束,系统可以直接终止应用。Apple 关于延长后台执行时间的说明

iOS 26 又增加了 BGContinuedProcessingTask。它适合用户在前台主动开始了一项需要数分钟甚至更久的任务,例如本地模型处理、图像分析,然后用户中途把应用切到后台。这种任务必须由用户操作启动,不能拿来冒充定时后台刷新。Apple 关于 BGContinuedProcessingTask 的说明

watchOS

watchOS 比 iOS 更严格。用户放下手腕后,普通应用很快就会进入后台并被挂起。普通 Swift Task 不会因为它跑在 Apple Watch 上,就自动获得整夜执行资格。

这也是为什么睡眠检测、运动、音频和 Smart Alarm 都需要专门的系统会话。

macOS

macOS 不一样。Mac 应用退到后台之后,通常仍然是一个正常运行的进程。窗口不在最前面,不等于进程被冻结。因此 GCD、Task、Timer 和普通网络请求原则上都可以继续运行。

它没有 iOS 那种统一的“进入后台几秒后挂起”限制。真正的限制来自 App Nap、系统睡眠、内存压力、用户退出应用,以及系统对 CPU、磁盘和定时器的调度。

所以,同样一段 Swift Task,放在 iOS 上不能视为后台保证,放在 macOS 上却可能连续运行很多天。这也是跨平台代码最容易产生误判的地方。

向系统预约的后台任务

另一类后台任务,是应用提前向系统登记:“以后条件合适时,请启动我一次。”

注意,是条件合适时,不是我指定几点就几点。

BGAppRefreshTask

BGAppRefreshTask 适合 iOS 和 iPadOS 的短内容刷新。例如刷新新闻、同步少量数据、更新下一批本地通知。

应用需要:

  • 在应用启动时注册任务标识。
  • Info.plist 中声明允许的标识。
  • 提交 BGAppRefreshTaskRequest
  • 设置 expiration handler。
  • 执行完成后报告成功或失败。

earliestBeginDate 只是最早可以执行的时间,不是预约时间。设置成早上 7 点,真正执行时间可能是 7 点多,也可能是几个小时后,甚至根本不执行。

系统会综合用户打开应用的习惯、电量、网络、后台刷新开关以及应用过去的执行表现决定是否运行。Apple 给这类任务的执行时间最多约 30 秒。Apple 关于后台策略的说明

这次 SleepTap 的教训就是,不能把所有可靠性都押在它上面。它适合兜底,不适合闹钟。

BGProcessingTask

BGProcessingTask 也需要提前注册,但它适合数据库维护、文件处理和比较慢的数据更新。

它可以运行数分钟,不过通常要求设备处于空闲状态。用户重新开始使用设备时,系统可能终止正在执行的 Processing Task。应用仍然必须提供 expiration handler。Apple 关于 BGProcessingTask 的说明

它的时间比 App Refresh 长,但调度时间更不可控。所以让 BGAppRefreshTask 先启动,再提交一个 Processing Task 等第二次唤醒,通常并不会变得更可靠,只是增加了一次不确定调度。

watchOS 后台刷新

watchOS 有自己的 WKApplicationRefreshBackgroundTask。应用可以建议一个未来时间,系统再决定什么时候唤醒。

这类任务通常只有几秒钟。适合读取少量数据、更新 complication 缓存、重新安排下一次任务,不适合执行长时间计算。Apple 关于 watchOS 后台执行的说明

而且,通过 Xcode 连接调试时,应用可能不会正常进入挂起状态。后台任务在调试器下成功,不足以证明自然调度一定成功。这个我现在是深有体会。

macOS 的后台调度

macOS 可以使用 NSBackgroundActivityScheduler 安排低优先级维护工作,例如自动保存、备份、内容获取和更新检查。

它适合可以延后的任务,建议间隔通常在 10 分钟以上。开发者提供 interval 和 tolerance,系统根据能耗、温度和 CPU 状态选择合适时间。Apple 关于 NSBackgroundActivityScheduler 的说明

它和 iOS 的 BGTask 有点像,但 Mac 应用本身通常不会因为退到后台就被挂起。所以它解决的更多是节能调度,而不是争取应用存活。

如果 Mac 应用退出后仍然需要长期服务,就不该指望一个普通 Task 或 Timer。应该注册 Login Item、LaunchAgent 或 LaunchDaemon。macOS 13 之后可以通过 SMAppService 管理,用户也可以在系统设置中关闭这些后台项目。Apple Service Management 文档

Login Item 可以从用户登录后一直运行到退出;LaunchAgent 由 launchd 代表当前用户管理;LaunchDaemon 则是系统级服务。它们没有“30 秒”这种短任务上限,但这已经是后台进程,不是普通应用刷新。

被事件主动唤醒的后台

还有一类不是应用预约系统,而是外部事件发生以后,系统认为应用需要处理,于是临时唤醒应用。

这里的“主动”也不是应用自己偷偷启动自己,而是 APNs、AlarmKit、WatchConnectivity、HealthKit 或 Core Location 主动通知系统。

静默远程通知

服务器可以通过 APNs 发送 content-available 静默通知。系统收到后,可能在后台启动或者唤醒 iOS、watchOS 应用。

应用得到的处理时间是 30 秒。

但静默推送是低优先级通知。系统不保证送达,发送太多还会限流。Apple 建议不要尝试每小时发送超过两三次。如果应用被强制退出,等待中的通知也可能被系统丢弃。Apple 关于后台推送的说明

所以,远程通知适合“服务器的数据变了,通知应用尽快刷新”,不适合精确闹钟,也不适合当成每五分钟执行一次的 cron。

AlarmKit 和 LiveActivityIntent

AlarmKit 的 Stop、Snooze 等按钮可以绑定 App Intent。我们在 SleepTap 中使用的是 LiveActivityIntent

当用户点击停止闹钟时,系统会在不打开应用界面的情况下启动应用进程,然后执行 Intent。也就是说,在 perform() 返回之前,我们可以更新睡眠记录、重新安排通知、更新起床日期,也可以尝试一次天气刷新。

这不是 BGTaskScheduler 调度的任务。它是用户操作触发的系统 Intent 执行窗口。

Apple 没有公开承诺它可以运行多少秒,因此正确做法就是尽快完成。不能在 Intent 中启动一个无限期 Task,然后立刻返回,期待应用继续活着。Intent 返回之后,系统什么时候挂起进程,不归应用决定。Apple 关于 LiveActivityIntent 的说明

还有一个条件很重要:必须真的执行 Intent。闹钟只是响了,但用户没有点击绑定的按钮,不等于我们的代码一定执行。

watchOS Smart Alarm Session

SleepTap 的 Smart Session 使用 WKExtendedRuntimeSession 的 Smart Alarm 类型。

这个机制是 watchOS 明确提供的。应用必须在前台时调用 start(at:) 安排会话,最远只能安排未来 36 小时内的时间。到了安排时间,即使应用已经被系统挂起或者终止,watchOS 也可以重新启动应用并开始这个会话。

Smart Alarm 的执行窗口是 30 分钟。它就是为起床前监测心率和动作、选择合适的唤醒时间设计的。每个应用一次只能安排一个这样的会话,而且必须声明对应的 Background Mode。Apple 关于 Extended Runtime Session 的说明

所以这也不是偷偷运行。它远比拿一个 Timer 在凌晨等着靠谱。

不过,30 分钟不是让应用满负荷计算 30 分钟。CPU 长时间过高,系统仍然可以因为资源超限取消会话。

WatchConnectivity

Apple Watch 把数据传给 iPhone 时,又分成两种情况。

如果 iPhone 可达,sendMessage 可以立即发送消息。Apple 明确说明,从 watchOS 发送消息时,如果配对的 iPhone 应用可达,系统会唤醒它。

如果不可达,可以使用 transferUserInfo。数据交给系统排队,即使发送端应用已经被挂起,传输仍可继续。系统会在条件合适时投递到另一端。

这就是 SleepTap 的 Smart Session 检测到真实起床后,可以让 iPhone 更新七天起床日期和天气的原因。不是手表直接在 iPhone 上运行代码,而是 WatchConnectivity 把事件交给系统,系统再唤醒 iPhone 应用处理。Apple WatchConnectivity 示例

这类回调没有公开的固定长时间保证。watchOS 收到 WatchConnectivity 后台任务时,Apple 的要求是尽快处理并调用完成方法。没有完成,反而会消耗系统分配给应用的后台预算。

HealthKit 与 Core Location

HealthKit 的 Observer Query 可以注册 Background Delivery。当匹配的健康数据发生变化时,HealthKit 会唤醒应用。它的频率是上限,不是保证。例如某些数据最多每小时通知一次,系统还会合并更新。Apple 关于 HealthKit 后台投递的说明

Core Location 也可以因为持续定位、显著位置变化、访问记录或区域监控唤醒应用。但需要正确的定位权限和 Background Mode。iOS 和 watchOS 会挂起普通后台应用,macOS 则不会仅仅因为应用不在前台就挂起它。Apple 关于后台定位的说明

定位后台模式只能用于真的需要定位的功能。为了让天气每天刷新一次而长期运行定位,这显然说不过去吧。

系统代替应用工作的后台

还有一些机制更特殊。真正耗时间的工作不是应用进程完成,而是系统代替应用完成。应用只在开始和结束时参与。

后台 URLSession

后台 URLSession 的上传和下载由另外一个系统进程执行。应用即使被挂起,传输仍然可以继续。下载完成后,系统再恢复或者重新启动应用,把结果交给 delegate。

所以一个 2GB 文件可以下载几个小时,并不意味着应用获得了几个小时 CPU 时间。真正有长时间资格的是系统下载进程,应用只得到一个较短的结果处理窗口。Apple 关于后台下载的说明

如果每次系统唤醒以后,应用又提交一个新的单独下载,系统还会逐步增加延迟。这也是为了防止开发者借后台 URLSession 不断唤醒应用。

WidgetKit

Widget 看起来一直显示在桌面上,很容易让人误以为 Widget Extension 一直在运行。其实没有。

WidgetKit 保存的是 Timeline。系统到了某个时间,直接显示开发者提前提供的 Timeline Entry。只有需要生成新 Timeline 时,系统才短暂启动扩展。

常用 Widget 每天通常有大约 40 到 70 次刷新预算,大致相当于每 15 到 60 分钟一次,但实际次数会根据用户查看频率和系统状态变化。Timeline Entry 最好至少间隔约 5 分钟。Apple 关于 Widget 刷新预算的说明

WidgetCenter.reloadTimelines 也只是告诉系统“数据变了,请考虑重新加载”,不是让 Widget 立即执行。

如果只是倒计时,更不应该每秒刷新 Timeline。WidgetKit 的动态日期文本可以由系统持续绘制,扩展本身根本不需要运行。

WidgetKit 适用于 iOS、iPadOS、macOS 和 watchOS。Apple Watch 的 complication 现在也是同一套思路。

本地通知和 Live Activity

本地通知由系统保存并展示。通知到时间出现,不代表应用被启动了。

Live Activity 也是系统负责显示。通过 ActivityKit 或推送更新 Live Activity,主要是在更新系统维护的界面状态,不等于主应用获得了一次任意后台执行机会。

只有用户点击绑定的 Intent、通知 Action,或者系统明确把事件交给应用处理时,应用代码才有执行资格。

持续型后台模式

音频播放、导航定位、蓝牙通信和 Workout Session 又是另外一种后台。

它们不是“每隔一小时启动一次”,而是功能已经在前台开始,进入后台后仍然需要持续运行。

执行时间通常跟真实会话绑定:

  • 音频播放期间可以继续处理音频。
  • 导航和符合条件的定位会话可以继续接收位置。
  • Workout Session 可以在运动期间运行。
  • 蓝牙外设通信可以在声明对应模式后继续。
  • watchOS Extended Runtime Session 按类型获得 10 分钟、30 分钟或者 1 小时。

这里没有一个统一的 30 秒限制,但也不能拿来执行无关工作。播放一段静音音频,只为了让天气刷新保持活跃,这不叫利用系统能力,这叫滥用后台模式。

系统审核和运行时限制看的都是功能是否匹配,而不是 Info.plist 里有没有成功勾选 Background Mode。

执行时间到底应该怎么看?

整理下来,大致是这样:

机制 主要系统 条件 可用时间
GCD、Swift Task macOS、iOS、watchOS 应用进程仍可运行 没有额外后台资格
iOS 普通退后台 iOS 场景进入后台 默认约 5 秒收尾
beginBackgroundTask iOS 前台工作尚未完成 系统动态决定,无固定保证
BGAppRefreshTask iOS、iPadOS 注册、提交、系统选择执行 最多约 30 秒
BGProcessingTask iOS、iPadOS 注册、设备空闲及相关条件 可运行数分钟,随时可能过期
BGContinuedProcessingTask iOS、iPadOS 26 以后 用户主动在前台开始工作 数分钟或更久,无固定保证
WKApplicationRefreshBackgroundTask watchOS 系统接受预约 通常几秒
Smart Alarm Session watchOS 前台安排,未来 36 小时内 30 分钟
静默推送 iOS、watchOS APNs 投递且系统未限流 30 秒
AlarmKit Intent iOS 用户执行绑定的闹钟操作 未公开固定值,应尽快完成
WatchConnectivity iOS、watchOS 消息到达或系统投递队列 未公开固定值,应尽快完成
HealthKit 后台投递 iOS、watchOS 数据变化、权限和 entitlement 有效 未公开固定值,事件处理应简短
后台 URLSession 苹果平台 系统接管上传或下载 传输可很长,应用回调时间很短
WidgetKit Timeline iOS、macOS、watchOS 系统刷新预算允许 扩展短暂运行,常用 Widget 每天约 40–70 次刷新
NSBackgroundActivityScheduler macOS 应用运行并注册可延后活动 无统一短时上限,受系统节能调度
Login Item、LaunchAgent、Daemon macOS 注册并获得用户或管理员允许 可长期运行,直到退出、注销或被禁用

总结

我现在判断一个后台功能,不再问“这个 API 能不能在后台运行”,而是问下面几件事:

  • 是应用还没有被挂起,还是系统明确重新启动了应用?
  • 是应用进程执行工作,还是系统代替应用完成工作?
  • 这是一次短事件,还是一个持续会话?
  • 系统给的是最早执行时间,还是承诺的执行时间?
  • 任务没有执行时,有没有另一个合法的兜底入口?

SleepTap 的起床日期更新,最可靠的入口不是 BGTask,而是每天必然发生的 AlarmKit Stop 或 Smart Alarm 的真实起床事件。BGAppRefreshTask 和 BGProcessingTask只负责兜底。天气刷新成功一天一次就够了,失败才安排更早重试。

这才是苹果后台任务真正的用法:不要寻找一个可以偷偷常驻的 API,而要把工作放进系统明确提供的执行机会里。

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

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 日

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

  • 7 月 27 日:升级 iOS 26.6。 苹果在这一天发布了 iOS 26.6,官方安全更新页面也记录了这个日期。我当天升级了系统,之后一直没有手动打开 SleepTap。当时当然没觉得有什么问题,后台任务本来就应该由系统调度,我没有理由每天打开一次应用检查它还活着没有。

  • 7 月 28 日到 8 月 2 日:一切看起来都很正常。 这几天我没有收到晒太阳通知,但也没有立刻把它和后台任务联系起来。手表里的起床时间是提前生成七天的,所以即使后台任务从升级之后一次都没成功,前几天仍然不会露馅。这个七天缓存原本是保护用户的,结果也把故障藏了七天。

  • 8 月 3 日:七天用完,问题出现。 我在 Apple Watch 上点击上床按钮,手表突然要求我手动输入起床时间。检查后发现,提前同步的起床日期已经全部耗尽。我手动打开一次 SleepTap,日期马上补齐到 8 月 10 日。于是我们先确认了日期生成、前台刷新和 Watch 同步都正常,把问题缩小到后台执行。同时也提出了第一个怀疑:会不会是升级系统之后没有再手动启动过应用,导致后台注册失效?这和 API 应有的行为不符,但和实际现象很像,所以先不急着下结论。

  • 8 月 4 日:继续等,不重启。 手表上的日期仍然只到 8 月 10 日,没有刷新出 11 日。此时有两个选择,要么重启手机,要么再保留一天自然观察。我选择先等,因为一旦重启,系统调度状态就变了。假如第二天任务自己恢复,我们还能证明它只是延迟;马上重启反而会把这个证据毁掉。

  • 8 月 5 日:等待无效,开始重启实验。 又等了一天,后台任务还是没有执行。自然恢复这条路基本可以排除了,我于是重启 iPhone,再给系统一天时间。这里仍然没有立刻改代码,因为重启和改代码一起做,即使恢复了,也不知道是谁起的作用。古法排错虽然慢,但至少不会自己骗自己。

  • 8 月 6 日:重启也没用,开始减少变量。 重启后又观察了一天,日期仍然没有向后延长。我修改代码、重新安装并运行,还升级了应用版本,避免系统如果真的针对旧版本保留了某种调度判断,新代码仍然继承旧状态。同时暂停天气后台任务,只注册起床日期这一个任务。可它还是没有执行。检查天气后又发现,晒太阳通知也已经多天没出现,这说明不是单独一条日期逻辑坏了,两个后台处理器都没有留下成功执行的迹象。也正是在这一天,我决定另建一个只有后台任务和本地通知的测试应用,和 SleepTap 并行观察。

  • 8 月 7 日:最小测试应用也沉默。 新应用没有天气、手表和数据库,处理器只要被调用,就写一条记录并发送通知。我手动打开过,也手动提交过请求,等了几个小时仍然没有通知。我一度担心从 Mac 断开 iPhone 会不会让应用也跟着断开,后来排除了:断开的只是 Xcode 调试连接,手机上的应用和已经提交给系统的请求都还在。新应用也失败,本来很像是 iOS 26.6 把整个后台调度弄坏了。可问题是,新应用照抄了 SleepTap 的 SwiftUI .backgroundTask 注册方式。两个使用同一种可疑写法的应用一起失败,根本不能证明系统不调度,只能证明这条注册路径值得怀疑。

  • 8 月 8 日:对照苹果 Demo,问题解决。 我把苹果官方示例和我们的代码逐项比较,终于注意到官方 Demo 并没有使用 SwiftUI .backgroundTask,而是在 AppDelegate 的 didFinishLaunchingWithOptions 中直接调用 BGTaskScheduler.register。再结合苹果 DTS 对 SwiftUI 注册时序问题的说明,我把测试应用改成和官方 Demo 一样的注册方式。14:00:53 提交请求,14:05:53 开始具备执行资格,14:09:09 处理器真正进入,通知也通过 iPhone Mirroring 显示在 Mac 上。证据已经足够,我随后用同样方式修改 SleepTap,把天气合并进起床日期任务,并保留用户手动打开应用时的天气刷新。

从升级到发现问题,刚好七天。从发现问题到真正解决,又用了五天。期间等待过、重启过、重装过、升级过版本、减少过任务,也怀疑过系统惩罚和 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 的古法注册。

第三方应用使用DocumentGroup时,模仿Pages的工具栏

SwiftUI

最近,我在开发iOS的Markdown应用的时候遇到一个问题,应用是使用DocumentGroup开发的,但是默认会显示标题,从而导致留给工具栏的空间太小,工具栏的图标经常被折叠起来。

IMG_1181

我一开始只是想把后退去掉,换成三个点的菜单,然后去掉标题。结果发现后退去不掉。于是我就想看看苹果自己是如何做的。于是我打开了苹果自己的Notes和Numbers。不过Notes其实是有内部库的,于是主要参考的Numbers。

IMG_1184

我把Numbers截图给AI,说想要弄一个这个风格的。结果AI搞不定。它虽然设置了标题为空,但是标题始终显示。于是我有把这个描述给Grok,Grok说,这是因为DocumentGroup自动包含了一层Navigation,你需要将它禁用。结果我禁用了,还是不行。又去问Grok,它又说,这是因为iOS的某些版本,有bug,不执行这个操作。但是网友总结了三种办法,1,2,3,然后挨个试。结果第一种就可以了。

这个是我应用最终的效果。

IMG_1205

最核心的代码只需要如下的部分。

截屏2026-04-12 10.50.33

Swift Package应用签名与公证实战总结

Swift Packages

问题背景

fork 了一个第三方 macOS 应用,原 release 版本未签名,但提供了源码,为Swift Package格式,需要重新签名并公证后分发。


遇到的坑

1. Swift Package Manager 无法启用 Hardened Runtime

问题:使用 swift build 构建的可执行文件,无法通过签名添加 hardened runtime。公证时一直报错:

The executable does not have the hardened runtime enabled.

原因:Swift Package Manager 构建时不会启用 hardened runtime,签名时添加也不被认可。

解决:使用 XcodeGen 生成 Xcode 项目,在项目配置中启用。

2. Apple Development 证书无法公证

问题:最初只有 Apple Development 证书(用于开发调试),公证时报错:

The binary is not signed with a valid Developer ID certificate.

原因:公证必须使用 Developer ID Application 证书,不能用 Apple Development。

解决:在 Apple Developer 账号中添加 Developer ID Application 证书。

3. 时间戳问题

问题:签名缺少安全时间戳:

The signature does not include a secure timestamp.

解决:在签名配置中添加 --timestamp 参数。

4. get-task-allow entitlement 问题

问题:分发版本包含开发用 entitlement:

The executable requests the com.apple.security.get-task-allow entitlement.

解决:在 entitlements 配置中明确设置 com.apple.security.get-task-allow: false


经验教训

证书类型

证书类型 用途
Apple Development 开发调试,不能公证
Developer ID Application 分发和公证 ✅

签名配置要点

  1. Hardened Runtime - 必须启用
  2. 时间戳 - 必须添加
  3. entitlements - 分发版本禁止包含 get-task-allow
  4. 使用 Manual 签名 - 避免自动签名冲突

最佳实践(简单有效)

1. 使用 Xcode 项目而非 SPM

# 安装 XcodeGen
brew install xcodegen

# 配置 project.yml(关键设置)
targets:
  YourApp:
    settings:
      base:
        ENABLE_HARDENED_RUNTIME: YES
        CODE_SIGN_STYLE: Manual
        CODE_SIGN_IDENTITY: "Developer ID Application: Your Name (TEAM_ID)"
        OTHER_CODE_SIGN_FLAGS: "--timestamp"
    entitlements:
      properties:
        com.apple.security.get-task-allow: false

# 生成项目
xcodegen generate

# 构建
xcodebuild -project YourApp.xcodeproj -scheme YourApp -configuration Release build

2. 验证签名

codesign -dvv YourApp.app/Contents/MacOS/YourApp

确认输出包含:

  • flags=0x10000(runtime) - hardened runtime 已启用
  • Timestamp=... - 有时间戳

3. 公证流程

# 1. 创建 zip
zip -r YourApp.zip YourApp.app

# 2. 提交公证(需要 App 专用密码)
xcrun notarytool submit YourApp.zip \
  --apple-id "your@email.com" \
  --password "app-specific-password" \
  --team-id "TEAM_ID"

# 3. 等待完成
xcrun notarytool wait <submission-id> --apple-id "your@email.com" --password "password" --team-id "TEAM_ID"

# 4. 附加票据
xcrun stapler staple YourApp.app

总结

最简单有效的方式

  1. XcodeGen 生成 Xcode 项目(不要用纯 SPM)
  2. project.yml 中配置好签名和 hardened runtime
  3. xcodebuild 构建
  4. notarytool 公证

不要试图用 swift build + 手动签名的方式来处理需要公证的分发版本,SPM 的构建产物不兼容。

NavigationSplitView的一些奇技淫巧

SwiftUI

Sidebar按钮隐藏起来的正确做法

使用

.toolbar(removing: .sidebarToggle)

可以隐藏侧边栏切换按钮。但是注意,这个不是放在作用在NavigationSplitView上面的。而不是作用在sidebar的视图上面。即必须按照如下的方式使用:

NavigationSplitView(columnVisibility: $columnVisibility) {
    sidebar
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

隐藏Sidebar的正确做法

NavigationSplitView是三栏的,但是如果你要使用两栏,则有两种方式。

使用sidebar

使用sidebar的好处是,sidebar可以隐藏,也可以显示。
使用sidebar+detail的方式。

不使用sidebar

如果你希望永远显示为两栏,不希望隐藏任何一个栏隐藏,那么使用此方式。

NavigationSplitView(columnVisibility: .constant(.doubleColumn)) {
    EmptyView()
        .toolbar(removing: .sidebarToggle)
    } content: {
    content
    } detail: {
    detail
}

这个的重点在于,通过sidebar设置隐藏了切换sidebar按钮。同时,选择两栏模式,默认不显示sidebar。这样sidebar就无法被调用出来了。并且一直是两栏。

配合Settings使用

在使用Settings时候,包含sidebar的NavigationSplitView的sidebar按钮会导致出现问题。因此,必须使用上面的第二种方式,隐藏sidebar,并且使用一直存在的两栏。

解决Xcode Cloud无法enable Swift Package包中的宏的问题

Swift Packages

最近我在做应用适配iOS/macOS 26的特性。今天在Xcode Cloud打包的时候遇到打包失败的错误。

Macro “DefaultsMacrosDeclarations” from package “Defaults” must be enabled before it can be used.

这个问题是我应用所使用的第三方的库 “Defaults”在其内部使用了宏。这个宏在Xcode本地编译时,需要用户手动点击确认才能继续。但是Xcode Cloud中,没有点击确认的位置。因此,就无法完成打包应用的过程。

解决办法

通过运行脚本的方式,在克隆完文件夹之后,运行脚本,规避掉对于宏的验证。

#!/bin/sh 
defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES

必须在Xcode中的根部位置,创建一个新组,命名为ci_scripts,然后在这个组中创建ci_post_clone.sh,内容是上面的内容。

必须在Xcode的根部位置创建组,并且命名也不能错。

小插曲

我其实最开始是像GPT 4.1提出了这个问题。GPT 4.1的解答只对了一半。它提出了创建文件夹和脚本,文件夹是正确的,脚本名字是错误的。并且它也没有告诉需要在Xcode中创建组,而只是说在项目的根目录创建就可以。最后,它创建的脚本内容不完全正确。

之后我使用了Google搜索。Google搜索默认的AI总结的是正确的,但应该就是从stackoverflow里的答案总结的。我最后是看的SO里的回答,进行的总结。

另外,我建议你完整阅读下面的第一个引用。我使用了里面最为简便的方案。而非最安全的。也许你看了之后,会选择一条不同的手段。

引用文献

How do I trust a swift macro target for Xcode Cloud builds?

Writing custom build scripts

什么?AccentColor又闹幺蛾子了?

SwiftUI

一年以前,我就踩过一次AccentColor的坑。没想到,一年之后我又掉进来了。

SwiftUI下,TextField诡异失去Focus下样式的问题

事情的起因是这样的。因为苹果的新系统发布了嘛。我不能免俗的也要改进我之前的应用,添加对于新系统特性的一些支持之类的。

但是我在修改代码后测试时发现,当使用ZStack模拟弹窗之后,弹窗后面的视图的颜色会出错。我一开始以为是ZStack的问题,于是将模拟弹窗改成了.fullScreenCover的方式。这个问题在当时看起来时解决了。但是今天我在使用中发现,这个问题又重新出现了。

于是我在Google上搜索了一下,没想到这还是一个SwiftUI长期存在的一个问题。

How do I stop the AccentColor from turning Gray when a sheet is being presented?

原来在SwiftUI中实际使用时。原本应该一致的Color.accentColor和Color("AccentColor")在实际使用中是不一致的。说得更具体些,就是Color.accentColor会使用应用设置和用户的系统设置。而Color("AccentColor")则是将AccentColor作为颜色资源从Asset文件夹直接读取。因此,虽然它的名字也叫"AccentColor",但是实际上它只是名字叫"AccentColor"的一个颜色,你改成别的名字,比如"MyAppColor"也是一样的。虽然这样会失去Color.accentColor一些独特的个性,但是能保证颜色的一致,即颜色不会莫名其妙的改变。

系统弹sheet的时候,Color.accentColor会改变的问题,应该就是sheet本身可能存在某种机制,将应用内设置的Color.accentColor从Asset文件夹设置的内容,改成了系统默认设置的内容。比如下图红色圈起来的部分,就是系统允许用户自定义用户AccentColor偏好的地方。

accent_color

不要使用NSDecimalNumber.intValue

Swift

今天遇到了一件怪事。函数返回值始终是0,但是中间计算过程又没啥问题。

最终判定,就是在计算百分比时将Decimal计算的百分比,通过(decimal as NSDecimalNumber).intValue进行输出时出错的。

深入分析具体的原因,是因为Int的精度不足以转换NSDecimalNumber,所以就自动设置为0了。这个对于普通人来说,可能很难理解。毕竟结果也才46,怎么说Int的精度不足呢?但是事实就是如此,你可以简单地认为就是46后面的小数点之后的位数过多了。

解决的办法有有两个,一种是直接使用.doubleValue,然后取整。

let intValue = Int(nsDecimalNumber.doubleValue)

另一种则比较复杂,使用了专门的rounding函数。

let rounded = nsDecimalNumber.rounding(accordingToBehavior: NSDecimalNumberHandler(
    roundingMode: .plain,
    scale: 0,
    raiseOnExactness: false,
    raiseOnOverflow: false,
    raiseOnUnderflow: false,
    raiseOnDivideByZero: true
))
let intValue = rounded.intValue