源码锚点 :平台源码以 Android 17 / API 37(
android-17.0.0_r1) 为准,内核以 android17-6.18-2026-06_r6 为准。为了让文章易读,文中源码做了删减和注释,每个片段都标了 AOSP 原始文件路径,建议对照源码食用。
序章:业主群里的「电量刺客」
想象一栋 24 小时营业的「夜光公寓」:
- 电池是公寓的地下水库,容量有限,白天偶尔有充电车(充电器)来补水;
- CPU 是中央空调,功率最大,一开机就是用电大户;
- 屏幕是外墙霓虹灯,亮起来哗哗掉水;
- 基带、Wi-Fi、GPS、相机则是楼里的对讲机、天线和各种电器;
- Android 系统是物业公司,负责决定什么时候开灯、什么时候拉闸;
- 每个 App 是住在这栋楼里的租户。
某天,物业经理(系统)在电梯口贴出一张电费分摊公示(Settings 里的电池用量页),上面写着:
| 租户 | 占比 |
|---|---|
| 抖音 | 18% |
| 某新闻 App(我们开发的) | 23% |
| 微信 | 12% |
业主群瞬间炸锅:「这个新闻 App 是电量刺客吧?我一天没打开它几次,它怎么用掉 23% 的电?!」
作为这家 App 的开发,你被拉进了群。要想洗清嫌疑,你必须回答三个问题:
- 系统是怎么算出 23% 这个数字的?(功耗归因原理)
- 我的 App 到底干了什么让系统这么记账?(WakeLock / Job / 定位 / 网络全链路源码)
- 怎么改,才能既保住功能又不背锅?(优化方法论 + 工具链)
这篇文章就沿着这三条线,从一块电池的物理特性,一路追到 Linux 内核的 suspend 流程,把 Android 电量优化的底层原理讲透。
一、电去哪了:三分钟搞懂功耗物理学
1.1 一条所有硬件工程师都会背的公式
手机里的芯片是 CMOS 电路,它的动态功耗有个经典近似公式:
P_dynamic ≈ α × C × V² × f
α:电路翻转活动因子(有多少晶体管在开关);C:等效开关电容(芯片物理属性);V:供电电压;f:时钟频率。
关键在 V 的平方项 :电压降到一半,动态功耗理论上降到四分之一。这就是为什么手机芯片调频时总是顺便降压 ------这也是 DVFS(Dynamic Voltage and Frequency Scaling,动态电压频率调节)名字的由来。
但注意两个坑:
- 公式只覆盖动态功耗 ,芯片还有漏电功耗(随工艺、电压、温度变化),GPU、内存、屏幕、电源转换损耗统统不在里面;
- 讨论续航要从功率走到能量 :
E = ∫ P(t) dt。低频降低了瞬时功率,却拉长了任务时间,所以低频不一定省电。
1.2 「先跑完再躺平」:race to idle
由此产生了两个相反的策略:
- race to idle(抢跑策略) :用高性能档位全速把活干完,然后尽早进入深度空闲。对短任务往往更省电------因为 CPU 空闲后可以进入低功耗状态,整体能量可能更低;
- 低速长跑:用低频档慢慢跑,适合后台可以慢慢算、且对峰值功率敏感的场景。
哪个更省电?没有跨设备的标准答案 ,必须以「同一工作量下的总能量」在目标设备上实测。这也是后面反复强调的一个原则:功耗结论必须来自目标设备的时间线和能量数据,别拿某款机器的一次测试当真理。
1.3 大小核与 EAS:让合适的核干合适的活
现代 SoC 不再是均匀的几个核,而是「小核(能效核)+ 中核 + 大核(性能核)」的异构结构。内核用一个数字描述每颗 CPU 的相对算力:
scss
capacity(cpu) = work_per_hz(cpu) × max_freq(cpu)
系统中最强 CPU 的原始 capacity 被归一化为 SCHED_CAPACITY_SCALE = 1024,其他 CPU 按比例缩放。
EAS (Energy-Aware Scheduling,能量感知调度)在给任务挑 CPU 时,不只看「这个核空不空」,还会查一张能量模型表(Energy Model):每个性能域(performance domain,通常就是一个 cpufreq policy)里,每个频点对应的功耗成本是多少,然后算出「这个任务放哪颗核、哪个频点最省电」。
几个容易被小白误解的点,划重点:
- 任务的利用率由 PELT (Per-Entity Load Tracking)维护,以 1024µs 为周期衰减,半衰期约 32ms------它不是「每隔 N 毫秒统计一次 CPU 占用率」的滑动窗口;
- 判断任务是否「装得下」某颗核时,内核预留了约 20% 余量:
util × 1280 < capacity × 1024才算 fit。所以 util=800 并不能塞进 capacity=800 的核; - 温控会压低可用 capacity。同一个任务冷机时 fit,机身热了就可能变成 misfit 被迁走------这就是为什么性能测试必须跑进「热稳态」。
Android 17 内核(6.18)的调频 governor 是 schedutil (kernel/sched/cpufreq_schedutil.c),它直接拿调度器的利用率信号换算目标频率,省略中间处理后核心逻辑是:
ini
util, uclamp_min, uclamp_max = collect_effective_cpu_util()
util = apply_iowait_or_other_boost(util)
perf = clamp(add_dvfs_headroom(util), uclamp_min, uclamp_max) // headroom ≈ 1.25 倍
raw_frequency = reference_frequency * perf / capacity
next_frequency = resolve_to_driver_supported_frequency(raw_frequency)
频率是离散的 OPP 档位 (Operating Performance Point,每档是「频率+电压」的组合),驱动在 policy 允许的范围内选一个不低于目标的档位。这就是「CPU 时间相同、耗电却不同」的第一层原因:同一个任务跑在小核低频和大核高频上,能量差异巨大。
二、电力公司的记账本:Android 是怎么算出「你的 App 用了 23%」的
2.1 三层计量体系:总表、分表、台账
回到夜光公寓。要回答「谁用了多少电」,物业需要三层信息:

