把一天摊成四条泳道 左侧是四个数据源的读数:站立 47 分钟、Mac 活跃 6 小时 12 分、环境噪声 58 分贝、心率 72。右侧是从早八点到晚十点的四条时间线:姿态条上零星几段站立,屏幕条上三大段活跃,噪声曲线在通勤和午饭时升高,心率曲线随之起伏。 姿态 stand 47 min 屏幕 active 6h12m 噪声 58 dBA 心率 72 bpm 08:00 → 22:00 · 一台表、一部手机、一台 Mac

手腕上的 Apple Watch 每天告诉我“站立 10 小时”,Mac 的屏幕使用时间又说我今天用了 7 个多小时。两个数字放在一起就很奇怪:一天醒着十五六个小时,站了 10 个小时,又在电脑前坐了 7 个小时,加起来已经快满了,那吃饭、通勤、开会都去哪了?

我真正想知道的是这么几件事:

  1. 一天里到底站了多久、坐了多久?
  2. 在屏幕前工作了多久?不同时间段里,工作状态怎么样?
  3. 当时周围的环境怎么样,比如有多吵?
  4. 手上只有 iPhone、Apple Watch 和 MacBook,能不能把这一天完整还原出来?有没有现成的软件已经做到了?

本文就顺着这几个问题,从每台设备“到底记下了什么”开始,一层一层往下拼。


一、站立与久坐:手表到底测了什么

现象:站立 10 小时,不等于站了 10 小时

先看那个“站立 10 小时”。它来自活动圆环的第三环,对应 HealthKit 里的 appleStandHour。Apple 的定义是:这一小时里站起来并活动了至少 1 分钟,就算一个站立小时1。所以“站立 10 小时”的真实含义是“有 10 个小时,每个小时里你至少站过 1 分钟”。哪怕每小时只站 1 分钟,也能拿满。

这个指标的设计目的是提醒人别一直坐着,而不是计量站了多久。真正计量时长的是另一个类型 appleStandTime:iOS 13 / watchOS 6 起提供,单位是时间,样本是累计型的,加起来就是当天站立的分钟数2。健康 App 里“站立分钟”看到的就是它。

那坐着呢?HealthKit 里根本没有“坐着”这个类型。 这不是遗漏,而是手表测不出来:它戴在手腕上,坐着和站着不动时,手腕的加速度几乎一样。

站立小时、站立分钟和推断出的坐着时间 10 点到 11 点这一小时,手表记下这一小时站立达标,但站立分钟只有 10:07 到 10:09 的 2 分钟。叠上 Mac 的键鼠记录:10:00 到 10:52 一直有输入,之后离开。于是推断坐了 50 分钟、站了 2 分钟,最后 8 分钟没有任何设备作证。 Apple Watch 记下的 · 10:00–11:00 叠上 Mac 的键鼠记录 appleStandHour Stood · 这一小时站立达标 appleStandTime 2 min(10:07–10:09) 其余 58 分钟没有记录 Mac not-afk 有键鼠输入 52 min afk 推断 坐 7 坐 43 min ? 10:00 10:07 10:52 11:00 站立小时只要求这一小时里站过 1 分钟,剩下的时间,手表不说你在干什么。 虚线是没有设备作证的时段:人离开了 Mac,手表也没记到站立。
图 1 · 站立小时打了勾,只说明这一小时里至少站过 1 分钟。把 Mac 的键鼠记录叠上来,才能把剩下的大部分时间推断为“坐着”。图中数值为示意。

第一次尝试:坐着 = 醒着 − 站着 − 走路

最直觉的办法是做减法:把醒着的时间减去站立分钟、减去步行和运动,剩下的就是坐着。睡眠时间手表也有记录,醒着的区间不难拿到。

这个办法有三个漏洞:

  • 没戴表的时间会被算成坐着。 关掉手腕检测,或者表摘下来充电,手表就不记站立,也没有后台心率3。减法会把这段空白算进“坐”。
  • 站着不动可能不算站。 Apple 的定义是“站起来并活动”,在白板前站着讲半小时、排队一动不动,手表未必记满(这一点是根据定义的推断,Apple 没有公开判定算法)。
  • 坐着时手臂动得多,可能被算成站。 社区里常有人反映坐着打字、开车也会“站立达标”,说明判断依据主要是手腕的运动(同样是推断)。

那换一个更底层的数据呢?CoreMotion 的 CMMotionActivity 会把运动状态分成静止、步行、跑步、乘车、骑行和未知几类,各带一个置信度4。可坐和站在这里都是 stationary,还是分不出来,而且系统只保留最近 7 天5。再往下一层,watchOS 的 CMSensorRecorder 可以让手表在后台以 50 Hz 录原始加速度,单次最多 12 小时,数据保留 3 天6。有了原始数据,理论上可以自己训练一个坐站分类器,但手腕上的加速度本来就缺少区分坐站的信息(推断:研究里区分坐站通常要把传感器贴在大腿上,靠大腿是水平还是竖直来判断)。

