Sleep Island

纯手机睡眠追踪的原理,以及它在哪些地方输给了手表

一篇工程日志:只用加速度计构建一个 iOS 睡眠追踪器——一个双层检测器、一个在夜晚结束时跑一次的自校准分期引擎,以及那些我用代码也绕不过去的硬限制。

它是什么,它不是什么

我做了一个 iOS 睡眠追踪器,只用手机的加速度计。没有可穿戴设备,没有账号,没有订阅。我的背景是后端和工具链开发,在做这个之前从没写过一行 Swift;过程中遇到不熟悉的 API,我就靠 LLM 顶上。我自己每晚用了一年,这是我之所以相信它不会在我手机上崩溃的全部依据——而且仅限于我在用的那一两台设备和系统版本。这是一个样本量为 1 的用户;它说明不了下面数据集里那 150 台手机上的崩溃率,我也不希望它被解读成更大的数据集为可靠性这个说法背书。它没有。

下面的准确度数字来自另一个独立的、更大的来源:565 个手机加可穿戴设备配对的夜晚,覆盖 150 台不同的 iPhone,对照的是通过 HealthKit 拉取的消费级可穿戴设备,而不是实验室级的多导睡眠监测(PSG)。我得把这个数据集说清楚:它是一个 opt-in 的便利样本,来自自我选择、恰好在同一晚也记录了可穿戴设备数据的用户,而不是随机分组的对照队列。565 个夜晚分摊到 150 台手机上,中位数大约是每台设备 3.8 个夜晚,所以大多数手机只贡献了寥寥几个夜晚。我会一直标注清楚这个参照究竟是什么、不是什么。

先把丑话说在前头,免得有人浪费时间:如果你想要分钟级精确的醒来检测,或者准确的睡眠分期,去买个腕戴设备。腕戴设备能拿到一些生理信号,而一部躺在床垫上的手机根本拿不到。这篇文章讲的是在没有这种硬件的情况下你能走多远,以及对那道无法弥合的鸿沟在哪里做一个诚实的交代。我全程的参照物是消费级可穿戴设备,而不是临床级 PSG,而且如下文所示,它甚至都不是清一色的 Apple Watch。我没做过临床验证,我也不会假装“和可穿戴设备一致”就等同于临床准确度。

我真正在意的那一个行为——这东西存在的全部理由——其实很平淡:我希望环境音(雨声、海浪声)在我清醒时一直放着,等手机判定我睡着了再自己停掉。而不是在我还醒着躺着的时候被一个固定计时器硬切掉。下面所有技术内容,都是为了让这一个副作用在恰当的时刻触发;然后,作为另一件独立的事,在事后产出一张站得住脚的睡眠分期图(hypnogram)。

两层,以及为什么要把它们分开

有两个检测器。分法是在线 vs 离线处理,一条实时路径和一条批处理路径。这本身不新鲜;它在这里之所以重要,是因为一个具体的失效模式,我下一节会讲到。

第 1 层是一个用 Swift 写的实时阈值状态机。它以恒定的 5Hz 采样加速度计,在一个滑动窗口上计算幅值 sqrt(x^2 + y^2 + z^2) 的方差,并据此驱动实时的副作用。当它把你判定为睡着、并且大约十分钟的安静状态稳定保持时,它会硬暂停音频。我要精确说明“暂停”是什么意思:它调用播放器的 pause()。不是音量淡出。声音停了。

有一个兜底机制,防止检测器万一一直不触发时出现无限播放的 bug,但我得精确描述它而不是夸大它。它是一个 14 小时的会话上限,每小时检查一次,如果在那之前没有别的东西停掉会话,它就强制停止。它不是一张紧密的、按分钟级的安全网。确实有一个按分钟的守护倒计时,但那只在用户选了一个固定的定时关闭时长时才运行,smart 模式或无限模式下不运行,所以对主要用例来说,14 小时上限才是真正的外层边界。

第 2 层是离线分期管线:一个平台无关的 JavaScript 引擎,在你停止录制之后,一次性把整晚的数据编译处理完。它做真正的分期工作。第 1 层在实时阶段永远只需要回答一个廉价的问题——“这个人现在大概是不是睡着了”——而且它可以允许有点出错,因为它控制的只是雨声要不要继续放。第 2 层回答那个昂贵的问题——“整晚到底是什么样子”——而且它能一次性看到全部数据。

