肇鑫的技术博客

肇鑫 / 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 的古法注册。

Finder快速预览使用扩展模式苹果没告诉你的事

Finder Quick Look

最近我给 Writer 的 Finder 快速预览增加了 Mermaid 支持。Writer 应用内本来就使用 WKWebView 预览 Markdown,Mermaid 也是通过本地 JavaScript 渲染的。既然应用里能显示,那么把同一套渲染代码放进 Quick Look,不就行了吗?

我一开始也是这么想的。

Writer 原来的 Quick Look 使用 data-based preview。扩展读取 Markdown,生成完整的 HTML,然后通过 QLPreviewReply 把 HTML 交给 Finder。标题、列表、表格、代码块这些静态内容都没问题,但 Mermaid 不一样。Mermaid 代码块不是一张现成的图片,需要 JavaScript 解析代码,再生成 SVG。

问题就是,Finder 拿到 HTML,不等于它会像 Safari 或 WKWebView 那样执行里面的 JavaScript。

所以我决定把 Quick Look 改成 view-based extension。也就是不再把一份 HTML 数据交给 Finder,而是由扩展自己提供一个 NSViewController,里面放一个 WKWebView

这下总该和 Writer 应用内的预览一样了吧?

我修改了 Quick Look 扩展以后,在 Finder 中预览 Mermaid 文档,看到的仍然是一个普通代码块。Mermaid 源码还在,流程图没有生成。

这很像是 JavaScript 没有执行。但继续检查之前,必须先确认一件更基础的事:Finder 调用的到底是不是我刚刚修改的扩展?

因为 /Applications 里还安装着旧版 Writer。旧版扩展本来就能把 Markdown 转成 HTML,只是会把 Mermaid 当作普通代码块。所以当时看到的结果并不能证明新代码失败了,只能证明 Finder 找到了某个 Writer Quick Look 扩展。

我删除了 /Applications 里的 Writer。删除之后,Finder 一度退回了系统的纯文本预览。这证明刚才把 Mermaid 显示成代码块的,的确是旧版 Writer。

之后我安装了包含新版 view-based Quick Look 扩展的测试版。Finder 不再显示纯文本,右上角也出现了“Open with Writer”。这说明新版扩展终于被调用了。

可这一次,预览窗口变成了一片空白。

所以这里其实有两个完全不同的问题。Mermaid 显示成代码块,是 Finder 调用了旧版 Writer。真正调用新版扩展之后,遇到的问题才是 WKWebView 白屏。

如果不先排除旧版扩展,我很可能会继续在 Mermaid 初始化代码里找问题。可实际上,前一个结果根本不是新代码生成的。

写一个什么都不做的 Quick Look

我临时创建了一个最小的 macOS 应用,只注册一种自定义文件类型,再给它加入一个最简单的 Quick Look view extension。

这个扩展不解析 Markdown,不读取主题,也没有 Mermaid。它只显示一个原生的 NSView,上面写着:

Quick Look view extension is working

安装之后,Finder 正确显示出了这个界面。

这说明 view-based Quick Look 本身没有问题。Finder 能加载扩展,扩展也能提供自己的视图。

不过这还不够。Writer 真正需要的是 JavaScript,不是一个静态的 NSTextField。于是我又把探针改成 WKWebView,加载一段完全内嵌的 HTML,并运行一小段 JavaScript,让页面显示:

JavaScript check: 6 × 7 = 42

结果,探针也变成了白色。

到这里,问题已经很清楚了。不是 Markdown,不是 Mermaid,也不是 Writer 的主题代码。只要在 Quick Look 扩展里换成 WKWebView,页面就出不来。

本地 JavaScript 为什么需要网络权限?

我查看了系统日志,终于看到了真正的错误:

Application does not have permission to communicate with network resources.
Invalid connection identifier (web process failed to launch)

这就很有意思了。

我加载的是 loadHTMLString,HTML 在内存里,JavaScript也在应用包里,没有 CDN,没有远程图片,甚至连一个 HTTP 请求都没有。你告诉我缺少网络权限?

可日志里写得很清楚,不是页面加载失败,而是 WebKit 的 Web Content 进程根本没有启动成功。

我于是给 Quick Look 扩展加入了:

<key>com.apple.security.network.client</key>
<true/>

重新编译,重新安装。

探针页面显示出来了,JavaScript 也成功计算出了 42

故障排除。

按照苹果文档,这个 entitlement 表示沙盒应用可以主动建立网络连接。从字面上看,一个完全离线的 WKWebView 不应该需要它。可在我当前使用的 macOS 版本和 Quick Look 扩展环境中,没有这个权限,WebKit 的内容进程就无法正常启动。

