你的 App 为什么是「电量刺客」?——从一条 WakeLock 追到 Linux 内核的 Android 省电原理全解

源码锚点 :平台源码以 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 的开发,你被拉进了群。要想洗清嫌疑,你必须回答三个问题:

  1. 系统是怎么算出 23% 这个数字的?(功耗归因原理)
  2. 我的 App 到底干了什么让系统这么记账?(WakeLock / Job / 定位 / 网络全链路源码)
  3. 怎么改,才能既保住功能又不背锅?(优化方法论 + 工具链)

这篇文章就沿着这三条线,从一块电池的物理特性,一路追到 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 的算法:

  1. 有 screen energy consumer(硬件分表)→ 用累计能量算出设备级屏幕耗电;
  2. 没有 → 用「亮屏时长 × 亮度档位 × profile」估算;
  3. 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);
    }
}

三个关键点:

  1. Binder token:服务端用客户端传来的 Binder 对象标识这把锁,release 时凭 token 找到记录;
  2. linkToDeath() :客户端进程暴毙,Binder 死亡回调帮服务端清理------这是故障兜底,不能替代正常 release。进程活着但业务泄漏时,token 依然有效,系统无从判断「任务其实结束了」;
  3. 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。

认知层(本文三大反直觉结论)

  1. CPU 时间相同 ≠ 耗电相同:核簇、频点、idle 进入、温控状态都会改写能量,CPU time 只是归因输入之一;
  2. FGS ≠ 免死金牌:前台服务不豁免 Doze、Job quota、Low Power Standby,也可能被 6 小时额度收割;
  3. 设置页的百分比是「记账值」不是「测量值」:它是 profile/controller/硬件能量 + 活动比例的归因结果,适合找异常、做排序,不适合当绝对精度。

电量优化从来不是「少干活」,而是把干活的时机、方式和计量,都交还给最懂全局的物业。祝各位的 App 早日摘掉「电量刺客」的帽子。


参考资料

官方文档

AOSP android-17.0.0_r1 源码

  • frameworks/base/core/java/android/os/PowerManager.java
  • frameworks/base/services/core/java/com/android/server/power/PowerManagerService.java
  • frameworks/base/services/core/java/com/android/server/power/stats/BatteryStatsImpl.java
  • frameworks/base/services/core/java/com/android/server/power/stats/processor/MultiStatePowerAttributor.java、processor/CpuPowerStatsProcessor.java、processor/ScreenPowerStatsProcessor.java
  • frameworks/base/core/res/res/xml/power_profile.xml
  • frameworks/base/apex/jobscheduler/service/java/com/android/server/job/JobSchedulerService.java、controllers/QuotaController.java
  • DeviceIdleController.java(DeviceIdle 两套状态机)
  • system/hardware/interfaces/suspend/aidl/default/SystemSuspend.cpp
  • hardware/libhardware_legacy/power.cpp

内核 android17-6.18-2026-06_r6

  • kernel/power/suspend.c、kernel/power/wakelock.c
  • drivers/base/power/wakeup.c、drivers/base/power/wakeup_stats.c
  • kernel/sched/cpufreq_schedutil.c、drivers/opp/

本文原理与案例参考自 Gracker/android-internals-wiki(《Android 技术内幕》)功耗与能耗优化章节,源码锚点为 Android 17 / API 37。如果觉得有帮助,点个赞再走吧~

相关推荐
mmsx2 小时前
Android 安全校验下沉到原生层:防 patch 与构建期自动化
android·安全·自动化
老牛日常随笔3 小时前
Google把Android测试搬进AI对话:ARTEMIS如何用自然语言操作你的手机
android·人工智能·智能手机
朱涛的自习室4 小时前
预算 1000 美金的开源软件,DeepSeek 只花 1 美金就做完了(Day 6)
android·前端·人工智能
mmsx4 小时前
Android APK 加固方案怎么落地:从加壳到校验的组合取舍
android·网络·安全·签名
hunterandroid5 小时前
Navigation Component 工程落地:从单 Activity 架构到深度链接配置
android·前端
撩得Android一次心动5 小时前
Jetpack Compose 知识点整理3【个人用】
android·笔记·学习·kotlin·android compose
Kapaseker5 小时前
别再只会写 <T>:Kotlin 泛型的实战用法
android·kotlin
FlightYe6 小时前
音视频修炼之容器与协议(一):MP4和MOV的box剖析
android·linux·c++·ffmpeg·音视频
Vitaliy Kostrov(柯维远)8 小时前
客户端如何检查直播流:从流内标记到停止播放
android·运维·服务器·驱动开发·网络安全·系统架构·cdn