前几天早上,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 启动开始,一步一步检查:
- Session 启动后,Watch 有没有发送取消消息?
- iPhone 有没有收到?
- 收到之后有没有执行取消?
- 执行了,是成功还是失败?
- 如果没执行,消息到底停在了哪里?
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 只用了两组测试就把问题拆开了:
- Watch 前台,立即发送,成功。
- iPhone 前台、Watch 后台,一分钟后发送,失败。
这两次测试得到的信息,比我们前面几天围绕 SleepTap 修改的所有兜底都更有价值。
我忘记了平台的限制,AI 也没有提醒我。最后还是靠古法处理:把复杂逻辑全部拿掉,只留下一个按钮、一分钟等待和一条消息。问题解决。