解决:找一个“坐着”的旁证

既然手表测不出坐着,就找别的设备作证。Mac 恰好是一个很好的“坐着”的证据:键盘和鼠标有输入,人几乎一定在桌前;手表又没记到站立,那这段时间大概率是坐着的。图 1 下半部分就是这样推出来的:10:00–10:52 Mac 一直有输入,扣掉 10:07–10:09 那 2 分钟站立,坐了 50 分钟。

这个规则有一个明显的例外:用升降桌站着办公。这时候 Mac 有输入、手表也可能记到站立,两份证据互相矛盾。ActivityWatch 社区有一个 aw-watcher-table 插件直接读升降桌的高度7,是目前能精确区分坐站的少数办法之一。

而 10:52 之后那 8 分钟,人离开了 Mac,手表也没记到站立,就真的没有任何证据了:可能是去会议室坐着开会,也可能站在茶水间聊天。

所以“坐着”这件事,最后要靠 Mac 来补。那 Mac 自己能交出哪些记录?


二、屏幕前时间:Mac 能交出什么

现象:屏幕使用时间看得到,拿不出来

Mac 自带的“屏幕使用时间”已经在记录每个 App 的使用时长,问题是它不给第三方用。

Apple 为屏幕使用时间提供的公开接口是 DeviceActivity 一族框架,但它的设计很明确:报表由 App 的一个扩展来渲染,扩展跑在沙箱里,不能联网,也不能把数据带出扩展的地址空间8。也就是说,你可以在自己的 App 里“画”一张屏幕时间报表给用户看,却拿不到报表背后的原始记录。

第一次尝试:直接读系统数据库

数据总得存在某个地方。取证领域早就研究过:macOS 和 iOS 把应用使用记录写在 ~/Library/Application Support/Knowledge/knowledgeC.db 里,按“流”分类存放:比如 /app/usage 记录前台应用,/display/isBacklit 记录屏幕亮灭,时间精确到秒9。较新的系统又把这类记录迁到了 Biome,变成一种叫 SEGB 的私有二进制格式10。

Timing 这类商业软件就是走这条路的:它导入屏幕使用时间,需要用户给它“完全磁盘访问权限”,官方文档也直说 Apple 改了存储方式,这个功能可能就会失效11。同一页还提到系统界面只给到小时粒度、只保留四周。

所以读系统数据库能用,但有两个问题:格式私有、没有文档,每次系统升级都可能变;而且它只记录“哪个 App 在前台”,前台开着不等于人在看。

解决:自己记录“人在不在键盘前”

更稳的办法是在 Mac 上跑一个自己的记录程序,只回答两个问题:

  1. 人在不在? CoreGraphics 的 secondsSinceLastEventType 能直接拿到距离上一次键鼠输入过了多少秒12,超过一个阈值就当人离开了。
  2. 在用什么? 监听前台应用切换,就能知道是哪个 App;想要窗口标题,还得给它“辅助功能”权限13。

这正是开源工具 ActivityWatch 的做法。它的 aw-watcher-afk 判断离开:超过 3 分钟没有键鼠输入,就从最后一次输入的时刻起记为 afk;aw-watcher-window 记录前台应用和窗口标题13。两路记录都存在本机,用 localhost:5600 上的 REST 接口就能取出来14。

前台窗口和 afk 记录合并成屏幕前时间 09:00 到 09:30,前台窗口依次是 Xcode、Safari、Xcode、Slack、Xcode。afk 记录在 09:12 到 09:16 标为离开,因为这段时间在 Safari 里读文档没有碰键鼠。两者取交集,屏幕前时间是 12 分钟加 14 分钟,共 26 分钟,中间 4 分钟存疑。 Mac 上的两路记录 · 09:00–09:30 合并:前台窗口 ∩ 不在 afk aw-watcher-window Xcode Safari Xcode Slack Xcode aw-watcher-afk not-afk afk not-afk 屏幕前 12 min ? 14 min 09:00 09:12 09:16 09:30 虚线这 4 分钟在 Safari 里读文档,没碰键鼠,超过 3 分钟就被记成了离开。 合计:屏幕前 26 分钟,应用切换 4 次;这 4 分钟算不算工作,记录里看不出来。
图 2 · “屏幕前时间”是前台窗口和“人在键盘前”两路记录的交集。只看键鼠输入,读文档、看视频这种不动手的时间会被漏掉。图中数值为示意。

图 2 也暴露了这种做法的边界:它测的是“手在键盘上”,不是“眼睛在屏幕上”。读长文档、看视频、开视频会议时手不动,就会被记成离开。ActivityWatch 的缓解办法是浏览器标签页在播放声音时不判离开13;把阈值从 3 分钟调到 5 分钟也能少漏一些,代价是真离开时会多算几分钟。

