Android 电源管理深度解析:从 Wakelock 滥用到 Doze 模式的省电工程实践
adb shell dumpsys batterystats —reset
操作 App 30 分钟…
adb bugreport bugreport.zip
导入 Battery Historian(Docker 部署最快):
```bash
docker run -p 9999:9999 gcr.io/android-battery-historian/stable:3.1 \
--port 9999
打开 http://localhost:9999,上传 zip 文件后会得到一张密密麻麻的时间线。第一次看到这张图容易懵,我建议只盯三行:Wakelock、JobScheduler / Alarm、Network。这三行基本覆盖了 80% 的后台耗电场景。
看一个典型的问题特征:用户息屏后,Wakelock 行持续出现红色长条,Network 行有间断的蓝色色块。这通常意味着后台有周期性网络请求在持有 Wakelock 执行。一个请求可能只耗几毫安,但每小时执行 60 次,电池就撑不到下午。
用 Perfetto 做毫秒级归因
Battery Historian 告诉你「什么时候出了问题」,Perfetto 告诉你「谁触发的问题」。Android 12 之后系统内置了 Perfetto 追踪,用 System Tracing App 抓取最方便:
# 命令行方式:抓 30 秒,包含 wakelock 和 work 分类
adb shell perfetto \
-c - --txt \
-o /data/misc/perfetto-traces/trace.perfetto-trace <<EOF
buffers: { size_kb: 65536 }
data_sources: {
config {
name: "android.power"
}
}
data_sources: {
config {
name: "android.network_packets"
}
}
duration_ms: 30000
EOF
打开 ui.perfetto.dev,定位到问题时间段,重点看两条轨道:
- Alarm 唤醒:显示
PendingIntent的来源包名和 Intent,可以直接定位到发起方的代码。一个常见场景是第三方 SDK 注册了高频 Alarm,但你从未留意过它的初始化时机。 - Wakelock 持有者:
acquire和release事件配对显示,中间的间隙就是锁持有时间。如果 release 始终不来,说明有代码忘了释放——这比内存泄漏更隐蔽,因为不会 crash。
三大耗电源头的治理策略
Wakelock:不只关心有没有释放
多数学过 Android 的开发者都知道 Wakelock 要成对释放。实际项目中的问题更复杂:
持锁时间过长。一个 WakeLock 拿了 3 秒,但实际执行逻辑只要 200ms。原因是代码结构把网络请求和锁释放绑在了同一个 try-finally 里,网络超时让锁白白耗着。拆开它们就行了。
// 坏例子:网络请求在锁保护范围内
wakeLock.acquire()
try {
api.fetchData() // 可能耗时 5-10 秒
processData()
} finally {
wakeLock.release()
}
// 改为:只保护必须唤醒 CPU 的操作
wakeLock.acquire()
val data = try {
api.fetchData()
} finally {
wakeLock.release()
}
processData() // 这部分不需要持有锁
嵌套持锁。A 模块拿了锁,调用 B 模块,B 又拿了一次锁。如果 B 异常退出,外层 A 的 release 正常执行,但 B 的锁泄露了。用引用计数型 WakeLock 或封装一个代理类来统一管理可以根治。
Alarm:从定时轮询到按需唤醒
Alarm 的耗电模型很简单:每次唤醒至少让 CPU 脱离 suspend 状态 3-5 秒,即使你的代码只执行 10ms。一个每小时执行 3600 次的 Alarm(每秒一次),会让设备几乎无法进入深度休眠。
收敛策略分三级:
第一级:合并周期。多个模块各自设 Alarm,时间相近的合并成一个调度窗口。比如 A 模块每 5 分钟拉取消息,B 模块每 6 分钟同步配置,取最大公约数做不到完全一致,但可以改成 A 和 B 都在 5 分钟窗口内执行。
第二级:延迟容错。用 setExactAndAllowWhileIdle 的场景极少。多数后台任务用 setWindow 甚至 setInexactRepeating 就够了,系统会把相邻的 Alarm 合并到一个唤醒窗口:
alarmManager.setWindow(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
triggerTime - tolerance,
tolerance,
pendingIntent
)
第三级:迁移到 WorkManager。WorkManager 底层利用 JobScheduler 的柔性调度,系统会根据电量、网络状态自动延迟非紧急任务。我之前把 80% 的 Alarm 任务迁移到 WorkManager 后,后台唤醒次数直接降到原来的 1/5。
Network:不止是减少请求次数
移动网络的耗电有一个容易被忽略的特性:建立连接本身比传输数据的功耗更大。蜂窝网络从 idle 到 active 再到 DCH(专用信道)耗时约 2 秒,期间功耗是 idle 的 10 倍以上。这就是为什么「减少网络请求次数」比「减少传输数据量」对省电更有意义。
具体可以做三件事:
- 请求合并:在 Doze 退出窗口(Maintenance Window)内集中发送积压的埋点,而非每次操作都实时上报。Google 自己的 Firebase Performance 就是这么做的。
- 降频策略:根据电池电量动态调整请求频率。低于 20% 时,非关键请求直接排队到充电时再发。
- Protocol Buffers 替代 JSON:不是为了省带宽——序列化和反序列化更快,CPU 活跃时间更短。一个 50KB 的 JSON 解析耗 CPU 30ms,等价的 protobuf 不到 5ms。
建立持续监控而非一次性优化
代码改完了,怎么确保不会退化?我踩过的坑是:每次发版前手动跑一轮 Battery Historian,过两个版本就没人记得了。
更好的做法是把电量监控集成到 CI:
# 自动化 batterystats 分析脚本
adb shell dumpsys batterystats --checkin com.yourapp > stats.txt
# 解析关键指标
grep "wake_lock" stats.txt | awk -F',' '{sum+=$4} END {print "Total wakelock time:", sum/1000, "s"}'
grep "alarm_trigger" stats.txt | awk -F',' '{total+=$3} END {print "Total alarms:", total}'
我设置了两条阈值线:每个版本对比上个版本的 Alarm 唤醒次数,增长超过 15% 就告警;Wakelock 总持锁时长超过 300 秒/小时也告警。不用追求绝对精确,趋势比绝对值更重要。
线上监控用火葬场——我是说 Firebase Performance——的 Network 指标配合自定义 trace,不用额外接入 SDK 就能看到主线程被网络请求阻塞的分布。如果一个网络请求 P99 耗时从 200ms 涨到 500ms,优先排查的不是性能,而是为什么在后台频繁发起这个请求。
从单点优化到架构决策
回过头看,电量优化最难的不是定位问题,而是在功能需求和功耗之间做权衡。产品要实时在线,但 Doze 模式限制后台网络;运营要频繁上报数据,但每次上报都要唤醒设备。
我的原则是:前台体验不妥协,后台功耗按优先级分级。用户在使用 App 时,该同步的同步、该渲染的渲染,不需要省;用户息屏后,只有 IM 消息、来电这种有时效性要求的功能才走实时通道,其余全部排队到系统维护窗口。
这套分级策略落地后,那个社交 App 的后台电量消耗从每天 23% 降到了 6%,应用商店的电量相关差评在两个月内消失了。省下来的不止是电量——WakeLock 减少后,System Server 的 Binder 调用量下降,整机流畅度也有肉眼可见的提升。这是电量优化的附加红利,也是它比其他性能优化更「系统级」的原因。