肇鑫的技术博客

肇鑫 / Owen Zhao

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

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

最新文章

绕过 macOS 27 beta 的菜单栏多区域点击问题

macOS

昨天把macOS从26升级到了27beta。结果发现,菜单栏的常驻图标发生了很大的变化。首先是隐藏菜单项的工具失效了。其次,就是多按钮的菜单项也没法逐一点击每个按钮了。为此,我今早趁着OpenAI 6.0可以使用的机会,Astra来研究这件事。我让ChatGPT 5.6 Sol High先生成了一份计划。A、B、C三个选项。然后用Astra Medium审核这个计划,然后用Light来执行。结果这A、B、C全都失败了。上一首、播放、下一首和打开窗口,四个图标排在同一个状态项里,无论点击哪个位置,响应的都是“下一首”。

旧方案

这种交互的原理很简单:一个 NSStatusItem 占据一整块菜单栏空间,里面划分几个区域。可以让 AppKit 把事件送到对应的子按钮,也可以自己读取点击的局部坐标,判断应该执行哪个动作。两种做法都依赖事件能够反映用户实际点击的位置。

新方案(失败版)

但在我的测试环境——macOS 27.0 beta,build 26A5425a——这个前提出了问题。我让 Codex 建了一个宽 120pt、每区 30pt 的最小项目,依次尝试了三种方式:

  • 四个真正的 NSButton,分别绑定 action:失败。(A)
  • 四个普通 NSView,各自安装点击手势:失败。(B)
  • 一个自绘视图,通过手势的 location(in:) 获取坐标,再按区域判断:失败。(C)

三次都只有 Next 响应。第三次的日志给出了线索:可见的两次完整回调,局部坐标都是 (60, 11),正好是 120×22 视图的中心。而 x=60 属于第三个区域,所以计算结果总是 Next。这说明在当前测试里,收到的事件位置没有反映不同的点击位置。

新方案(成功版)

后来,Codex 查到了 Stats 的原始问题报告:Combined modules: per-module popups don't open on macOS 27 beta(#3456)。报告者遇到的是合并模式下点击 CPU 却打开 GPU,并提出了绕过方式:统一通过状态栏按钮的 action 接收点击,再用实际鼠标屏幕位置判断点击了哪个区域。 报告者称在另一个 macOS 27 beta build 上验证有效。我们接下来的实现采用了这个思路,并不是自己想到的。

我保留了单个状态项,把四个符号合成为一张图片,显示在标准 NSStatusBarButton 上。整个按钮只绑定一个 action。收到 action 后,立即读取 NSEvent.mouseLocation,再把按钮范围转换成屏幕矩形,两者在同一个坐标系中比较。

核心代码如下,放在已有的 AppDelegate 中。图标绘制和具体播放动作省略:

private var statusItem: NSStatusItem!

private func createStatusItem() {
    statusItem = NSStatusBar.system.statusItem(withLength: 120)
    statusItem.button?.target = self
    statusItem.button?.action = #selector(statusClicked(_:))
    // button.image 为包含四个固定排列符号的合成图片。
}

@objc private func statusClicked(_ sender: NSStatusBarButton) {
    let mouse = NSEvent.mouseLocation
    guard let window = sender.window else { return }

    let screenRect = window.convertToScreen(
        sender.convert(sender.bounds, to: nil)
    )

    // 本实验只处理鼠标点击,避免按鼠标位置解释键盘激活。
    guard let type = NSApp.currentEvent?.type,
          type == .leftMouseUp || type == .leftMouseDown else {
        return
    }

    guard mouse.x.isFinite, mouse.y.isFinite,
          screenRect.width > 0,
          screenRect.contains(mouse) else {
        return
    }

    let x = (mouse.x - screenRect.minX) * 120 / screenRect.width
    let region = min(3, Int(x / 30))
    let actions = ["Previous", "Play", "Next", "Window"]
    print(actions[region])
}

这里的转换分两步:convert(_:to: nil) 把按钮内部范围转换到窗口坐标,convertToScreen 再转换到屏幕坐标。减去屏幕矩形的左边界,就得到鼠标在按钮内的横向位置,随后按四个区域分配动作。如果实际界面的区域不等宽,就应该使用各自的布局范围。