把它们分开,意味着实时检测器的活儿和分期管线的活儿永远不会互相争抢。实时那个为响应灵敏的副作用做优化;离线那个为完整记录上的正确性做优化。再说一遍,在线-vs-离线是家常便饭;有意思的部分在于,当你让实时层去喂离线层时会发生什么。

为什么用恒定 5Hz,以及我删掉的那个污染循环

早先有个版本在省电上更“聪明”。它有 eco 和 burst 两种自适应采样模式:看起来安静时就少采几次,不安静时就升上去。我把这些全删了。下面就是逼我做出这个决定的失效模式。

自适应采样意味着采样率取决于实时分类的结果。如果第 1 层在你其实还醒着、还在看书的时候就早早误判成了“睡着”,它就会降采样。这段降采样、质量退化的数据随后被前馈进离线分析器,而后者现在不得不去对一个“数据密度本身就是由一次可能出错的猜测决定的”夜晚做分期。一次糟糕的实时判断,污染了离线引擎本应用来发现“这次实时判断很糟糕”的那份证据。这是一个先有鸡还是先有蛋的污染循环:错误反过来喂养了产生这个错误的条件。

恒定 5Hz 斩断了这个循环。不管第 1 层在当下相信了什么,离线引擎永远看到一份均匀的、全速率的记录。这要多花一些本可以省下的电,而我是有意做这个取舍的:正确性优先于电量。数据采集足够便宜,所以一直按这个速率付出代价没问题;我拒绝做的事,是让一个实时启发式去决定离线分析器被允许看到哪些数据。

我得把这一点和准确度那一节联系起来,免得它听起来比实际更要紧。斩断这个污染循环保护的是数据完整性,它保证离线引擎看到一份未被污染的记录。它并没有换来有意义的睡/醒区分能力:正如你接下来会看到的,实测的二分类器落在 Cohen kappa 中位数大约 0.00 的位置,不比一个恒定输出“睡着”标签的函数更好。所以请把恒定 5Hz 看作一种站得住脚的工程卫生习惯,它消除了一类自找的错误,而不是一次已被证明的准确度胜利。硬件天花板远在这个决定能起作用的位置之下。

离线分期管线:baseline、Cole-Kripke、Viterbi

离线管线在夜晚结束时跑一次,但它不是一条干净的三步链;它是一系列处理 pass。它从一个逐夜自校准的 baseline 开始。人、床垫、手机摆放方式的差异大到固定阈值毫无希望,所以引擎在分期任何东西之前,先确定对这个具体夜晚而言“安静”意味着什么:取这一夜自己最安静的 20% 窗口(方差分布的 p20)作为它的参考下限。在此基础上,它运行 1992 年的 Cole-Kripke 体动记录(actigraphy)算法——一个 7 分钟的加权滑动窗口——来做睡-vs-醒的判断。关键在于,这个睡/醒决定是 Cole-Kripke 在上游做的,不是 HMM。

只有在那之后,并且在经过几个平滑和清理 pass(事件融合、二值平滑、醒来清理、短岛剪除)之后,hidden Markov model 才运行。而且它只在那些已经被标记为睡着的分钟上运行。它的三个阶段不是 wake/light/deep;它们是 deep、core、light,是睡眠深度的三个层级。HMM 不决定你是否睡着;它在一份已经由上游把清醒分钟剔除掉的记录上,分配睡得有多深。它用对数空间的 Viterbi 求最可能路径,加上前向-后向算法求后验。用对数空间,是因为你在整晚里要把一长串小概率连乘起来,否则一定会下溢。

有一处不匹配我应该点名,而不是糊弄过去:Cole-Kripke 是在腕部体动记录上开发和验证的,而我喂给它的是床垫的运动,不是腕部运动。透过床垫传来的全身运动,和腕部运动含义不同,所以这些活动计数(activity counts)并不携带原始系数所假设的那套语义。逐夜自校准在一定程度上是在补偿“把一个腕部算法用在了非腕部场景”这件事。这是一个已知的妥协,而不是对那篇论文的干净应用。

值得说清楚:HMM 是在生产环境里实跑的,不是 shadow 模式。我在 2026-05-07 把默认值翻成了开。

这个引擎特意做成平台无关的 JavaScript。iOS 那一侧的耦合就是单个 JSContext 桥(它今天恰好是 714 行,但代码里带着死路径,所以我不会拿行数当作任何东西的证据)。在这个桥之上有一条热更新通道,它会做一个可选的、基于 SHA256 内容哈希的校验,走 HTTPS。我想精确说明这个校验做了什么、没做什么:哈希和它所哈希的那份 JS 负载走的是同一条 HTTPS 通道,所以任何能控制服务器或这条通道的人,就同时控制了两者。这意味着它对那种威胁不提供任何防篡改能力;它只能检测意外的损坏或截断。它不是密码学签名,也不是签名代码验证,而且当服务器省略哈希时它会被整个跳过。