到这里,“在屏幕前多久”有了一个分钟级的答案。但用户还想知道不同时间段里工作状态怎么样,这就不只是“在不在”的问题了。


三、工作状态:从行为和身体两侧看

什么叫“工作状态”

“状态好不好”没有一个能直接测量的物理量。我们能做的,是找几个可以测量、又和状态有关的信号,从两个方向去逼近:人在电脑上做了什么(行为),以及身体当时处在什么状态(生理)。

逼近“工作状态”的几类信号
信号来源能说明说明不了
行为(Mac)
连续停留在一个应用aw-watcher-window长时间不切换,往往是在专注做一件事在 IDE 里发呆也算“专注”
应用切换频率aw-watcher-window一刻钟切换几十次,大概率在被打断查资料时频繁切换也可能很高效
键鼠输入量aw-watcher-input在产出还是在浏览思考时手是不动的
屏幕内容截屏 + 大模型具体在干什么,语义最丰富隐私代价最高
生理(Apple Watch)
心率HeartRate紧张、活动、咖啡因都会让它升高分不清是哪个原因
HRVHRV SDNN自主神经的负荷白天样本太稀,只能看大趋势

行为信号是分钟级、连续的;生理信号在白天是稀疏的点。两侧单独看都会误判,叠在一起才有参考价值。

图 3 · 没有哪个信号直接等于“状态”。行为信号说明人在做什么,生理信号说明身体在承受什么。

行为一侧:切换和停留

行为信号里最实用的是应用切换。图 2 那半小时里切换了 4 次,主要停在 Xcode;如果另一个半小时在 Slack、邮件、浏览器之间切换了 40 次,不用任何算法也看得出来,后者是一段被打断的时间。RescueTime、Rize 这类工具给出的“专注度评分”,核心也是这套逻辑:按应用分类打分,再看连续时长和切换1516。

它的局限也写在表里:应用名只说明“在哪”,不说明“在干什么”。同样是开着浏览器,可能在查文档,也可能在刷新闻。于是有了一类新工具:定时截屏,再让大模型读截图,总结这段时间在做什么。开源的 Dayflow 就是这样生成一天的活动卡片时间线的,模型可以跑在本地17;screenpipe 则持续录屏、做 OCR 和语音转写18。语义最丰富,隐私代价也最高。

身体一侧:心率能用,HRV 太稀

Apple Watch 白天会在后台测心率。按 Apple 支持页的说法,较新的机型白天可以持续测量,老机型则在你静止时周期性地测、间隔不固定19。总之,心率是一条足够密的曲线,能对上每一段工作。

HRV 就不一样了。我在从两个对不上的 HRV 数字说起里讲过,手表只在静止、戴稳时才测 HRV,白天的样本很稀,单次读数的噪声又大。拿它去判断“10:00–10:15 是不是专注”是不现实的,它只适合回答“今天下午整体是不是比上午紧”这种大尺度的问题。

而且生理信号不特异:心率升高可能是紧张,也可能是刚爬完楼梯、刚喝了咖啡。所以它要和行为信号放在一起看:同样是心率 90,一段是 Mac 上连续 40 分钟写代码,一段是刚从楼下走上来,含义完全不同。

到这里,人这一侧的信号差不多齐了。还剩最后一块拼图:人所处的环境。


四、环境:噪声、日光与地点

噪声:手表一直在听,但不录音

Apple Watch 的噪声 App 用麦克风测量环境声级,写进 HealthKit 的 environmentalAudioExposure,单位是 A 计权声压级(dBA)20。按 Apple 支持页的说法,它在一天里周期性地测量,只测声级,不录音也不保存任何声音;具体多久测一次,Apple 没有公开21。所以我们能拿到的是一串带时间区间的分贝值,而不是音频。

这里有一个容易踩的坑:分贝不能直接平均。 分贝是对数刻度,65 dB 的声能是 55 dB 的 10 倍。一刻钟里 5 分钟是 54 dB、10 分钟是 66 dB,算术平均是 62 dB,但按声能平均要先换回能量:

Leq=10log⁡10(1T∑iti⋅10Li/10)L_{eq} = 10 \log_{10}\left(\frac{1}{T}\sum_i t_i \cdot 10^{L_i/10}\right)

其中 tit_i 是每段的时长,LiL_i 是这段的分贝值,TT 是总时长。代入上面的例子:

10log⁡105⋅105.4+10⋅106.615≈64.410 \log_{10}\frac{5 \cdot 10^{5.4} + 10 \cdot 10^{6.6}}{15} \approx 64.4

结果是 64.4 dB。这就是“等效连续声级” LeqL_{eq},HealthKit 里每个噪声样本本身就是这种值。差的这 2 dB,对应的声能差了将近 60%。