这次四个图标终于能够分别响应。截图里的四项计数各为 1,总计 4。日志中,Play 对应的 x 约为 43.91,Previous 为 9.18,Window 为 102.70,都落在正确区域。

它能成功的关键,是不再依赖事件自带的位置来分区。 可见日志中,currentEvent 的窗口坐标仍然是 (68, 15),但 NSEvent.mouseLocation 取得的屏幕鼠标位置会随着点击变化。标准按钮负责告诉应用“发生了点击”,实际鼠标位置负责告诉应用“点在哪里”。这就绕过了本次测试中失效的子视图分发和事件坐标路径,也保留了播放器作为一个整体的固定排列。

这仍然是一个经过基础鼠标点击验证的 beta 绕过方案。我们没有确定系统内部为什么会给出固定坐标;多屏、菜单栏自动隐藏和快速移开鼠标也还需要测试,因为这里采样的是回调时的鼠标位置。至少在当前 build 上,菜单栏播放器不必拆成几个可以各自移动的状态项,原来的整体交互可以保留下来。

一个 AlarmKit 闹钟取消失败,我和 AI 查了三天

watchOS

前几天早上,SleepTap 的 Smart Session 在 Apple Watch 上运行。我选择了 Stop,原以为这一次睡眠流程就结束了。结果到了六点,iPhone 上的 AlarmKit 还是照常响了。

这就很奇怪了。

按照 SleepTap 当时的设计,Apple Watch 上的 Smart Session 启动后,会通知 iPhone 取消这一轮的 AlarmKit。即使这里没有成功,用户选择 Stop、记录起床时,还有另一条取消路径。

两条取消路径都在,AlarmKit 怎么还能响?

我一开始怀疑,是不是最近的提交把原来的取消逻辑覆盖了。于是我让 AI 检查代码。

我们先盯上了 Stop 的兜底取消

AI 检查之后,发现代码里的确有两套取消路径。

一套是在 Smart Session 启动时执行。Smart Session 既然已经开始在手表上负责唤醒用户,iPhone 上的 AlarmKit 就应该取消。这是最早设计的主路径。

另一套是在用户选择 Stop、记录起床时执行。Watch 会把起床事件同步给 iPhone,iPhone 收到之后也会取消 AlarmKit。这算是一条兜底路径。

AI 最初怀疑,问题可能与起床事件、清空事件以及 WatchConnectivity 的消息顺序有关。比如起床事件发出后,清空时间线的消息又紧接着发出。假如清空消息先被处理,起床事件再到达时可能被墓碑机制过滤,最终没有触发 AlarmKit 取消。

这个推理听起来有道理,于是我们先修改了起床事件的处理,让 AlarmKit 取消不再依赖事件是否成功合并。

但是我后来越想越觉得不对。

Smart Session 启动时的取消才是主路径。Stop 时再取消,只是兜底。如果主路径已经成功,那么后面的消息顺序根本不应该影响 AlarmKit。

而且 Stop 和起床事件同步发生得非常接近。如果第一条即时消息都发不出去,紧接着发送的第二条消息,成功概率又能高到哪里去?保留两套路径,反而会让下一次排查变得更困难。出了问题以后,我们永远不知道究竟是哪条路径失效了。

于是我让 AI 删除 Stop 时的兜底取消,只留下 Smart Session 启动时这一条权威路径。

这样做还有一个好处:如果 AlarmKit 第二天又响了,我们就能确定,问题一定发生在 Session 启动这条链路上。

结果第二天,AlarmKit 又响了。

到底是哪一步没有执行?

这一次,我让 AI 不要再分析兜底,也不要再讨论 Stop。应该从 Smart Session 启动开始,一步一步检查:

  1. Session 启动后,Watch 有没有发送取消消息?
  2. iPhone 有没有收到?
  3. 收到之后有没有执行取消?
  4. 执行了,是成功还是失败?
  5. 如果没执行,消息到底停在了哪里?

AI 一开始还是被后面的起床路径吸引了注意力。我只好再次提醒它,我们已经删除了 Stop 时的兜底。现在应该查的是 Session 启动,不是起床。

从 iPhone 的日志来看,没有找到对应的接收和取消记录。也就是说,问题并不是 AlarmKit 收到命令以后取消失败,而是 iPhone 根本没有收到 Watch 在 Session 启动时发送的那条消息。

