AlarmManager 定时任务:先分清「没跑」和「不准」,再谈优化
先说结论:同一个问题有两个完全不同的问法
给 App 做"定时任务"功能后,用户反馈通常只有一句话:"我的任务没跑。"
但这句话背后至少混着三个层次的问题,混在一起排查,一定越查越乱:
| 层次 | 问题本质 | 观测手段 |
|---|---|---|
| 调度层 | 闹钟有没有被系统登记、落在什么时刻 | dumpsys alarm:origWhen / window / exactAllowReason |
| 唤醒层 | 闹钟到点后,广播有没有真的送达 | BroadcastReceiver 内的 System.currentTimeMillis() 时间戳 |
| 执行层 | 任务逻辑跑成功了没有、结果对不对 | 任务表里的 last_run_ts / last_result |
"能不能跑"是执行层,"准不准时"是调度层 + 唤醒层。 一开始我把三者混在一起看,甚至拿"通知上屏时间"当准时性指标,结论全是错的 —— 通知时间包含了前台服务启动、模型冷启动、联网推理,跟闹钟准不准没有因果关系。
这篇讲清楚每一层怎么读、怎么算偏差,以及几个让我白跑一趟的坑。
层一:dumpsys alarm 里到底看哪三个字段
在真机上执行:
adb shell dumpsys alarm | grep -A2 "TIMER_WAKE"
输出里关键的一行长这样:
RTC_WAKEUP #0: Alarm{... type=RTC_WAKEUP when=+6h32m origWhen=2026-11-12 08:00:00.000
window=0 exactAllowReason=policy_permission ...}
三个字段各有含义:
| 字段 | 读法 |
|---|---|
origWhen |
原始请求时刻。这是你调 setAlarmClock / setExact… 时传进去的时间,是"理想触发时间",也是算偏差的基准 |
window |
允许的触发窗口。0 表示精确闹钟;非 0(如 +15m)表示系统可以在窗口内任意时刻触发,此时偏差不是 bug,是你自己允许的 |
exactAllowReason |
精确闹钟为什么被放行。policy_permission 说明走的是系统闹钟级通道(setAlarmClock),权限没问题 |
这三个字段合起来能回答第一个问题:闹钟有没有被登记、是不是精确的、有没有被系统降级成窗口闹钟。
一个常见误判:window 非 0 就以为是 ROM 改坏了。实际上 setWindow() / setInexactRepeating() 本身就带窗口,窗口内触发完全合规。
第二层坑在国产 ROM。隐私合规收紧后,部分厂商的"省电优化"会把精确闹钟代理成窗口闹钟,exactAllowReason 会变成别的原因值,when 也会被加上几分钟浮动。这时候看到的偏差属于系统行为,不是应用代码问题 —— 加白名单(dumpsys deviceidle whitelist 里能看到 user,<包名>,<uid>)能缓解,但根治要用户手动关省电限制。
层二:广播时间戳 —— 为什么"事后抓日志"一定抓不到
这一层是最容易踩坑的地方,也是我这次差点白跑一趟的原因。
原本的计划:任务设定在 08:00 和 08:30,等到 08:35 之后再去 logcat 里翻 TimerWakeReceiver 的日志,拿到实际触发时间戳,跟 origWhen 相减算秒级偏差。
结果:08:35 去抓,TimerWakeReceiver 命中 0 条。
原因很简单:logcat 的 main 缓冲区是环形的,容量固定(默认 256KB 量级,按条数和字节算),业务日志一多,几分钟前的记录就被滚掉了。我抓的时候 main 缓冲的最新记录已经滚到 10:37 —— 08:00 的记录根本不存在了,不是"没触发",是"看不见了"。
所以有三条可行路径:
- 到点当场抓:闹钟设定时间前后守在机器上,
adb logcat -s TimerWakeReceiver实时收。最可靠,但要求人在场。 - 让任务自己落文件:
BroadcastReceiver里第一件事就是把System.currentTimeMillis()追加写进文件或数据库,之后随便什么时候取。推荐做法,因为可回溯、可对比多次触发。 - 用 events 缓冲而不是 main:
adb logcat -b events记录的是系统事件(如am_proc_start、alarm相关),滚动慢得多,但只能看到"进程被拉起",拿不到你自己打的日志。
我最后走的是"回头看数据库":把 timer_tasks 表导出来,用 last_run_ts 反推触发时刻。能算偏差,但缺点是要假设"任务写库的时刻 ≈ 触发时刻" —— 中间隔了模型调用和写盘,这条假设只在任务很短的时候成立(早报那种带联网推理的,误差会被吃掉几十秒)。
层三:执行层的两个字段就够了
任务表(简化):
CREATE TABLE timer_tasks (
id INTEGER PRIMARY KEY,
name TEXT,
goal TEXT, -- 任务目标描述
cron_desc TEXT, -- 展示用的计划描述
enabled INTEGER,
last_run_ts INTEGER,-- 上次执行的 epoch 毫秒;0 = 从未跑过
last_result TEXT, -- 上次执行结果文本
allow_tools TEXT, -- 预授权工具清单
...
);
读法:
last_run_ts = 0→ 从没跑过,问题在调度层或唤醒层,别急着查业务代码。last_run_ts有值但last_result是空/报错 → 跑到了执行层,看结果文本定位。last_run_ts与计划时刻差几十秒 → 用层一、层二的结论解释这个差值,不要拿"通知上屏时间"算。
真机是 release 包时,run-as 不可用(debuggable=false)。读私有目录要 root:
adb shell "su -c 'cp /data/data/<pkg>/databases/timer.db /sdcard/timer_probe.db; chmod 666 /sdcard/timer_probe.db'"
adb pull /sdcard/timer_probe.db
sqlite3 -line timer_probe.db "SELECT * FROM timer_tasks"
没有 root 的机器只能退到层一(dumpsys alarm)间接核对,拿不到执行结果。
一次真实测量:两个样本,结论完全不同
同一天设了两条任务,正好构成一组对照:
| 任务 | 计划 | 实际 | 偏差 | 任务性质 |
|---|---|---|---|---|
| 每日早报 | 08:00 | 08:00:31 | +31 秒 | 联网抓天气 + 新闻 + 落文件 |
| 早安问候 | 08:30 | 08:30:02 | +2 秒 | 纯文本,不联网 |
调度层两边都干净:两条 RTC_WAKEUP,window=0,exactAllowReason=policy_permission,走 setAlarmClock 通道,Doze 白名单已加,任务服务前台化正常。
那 31 秒差在哪?
早报那条是链路样本:setAlarmClock 唤醒的瞬间,设备刚从深度待机出来,网络栈要重新激活、DNS 要重解析,模型侧还要冷启动。这些时间全部计入 last_run_ts - origWhen。而早安那条是干净样本:不联网、不写文件,2 秒基本就是"广播投递 + 服务启动 + 写库"的开销。
所以:
- 测调度精度,用干净样本(纯文本任务),偏差就是调度本身。
- 测端到端可用性,用链路样本(联网任务),看的是"能否跑通"。
- 两个样本混在一起,就会得出"定时任务不准"的错误结论 —— 实际上 31 秒里几乎没有一秒是闹钟的问题。
四个把验证带偏的坑
1)单次闹钟不会自动续期
setAlarmClock / setExact 都是一次性的。触发完 *walarm* 条目消失,如果任务完成回调里没有重新排下一次,第二天不会再响。
现象很像"偶发不执行",实际是"第一次之后就死了"。修法:在任务的完成分支和失败分支都补一次 scheduleNext() —— 失败不补,任务就永久停摆。
2)force-stop 之后装包,MY_PACKAGE_REPLACED 不投递
被测 App 被 force-stop 过(或者用户手动停过),此时直接 adb install -r 覆盖安装,App 收不到 MY_PACKAGE_REPLACED,所有依赖该广播的重排逻辑都不执行。
正确顺序:
adb shell am start -n <pkg>/<launcher>
adb install -r app.apk
先 am start 解除 stopped 态。这个坑极隐蔽:装包成功、日志干干净净、闹钟就是不重排。
3)别在测试机上用 setExactAndAllowWhileIdle 就以为测到了 setAlarmClock
两者精度不同。setExactAndAllowWhileIdle 在 Doze 下有每分钟一次的限制,setAlarmClock 是系统闹钟级通道。如果代码里用一套、验证时看的是另一套,数据对不上。
4)"到点没响"先确认闹钟还在不在
排查顺序固定成三步:dumpsys alarm 有没有条目 → 有的话 origWhen 是否正确 → 正确的话再看广播有没有到。
跳过第一步直接查代码,是浪费时间的标准姿势:大部分"任务没跑"最终是闹钟根本没登记(比如重排逻辑被异常打断)。
结论
- 先分层,再算偏差:调度层(
origWhen/window/exactAllowReason)→ 唤醒层(接收器时间戳)→ 执行层(last_run_ts/last_result),三层混看必然误判。 logcatmain 缓冲不能当历史日志用,几分钟就滚掉;要可回溯,就让代码自己落文件或写库。- 测准时要控制变量:纯文本任务测调度,联网任务测链路,别用同一个指标衡量两件事。
- 偏差先归因到系统:
window非 0、Doze、厂商省电代理,都会产生"看起来不准"的合法偏差。 - 失败分支也要续期,否则一次失败等于永久停摆。

浙公网安备 33010602011771号