另外两个相关的类型也值得记下:headphoneAudioExposure 记录耳机里的音量;environmentalSoundReduction(iOS 16 起)记录戴降噪耳机时环境声被压低了多少22。后者很有意思:一段时间里一直在降噪,往往说明环境吵、人又想专注(推断)。

日光与地点

  • 日光时间:watchOS 10 起,较新的 Apple Watch 用环境光传感器估算你在日光下待了多久,写进 timeInDaylight23。它可以粗略地区分室内和户外。
  • 地点:Core Location 的 CLVisit 会在你到达、离开一个地点时通知 App,给出坐标和起止时间24。“办公室 09:05–12:10”“餐厅 12:20–13:05”这类停留就是这样来的。iPhone 自己的“重要地点”则没有公开接口,第三方读不到。

拿不到的那部分:SensorKit

Apple 其实有一个更全的传感器框架 SensorKit:环境光、气压、键盘使用指标、手机使用报告、常去地点、是否戴在手腕上、语音指标……几乎就是“还原一天”的现成原料25。但它只授予获得 Apple 批准的研究项目26,新数据还要等 24 小时才能读取27。个人开发者和普通 App 都拿不到。

现在,人、身体、环境三方面的数据都找到了来源。接下来的问题是:它们怎么拼到同一条时间线上?


五、拼成一条时间线

问题:每路数据的“时间形状”不一样

把这些数据摆在一起,会发现它们记录时间的方式各不相同:

  • 区间累计:站立分钟、步数。一条样本说的是“10:07–10:12 之间站了 5 分钟”“09:58–10:03 走了 200 步”。
  • 时间点:心率。一条样本就是“10:07 心率 78”。
  • 区间均值:噪声。一条样本是“10:05–10:20 的等效声级 66 dB”。
  • 事件区间:Mac 的 afk 和前台窗口。一条事件是“09:00–09:12 not-afk”。

数据也分在两台机器上:健康数据从手表同步到 iPhone 的 HealthKit;Mac 的记录在本机的 ActivityWatch 里。

两台设备的数据汇入同一个脚本 iPhone 的 HealthKit 里有站立分钟、步数、心率、环境噪声四类记录,时间形状分别是区间累计、区间累计、时间点和区间均值,通过健康 App 导出为 export.xml。Mac 上 ActivityWatch 有 afk 和前台窗口两个 bucket,都是事件区间,通过本机 REST 接口读取。两边都汇入 day_timeline.py,摊进 15 分钟一格的时间表。 iPhone · HealthKit Mac · ActivityWatch 合并 Record 时间形状 · 单位 AppleStandTime区间累计 · min StepCount区间累计 · count HeartRate时间点 · count/min EnvironmentalAudioExposure区间均值 · dBASPL 手表同步到 iPhone bucket 时间形状 · 字段 aw-watcher-afk_mbp事件区间 · status aw-watcher-window_mbp事件区间 · app GET localhost:5600/api/0/buckets/…/events export.xml 健康 App → 导出所有健康数据 day_timeline.py 摊进 15 分钟格子
图 4 · 两台设备、四种时间形状。健康数据从 iPhone 导出,Mac 的记录从本机接口读取,最后在一个脚本里对齐。

健康数据怎么搬到 Mac 上?最省事的是健康 App 里“导出所有健康数据”,得到一个 export.xml,每条样本是一个 <Record> 节点,带类型、数值、单位和起止时间。缺点是要手动点、文件动辄几百 MB 甚至几个 GB。想自动化,可以用 Health Auto Export 这类 App 定时把指定类型推到本地或自建的接口28。

第一次尝试:按事件对齐

最直觉的做法是以 Mac 的事件为主轴:每一段 not-afk 事件,去找它时间范围内的心率、噪声、站立。问题是 Mac 事件的长短差很多,有的 10 秒,有的两小时;人离开 Mac 的时间段又根本没有事件,手表那边的数据就挂不上去。

解决:切成等长的格子,按重叠比例分摊

更稳的办法是先把一天切成等长的格子(比如 15 分钟一格),再把每路数据按它的时间形状摊进格子里:

  • 区间累计型按重叠时长的比例分摊。09:58–10:03 的 200 步,有 3 分钟落在 10:00–10:15 这一格,就分给它 120 步。
  • 时间点型取格子内的平均。
  • 区间均值型按重叠时长加权,噪声还要按上一节的声能平均。
  • 事件区间型直接算重叠了多少秒。