同一条通道让我可以不经过 App Store 审核就发布算法变更,因为分期逻辑是被解释执行的 JS 配置,不是原生二进制。我直接把风险说清楚,而不是把它叫作灰色地带:在审核之外发布会改变 app 行为的解释型逻辑,很可能违反了 App Store 审核指南,而不只是擦着它模糊的边。Apple 可能因此拒绝或下架这个 app,那样热更新这个好处就整个没了。如果被要求,我会停;我也不会假装那个内容哈希校验让这个绕过审核变得正当,因为它并没有。

因为这个引擎是纯 JS、没有 iOS 依赖,原则上一个 Android 移植是可行的,跑在 QuickJS 而不是 JavaScriptCore 上。我得小心,不要把它当成一个我已经入账的回报来卖:我还没试过,而且 JS 引擎之间的差异(浮点边界情况、Math 行为、性能)可能改变数值分期的输出,所以“一份不会漂移的实现”是一个希望,不是一个结果。把可移植性只当作一个设计目标看待。

JSContext 性能,以及那个会让你崩溃的坑

在 JSContext 里跑你的算法并不新鲜;React Native 多年来干的正是这个。我提它,只是为了先堵住“JS 对信号处理来说太慢了”这个反驳。在我自己一台较新的 iPhone 上,对一个八小时的夜晚做整晚编译,单次测量大约是 100ms;我没有刻画过跨设备的方差,而且更老的受支持机型会更慢,可能慢上好几倍,所以别把 100ms 当成一个通用数字。它工作在 30 秒的 epoch 上,而不是原始的 5Hz 样本上:按算术算,5Hz 跑八小时大约是 14.4 万个原始样本(5 * 3600 * 8),但 HMM 和 Cole-Kripke 这些 pass 跑在几百个 30 秒窗口上,而不是那 14.4 万个样本上。它在一个队列上串行运行,这没问题,因为它在夜晚结束时恰好运行一次,永远不在热路径上。它身上没有任何实时的东西。

有一个真正的坑,而且代码里写了注释,免得我再踩一次。如果你从一个 Swift 协作线程(cooperative thread)调用 queue.sync,你会触发一个 libdispatch precondition,然后 app 会崩溃——具体是在 iOS 17/18 的 Release 构建上。所以异步路径是被强制的:queue.async 加一个 continuation 桥接回来。这种东西在 Debug 里能过,在模拟器里能过,然后在真机的 Release 构建上崩溃,而那是你最不想发现它的地方。

准确度,对照消费级可穿戴设备测量

这些数字来自 565 个配对的夜晚——同一个人同时跑了手机追踪器、并且在同一晚也有一个消费级可穿戴设备在记录——覆盖 150 台不同的 iPhone,时间跨度约六周(2026-04-26 到 2026-06-07)。再说一遍,这是一个 opt-in 的便利样本,不是对照研究,而且大多数手机各自只贡献了几个夜晚。参照时间线通过 HealthKit 进来:按片段算,大约 78% 被标记为 apple_healthkit,约 22% 是其它 HealthKit 时间线——它们被导入了,但 HealthKit 并没有把它们识别为 Apple Watch——外加少数来自华为和 Garmin 的。那 22% 是我最想标为有效性问题、而不是当作脚注的部分:我大约五分之一的 ground truth 来自我无法识别的设备,其睡眠分期质量未知,而不同可穿戴设备的准确度有实质性差异。把它们聚合成单一的“参照”,会把误差推进下面每一个对比数字里,而我没有把这些数字切到只含“已识别为 Apple Watch”的子集,所以我没法告诉你到底有多少。下面所有内容都是群体聚合,在服务端计算的;这里没有任何按用户的数据,也没有任何按用户的时间线离开过脚本。