继续往前查,Watch 发送消息之前检查了 WCSession.isReachable。Smart Session 启动时,这个值是 false,所以代码没有通过 sendMessage 发送即时消息,而是走了延迟投递。

到这里,我的判断是:这可能是最近 WatchConnectivity 的问题。

因为最近几个版本的 iOS 和 watchOS 下,isReachable 的表现的确不太稳定。有的时候明明手机和手表都在身边,它还是返回 false。既然这个值不可靠,那我们是否可以不检查它,直接调用 sendMessage,然后根据成功或者失败回调来决定结果?

AI 也同意这个办法。

于是我们修改代码:不再依赖 isReachable,Smart Session 启动后直接调用 sendMessage,并要求 iPhone 返回确认。只要 iPhone 收到并成功取消 AlarmKit,Watch 就能拿到成功回调;如果发送失败,也会进入错误回调。

代码看上去比原来更可靠了。至少不再因为一个可能错误的 isReachable 提前放弃发送。

我们提交了修改,继续等第二天。

第二天,AlarmKit 还是响了。

不能再按天测试了

这时已经连续几天出现同一个问题。每次修改一点代码,等第二天早上,再看六点的 AlarmKit 会不会响。这种测试效率实在太低。

而且 SleepTap 本身的逻辑已经比较复杂。Smart Session、AlarmKit、WatchConnectivity、睡眠时间线、清空墓碑和起床事件都混在一起。只看 SleepTap 的结果,很难知道失败的究竟是哪个环节。

我于是提出,干脆单独做一个最小测试应用。不要放在 SleepTap 里面,单独建立一个 iPhone 和 Apple Watch 项目,结构和 SleepTap 相似,但是把等待周期缩短到一分钟。

这个应用后来叫 WC Lab。

WC Lab 没有睡眠检测,也没有 AlarmKit 所有权,更没有那些时间线同步。它只做一件事:Apple Watch 给 iPhone 发送消息。

手表上有两种测试。第一种是立即发送,第二种是一分钟后发送。第二种测试会安排一个类似 Smart Session 的后台会话,等手表应用进入后台以后,再调用 sendMessage

第一次测试,立即发送成功了。

这说明配对、Session 激活、iPhone 接收代码以及消息内容本身都没有问题。sendMessage 不是完全不能用。

但是延迟一分钟的测试失败了。

我先问 AI,sendMessage 是否要求 iPhone 应用必须处于前台。AI解释了 isReachable 的条件,但我觉得问题可能不在 iPhone,而在 Apple Watch。

因为立即发送时,手表应用还在前台。延迟一分钟时,手表应用已经进入后台。两次测试真正发生变化的,是 Watch 这一端。

于是我又做了一次测试。这次特意让 iPhone 应用一直保持在前台,只让 Apple Watch 应用进入后台。

一分钟后,发送依然失败。

到这里才终于确定:在 SleepTap 使用的这条 WKExtendedRuntimeSession 后台路径里,Watch 应用虽然正在运行,但 WatchConnectivity 的即时通信通道不可用。isReachable 返回 false,并不是它判断错了。它判断得很对。

我们前面所谓的“绕过 isReachable”,其实什么也没有绕过。直接调用 sendMessage,只是让系统通过错误回调再次告诉我们:对端现在不可达。

我把一个正确的 false,当成了系统 bug。AI 不但没有提醒我,还和我一起想办法绕过它。

后台能运行,为什么不能发消息?

这正是这次最容易让人误判的地方。

Smart Session 的代码明明在后台运行。既然代码能运行,调用 sendMessage 又有什么不可以?

但是“Watch 应用正在后台执行”和“iPhone 可以接收 Watch 的即时消息”是两种不同的能力。WKExtendedRuntimeSession 给了应用一段后台执行时间,并没有同时保证 WatchConnectivity 的即时通道处于可达状态。

这就好比一间办公室允许你晚上继续加班,不代表楼下的快递还在营业。你人在办公室,电脑也开着,但快递窗口已经关了。继续按发送按钮,并不能把窗口重新按开。

我以前其实知道 WatchConnectivity 有这个限制,只是时间久了忘记了。AI 看到了 isReachable == false,也没有从应用处于后台这个事实联想到平台限制。再加上最近 WatchConnectivity 的确出现过一些不稳定现象,我们就很自然地把这一次也归到了系统问题上。