10:00 到 10:15 这一格是怎么填出来的 五路样本铺在 09:45 到 10:30 的时间轴上。站立样本 10:07 到 10:12 整段落在格内,贡献 5 分钟;两段步数各有一部分落进格内,共 240 步;格内三个心率点平均 74;两段噪声按重叠时长做声能平均,得到 64 分贝;afk 记录全程 not-afk,屏幕前 15 分钟。结果推断为屏幕前、站过。 五路样本 · 09:45–10:30 落进这一格 这一格的结果 10:00 10:15 AppleStandTime 5 min StepCount 200 300 240 步 HeartRate 71 78 74 74 bpm AudioExposure 54 dB 66 dB 64 dB aw-watcher-afk not-afk 15 min 虚线框是 10:00–10:15 这一格:区间样本按重叠时长摊进来,时间点样本取平均。 10:00 屏幕 15 站立 5 步数 240 心率 74 噪声 64 dB 推断:屏幕前·站过(Mac 一直有输入,手表也记到了 5 分钟站立,可能是升降桌)
图 5 · 一格是怎么填出来的。步数 = 200 × 3/5 + 300 × 2/5 = 240;噪声按声能平均是 64 dB,算术平均会得到 62 dB。图中数值为示意。

每一格都填好之后,再用几条简单的规则给它贴一个状态标签:

  • 步数很多:走路或通勤。
  • Mac 有输入超过 10 分钟:在屏幕前;手表没记到站立就是坐着,记到了就是“站过”。
  • Mac 没输入、手表记到站立:站着或走动。
  • Mac 没输入、手表也没记到站立、但有心率:静止,坐站不明。
  • 连心率都没有:多半是没戴表。

最后两条很关键:规则不知道的时候,要老实写“不知道”,而不是把它塞进“坐着”。图 1 里那 8 分钟就属于这一类。

把一整天的格子加起来,就得到了开头想要的几个数:站了多久、坐在屏幕前多久、还有多少时间是“静止但不知道坐还是站”。文末给出了完整的脚本。

这件事自己拼要写不少代码,那有没有软件已经做好了?


六、现有软件做到了哪一步

按前面拆出的几个维度,把常见的软件放在一起比一比:

现有软件覆盖了哪些维度
软件站 / 坐屏幕前工作状态噪声地点
Apple 自带
健康 / 健身站立小时、站立分钟无心率、HRV 原始数据有数据无
屏幕使用时间无小时粒度,不能导出无无无
Mac 时间追踪
ActivityWatch仅升降桌插件分钟级,本地窗口、键鼠量无无
Timing无分钟级,含手机项目归类、AI 摘要无无
RescueTime / Rize无有,云端专注度评分无无
Dayflow无截屏推断大模型总结无无
手机与聚合
Arc Timeline步行等出行方式无每段停留的心率无停留与行程
Health Auto Export导出站立分钟无导出心率、HRV导出分贝无
Exist按天接 RescueTime按天找相关性无按天

没有一行是全绿的。每个软件只管一两块,且没有一个把 Mac 上的工作记录和手表上的身体数据放在同一条分钟级时间线上。

图 6 · 现有软件各管一段:Mac 端管屏幕,Arc 管地点,健康数据要靠导出工具搬出来。

几个值得单独说的:

  • Mac 端,ActivityWatch 开源、数据在本地、有 REST 接口,最适合拿来自己拼7。Timing 更省心,还能把 iPhone 的屏幕使用时间导进来,但如上一节所说,它依赖系统私有数据库11。
  • 地点,Arc Timeline 用 iPhone 的定位和运动传感器把一天切成“停留”和“行程”,还会在每段旁边读 Apple Health 的步数和心率,数据存在手机本地,可以每天自动导出 JSON29。它是线下这一半做得最完整的。
  • 健康数据搬运,Health Auto Export 可以把 HealthKit 里一百多种指标按分钟导出为 JSON/CSV,或推到自己的服务28。前面图 4 里手动导出 export.xml 那一步,可以用它替代。
  • 聚合,Exist 能把 Apple Health 和 RescueTime 等数据放在一起,但粒度是按天,做的是“哪些因素和心情相关”,不是时间线30。
  • 个人实践,Felix Krause 的 howisFelix.today 从 2019 年起记录了约 38 万条个人数据,来源是 RescueTime、地点签到、天气接口和一个 Telegram 机器人手动录入,统一存进自己的数据库3132。它说明这件事做得到,也说明了代价:相当一部分靠手动录入,粒度也主要是按天。
  • 已经消失的路线,Rewind(后改名 Limitless)曾主打 Mac 录屏加随身录音的“全量回放”,被 Meta 收购后已停止录屏和录音33。

所以结论是:目前没有一个软件能单独做到“还原一天”。 最接近的组合是 Mac 上的 ActivityWatch 或 Timing、手机上的 Arc Timeline,加上 Health Auto Export 把手表数据搬出来,最后自己按时间对齐。空白主要在两处:一是坐和站的精确区分,二是把 Mac 上的工作和手表上的身体数据放在同一条分钟级时间线上。