我不敢说所有 macOS 版本、所有 Quick Look 扩展都一定如此。但至少在实际测试中,这不是推测,也不是所谓“可能和沙盒有关”。系统日志和最小探针已经把因果关系摆在这里了。

苹果告诉你 network.client 是用来联网的,却没有告诉你,在 Quick Look 扩展里,即使只加载一段本地 HTML,它也可能决定 WKWebView 能不能活着启动。

这才是这次最坑的地方。

给了网络权限,文档不就可以偷偷联网了吗?

我本来不愿意给 Quick Look 增加网络权限,就是因为快速预览的内容来自用户文件。Markdown 里可以写远程图片,也可以写 HTML。如果直接让这些内容进入一个能够联网的 WKWebView,那么用户只是在 Finder 里按了一下空格,文档就可能向外部服务器发出请求。

这肯定不行。

但这里要分清两件事。扩展拥有网络 entitlement,是为了让 WebKit 的进程能够正常启动,并不意味着页面里的内容也必须获得网络访问能力。

Writer 的 Quick Look 页面加入了严格的 Content Security Policy:

default-src 'none';
img-src data: blob:;
script-src 'nonce-writer-mermaid';
connect-src 'none';
frame-src 'none';
object-src 'none';

也就是说,页面默认什么都不能加载。图片只允许 data:blob:,脚本只允许 Writer 自己注入并带有指定 nonce 的 Mermaid 脚本,网络连接直接设为 none

在 HTML 进入 WKWebView 之前,扩展还会清理 scriptiframeobject、事件属性和其他主动内容。普通 Markdown 的本地附件会转换成 data: URL,远程图片则继续显示占位内容。

这叫给 WebKit 启动进程的权限,不叫给 Markdown 文件自由上网的权限。两者看起来差不多,实际完全不是一回事。

还有一个容易被忽略的问题

改成 view-based extension 以后,Quick Look 的入口也变了。

原来的实现是生成一个 QLPreviewReply。现在则是由 QLPreviewingController 实现异步的 preparePreviewOfFile(at:),自己创建并维护 WKWebView

如果调用 loadHTMLString 以后立刻告诉 Finder“准备好了”,WebView 其实可能还没有完成导航。于是我让扩展等待 WKNavigationDelegate.didFinish,失败、导航失败或者 Web Content 进程终止时,也会结束等待并返回真正的错误。

这不是为了架构漂亮,而是 Quick Look 的生命周期和普通应用窗口不同。应用里的 WKWebView 一直活着,晚一点显示也没关系。Finder 是来向扩展要一份预览的,扩展什么时候回答“准备完成”,本身就是协议的一部分。

同时,Mermaid 的 Tiny 构建和许可证也必须真正进入 .appex 的资源目录。放在主应用里不算。Finder 运行的是扩展进程,它不会因为资源在 Writer.app 的另一个角落,就自动帮你找到。

最后怎么确认不是自我感觉良好?

这类问题只看 Xcode 构建成功没有意义。Quick Look 扩展编译成功,不等于 Finder 正在使用它。Finder 正在使用它,也不等于 WebKit 进程成功启动。WebKit 启动了,也不等于 JavaScript真的执行了。

我最后做了几层验证:

  1. 删除已安装的 Writer,确认 Finder 退回系统纯文本预览。
  2. 安装最小探针,确认原生 Quick Look view extension 能正常显示。
  3. 给探针加入 WKWebView 和 JavaScript,复现白屏。
  4. 加入 network.client 后,确认 JavaScript 成功算出 6 × 7 = 42
  5. 安装新的 Writer,在 Finder 中预览 Mermaid 流程图,确认显示的是生成后的 SVG,而不是 Mermaid 源码。
  6. 再预览我实际使用的 iCloud Markdown 文档,确认标题、段落、编号和列表都正常。
  7. Writer 和 Quick Look 两个 scheme 都重新构建,16 个 Mermaid 测试全部通过。

其中最有价值的不是后面的测试全部通过,而是那个什么都不做的探针。没有它,我很容易继续怀疑 Markdown 渲染器、主题样式、资源路径或者 Finder 缓存。它把几十个变量砍到只剩一个:Quick Look 扩展里的 WKWebView 为什么启动不了?

总结

这次遇到的坑可以归纳成三件事:

  1. data-based Quick Look 能显示 HTML,不代表它会按照应用内 WKWebView 的方式执行 JavaScript。
  2. view-based Quick Look 可以使用 WKWebView,也可以运行 Mermaid,但在实际测试的系统环境中,即使内容完全离线,扩展仍然需要 com.apple.security.network.client,否则 Web Content 进程可能直接启动失败。
  3. 给扩展网络 entitlement 之后,仍然要使用 CSP 和 HTML 清理限制文档内容。权限是让 WebKit 活下来,不是让预览文件随便联网。