这条弯路走得很完整:先怀疑业务逻辑,再怀疑消息顺序,接着怀疑 isReachable,最后还专门删掉检查直接发送。每一步单独看都有理由,合在一起就是好几天。

取消 AlarmKit 只能提前

既然后台 Session 启动时不能可靠地即时联系 iPhone,那么取消 AlarmKit 的操作就不能放在那里。

最后我们把操作提前到了用户点击“上床”的时候。

用户在 Apple Watch 上点击上床时,Watch 应用仍然处于前台。这时 Watch 会把睡眠事件、绝对起床时间以及 AlarmKit 的所有权,通过同一条消息交给 iPhone。iPhone 收到之后,取消对应时间的 AlarmKit,由 Watch 的 Smart Session 接管唤醒。

如果这条前台消息发送失败,iPhone 就继续保留 AlarmKit。这样至少还有一个可靠的兜底,不会出现 Watch 没接管、iPhone 又已经取消的情况。

如果用户后来删除了这次睡眠记录,Watch 就把同一个绝对起床时间交还给 iPhone,重新恢复 AlarmKit。如果用户已经起床,就把这一次闹钟标记为已经消费。

这次修改的重点不是增加更多兜底,而是明确谁拥有这一次闹钟。iPhone 拥有,就由 AlarmKit 提醒;Watch 拥有,就由 Smart Session 提醒。所有权的切换必须发生在 Watch 仍然处于前台、即时通信真正可用的时候。

watchOS 后来还弹出了一个从没见过的提示

WC Lab 测试完成后,Apple Watch 又出现了一个我从来没有见过的系统提示。大意是:

WC Lab Watch 一直在后台运行,但未能播放计划中的闹钟。是否要禁止这个应用以后运行后台任务?

下面有两个按钮:取消,或者禁用后台运行。

看到这个提示,我才意识到,这个最小测试甚至把另一个事实也证明了。

WC Lab 的后台 Session 的确运行了。否则系统不会说它“已经在后台运行”。但是应用最终没有播放计划中的闹钟,因为我们建立这个 Session 的目的只是测试后台 sendMessage,并没有通过 notifyUser 完成闹钟提醒。

也就是说,后台 Session 成功运行和 sendMessage 失败,可以同时成立。

这恰好就是我们前面一直没有分清楚的两件事。

我觉得 watchOS 这个提示其实挺好。至少用户知道某个应用申请了闹钟类后台运行,却没有完成它承诺的任务。系统没有偷偷降低权限,而是明确询问用户,是否要禁止这个应用以后继续在后台运行。

如果系统什么都不说,只是在后台悄悄惩罚应用,开发者可能会彻底一头雾水。毕竟这一次,我和 AI 一起查了好几天,才发现原来不是 AlarmKit,也不是消息顺序,更不是 isReachable 判断错误,而是 Watch 应用在后台时根本没有即时发送这条消息的条件。

当然,这个提示对开发者仍然不够。它只能告诉用户“应用没有完成计划中的闹钟”,却不会告诉开发者为什么失败。开发者如果拿不到对应的日志,还是要靠最小测试一点一点排除。

总结

这次我真正学到的不是“不要使用 sendMessage”。sendMessage 在 Watch 前台时完全可以正常工作。

真正的教训是,不要因为 Watch 应用正在后台执行,就想当然地认为 WatchConnectivity 也处于可用状态。后台执行和即时通信是两套能力。isReachable 返回 false 时,也不要因为这个结果不符合自己的预期,就先认定它是系统 bug。

还有就是,复杂项目里的问题如果一天只能验证一次,就应该尽早建立最小测试应用。WC Lab 只用了两组测试就把问题拆开了:

  1. Watch 前台,立即发送,成功。
  2. iPhone 前台、Watch 后台,一分钟后发送,失败。

这两次测试得到的信息,比我们前面几天围绕 SleepTap 修改的所有兜底都更有价值。

我忘记了平台的限制,AI 也没有提醒我。最后还是靠古法处理:把复杂逻辑全部拿掉,只留下一个按钮、一分钟等待和一条消息。问题解决。

苹果平台 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的规则改为全局,然后重新测试。这样就可以了。

最后

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