七、边界:哪些还原不了

拼到这一步,还有几块是现有设备补不上的:

  • 离开电脑后的“坐还是站”:开会、吃饭、在沙发上看手机,手表和 Mac 都无法区分。可以借助地点(在会议室,多半坐着)或日历(这段时间有会)来猜,但仍然是猜。
  • 不动手的工作:读文档、思考、开会,在键鼠记录里都表现为“离开”。截屏加大模型能补上一部分,代价是隐私。
  • 私有数据会变:屏幕使用时间的数据库、Biome 的格式都没有文档,系统升级就可能失效;SensorKit 这样的官方通道又不对个人开放。
  • 采样不均匀:心率密、HRV 稀、噪声的测量间隔不公开。格子切得越细,空格越多;切得越粗,越看不出一段时间内部的变化。15 分钟是一个折中。

小结

从“站立 10 小时 + 屏幕 7 小时”这两个对不上的数字出发,我们一路拼到了:

  1. 手表的“站立小时”不是站立时长:它只要求每小时站过 1 分钟;真正的时长在 appleStandTime 里。HealthKit 没有“坐着”,手腕也很难区分坐站。
  2. “坐着”要靠 Mac 作证:Mac 有键鼠输入、手表没记到站立,才推断为坐着;两边都没有证据的时间,要老实标成“不明”。
  3. 屏幕前时间要自己记:系统的屏幕使用时间拿不出原始数据,ActivityWatch 这类工具用“前台窗口 ∩ 不在 afk”给出分钟级记录,代价是漏掉不动手的时间。
  4. 工作状态要两侧一起看:应用切换和停留说明人在做什么,心率说明身体在承受什么;HRV 太稀,只能看大趋势。
  5. 环境数据手表已经在采:噪声以等效声级存进 HealthKit,平均时要按声能平均;日光时间和 CLVisit 补上室内外和地点。
  6. 拼接的关键是等长格子:四种时间形状的数据按重叠比例摊进 15 分钟一格,再用规则贴标签。
  7. 没有现成软件全覆盖,最接近的是 ActivityWatch / Timing + Arc Timeline + Health Auto Export 的组合。

下面是图 4、图 5 那条路的完整实现:读健康 App 导出的 export.xml 和本机 ActivityWatch 的记录,输出 15 分钟一格的时间表和全天合计。只用 Python 标准库。