所以,Finder 的快速预览使用扩展模式以后,运行起来的确可以和应用内预览很接近。但这个“很接近”不是把 WKWebView 塞进 NSViewController 就结束了。

真正的结论是:Quick Look 扩展可以执行 JavaScript,也可以渲染 Mermaid。之前那片空白不是平台不支持,而是 WebKit 的进程根本没有成功启动。苹果没告诉你的,恰恰就是这一点。

苹果表上SleepTap的Smart Alarm的Session在Stop之后,对应的Smart Stack却没有立刻刷新问题的追踪

watchOS

前天早上,SleepTap 的 Apple Watch Smart Alarm Session 把我叫醒了。系统界面给了两个选择,一个是 Open,一个是 Stop。以往我都是用Open,这次我特意没有选择Open,而是选择了 Stop。

Session 的确停了。但是我转动表冠打开 Smart Stack,发现 SleepTap 仍然显示 Wake Up。后来我手动进入 SleepTap,应用里已经没有 Wake Up 了。再回到 Smart Stack,它也变成了当晚 23:00 上床。

我当时认为,Stop 只是终止了 Session,其余步骤是在我进入应用后才执行的。这是我根据按下 Stop 后看到的现象作出的判断。但我并不确定,于是询问 AI。

Stop 到底做了什么?

AI和我讲,Apple Watch 的系统 Stop 会使 WKExtendedRuntimeSession 失效,然后调用:

extendedRuntimeSession(_:didInvalidateWith:error:)

SleepTap 收到这个回调以后,会检查 Session 是否正常结束、之前是否已经通过 notifyUser 叫醒用户,以及这次失效是不是应用自己触发的。如果这些条件都符合,就会把它当成用户点击了系统 Stop,自动记录起床。

记录起床以后,SleepTap 还会写入 HealthKit,清空本轮睡眠时间线,通知手机取消对应的起床闹钟,然后重新生成 Smart Stack 使用的数据,最后调用 reloadAllTimelines() 请求 WidgetKit 刷新。

这套后台路径本来就存在,并不是一定要打开应用。

不过代码里也的确有一个前台兜底。如果系统已经叫醒了用户,但是后台处理没有完成,那么用户下次进入 SleepTap 时,应用会再次检查状态并补记起床。这样一来,前天的现象其实存在两种解释:一种是后台处理没有完成,打开应用以后走了兜底;另一种是后台早就处理完了,只是 Smart Stack 还在显示旧内容,打开应用以后又请求了一次刷新。

只看 Smart Stack,根本分不清。于是我和AI说,那我再试一晚,明天不手动进应用,看看Smart Stack到底能不能更新。AI说,应该这么做,并且建议我分不同时段,多次观察,每次截图。

第二天的古法验证

今天的 Session 大约在凌晨 4:30 开始运行,我在 4:33 左右点击了 Stop。之后我一直没有打开 SleepTap。期间我多次观察,在几分钟后,半小时后都观察了,看到的都还是Wake Up。到了 5:33,我发现 Smart Stack 已经从 Wake Up 自动变成了 Bed Time 23:00

这至少证明了一件事:不打开应用,后台链路也能完成。前台兜底并不是必需的。

但我很快又产生了另一个判断:难道 extendedRuntimeSession(_:didInvalidateWith:error:) 花了接近一个小时才执行?

我询问AI,AI建议我去看健康应用,根据健康应用的起床时间来辅助判断。

我打开健康 App,里面记录的起床时间是 4:33,和点击 Stop 的时间基本一致。SleepTap 在后台记录起床时,使用的是当时的当前时间。如果这段代码真的拖到 5:33 才执行,健康里的起床时间也应该接近 5:33,而不会是 4:33。

也就是说,Session 的失效回调和起床记录在 4:33 左右就已经完成了。延迟的不是业务处理,而是 Smart Stack 的显示。

这不叫回调执行了一小时,这叫 WidgetKit 一小时以后才把结果画出来。

当然,我只是在 5:33 才发现它发生了变化,并不能证明它正好延迟了一个小时。实际刷新时间位于我上一次查看和 5:33 之间。不过可以确认的是,在这段时间里,HealthKit 里的数据已经是正确的,而 Smart Stack 仍然可能显示旧的 Wake Up

reloadAllTimelines() 这个名字很容易让人误会。看起来像是“立即重新加载所有 Timeline”,其实它只是向 WidgetKit 提交刷新请求。什么时候重新生成 Timeline,什么时候重绘,什么时候改变 Smart Stack 里的优先级,最终还是系统说了算。调用成功,不等于用户马上能看到结果。