先从我最不希望你误读的那个数字开始。在二分类睡/醒上,手机和可穿戴设备在中位数 94.6% 的分钟上一致(p25 0.886,p75 0.977)。这看起来很棒,直到你算 Cohen's kappa——它会修正掉那种你靠碰运气也能得到的一致——而 kappa 的中位数大约是 0.00(p75 也只有 0.108)。用大白话说就是:那 94.6% 里几乎全部,都不过是两条时间线在你显然睡着的那一长段里,简单地一致认为“睡着”。逐分钟来看,在那个真正要紧的活儿上——把睡着和醒着区分开——纯手机分类器并不比一个把每分钟都盲目标成“睡着”的函数有意义地更好。所以请别把 94.6% 读成“94.6% 正确”。它不是。这是这篇文章里最重要的一句招供,我宁愿自己说出来,也不想被人挖出来。

在醒来这一侧,你能从物理上看到那道限制。有 47% 的夜晚,手机报告整晚零次醒来段(wake-bout),而可穿戴设备上这个比例是 8% 的夜晚。短暂的腕部唤醒——腕部传感器能捕捉到的那种几分钟的醒来——对一部放在床头柜上的手机来说基本是隐形的。这是传感器的局限,不是我没调好的某个旋钮。没有哪个阈值是我能设的,能让床垫像手腕那样感知到一次三十秒的唤醒。

手机做得最好的地方是总睡眠时长,而即便在这里我也该把分布而不只是中间值给你。绝对误差中位数是每晚 42 分钟,但那是中位数:p25 是 18 分钟,p75 是 90 分钟,所以有四分之一的夜晚偏差在一个半小时或以上,而且我没有给出 p90,也没有给一个硬上界。误差没有一致的方向,它不是系统性地多算或少算,偏倚(bias)接近零,但分布很宽。对一个典型的七小时夜晚来说,42 分钟的中位数大致是中位数处的约 10%,外加一条我没有报出的尾巴。所以我会把它叫作“可用于看趋势和夜晚的大致形状”,而不是“已验证的准确度”。醒来细节和分期才是它撑不住的地方。

在深度上,跨整个队列,手机的深睡百分比中位数比可穿戴设备低大约 5.2 个百分点(深睡% 偏倚为 -5.2pp)。在队列尺度上,手机报告的深睡比可穿戴设备少,这和我在自己设备上看到的那一小撮残留的深睡少计是一致的。我不会更进一步说它验证了什么:这两个观察可能共享同一个系统性成因,而且这里的参照是一组异质的消费级可穿戴设备,它们自己的深睡分期本身就不可靠。所以我没法有把握地把这 -5.2pp 的差距归到手机头上、而不是参照本身的误差。它是一个真实的、方向一致的、幅度不大的差距;我能宣称的就这么多。

我得对 4 类分期格外小心,因为它根本不来自这个队列。我唯一的分期一致性证据,是在我自己的设备、我自己的手机上做的单受试者、9 个夜晚的校准,我没法把它泛化;那个 565 夜的队列并没有验证 4 类分期。所以我刻意不去引用一个分期一致性百分比、把它当成一个结果来摆,因为我手里唯一的数字是一具身体在一台手机上得来的。那个大队列只验证了四件事,且仅此四件:二分类睡/醒、总时长、队列层面的深睡偏倚,以及 WASO/醒来段行为。在同一组 9 夜的数据上,校准之前,模型把深睡多报了大约 60 个百分点(一个 +59.5pp 的数字,我没法把它溯源到队列文件,只能溯源到那个校准产物)。我会把这个少读成“校准有帮助”,多读成“分期是被拟合到了一个人身上”的证据:如果单单一步校准就能把深睡甩动那么多,那校准后的输出基本上就是校准在一个受试者身上替你干活,而我没有任何理由相信这个拟合能迁移到别的身体上。

REM 完全不在这一切里。手机分类器从不输出 REM;它只输出 deep、core、light 和 awake。只有加速度计的数据无法区分 REM,所以代码拒绝去宣称它,而不是猜一个、再把这个猜测当成一个睡眠阶段呈现出来。因此 REM 既不被输出,也不被验证。

有一件事值得说,因为它读起来像个矛盾、但其实不是:这些可穿戴设备的时间线只被用在恰好一个方向上。它们用来测量手机的准确度。它们从不被反馈回去训练或调校手机分类器。这让它和后面会讲到的隐私墙保持一致——没有任何可穿戴数据被用来改进模型,它只在事后、且以聚合的形式,被用来给模型打分。

直白地说,结论是:在分钟级精确的醒来检测和在分期上,腕戴设备都胜过这个东西,而且两件事上都不接近。手机可用于总睡眠时长和夜晚的大致形状,它在队列层面比可穿戴设备报告的深睡少一个不大的量,且参照本身并不完美。如果你需要准确的分期或准确的醒来检测,那是腕戴设备的活儿,我宁愿这样告诉你,也不愿把一个“大体上只意味着‘你睡着了,而那个显而易见的猜测也是这么说的’”的 94.6% 包装得花里胡哨。