Python
"""把 Apple 健康导出(export.xml)和 ActivityWatch 的记录拼成一张 15 分钟一格的时间表。
用法:python3 day_timeline.py export.xml 2026-11-01
依赖:只用标准库;ActivityWatch 需在本机 5600 端口运行。
"""
import json
import math
import sys
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from collections import defaultdict
from datetime import datetime, timedelta
EXPORT, DAY = sys.argv[1], sys.argv[2]
TZ = datetime.now().astimezone().tzinfo
START = datetime.fromisoformat(DAY).replace(hour=7, tzinfo=TZ)
END = START + timedelta(hours=17) # 07:00 – 24:00
SLOT = timedelta(minutes=15)
SLOTS = [START + i * SLOT for i in range(int((END - START) / SLOT))]
WANTED = {
"HKQuantityTypeIdentifierAppleStandTime": "stand", # 分钟
"HKQuantityTypeIdentifierStepCount": "steps",
"HKQuantityTypeIdentifierHeartRate": "hr", # 次/分
"HKQuantityTypeIdentifierEnvironmentalAudioExposure": "noise", # dBA
}
def overlap(a0, a1, b0, b1):
"""两段时间重叠的秒数。"""
return max(0.0, (min(a1, b1) - max(a0, b0)).total_seconds())
def spread(table, key, t0, t1, value, mode):
"""把一条样本分摊到它覆盖的格子里。sum 按重叠比例摊,mean 记成带权样本。"""
span = max((t1 - t0).total_seconds(), 1.0)
for s in SLOTS:
sec = overlap(s, s + SLOT, t0, max(t1, t0 + timedelta(seconds=1)))
if sec:
if mode == "sum":
table[s][key] += value * sec / span
else:
table[s].setdefault(key + "_w", []).append((value, sec))
# 1. 健康数据:流式解析,导出文件动辄上 GB
health = defaultdict(lambda: defaultdict(float))
for _, el in ET.iterparse(EXPORT):
kind = WANTED.get(el.get("type")) if el.tag == "Record" else None
if kind:
t0 = datetime.strptime(el.get("startDate"), "%Y-%m-%d %H:%M:%S %z")
t1 = datetime.strptime(el.get("endDate"), "%Y-%m-%d %H:%M:%S %z")
if t1 >= START and t0 < END:
mode = "sum" if kind in ("stand", "steps") else "mean"
spread(health, kind, t0, t1, float(el.get("value")), mode)
el.clear()
# 2. ActivityWatch:键鼠是否活跃 + 前台应用
def aw_events(prefix):
base = "http://localhost:5600/api/0/buckets/"
buckets = json.load(urllib.request.urlopen(base))
bid = next(b for b in buckets if b.startswith(prefix))
q = urllib.parse.urlencode({"start": START.isoformat(), "end": END.isoformat(), "limit": -1})
for e in json.load(urllib.request.urlopen(f"{base}{bid}/events?{q}")):
t0 = datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00"))
yield t0, t0 + timedelta(seconds=e["duration"]), e["data"]
active = [(t0, t1) for t0, t1, d in aw_events("aw-watcher-afk_") if d["status"] == "not-afk"]
apps = defaultdict(lambda: defaultdict(float))
for t0, t1, d in aw_events("aw-watcher-window_"):
for a0, a1 in active: # 只算人在键盘前的那部分
lo, hi = max(t0, a0), min(t1, a1)
for s in SLOTS:
sec = overlap(s, s + SLOT, lo, hi) if lo < hi else 0
if sec:
apps[s][d["app"]] += sec
def mean(pairs):
return sum(v * w for v, w in pairs) / sum(w for _, w in pairs)
def db_mean(pairs):
"""分贝不能直接平均,先换回声能再平均(等效连续声级 Leq)。"""
return 10 * math.log10(mean([(10 ** (v / 10), w) for v, w in pairs]))
# 3. 逐格输出,并按规则猜一个状态
print("时间 屏幕 站立 步数 心率 噪声 前台应用 推断")
total = defaultdict(float)
for s in SLOTS:
h = health[s]
screen = sum(apps[s].values()) / 60
stand, steps = min(h["stand"], 15), h["steps"]
hr = round(mean(h["hr_w"])) if "hr_w" in h else None
noise = round(db_mean(h["noise_w"])) if "noise_w" in h else None
top = max(apps[s], key=apps[s].get) if apps[s] else "-"
if steps > 600:
state = "走路/通勤"
elif screen >= 10: # 推断:Mac 在用,手表没记到站立就当坐着
state = "屏幕前·坐" if stand <= 3 else "屏幕前·站过"
elif stand >= 5:
state = "站着/走动"
elif hr is not None:
state = "静止·离开屏幕" # 坐着开会、站着聊天、吃饭……这里分不出来
else:
state = "没数据" # 多半是没戴表
total[state] += 15
total["屏幕"] += screen
total["站立"] += stand
print(f"{s:%H:%M} {screen:4.0f} {stand:4.0f} {steps:4.0f} "
f"{hr or '-':>4} {noise or '-':>4} {top[:14]:<14} {state}")
print("\n合计(分钟):", {k: round(v) for k, v in total.items()})

用模拟数据跑出来的一段输出长这样:

时间 屏幕 站立 步数 心率 噪声 前台应用 推断
08:30 0 0 1650 69 48 - 走路/通勤
09:00 15 0 0 72 45 Code 屏幕前·坐
09:15 15 0 0 70 45 Code 屏幕前·坐
10:00 15 5 0 69 66 Code 屏幕前·站过
12:00 0 0 0 73 52 - 静止·离开屏幕
...
合计(分钟): {'静止·离开屏幕': 510, '屏幕': 480, '站立': 25, '走路/通勤': 30, '屏幕前·坐': 450, '屏幕前·站过': 30}

规则部分故意写得很朴素,方便按自己的情况改:用升降桌的,可以把“屏幕前·站过”改成按站立分钟的比例拆开;有 Arc Timeline 导出的地点,可以把“静止·离开屏幕”按地点再细分成“会议室”“餐厅”。


参考资料