这和我以前调查 Widget 问题时遇到的情况一样:共享数据已经变了,Widget 还在显示旧数据。很多时候不是数据没写进去,也不是业务代码没执行,而是 WidgetKit 继续保留了上一份 Timeline。

总结

这两天的调查结果已经很明确:

点击 Stop 后,Smart Alarm Session 会正常结束。SleepTap 的后台回调也会立即记录起床、写入 HealthKit,并更新 Smart Stack 使用的数据。打开应用只是兜底,不是完成这些操作的必要条件。

Smart Stack 最多可能在相当长的一段时间里继续显示旧的 Wake Up,但它最终会自行刷新。这是 WidgetKit 的可见状态延迟,不是 Session 延迟,也不是睡眠记录延迟。

一开始我根据界面判断后台代码没有执行。第二天又根据刷新时间判断回调执行了一个小时。两次判断都很合理,也都被健康数据推翻了。

所以,调查 Widget 问题,不能只看 Widget。界面是界面,数据是数据。把两者混在一起,肯定会追错方向。

处理copilot update更新缓慢问题获得的两个意外收获

大模型

今早发现copilot update更新的时候突然变慢了,速度只有10KB/s,于是跑去找ChatGPT问了问,为什么我终端已经设置了all_proxy,但是copilot update的时候却没有使用?

ChatGPT和我说,这是因为copilot这种有时候只会使用环境变量,我需要设置的是http_proxy和https_proxy。我设置好之后,更新了copilot,这下有500KB/s的速度了。

两个意外收获

再次打开copilot,对话后我发现,默认的模型变成sonnet-4.6了。我很意外!因为我是中国用户的原因,很久以前就没法使用任何Claude的模型,只能使用OpenAI的。之前我虽然听说如果VPN开启全局模式也能做到,但是我嫌麻烦,就没有弄。

此外,我还发现,现在copilot响应速度快了很多。之前我就觉得copilot的gpt-5.4比OpenAI Codex app里的慢很多。我还以为是copilot的问题。因为我问ChatGPT为啥copilot比Codex的慢,它还给我解释的头头是道,什么copilot搞了负载均衡,额外会加载其他内容,还有Codex可能是独享的,copilot的是共享的。不过从这次改变可以知道,其实原因并不是ChatGPT说的那样,就是因为原本的代理设置不被copilot支持导致的。我们应该警惕大模型的回复,有的时候它说的也不一定对。

中国开发者测试Google Play应用内购买的正确方法

Android

作为一名苦逼的中国开发者,我们要想测试发布到Google Play的商店,会收到中美双方的排挤。本文是我通过调查资料,最终总结的一个我认为最适合的方式,希望后来者可以少走弯路。

开发者账号

你需要一个开发者账户,一个测试账号。这是因为作为收钱的开发者账户,我们需要使用自己的真实信息,这样才能通过Google的开发者账户的验证。因此,开发者账户是中国账户,加上单Visa的信用卡即可,我使用的是招商银行的单Visa的全币卡。你还需要找银行要一下对账单,准备好身份证,这些都是Google验证时需要的。

测试账户

测试账户需要新建一个美国账户。感谢AI,ChatGPT告诉我,Google账户是根据你注册时的IP来判断你属于哪个国家的。所以,我们主要开启一个美国的VPN,然后注册就可以。特别的,注册时会要求手机号来验证。很多人担心能否用中国的手机号来验证,没问题的。因为这个只是验证你是真人,不作为判断国别的依据。因此,只要你注册时的IP是美国,以后也一直使用这个IP,就没问题。

此外,由于我们的测试账户只是为了测试。并且我们也没有美国的信用卡用来支付,所以我们的账户将不绑定任何信用卡。以免因为绑定了中国的信用卡而导致账户被风控。

测试应用

  1. 上传应用到Google Play
  2. 选择内部测试
  3. 然后在内部测试中,创建一个新的组,将测试账户的邮箱添加上去
  4. 复制定下的链接,用手机登录测试账户,打开这个链接
  5. 安装测试应用,就可以测试了。

其他问题

如果测试时,在选择“快速卡,一直通过”之后遇到问题,那是因为你的VPN选择的是规则,导致Play商店和Google服务的IP不一致。解决办法就是关掉应用,然后将VPN的规则改为全局,然后重新测试。这样就可以了。

最后

我们采用这种方式,是没办法的的办法。因为我们中国的开发者账户直接用来测试,经常会遇到“设备或者账户不支持支付”这类的问题。那样的话,我们就没有办法测试应用内购买是否正确了。

第三方应用使用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 的构建产物不兼容。