8 小时的 Live Activity 高墙

先给结论:锁屏上的 Live Activity 在整晚范围内并不可靠,而且我没法从用户态把它做可靠。在我测过的那些 iOS 版本和设备上,从我的设备日志看,iOS 一贯会在运行约 8 小时时结束一个 Live Activity。在那之后,每一次 update() 和 end() 调用都变成空操作(no-op),锁屏冻结在中途某个状态,卡在它最后渲染的那一帧上。我在我跑过的那些版本上有设备日志为证,而且 Apple 把 Live Activity 的预算文档化为“近似”、并且它在不同系统版本间变过,所以我把它当作“我一贯观察到的现象”来报告,而不是当作一个被证明的、普适的“8 小时、每台设备、每一次”。

下面是我为了让冻结更少见而做的事,但我清楚这里面没有一样是真正的修复。我在进入前台时重建 activity,以重置运行时计时器。我在 17.2+ 上用一个后台的 push-to-start 续期,越过 7.5 小时这个点。我还在约 7.83 小时时调用一次优雅的 end(),让系统干净地把它拆掉,而不是在 8 小时处把它焊死——那会让它冻结、并在本次会话剩下的时间里无法恢复。这些动作减少了你看到锁屏卡死的频率。它们不能保证你永远不会看到,因为底层那道限制是操作系统的,我能做的全部就是去管理我对抗它的胜算。

电量:先录制,后编译

电量策略直接源自那个双层拆分。采集很便宜,整晚以恒定 5Hz 运行。昂贵的运算——Cole-Kripke 和 Viterbi——在夜晚结束时恰好运行一次,而不是持续运行。在你睡完之前,你不为分期付费。

这里我必须认领一个真实的缺口,而不是把它包装成谦虚。我没有一个我敢跨手机、跨系统版本、跨电池健康度去捍卫的整晚耗电数字,而对此诚实的解读令人不太舒服:我把“恒定 5Hz 加一条静音音轨”说成一个刻意的“正确性优先于电量”的取舍,但我其实从没测过这个取舍要付出多少代价。如果你从没给代价标上一个数字,你就很难说一个成本-收益的决定是“刻意”的。所以把“正确性优先于电量”当作我的设计意图,而不是一个已被测量、已被论证的取舍。在指名道姓的设备上测量整晚耗电,是我仍然欠下的活儿。

关于代价花在哪里,我能告诉你的是:一条静音音轨让 AVAudioSession 保持存活,从而让 CoreMotion 整晚持续触发。那个 session 才是真正的整晚成本驱动因素,不是那些算术。如果你对电量敏感,那就是要盯的那个开支项。

我在整晚里确实做节流的,是状态机的特征处理节奏(cadence),我想精确陈述它,因为这一点很容易被说过头。在你清醒时根本没有任何节流:它就以自然的 1Hz 节奏处理特征,每一次每秒更新都处理,不跳过。一旦你被判定为睡着,它会加上一个 10 秒的跳过门,于是每秒的特征更新里大约每十次才有一次真正被处理——例外是剧烈运动会绕过这个门,所以一次真实的移动永远不会被跳过。这一切都不触及原始加速度计:5Hz 的原始采样永远不被节流,无论清醒还是睡着。这个区分正是上面那一节污染循环的全部要点:处理节奏可以安全地变化,数据密度不行。

隐私:并非完全离线,我会直说

我不会宣称“没有任何东西离开你的手机”,因为那不是真的。没有账号,没有登录。唯一的标识符是 identifierForVendor:一个稳定的、按设备、按供应商的 ID。那是假名化(pseudonymous)的,不是匿名的,我不会把它打扮成后者。

实际的划分是这样的。app 不上传原始的打鼾和梦话音频(PCM);它把音频写到设备本地的 Documents,并且隐私清单(privacy manifest)里明确解释了为什么 AudioData 是被有意地不声明的。我会谨慎地界定这个说法的范围,而不是把“永不离开设备”当作一个绝对值来说:我在描述的是当前代码做了什么——原始音频被本地录制、不上传。我不会就所有时间里的所有代码路径都承诺这一点,因为打鼾功能跑在 Apple 的设备端 SoundAnalysis 模型和音频会话之上(系统级处理,我并不能完全控制),也因为前面讲到的热更新通道能在 App Store 审核之外改变 app 行为。所以这个保证是关于今天发布的这份代码的,而不是关于我未来可能推送的每一份配置的。

