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 文件后会得到一张密密麻麻的时间线。第一次看到这张图容易懵,我建议只盯三行:WakelockJobScheduler / AlarmNetwork。这三行基本覆盖了 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 持有者acquirerelease 事件配对显示,中间的间隙就是锁持有时间。如果 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 调用量下降,整机流畅度也有肉眼可见的提升。这是电量优化的附加红利,也是它比其他性能优化更「系统级」的原因。