objectivec
总表
Fuel Gauge / 库仑计
整块电池 µAh
会计核算
PowerAttributor +
各组件 PowerStatsProcessor
楼层分表
PowerStats HAL / ODPM
每条电源轨累计能量 µWs
谁在什么时候
干了什么
系统服务推送事件
用电台账
BatteryStatsImpl
记录 UID 级活动时长/计数
CPU 跑了多久
跑了哪个频点
内核拉取快照
电器铭牌
power_profile.xml
每个组件的 mA 电流参数
对外公示
BatteryUsageStats /
UidBatteryConsumer(mAh)
Settings 电池页
dumpsys batterystats
Battery Historian
这三层是三种完全不同的数据,不能直接横向比较:
| 层次 | 典型对象 | 记录内容 | 单位 |
|---|---|---|---|
| 电池侧 | Fuel Gauge、库仑计 | 整块电池的电流/电压/剩余电量 | µAh、µA、mV、% |
| 模块计量 | PowerStats HAL、ODPM | 各电源轨(rail)的累计能量 | µWs |
| 活动记账 | BatteryStatsImpl | UID 和组件的时间、计数、状态 | ms、次数、字节 |
- 总表(Fuel Gauge / 库仑计) :对电池充放电电流做时间积分,只能告诉你「整块电池掉了多少」,无法知道哪次电流是哪个 App 引起的;
- 分表(ODPM,On-Device Power Monitor) :Pixel 6 及之后的 Pixel 在 PMIC 里给每条电源轨装了能量计量,能量单位 µWs(= 微焦耳)。但 rail 也是设备级的------GPU rail 的读数里可能混着系统合成器和其他进程的活;
- 台账(BatteryStatsImpl) :运行在 system_server 里,记录的是「哪个 UID 在什么时间持有什么锁、跑了什么 Job、请求了什么硬件」这类活动线索。最终把能量分给谁,靠的是这些活动比例。
2.2 power_profile.xml:每台电器的铭牌
没有硬件分表的设备怎么办?靠「铭牌估算」。frameworks/base/core/res/res/xml/power_profile.xml 描述每个组件在不同状态下的平均电流(mA) :
xml
<!-- frameworks/base/core/res/res/xml/power_profile.xml -->
<device name="Android">
<item name="screen.on.display0">0.1</item> <!-- 亮屏基础电流 -->
<item name="screen.full.display0">0.1</item> <!-- 全亮电流 -->
<item name="wifi.on">0.1</item>
<item name="wifi.active">0.1</item>
<array name="cpu.active">
<value>0.1</value>
</array>
<array name="cpu.clusters.cores">
<value>1</value> <!-- 每个簇的核数 -->
</array>
...
</device>
归因计算时,电流乘以活动时长折算成电荷量:
scss
charge(mAh) = current(mA) × duration(ms) / 3_600_000
⚠️ 注意 :上面 AOSP 文件里的 0.1 是故意设置的占位错误值,AOSP 注释明确要求 OEM 出货前必须用目标设备的实测值覆盖。所以任何「用 AOSP 默认 power_profile 算出来的耗电报告」都没有设备测量意义。现代设备还会给 Wi-Fi/modem/Bluetooth 提供 controller 数据(idle/rx/tx 时长、发射档位),质量远好于纯 profile 估算。
2.3 push 与 pull:台账是怎么记的
BatteryStatsImpl 记账有两种方式,这是理解整本书「归因」体系的关键:
- Push(推送) :控制组件状态的服务在状态变化的那一刻 主动上报------屏幕亮灭、Wi-Fi 开关、定位开关、WakeLock 的 acquire/release;
- Pull(拉取) :CPU 时间、频点驻留这类累计统计,在状态切换或采样点拉一次快照。
一次 UID 归因可能同时用到:CPU 活跃时间与频点、进程前后台状态、WakeLock/Job/FGS/传感器时长、网络字节数与 controller activity、EnergyConsumer 累计值......所以别指望用一个公式通吃。
2.4 Android 17 的归因管线:会计换人了
Android 16 之前,各组件各有一个 *PowerCalculator(如 CpuPowerCalculator、ScreenPowerCalculator)。Android 17(android-17.0.0_r1)已把这组顶层 Calculator 移除,主路径变成:
bash
BatteryUsageStatsProvider
→ PowerAttributor(接口)
→ MultiStatePowerAttributor(默认实现,读取 BatteryStats history)
→ processor/CpuPowerStatsProcessor
→ processor/ScreenPowerStatsProcessor
→ ...
以屏幕为例,ScreenPowerStatsProcessor 的算法:
- 有 screen energy consumer(硬件分表)→ 用累计能量算出设备级屏幕耗电;
- 没有 → 用「亮屏时长 × 亮度档位 × profile」估算;
- UID 分配阶段:按「应用处于最前台 Activity 的时长占比」把屏幕耗电摊给各 App(Android 17 移除了旧实现中 10 分钟的 smear 门槛)。
CPU 更复杂一点:CpuPowerStatsProcessor 把 CPU 统计按「设备状态 × 屏幕状态 × UID 进程状态」聚合,有硬件能量时先用 profile 的 power bracket 校正总量,再按 UID 活动比例分配------硬件能量优先,但 UID 归属永远靠活动统计,因为 HAL 只知道「这个核簇用了多少能量」,不知道「哪条线程是哪个 App 的」。
2.5 想自己读电量?BatteryManager 的几个属性
ini
val bm = context.getSystemService(BatteryManager::class.java)
// 剩余电荷量,单位 µAh
val charge = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CHARGE_COUNTER)
// 瞬时电流,单位 µA(符号方向、精度由设备实现决定!)
val current = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW)
// 剩余百分比
val capacity = bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
还有几个「设置页百分比仅供参考」的边界要知道:
- 百分比 = 归因结果,受查询窗口、reset 边界、profile 质量、共享资源分配、系统未归因量影响;
- 所有 UID 百分比相加不一定等于电池端观测到的全部消耗(还有基带待机、供电转换损耗、系统组件等);
- 拔掉充电器不会无条件重置统计 ------Android 17 的
BatteryStatsImpl.shouldResetOnUnplugLocked()会根据满电、高电量阈值(默认 90,OEM 可配)、一次明显充电和统计时长决定是否 reset。统计数据持久化在/data/system/batterystats.bin,跨重启保留。
回到业主群:那 23% 只是「按活动比例摊给我们的记账值」。要定罪,还得看我们到底让硬件干了什么。下一章,抓最典型的「案犯」。
三、谁在半夜开灯:WakeLock 全链路源码剖析
夜光公寓有个省电大招:深夜拉总闸(system suspend),整栋楼断电休眠,只留门铃、火警、对讲机(能唤醒系统的硬件中断)随时待命。
而 WakeLock,就是租户往总闸上挂的一把**「禁止拉闸」的锁**。它是 Android 功耗问题里出场率最高的反派,也最容易被误解。
3.1 关灯 ≠ 拉总闸:先分清两层低功耗
bash
屏幕关闭
↓
CPU 无任务时仍可逐个进入 idle state ← 「关灯」,per-CPU 行为
↓
PowerManagerService 判断是否需要 CPU suspend blocker
├─ 需要:SystemSuspend 暂不发起 system suspend ← 「总闸保持合闸」
└─ 不需要:经过 wakeup_count 握手后进入 system suspend ← 「拉总闸」
- CPU idle :某颗 CPU 没有可运行任务时进入自己的空闲状态。持有 partial wake lock 不会强迫 CPU 一直满频跑指令;
- system suspend :整机进入更深度的休眠。有效的 partial wake lock 阻止的正是这一步。
所以「App 持锁」不等于「CPU 一直满负载」,但锁会让整机更难形成稳定的睡眠区间;如果锁内还有轮询、网络、定位,那能耗就是雪上加霜。
3.2 WakeLock 家族一览
| Level | 语义 | 应用建议 |
|---|---|---|
PARTIAL_WAKE_LOCK |
屏幕可灭,CPU 不进入 system suspend | 唯一值得讨论的,短时使用 |
PROXIMITY_SCREEN_OFF_WAKE_LOCK |
距离传感器控制屏幕开关 | 通话场景专用 |
SCREEN_DIM_WAKE_LOCK |
保持屏幕暗亮 | API 17 废弃 |
SCREEN_BRIGHT_WAKE_LOCK |
保持屏幕亮 | API 13 废弃 |
FULL_WAKE_LOCK |
保持屏幕与设备唤醒 | API 17 废弃 |
想让界面保持亮屏?用 FLAG_KEEP_SCREEN_ON / View.setKeepScreenOn(),系统会随窗口可见性自动管理,界面不可见后自动恢复正常屏幕策略------比手撕 WakeLock 安全得多。
3.3 申请之前先问一句:有「代驾」吗?
大量系统 API 在自己的执行窗口内已经代持了 WakeLock,你再手动申请一把,只是白白延长持锁时间:
| 工作 | 优先机制 | 锁由谁管理 |
|---|---|---|
| 可延迟后台任务 | WorkManager / JobScheduler | 调度框架在 Job 执行期代持 |
| 用户发起的长时上传下载 | user-initiated data transfer job | JobScheduler |
| 普通下载 | DownloadManager | 系统 |
| 音频播放 | Media APIs(Media3 可配 wake mode) | 音频栈/播放器 |
| 定位 | LocationManager / Fused Location | 定位栈 |
| 传感器 | wake-up sensor / batching | Sensor framework |
| 到点提醒 | AlarmManager | Alarm 投递期系统代持 |
3.4 正确的持锁姿势(源码模板)
只有当「CPU 睡了会导致当前短操作无法安全完成」时,才值得亲手持锁。标准写法四要素:稳定 tag、单一 owner、超时保险、finally 释放:
java
// 来自本 wiki 11.3 的安全示例
public final class CpuBoundExport {
// ① tag 稳定:硬编码包名/类名/操作名,不放时间戳、随机数、用户 ID
private static final String WAKE_LOCK_TAG = "com.example.export:CpuBoundExport";
// ② 超时由业务最慢执行时长推导,只是故障保险,不是正常释放手段
private static final long WAKE_LOCK_TIMEOUT_MS = TimeUnit.MINUTES.toMillis(10);
public static void run(Context context) {
PowerManager pm = context.getSystemService(PowerManager.class);
PowerManager.WakeLock wakeLock = pm.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG);
wakeLock.setReferenceCounted(false); // ③ 非引用计数,生命周期集中管理
wakeLock.acquire(WAKE_LOCK_TIMEOUT_MS);
try {
exportCurrentItem(); // 真正的短时 CPU 工作
} finally {
if (wakeLock.isHeld()) {
wakeLock.release(); // ④ 成功/失败/取消都必须走到这里
}
}
}
}
三条实战军规:
- tag 要稳定 :tag 是线上聚合的维度。高基数 tag(把请求 ID 塞进 tag)会让 vitals 无法聚合,系统还可能因怀疑含个人信息而把它脱敏成
_UNKNOWN; - 引用计数 :默认开启,两次
acquire()要两次release(),release 多了会抛 under-lock 异常。共享锁必须明确记录 owner,普通单任务建议关掉并集中管理; - held ≠ 有效 :
isHeld()只说明客户端没调 release;Doze、冻结、allowlist 等策略可能已经让系统逻辑上忽略 这把锁。API 33+ 可用WakeLockStateListener观察服务端的 enabled 状态。
3.5 从 App 到 system_server:acquire 的调用链(源码)
来看 acquire() 到底发生了什么。首先是应用进程这一侧:
csharp
// frameworks/base/core/java/android/os/PowerManager.java(删减)
public void acquire() {
synchronized (mToken) {
if (!mRefCounted || mCount++ == 0) {
// mToken 是一个 Binder 对象:服务端靠它识别"这一持锁关系"
mService.acquireWakeLock(mToken, mFlags, mTag, mPackageName,
mWorkSource, mHistoryTag);
}
mHeld = true;
}
}
然后跨 Binder 进入 system_server 的 PowerManagerService:
scss
// frameworks/base/services/core/java/com/android/server/power/PowerManagerService.java(删减)
private void acquireWakeLockInternal(IBinder lock, int flags, String tag,
String packageName, WorkSource ws, String historyTag, int uid, int pid) {
synchronized (mLock) {
int index = findWakeLockIndexLocked(lock);
WakeLock wakeLock;
if (index >= 0) { // 已存在的锁:更新标志位
wakeLock = mWakeLocks.get(index);
...
} else {
wakeLock = new WakeLock(lock, flags, tag, packageName, ws, historyTag, uid, pid);
// 关键 ①:注册死亡回调。客户端进程一旦被杀,服务端自动清理锁记录
lock.linkToDeath(wakeLock, 0);
mWakeLocks.add(wakeLock);
...
}
applyWakeLockFlagsOnAcquireLocked(wakeLock, uid);
mDirty |= DIRTY_WAKE_LOCKS;
updatePowerStateLocked(); // 关键 ②:重算整个电源状态
// 关键 ③:通知 BatteryStats 记账(这就是台账里的 push 事件)
mNotifier.onWakeLockAcquired(wakeLock.mFlags, wakeLock.mTag,
wakeLock.mPackageName, wakeLock.mOwnerUid, wakeLock.mOwnerPid,
wakeLock.mWorkSource, wakeLock.mHistoryTag);
}
}
三个关键点:
- Binder token:服务端用客户端传来的 Binder 对象标识这把锁,release 时凭 token 找到记录;
- linkToDeath() :客户端进程暴毙,Binder 死亡回调帮服务端清理------这是故障兜底,不能替代正常 release。进程活着但业务泄漏时,token 依然有效,系统无从判断「任务其实结束了」;
- Notifier → BatteryStats :
PowerManagerService里的 Notifier 组件把 acquire/release 事件转给BatteryStatsImpl.noteStartWakeLock(),这就是你后来在dumpsys batterystats里看到的按 UID/tag 聚合的统计来源。
3.6 汇总的艺术:N 把 App 锁 → 1 个 suspend blocker
应用可能有几十把锁,但系统不能每把都去内核注册------PowerManagerService 把所有「需要保持 CPU」的锁汇总成一个用户态 suspend blocker:
ini
// PowerManagerService.java(删减)
private void updateSuspendBlockerLocked() {
// mWakeLockSummary 是所有有效 WakeLock 标志位的汇总位图
final boolean needWakeLockSuspendBlocker = (mWakeLockSummary & WAKE_LOCK_CPU) != 0;
...
if (needWakeLockSuspendBlocker && !mHoldingWakeLockSuspendBlocker) {
mWakeLockSuspendBlocker.acquire(); // 名为 "PowerManagerService.WakeLocks"
mHoldingWakeLockSuspendBlocker = true;
} else if (!needWakeLockSuspendBlocker && mHoldingWakeLockSuspendBlocker) {
mWakeLockSuspendBlocker.release();
mHoldingWakeLockSuspendBlocker = false;
}
}
这条汇总链一路下探到 SystemSuspend:
scss
PowerManagerService.updateSuspendBlockerLocked()
→ mWakeLockSuspendBlocker.acquire()
→ JNI nativeAcquireSuspendBlocker("PowerManagerService.WakeLocks")
→ hardware/libhardware_legacy/power.cpp acquire_wake_lock()
→ ISystemSuspend.acquireWakeLock(PARTIAL, "PowerManagerService.WakeLocks")
→ SystemSuspend 的 suspend counter +1
这就是诊断时的经典困惑 :BatteryStats 里能看到一堆应用 tag,SystemSuspend 侧却只有一个 PowerManagerService.WakeLocks------前者是应用归因层,后者是汇总后的用户态阻止项。
3.7 SystemSuspend:每晚巡楼的电工(源码主循环)
Android 10 起,用户态挂起协调从旧的 libsuspend 迁到了 SystemSuspend 守护进程(system/hardware/interfaces/suspend/aidl/default/SystemSuspend.cpp)。它的 suspend 线程是个永动的循环:
scss
// system/hardware/interfaces/suspend/aidl/default/SystemSuspend.cpp(伪代码,注释为原文逻辑)
while (true) {
// ① 读当前 wakeup_count:内核自上次以来的唤醒事件计数
::read(wakeupCountFd, &wakeupCount, sizeof(wakeupCount));
// ② 阻塞等待用户态 suspend counter 归零
// ------ 只要还有任何一个 suspend blocker(比如 PMS 那把汇总锁),就在这里睡
counterFd->read(...);
// ③ 把第①步读到的值写回 /sys/power/wakeup_count
// 写失败 = 读取之后又来了新的唤醒事件 → 本轮放弃,回到①
if (!WriteStringToFd(wakeupCount, wakeupCountFd)) continue;
// ④ 正式拉闸:写 "mem" 到 /sys/power/state,内核开始挂起流程
if (!WriteStringToFd(sleepState, stateFd)) continue;
// 内核返回 = 被某个唤醒源叫醒了,回到①继续下一轮
}
这个循环体现了内核的 wakeup_count 握手协议:先读后写,保证「决定挂起」到「真正挂起」之间没有新唤醒事件插进来,避免带病休眠。
3.8 内核侧:wakeup_source
内核用 struct wakeup_source 记录一切「能阻止或打断 suspend」的实体。关键锚点(android17-6.18-2026-06_r6):
drivers/base/power/wakeup.c:注册、激活、停用与 tracepoint;drivers/base/power/wakeup_stats.c:/sys/class/wakeup/wakeupN/统计属性;kernel/power/suspend.c:system suspend 主流程;kernel/power/wakelock.c:用户态兼容接口。
内核侧的唤醒源除了用户态锁,还有 alarmtimer、输入设备、USB、蓝牙、modem 驱动等等------看到某个 kernel source 时,不能按名字去找同名 App tag,它们不是逐把映射的。
root 的实验机上可以看这张总表(量产 user build 读不了 debugfs):
bash
adb root
adb shell cat /sys/kernel/debug/wakeup_sources
关键字段速查:
| 字段 | 含义 |
|---|---|
| active_count | 从 inactive → active 的次数 |
| event_count | 上报 wakeup event 的次数 |
| wakeup_count | 被计入系统唤醒的次数 |
| active_since | 当前已持续活跃的时间(未活跃为 0) |
| total_time | 累计活跃时间 |
| max_time | 单次最长活跃时间 |
| prevent_suspend_time | 阻止 suspend 的累计时间 |
比较两次快照请用增量,设备运行很久后的绝对累计值没法归因到这次复现。
3.9 完整时序图:一条 WakeLock 的一生
把前面所有环节串起来------这就是 App 的一次 acquire/release 在整机里的完整旅程:

scss
应用进程PowerManagerService(system_server)Notifier →BatteryStatsImplSystemSuspend(用户态守护进程)Linux 内核newWakeLock(PARTIAL, tag)创建 Binder tokenacquireWakeLock(token, flags, tag, ws) [Binder]acquireWakeLockInternal()创建服务端 WakeLock 记录lock.linkToDeath(wakeLock, 0)onWakeLockAcquired → noteStartWakeLock(uid, tag)updatePowerStateLocked()mWakeLockSummary & WAKE_LOCK_CPU ≠ 0需要 CPU suspend blockeracquireWakeLock(PARTIAL,"PowerManagerService.WakeLocks") [Binder]suspend counter +1suspend 线程卡在第②步等计数归零整机无法进入 system suspend(App 侧 CPU 空闲时仍会进各自 idle state)releaseWakeLock(token) [Binder]onWakeLockReleased → noteStopWakeLock(uid, tag)updateSuspendBlockerLocked()所有 CPU 锁已释放?releaseWakeLock("PowerManagerService.WakeLocks")suspend counter 归零,线程被唤醒读 /sys/power/wakeup_count写回 /sys/power/wakeup_count(wakeup_count 握手)写 "mem" 到 /sys/power/stateenter_state() → 按序挂起设备整机进入 system suspend之后由门铃/火警/Alarm 等wakeup source 中断唤醒系统应用进程PowerManagerService(system_server)Notifier →BatteryStatsImplSystemSuspend(用户态守护进程)Linux 内核
3.10 系统也会「没收」你的锁:setWakeLockDisabledStateLocked
你以为挂上锁就稳了?PowerManagerService 还有一道 setWakeLockDisabledStateLocked() 检查,以下情况会把已申请的锁逻辑禁用:
- WorkSource 归因的 UID 进入了 cached 状态(进程被缓存回收线压住);
- owner 进程进入 frozen 状态(被 Cached App Freezer 冻结);
- 设备进入 deep idle / light idle(Doze 的两种形态);
- 不在 device-idle allowlist、临时 allowlist、Low Power Standby allowlist 里;
- force-suspend 或按 power group 强制禁用。
状态变化时,updateWakeLockDisabledStatesLocked() 会为禁用的锁发逻辑 release 、重新启用时发逻辑 acquire------注意这是服务端状态与统计的更新,不代表客户端调用了对应方法。这也是「我的锁明明 held 着,怎么设备还是睡了/网络断了」的直接答案。
3.11 Google Play 的红线:excessive partial wake lock
最后是最现实的商业后果。Android vitals 对「过度持锁」的判定:
- 所有非豁免的 partial wake lock 合计,在 24 小时内达到或超过 2 小时(统计的是后台或 FGS 期间的持锁时间);
- 豁免范围:audio、location ,以及 JobScheduler user-initiated API 创建的锁;
- 若 28 天内超过 5% 的应用会话 命中该问题------注意是「会话比例」不是「持锁时间占比」------就会影响商店表现。2026 年 3 月起,Google Play 已逐步对持续超标的 App 减少推荐曝光,并可能在商店详情页展示耗电警告。
四、深夜巡逻队:Doze 与 App Standby Buckets
拉总闸解决「整机休眠」,但对租户提交的任务(Job、Alarm、网络),系统还有一套更细的管理。毕竟单个 App 只知道自己的事急不急,系统还要统筹:电量、充电状态、屏幕、移动状态、温度、内存、网络,以及其他所有 App。
4.1 Doze:两套独立的状态机
Android 6.0 引入 Doze:设备长时间未使用时,暂停普通后台网络、忽略 App WakeLock、延后 Job/Sync/普通 Alarm,并在「维护窗口」里批量放行积压的工作。Android 7.0 又加了更轻的 Light Doze------两者在 DeviceIdleController 里是两套独立的状态机 (dumpsys 里分别是 mLightState 和 mState):