Footnotes

  1. appleStandHourApple Developerdeveloper.apple.com ↗每小时“站起来并活动至少 1 分钟”算一个站立小时https://developer.apple.com/documentation/healthkit/hkcategorytypeidentifier/applestandhour ↩

  2. appleStandTimeApple Developerdeveloper.apple.com ↗iOS 13 / watchOS 6 起,累计型时间样本https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/applestandtime ↩

  3. About wrist detection on Apple WatchApple Supportsupport.apple.com ↗关闭手腕检测后不记录站立,也没有后台心率https://support.apple.com/en-us/105002 ↩

  4. CMMotionActivityApple Developerdeveloper.apple.com ↗stationary / walking / running / automotive / cycling / unknown,加 confidencehttps://developer.apple.com/documentation/coremotion/cmmotionactivity ↩

  5. queryActivityStarting(from:to:to:withHandler:)Apple Developerdeveloper.apple.com ↗系统最多只保留最近 7 天的活动数据https://developer.apple.com/documentation/coremotion/cmmotionactivitymanager/queryactivitystarting(from:to:to:withhandler:) ↩

  6. recordAccelerometer(forDuration:)Apple Developerdeveloper.apple.com ↗50 Hz,单次最多 12 小时,数据保留 3 天https://developer.apple.com/documentation/coremotion/cmsensorrecorder/recordaccelerometer(forduration:) ↩

  7. ActivityWatch WatchersActivityWatchdocs.activitywatch.net ↗aw-watcher-input、aw-watcher-table(升降桌)、Life Cycle 导入等https://docs.activitywatch.net/en/latest/watchers.html ↩ ↩2

  8. DeviceActivityReportApple Developerdeveloper.apple.com ↗报表扩展跑在沙箱里,不能联网,也不能把数据带出扩展https://developer.apple.com/documentation/deviceactivity/deviceactivityreport ↩

  9. Knowledge is Power! Using the macOS/iOS knowledgeC.db Database to Determine Precise User and Application UsageSarah Edwards · mac4n6 · 2018-08-05mac4n6.com ↗/app/usage、/app/inFocus、/display/isBacklit 等流https://www.mac4n6.com/blog/2018/8/5/knowledge-is-power-using-the-knowledgecdb-database-on-macos-and-ios-to-determine-precise-user-and-application-usage ↩

  10. Bringing it Back with Biome DataMagnet Forensicsmagnetforensics.com ↗应用使用记录迁到 Biome,SEGB 二进制格式https://www.magnetforensics.com/blog/bringing-it-back-with-biome-data/ ↩

  11. Screen Time IntegrationTimingtimingapp.com ↗读屏幕使用时间数据库需要完全磁盘访问权限;系统界面只有小时粒度、保留四周https://timingapp.com/help/screen-time ↩ ↩2

  12. secondsSinceLastEventType(_:eventType:)Apple Developerdeveloper.apple.com ↗https://developer.apple.com/documentation/coregraphics/cgeventsource/secondssincelasteventtype(_:eventtype:) ↩

  13. ActivityWatch FAQActivityWatchdocs.activitywatch.net ↗超过 3 分钟没有键鼠输入就记为 afk;读窗口标题需要辅助功能权限https://docs.activitywatch.net/en/latest/faq.html ↩ ↩2 ↩3

  14. ActivityWatch REST APIActivityWatchdocs.activitywatch.net ↗https://docs.activitywatch.net/en/latest/api/rest.html ↩

  15. RescueTimeRescueTimerescuetime.com ↗https://www.rescuetime.com/ ↩

  16. RizeRizerize.io ↗https://rize.io/ ↩

  17. DayflowGitHubgithub.com ↗https://github.com/JerryZLiu/Dayflow ↩

  18. screenpipeGitHubgithub.com ↗https://github.com/screenpipe/screenpipe ↩

  19. Monitor your heart rate with Apple WatchApple Supportsupport.apple.com ↗https://support.apple.com/en-us/120277 ↩

  20. environmentalAudioExposureApple Developerdeveloper.apple.com ↗单位 dBASPL,离散的等效连续声级https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/environmentalaudioexposure ↩

  21. Measure noise levels with Apple WatchApple Supportsupport.apple.com ↗全天周期性测量,只用麦克风测声级,不录音也不保存https://support.apple.com/en-us/102315 ↩

  22. environmentalSoundReductionApple Developerdeveloper.apple.com ↗https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/environmentalsoundreduction ↩

  23. timeInDaylightApple Developerdeveloper.apple.com ↗iOS 17 / watchOS 10 起https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/timeindaylight ↩

  24. CLVisitApple Developerdeveloper.apple.com ↗https://developer.apple.com/documentation/corelocation/clvisit ↩

  25. SRSensorApple Developerdeveloper.apple.com ↗https://developer.apple.com/documentation/sensorkit/srsensor ↩

  26. Configuring your project for sensor readingApple Developerdeveloper.apple.com ↗只授予获批的研究项目https://developer.apple.com/documentation/sensorkit/configuring-your-project-for-sensor-reading ↩

  27. SRFetchRequestApple Developerdeveloper.apple.com ↗新数据有 24 小时冻结期https://developer.apple.com/documentation/sensorkit/srfetchrequest ↩

  28. Health Auto ExportHealthyAppshealthyapps.dev ↗https://www.healthyapps.dev/ ↩ ↩2

  29. Arc TimelineBig Pauabigpaua.com ↗https://www.bigpaua.com/arcapp/ ↩

  30. ExistExistexist.io ↗https://exist.io/ ↩

  31. How is Felix today?Felix Krause · howisfelix.todayhowisfelix.today ↗https://howisfelix.today ↩

  32. FxLifeSheetFelix Krause · GitHubgithub.com ↗https://github.com/KrauseFx/FxLifeSheet ↩

  33. LimitlessLimitlesslimitless.ai ↗被 Meta 收购,Rewind 已停止录屏与录音https://www.limitless.ai/ ↩