会上传的,是分钟级的运动特征和阶段标签,挂在那个 identifierForVendor 上,用来调校逐夜校准。我得精确说明“调校逐夜校准”是什么意思,因为它读起来和准确度那一节里的“从不调校”有矛盾。逐夜校准就是分期那一节里那个逐夜自校准的 baseline:它在设备端、针对那一个夜晚,从这一夜自己的方差分布里推导出阈值。上传的运动特征和阶段标签让我能在用户之间以聚合方式看出,那套校准逻辑是不是在正常工作,并据此调整我发布的算法。永远不会发生的事:没有任何源自可穿戴设备的信号会喂进校准,无论是直接地、还是间接地经由聚合。可穿戴设备的时间线只在事后给准确度打分。所以当准确度那一节说模型从不用可穿戴数据来调校时,那句话的精确含义是:你的运动特征可以影响我发布的校准逻辑;可穿戴数据不会。

我并不宣称“无法从一段足够长的、绑定在一个稳定设备 ID 上的分钟级运动特征序列里把你重新识别出来”;那是一种行为指纹,我不会假装不是。我告诉你的是究竟有什么上传、有什么不上传:运动特征和阶段标签会上传,你录下的音频不会。

打鼾检测,以及促成它的那个 1 星差评

打鼾检测不是自研的 DSP,我也不会暗示它是。它是 Apple 的 SoundAnalysis ML 模型加一个 RMS 门。我用了 Apple 训练好的模型,是因为我没法为这件事从头造一个更好的声音分类器,而不是因为我证明了它是对的工具——我没测过那个。为了和这篇文章其余部分对齐:我没测过它的误报率,而 SoundAnalysis 偶尔会把一台风扇或伴侣的呼吸标记成打鼾。所以把这些鼾声片段当作一段你拿来回放检查的录音,而不是一个指标,也不是任何意义上经过验证的“检测”。

这个功能存在的理由并不光彩。我有史以来第一条 App Store 评价是一个 1 星,用中文写的,要求加录鼾功能。整条评价就这一句。他们想要它是对的,他们说没有它这 app 就不完整也是对的,所以我把它做了。我宁愿如实交代这个真正的起源,也不去编一个更体面的产品愿景故事。

这份代码并不干净

既然这是一篇工程日志,下面就是我平时会略去不写的那部分。分期引擎和实时检测器都很大,行数高,部分是因为我还没砍掉的重复和死路径,而不是因为这个问题需要那么多代码。一个干净的重写会是现在大小的一个零头,这正是我为什么不会拿这些行数当作任何东西的度量。SimplifiedSleepDetector 是一个大约 2,500 行的上帝对象(god object);到这份上,这名字已经成了个小玩笑。我明确点出这个体量,是因为另一种解读——考虑到我在不熟悉的 API 上靠了 LLM——会是“未经审阅的批量代码”,而诚实的回答是,其中有一些确实就是那样。

那段停止逻辑——决定录制结束的那个东西——其实是在一个地方做决定的:单个评估函数拍这个板。摊开的是执行(enforcement)。那一个决定随后必须横跨三层去落实:一个守护通知的扇出(fan-out),发给三个以上的订阅者;一个 15 秒的轮询定时器;以及一个共享的播放对账检查。所以决策点是单一的;执行被三重化了。里面还至少有一条货真价实的死路径:.autoStopTriggered 被发出去了,却没有订阅者。它什么也不做,却还留在那儿。

把我喜欢的部分叫“决策”、把我不喜欢的部分叫“疤痕组织”,那未免太方便了。我不会假装停止路径是干净的。决定在一个地方,这没问题,但为了落实这一个决定,要横跨三套各自独立的机制——一个通知扇出、一个轮询、一个对账检查——这比这活儿应有的机制要多,而那条仍然躺在里面的死通知,是我没给自己擦干净屁股的证据。这是一个设计与整洁度上的失败,而不只是我能挥手带过的“不太整齐”。我真正站得住、愿意为之背书的架构——双层拆分、恒定 5Hz、平台无关的 JS 引擎、拒绝输出 REM——是真实的。这堆烂摊子也是真实的。假装只有前半截是真的,会让这篇文章里其它一切都更不值得信。

睡眠岛是一款 iOS 应用,本文描述的就是它的实现。