bash
Deep Doze 状态机 mState
Light Doze 状态机 mLightState
窗口结束,空闲间隔逐步拉长
窗口结束
设备移动 / 亮屏 / 插电
运动 / 亮屏 / 充电
SENSING / LOCATING
熄屏 + 未充电 + 持续静止
依赖 significant motion 检测器
IDLE
严格限制,延长 suspend 驻留
IDLE_MAINTENANCE
维护窗口
进入轻度空闲
熄屏 + 未充电
不要求设备静止
LIGHT_IDLE
限制后台网络 / Job / Alarm
LIGHT_MAINTENANCE
维护窗口:暂时放宽限制
ACTIVE:全部限制解除
Doze 中被限制的能力清单:
- 普通网络访问暂停;
- App 持有的 WakeLock 被忽略(见 3.10 节);
set()/setWindow()/setExact()普通 Alarm 延后到维护窗口;- JobScheduler / WorkManager / SyncAdapter 延后;
- 后台 Wi-Fi 扫描停止;
- FGS 可以提高进程重要性,但拿不到设备级 Doze 豁免------这是最高频的误解!
能穿过 Doze 的特殊通道只有几条,且都带频率约束:
setAndAllowWhileIdle()/setExactAndAllowWhileIdle():约每 App 九分钟一次 的频率边界(系统还可拉长),只适合用户可感知的关键事件,不能拿来高频轮询;setAlarmClock():用户可见闹钟,系统为交付离开低功耗模式;- 高优先级 FCM:只给「时间敏感且会产生用户可见通知」的消息一次短暂的网络 + partial WakeLock 机会。
Android 17 还新增了 listener 型的 setExactAndAllowWhileIdle(int, long, String, Executor, OnAlarmListener):回调直接在指定 Executor 执行,进程只需保持组件存活,不必为等一个精确回调而整夜持锁------这是官方在给「持锁等回调」这种反模式修路。
4.2 维护窗口不是定时器
设备持续空闲时,维护窗口之间的休眠间隔会逐步增长 ,具体时长由平台版本、资源 overlay、DeviceConfig 与设备实现决定。App 不能把某款手机的窗口间隔当调度 SLA。窗口到来只代表「限制暂时放宽」,你的 Job 还要满足自身约束、standby bucket、quota 和并发条件,不保证马上执行。
4.3 App Standby Buckets:会员等级体系
Doze 管「整机闲不闲」,App Standby Bucket 管「你这个 App 值不值得被照顾」。系统按使用模式把 App 分进五档会员(API 28+),配额天差地别:
| Bucket | Regular jobs | Expedited jobs | Alarms | 后台网络 |
|---|---|---|---|---|
| Active(活跃) | 60 分钟内最多约 20 分钟 | 24 小时内最多约 30 分钟 | 无频率限制 | 不限制 |
| Working set(常用) | 4 小时内最多约 10 分钟 | 24 小时内最多约 15 分钟 | 每小时约 10 次 | 不限制 |
| Frequent(频繁) | 12 小时内最多约 10 分钟 | 24 小时内最多约 10 分钟 | 每小时约 2 次 | 不限制 |
| Rare(很少用) | 24 小时内最多约 10 分钟 | 24 小时内最多约 10 分钟 | 每小时约 1 次 | 禁用 |
| Restricted(受限) | 每天一次、单批约 10 分钟 | 24 小时内最多约 5 分钟 | 每天一次 | 禁用 |
(上表为 2026 年官方近似指导值,充电时多数放宽;数字随版本会变,别硬编码进业务逻辑。)
Restricted 桶怎么进来的?Android 13+ 上,8 天无互动、或 24 小时内广播/binding 次数过多,都可能被扔进去。角色类 App(配套设备、VPN、默认拨号、活动 widget 等)有豁免。
架构上:AppStandbyController 评估 bucket → QuotaController 按桶判断配额 → JobSchedulerService 汇总所有约束分发任务。Android 17 还提供了 JobScheduler.getPendingJobReasonStats(jobId),能把「Job 到底在等什么」按原因累计时长:
PENDING_JOB_REASON_APP_STANDBY:bucket 拦住了;PENDING_JOB_REASON_QUOTA:配额用完了;PENDING_JOB_REASON_DEVICE_STATE:Doze/省电/内存/温度;PENDING_JOB_REASON_JOB_SCHEDULER_OPTIMIZATION:调度器主动选了更合适的时间;- 显式 constraint reason:充电、网络等业务约束未满足。
4.4 一次 Job 的「求职之旅」
把 Job 提交进系统后到真正执行,要过多少关?看这张时序图:

scss
应用进程JobSchedulerService约束控制器群(Connectivity/Charging/Idle...)QuotaController(+AppStandbyController)JobServiceContext进程schedule()/enqueueUniqueWork() [Binder]Android 11+:高频调用调度入口会被节流Android 12+:每 App 最多 150 个已调度 Job登记 Job,等待各自约束满足运行资格评估查 standby bucket → 查运行时长配额(Android 16+:与 FGS 并发的 Job 也受 quota)不满足 → Job 保持 PENDINGpending reason 记为 APP_STANDBY / QUOTA ...Doze / Battery Saver / 热状态 / 内存检查设备处于 Doze 且无豁免 → 继续等维护窗口约束 + 配额 + 设备状态全部通过bindService 到目标进程(必要时先拉起进程)onStartJob() 回调Job 执行期系统代持 CPU WakeLockApp 无需自己持锁jobFinished(params, needsReschedule)归还执行槽,记录 stop reason剩余约束未变的下一批 Job 继续排队应用进程JobSchedulerService约束控制器群(Connectivity/Charging/Idle...)QuotaController(+AppStandbyController)JobServiceContext进程
4.5 Battery Saver 与 Low Power Standby:另外两只手
- Battery Saver(省电模式) :
PowerManager.isPowerSaveMode()查询,ACTION_POWER_SAVE_MODE_CHANGED监听。它不会 简单把所有 App 降成 Rare 桶,而是分头调整 Job、Alarm、定位(getLocationPowerSaveMode()有「熄屏停 GNSS」「只允许前台」等多种策略)、网络、显示与性能策略,OEM 还会加料; - Low Power Standby(API 33+,低功耗待机) :比 Doze 更直接------非交互且不在维护窗口时直接禁网 + 忽略 WakeLock,FGS 也在射程内。排查「FGS 明明活着却断网」时,记得把 LPS、Doze、Data Saver 三家分开取证。
五、正确的打开方式:把工作交给系统的「快递公司」
理解了原理,反过来就是方法论:系统省电的全部智慧,在于「合并、延后、批处理、让硬件多睡」 。所以 App 优化的第一性原理是:主动把「合并与延后的自由」交还给系统。
5.1 先选对执行机制
| 业务性质 | 建议入口 | 必须接受的系统边界 |
|---|---|---|
| 用户正在界面里等 | 协程/线程池/进程内异步 | 页面退出取消无用工作,别阻塞主线程 |
| 可延迟、要求重启后继续 | WorkManager(平台组件可直接用 JobScheduler) | 受约束/配额/bucket/Doze 影响,时间不精确 |
| 用户要求准点提醒 | AlarmManager | 优先不精确闹钟;精确闹钟有权限与场景限制 |
| 用户知情的持续任务 | 对应类型的 FGS | 持续通知、启动豁免、类型权限,部分类型限时 |
| 服务端有新事件才处理 | FCM / 业务推送 | 优先级要匹配用户可见性,要有降级方案 |
选错机制 = 主动放弃所有省电机会:把普通同步塞进精确闹钟或常驻 FGS,等于在夜光公寓里自己开了一台通宵空调,还嫌物业不给电。
5.2 WorkManager:把延迟空间交给系统
scss
// 约束表达的是"业务条件",不是对系统的命令
val uploadConstraints = Constraints.Builder()
.setRequiresCharging(true) // 插电时才跑大活
.setRequiredNetworkType(NetworkType.UNMETERED) // 注意:UNMETERED ≠ Wi-Fi!
.build()
val upload = OneTimeWorkRequestBuilder<UploadWorker>()
.setConstraints(uploadConstraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"pending-media-upload",
ExistingWorkPolicy.KEEP, // 唯一工作:同任务重复入队时复用,避免堆积
upload)
划重点:
UNMETERED是「系统判定不计量」,某些 Wi-Fi 会被标成计费,某些蜂窝套餐也可能不计量;只要联网就用CONNECTED,多加一个约束 = 多一分被推迟的理由;- 周期任务最小间隔 15 分钟,这是请求下限不是准点承诺,flex window 让系统在周期尾部挑时机;
Result.retry()只用于可恢复错误------鉴权失败还无限重试,等于反复唤醒设备和服务器;- Android 16 起,top state 启动后界面消失仍在跑的 Job、以及与 FGS 并发的 Job,都要遵守 job runtime quota (WorkManager/JobScheduler/DownloadManager 全家受影响),用
WorkInfo.getStopReason()看被谁叫停。
5.3 AlarmManager:精确是有价的
| 能力 | 授权方式 | 适用边界 |
|---|---|---|
SCHEDULE_EXACT_ALARM |
用户授予,可被撤销 | 使用面较宽;调用前检查 canScheduleExactAlarms() |
USE_EXACT_ALARM |
安装即授,用户不可撤 | 仅闹钟/计时器/日历等核心场景,受 Play 政策约束 |
OnAlarmListener 版 setExact() |
无需精确闹钟权限 | 进程内监听器,进程退出后不再投递 |
普通同步/清理/重试请交给 WorkManager;用户接受时间窗就用 setWindow();setExactAndAllowWhileIdle() 的九分钟频率边界是防滥用红线不是轮询周期;闹钟回调里只做短操作,后续工作转交 Job/Worker。
5.4 FGS:挂着执照也得省电
前台服务 = 挂着营业执照的通宵店面。它提高进程重要性、展示持续通知,但不免除 Doze、Job quota、网络限制和硬件资源管理。而且从 Android 14 起,类型权限前置校验;从 Android 15 起,两类服务有硬性时长额度:
| 类型 | 平台限制 | 超时回调 | 未及时停止 |
|---|---|---|---|
shortService |
约 3 分钟 | Service.onTimeout(int) |
触发 ANR |
dataSync(target 35+) |
后台状态每 24h 累计 6h | Service.onTimeout(int, int) |
数秒内不停 → 进程异常终止 |
mediaProcessing(target 35+) |
后台状态每 24h 累计 6h | Service.onTimeout(int, int) |
同上 |
额度按类型共享、回前台重置。超时回调里只该「存进度、放资源、stopSelf()」。Android 17 还加了后台音频 hardening :后台播放/抢焦点/调音量,必须「可见 Activity 或运行着非 shortService 的 FGS」(target 37 还要求 FGS 具备 while-in-use 能力),否则静默失败------日志里搜 AudioHardening 可定位。
5.5 定位:priority 是偏好,不是开关
FusedLocationProvider 的 priority 表达的是「精度与功耗的偏好」,不保证具体传感器组合:
| Priority | 契约含义 | 合适的业务 |
|---|---|---|
PRIORITY_HIGH_ACCURACY |
偏高精度,可能增加功耗 | 用户可见导航、运动轨迹 |
PRIORITY_BALANCED_POWER_ACCURACY |
精度功耗折中 | 天气、附近内容 |
PRIORITY_LOW_POWER |
偏低功耗,允许降精度 | 容错较高的低频场景 |
PRIORITY_PASSIVE |
只蹭别人的定位结果 | 辅助更新、保鲜缓存 |
写法上给请求设终点:
scss
val request = LocationRequest.Builder(
Priority.PRIORITY_HIGH_ACCURACY, 30_000L)
.setMinUpdateIntervalMillis(30_000L)
.setMaxUpdateDelayMillis(120_000L) // 允许批量交付 → 减少 App 被唤醒次数
.setDurationMillis(45 * 60_000L) // 会话上限:时间一到自动停
.build()
一次性的「当前位置」用 getCurrentLocation() + CancellationSignal;「进出场事件」用 Geofencing(分钟级事件入口,别当秒级轨迹用);会话结束、权限撤销、页面离开时 removeLocationUpdates() ------「注册」和「注销」必须像 onStart/onStop 一样对称。
5.6 网络与推送:outbox 模式
零散请求是蜂窝功耗大杀器:每次请求都有 DNS、建连、TLS、发包的固定成本,还可能延长基带的 tail time (传输结束后保持高功耗的尾巴)。对策是持久化 outbox(本地待发队列)+ 单一上传 Worker:
css
object TelemetryUpload {
fun notifyOutboxChanged(context: Context) {
val upload = OneTimeWorkRequest.Builder(OutboxUploadWorker::class.java)
.setConstraints(Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"telemetry-outbox-upload", ExistingWorkPolicy.KEEP, upload)
}
}
Worker 里循环读取大小受限的批次 ,服务端确认后事务删除;没发完就 Result.retry()。注意三点:交互类请求(支付确认、用户点发送)不能为批量而延后;推送只用于「服务端状态变了」的场景,high priority 只给时间敏感且用户可见的消息;消息可能重复/延迟/丢失,客户端仍需版本号或游标兜底。
六、六个电量事故现场复盘
理论讲完,来看六个真实感十足的事故现场。每个都按「故障代码 → 案情分析 → 修复 → 验证」四步走。
案件一:前台服务轮询(每 5 秒问一次「有新消息吗」)
scala
// ❌ 故障代码:为了"保活",在前台服务里死循环轮询
public final class MessagePollingService extends Service {
private volatile boolean stopped;
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
startForeground(1, buildNotification());
new Thread(() -> {
while (!stopped) {
checkNewMessages(); // 没新消息也照样唤醒 + 网络握手
SystemClock.sleep(5_000);
}
}, "message-poll").start();
return START_STICKY; // 被杀后还自动复活
}
...
}
案情分析:通知只说明「用户看得见这个服务」,不降低轮询开销。每 5 秒一次的定时器唤醒 + 网络握手 + 进程保活,三宗罪齐了;START_STICKY 还让进程被杀后死而复生。
修复:按业务时效分流------实时消息走共享推送通道(high priority 只给用户可见的通知);后台刷新走「普通推送触发 + 低频兜底同步」;可延迟上传走 WorkManager。
arduino
// ✅ 修复后的兜底同步:唯一工作 + 约束 + 退避
public final class MessageSync {
public static void enqueue(Context context) {
OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(MessageSyncWorker.class)
.setConstraints(new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED).build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build();
WorkManager.getInstance(context).enqueueUniqueWork(
"message-recovery-sync", ExistingWorkPolicy.KEEP, request);
}
}
验证:对比修复前后「单位时间唤醒次数、CPU running 时间、网络请求数」与「消息端到端延迟」,两头都要看------省电不能以丢消息为代价。
案件二:后台持续高精度定位
java
// ❌ 故障代码:1 秒间隔 + 零位移门槛,且从不注销
public final class LocationTracker implements LocationListener {
public void start() {
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER, 1_000, 0, this);
// 没有 removeLocationUpdates()!Activity 销毁后 GNSS 依旧工作
}
}
案情分析 :参数没有业务依据(1s/0m 是导航级配置,拿来干别的就是浪费);生命周期没有对称注销。系统可能因后台限制降低回调频率,但请求本身还挂在 provider 上 ------顺带一提,Android 17 的 LocationProviderManager 会综合校验 Manifest/运行时权限/AppOps、UID 前后台、Battery Saver 定位模式等,不通过的 registration 不会驱动底层 provider,但其他 App 的请求仍会让 GNSS 工作,所以「我这没回调」推不出「整机定位零功耗」。
修复:
scss
// ✅ 一次性需求:明确终点
locationManager.getCurrentLocation(LocationManager.FUSED_PROVIDER,
cancellationSignal, executor, consumer); // 页面销毁时 cancel()
// ✅ 连续需求:参数由产品场景推导 + 会话时长上限 + 停止导航时 removeLocationUpdates()
// ✅ 进出场事件:Geofencing(分钟级入口)
验证 :dumpsys location 看各 provider 的 request/registration 与前后台限制;Battery Historian 对齐定位、WakeLock、熄屏、省电模式的时间线;有条件用电源轨/外接仪表确认 GNSS 能量同步下降。
案件三:零散网络请求反复激活蜂窝链路
案情 :埋点、日志、配置检查各自为政,一分钟十几个小请求。蜂窝环境下,碎片化传输让基带反复从 idle 进入高功耗状态,还可能被 tail time 反复拖住。TrafficStats 的字节数不能换算毫安时------同样 1MB,Wi-Fi 和弱信号 5G 的能量差出几倍。
修复 :5.6 节的 outbox 模式 + 连接复用 + 按错误类型区分重试(网络瞬断退避重试、限流遵守 Retry-After、参数错误直接停);大文件支持断点续传和幂等提交。
验证:客户端调用日志看请求间隔与包大小;NetworkStatsManager 看UID 流量;有 modem 电源轨的设备看 rail delta;在 Wi-Fi/强信号蜂窝/弱信号蜂窝三档下分组复测,把「信号」从代码收益里剥离。
案件四:泄漏的 NetworkCallback 带着页面阴魂不散
scala
// ❌ 故障代码:只注册,不注销
public final class NetworkScreen extends Activity {
private final ConnectivityManager.NetworkCallback callback =
new ConnectivityManager.NetworkCallback() {
@Override public void onAvailable(Network network) {
renderNetwork(network); // 页面销毁后回调还在来!
}
};
@Override protected void onStart() {
super.onStart();
getSystemService(ConnectivityManager.class)
.registerDefaultNetworkCallback(callback);
}
}
案情分析 :匿名内部类经回调引用 Activity,页面回收不掉;更糟的是已销毁的页面还在持续执行 UI/DB/网络工作------泄漏的直接功耗损失不在于「多占内存」,而在于「保留的对象还在干活」。
修复:对称释放 + registered 标志位防重复注册:
scss
// ✅ onStart 注册,onStop 注销
@Override protected void onStop() {
if (registered) {
getSystemService(ConnectivityManager.class)
.unregisterNetworkCallback(callback);
registered = false;
}
super.onStop();
}
证据链(定罪顺序很重要):heap dump 显示已销毁组件仍被引用 → trace/日志显示回调仍在投递 → 回调带来可量化的 CPU/Binder/网络工作 → 修复后 retained object、回调量、资源时间同步下降。
案件五:用 WakeLock + allow-while-idle 对抗 Doze
案情 :服务里长期持 partial WakeLock,检测到 isDeviceIdleMode() 后再用 setExactAndAllowWhileIdle() 定时把自己叫醒继续干活。这是典型的与系统为敌:Doze 本来就是要攒批处理,你却九分钟一次把整机拽醒,既烧电又挤占全系统的唤醒预算。
修复 :即时消息回归推送;普通刷新接受维护窗口延迟;只有闹钟/日历这类用户明确要求准点的功能才配用 exact alarm。如果回调只在当前进程存活期间有意义,用 Android 17 的 listener 型 idle alarm,别再整夜持锁等:
less
// ✅ API 37:listener 型 allow-while-idle,进程内回调,无需持锁等待
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.ELAPSED_REALTIME_WAKEUP,
SystemClock.elapsedRealtime() + delayMillis,
"visible-session-deadline",
executor, // 回调直接跑在指定 Executor 上
listener); // 组件结束时务必 cancel(listener)
案件六:每个模块各自注册周期任务
案情 :同步、日志、配置、清理四个模块,各自注册了周期 Job(同样的网络约束、相近的时限),页面/广播/推送还重复调 schedule()。后果:四个 Job 分别拉起进程和网络、四个模块独立重试并在服务恢复时形成请求峰值、150 个 Job 上限和调度入口节流提前抵达。
修复 :同约束工作收敛到一个 JobInfo,用持久化的 JobWorkItem 排队(Android 14+ 支持):
scss
// ✅ 共享一个稳定 JobInfo,工作内容作为 JobWorkItem 入队
JobInfo job = new JobInfo.Builder(JOB_ID, service)
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED)
.setPersisted(true)
.build();
context.getSystemService(JobScheduler.class).enqueue(job,
new JobWorkItem.Builder().setExtras(extras).build());
注意:同一队列要保持 JobInfo 内容稳定(乱改 extras 可能导致运行中 Job 被停掉重启);JobWorkItem 只管排队,业务数据仍需自己的幂等键;合并后仍受 bucket、quota 与设备状态约束。合并时不能只看时间接近------精确时限、网络类型、失败语义不同的工作要保留独立调度。
七、破案工具箱:诊断工具链
最后回到业主群的 23%。给你一套从轻到重的取证工具。
7.1 dumpsys 命令全家桶
perl
# 专用测试机:建立明确采集窗口(会清统计,勿在用户设备执行!)
adb shell dumpsys batterystats --reset
# 打开完整 wakelock 历史(更耗历史缓冲区,只覆盖测试窗口)
adb shell dumpsys batterystats --enable full-wake-history
# ......执行固定测试脚本......
adb shell dumpsys batterystats > batterystats.txt # UID 活动/锁/Job/网络统计
adb shell dumpsys deviceidle # Light/Deep Doze 状态
adb shell dumpsys jobscheduler com.example.app # Job 等待原因
adb shell dumpsys alarm # 闹钟排期
adb shell dumpsys power # 当前锁记录与电源状态
adb shell dumpsys suspend_control_internal --wakelocks # 用户态 suspend blocker
adb shell am get-standby-bucket com.example.app # 会员等级
adb bugreport bugreport.zip # 交给 Battery Historian
各服务的分工:dumpsys power 答「现在有哪些客户端锁记录」;BatteryStats 答「谁在什么时间持有并被归因」;SystemSuspend 答「用户态 blocker 与唤醒统计」。
7.2 Battery Historian:监控录像回放
把 bugreport 拖进 Historian(官方仓库 2022 年底已归档只读,本地 Docker 部署 gcr.io/android-battery-historian/stable:3.1,别把含账号信息的 bugreport 传给不明第三方网站)。时间线重点行:
| 行 | 看什么 | 下一步 |
|---|---|---|
| Battery level | 长窗口掉电趋势 | 短窗口别依赖百分比 |
| CPU running | 熄屏后是否还有 CPU 活动 | 与 WakeLock/Alarm/Job 交叉 |
| Userspace wakelock | 按 UID/tag 看累计次数与时长 | 回查持锁代码 |
| JobScheduler/Sync | 任务窗口与周期 | 核对约束与停止原因 |
| Mobile radio/Wi-Fi、GPS/sensor | 网络与定位活动窗口 | 核对请求方与批处理 |
记住:色块只表示「事件在活动」,读不出焦耳数。
7.3 Perfetto:把代码和电源事件放进同一条时间轴
yaml
buffers { size_kb: 32768 fill_policy: RING_BUFFER }
data_sources {
config {
name: "android.power"
android_power_config {
battery_poll_ms: 1000
battery_counters: BATTERY_COUNTER_CHARGE
battery_counters: BATTERY_COUNTER_CAPACITY_PERCENT
collect_power_rails: true # 输出设备提供的电源轨
}
}
}
data_sources {
config {
name: "linux.ftrace"
ftrace_config {
ftrace_events: "power/wakeup_source_activate"
ftrace_events: "power/wakeup_source_deactivate"
ftrace_events: "power/suspend_resume"
ftrace_events: "power/cpu_idle"
ftrace_events: "power/cpu_frequency"
}
}
}
duration_ms: 60000
wakeup source 的 activate/deactivate 按 source 名称配对分析;android.power 有没有 rail 取决于设备的 PowerStats HAL(Pixel 6+ 有 ODPM),轨道缺失 ≠ 没有耗电。
7.4 PowerMonitor:App 内自己读能量(API 35+)
kotlin
// SystemHealthManager 异步读取设备公开的电源监测通道
val manager = context.getSystemService(SystemHealthManager::class.java)
manager.getSupportedPowerMonitors(executor) { monitors ->
manager.getPowerMonitorReadings(monitors, executor,
object : OutcomeReceiver<PowerMonitorReadings, RuntimeException> {
override fun onResult(readings: PowerMonitorReadings) {
// readings.getConsumedEnergy(monitor):自开机累计的 µWs
// ⚠️ 用同一 monitor 的前后差值 + 时间戳比较场景,
// 累计值含插电期间能量,不能当"本次操作耗电"
// ⚠️ 处理 ENERGY_UNAVAILABLE(能量不可用)情况
}
override fun onError(error: RuntimeException) { /* 设备不支持 */ }
})
}
7.5 四层证据表:别用一层证据定另一层的罪
| 层级 | 工具 | 能回答的问题 |
|---|---|---|
| 应用 | 日志、Background Task Inspector | 哪段业务申请/完成/取消/重试 |
| Framework | dumpsys power、BatteryStats、Historian | token、tag、UID、WorkSource、前后台时间 |
| SystemSuspend | dumpsys suspend_control_internal | 用户态 blocker 与 suspend/wakeup 统计 |
| 内核/硬件 | wakeup sources、Perfetto、电源轨、外接仪表 | 哪个 source 活跃、整机能量变化 |
终章:给夜光公寓新物业的 Checklist
把全文收拢成一张可以直接贴在工位上的清单:
代码层
- 有专用 API 时不手撕 WakeLock(WorkManager/DownloadManager/Media/定位栈都代持);
- 手持锁必须:稳定 tag + 单一 owner + 业务推导的超时 + finally 释放,覆盖成功/失败/取消/销毁;
- 可延迟任务交给 WorkManager/JobScheduler,只加必要约束(
UNMETERED≠ Wi-Fi); - 精确闹钟只给用户可感知的准点功能,权限被撤销要有降级路径;
- FGS 类型、权限、启动资格、时长额度(shortService 3 分钟、dataSync/mediaProcessing 6h/24h)逐项核对;
- 定位按业务容差选 priority、给请求设终点、退出会话必注销;
- 网络合并可延迟上传、复用连接、区分可重试与永久错误;
- 每一个注册(callback/listener/协程/锁)都有对称的注销路径。
验证层
dumpsys batterystats --reset只在专用测试机执行,测试后恢复 battery/deviceidle 状态;- 固定设备、系统版本、网络、亮度、温度、数据集;多轮交错取分布,不拿单次百分比起哄;
- 同时看「唤醒次数/CPU 时间/rail delta」和「业务成功率/端到端延迟」------省电不能以功能损失为代价;
- 用 pending reason / stop reason 解释「为什么没跑」,而不是抱怨调度器有 bug。
认知层(本文三大反直觉结论)
- CPU 时间相同 ≠ 耗电相同:核簇、频点、idle 进入、温控状态都会改写能量,CPU time 只是归因输入之一;
- FGS ≠ 免死金牌:前台服务不豁免 Doze、Job quota、Low Power Standby,也可能被 6 小时额度收割;
- 设置页的百分比是「记账值」不是「测量值」:它是 profile/controller/硬件能量 + 活动比例的归因结果,适合找异常、做排序,不适合当绝对精度。
电量优化从来不是「少干活」,而是把干活的时机、方式和计量,都交还给最懂全局的物业。祝各位的 App 早日摘掉「电量刺客」的帽子。
参考资料
官方文档
- Optimize for Doze and App Standby
- App Standby Buckets
- Use wake locks / Follow wake lock best practices / Debug wake locks locally
- Excessive partial wake locks
- Foreground service timeouts
- Battery Historian
- PowerMonitor / SystemHealthManager
- Android Studio Power Profiler(ODPM)
AOSP android-17.0.0_r1 源码
frameworks/base/core/java/android/os/PowerManager.javaframeworks/base/services/core/java/com/android/server/power/PowerManagerService.javaframeworks/base/services/core/java/com/android/server/power/stats/BatteryStatsImpl.javaframeworks/base/services/core/java/com/android/server/power/stats/processor/MultiStatePowerAttributor.java、processor/CpuPowerStatsProcessor.java、processor/ScreenPowerStatsProcessor.javaframeworks/base/core/res/res/xml/power_profile.xmlframeworks/base/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java、controllers/QuotaController.javaDeviceIdleController.java(DeviceIdle 两套状态机)system/hardware/interfaces/suspend/aidl/default/SystemSuspend.cpphardware/libhardware_legacy/power.cpp
内核 android17-6.18-2026-06_r6
kernel/power/suspend.c、kernel/power/wakelock.cdrivers/base/power/wakeup.c、drivers/base/power/wakeup_stats.ckernel/sched/cpufreq_schedutil.c、drivers/opp/
本文原理与案例参考自 Gracker/android-internals-wiki(《Android 技术内幕》)功耗与能耗优化章节,源码锚点为 Android 17 / API 37。如果觉得有帮助,点个赞再走吧~