3. Android Wakelock机制:原理、类型与释放策略

Wakelock 这东西,说白了就是一把「锁」。

它锁住的是系统休眠的权限。你拿着这把锁,系统就不敢睡。你放手了,系统才能安心进入休眠。我在早期做功耗优化时,发现很多应用层开发者对 Wakelock 的理解就是「用就完了」,结果导致手机一晚上掉电 30%。嗯,这其实是个大坑。

3.1 Wakelock 的工作原理

Wakelock 的核心机制其实很简单:谁持有锁,谁阻止休眠

系统内核里有一个「suspend 流程」,每次尝试休眠前,都会检查当前是否有活跃的 Wakelock。只要有一个锁没释放,系统就乖乖等着。我习惯把这种机制叫做「投票制」——每个 Wakelock 都是一张反对票,只要有一张反对票,休眠提案就被否决。

关键流程:

  • 应用层通过 PowerManager 申请 Wakelock
  • Binder 调用到系统服务 PowerManagerService
  • PMS 通过 native 层调用内核的 wakeup_source 接口
  • 内核维护一个 wakeup_sources 链表,每次 suspend 前遍历检查

我记得有一次调试一个第三方 App 导致的不休眠问题,抓了 kernel log 发现有个 wakeup_source 一直处于 active 状态。顺着 pid 查过去,发现是某个直播 App 在后台持有一个 PARTIAL_WAKE_LOCK 没释放。你想想看,用户都锁屏了,App 还在后台跑,这不是耍流氓吗?

3.2 Wakelock 类型详解

Android 定义了四种 Wakelock 类型,每种类型的「锁力」不一样。我整理了一张表,方便你对照:

类型 CPU 屏幕 键盘灯 典型场景
PARTIAL_WAKE_LOCK 保持唤醒 可关闭 可关闭 后台音乐播放、下载任务
SCREEN_DIM_WAKE_LOCK 保持唤醒 保持亮屏(变暗) 可关闭 视频播放、导航
SCREEN_BRIGHT_WAKE_LOCK 保持唤醒 保持亮屏(全亮) 可关闭 游戏、交互操作
FULL_WAKE_LOCK 保持唤醒 保持亮屏(全亮) 保持亮灯 全屏游戏、VR 应用

这里有个细节要注意:从 Android 5.0 开始,SCREEN_DIM、SCREEN_BRIGHT、FULL 这三种类型已经被废弃了。为什么?因为 Google 觉得应用不应该直接控制屏幕亮度,这太粗暴了。现在推荐的做法是用 FLAG_KEEP_SCREEN_ON 这个 Window 属性来代替。

避坑指南:

我曾经在适配 Android 9 时,发现一个老项目里用了 FULL_WAKE_LOCK。编译没问题,但运行时 logcat 会打印一条 deprecated 警告。更坑的是,在某些厂商的 ROM 上,这个锁根本不起作用。所以,新项目千万别再用这三种类型了。

3.3 PARTIAL_WAKE_LOCK 的正确使用姿势

PARTIAL_WAKE_LOCK 是目前唯一推荐使用的 Wakelock 类型。它只锁 CPU,不锁屏幕。说白了就是:CPU 可以继续跑,但屏幕该关就关。

我习惯这样使用:

// 申请锁
PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
PowerManager.WakeLock wl = pm.newWakeLock(
    PowerManager.PARTIAL_WAKE_LOCK, 
    "MyApp:BackgroundTask"
);
wl.acquire();

// 执行后台任务
doBackgroundWork();

// 释放锁
wl.release();

嗯,这段代码看起来没问题对吧?但实际项目中,我见过太多人在这里翻车了。最常见的问题就是:忘记释放锁

我的习惯:

我每次申请 Wakelock 都会配套写一个 try-finally 块,确保 release 一定会被执行。另外,我会在 tag 里加上包名和具体用途,这样出问题时方便定位。比如 "com.example:MusicPlayer" 就比 "MyLock" 好一万倍。

3.4 Wakelock 超时机制

你可能会问:如果应用申请了锁,但崩溃了怎么办?系统岂不是永远睡不了?

答案是:系统有兜底机制

acquire() 方法有一个带超时参数的重载:

// 申请锁,最多持有 10 秒
wl.acquire(10 * 1000L);

这个超时时间到了之后,系统会自动释放锁。我建议所有后台任务都加上超时,哪怕你觉得任务肯定能在 5 秒内完成。为什么?因为网络抖动、IO 阻塞这些情况你根本预料不到。

我记得有一次,一个下载服务在弱网环境下卡住了,持有一个 PARTIAL_WAKE_LOCK 长达 40 分钟。用户投诉手机发烫,我们查了半天才发现是 acquire 没设超时。从那以后,我定了个规矩:所有 Wakelock 必须设超时,除非你能证明不需要

3.5 释放锁的注意事项

释放锁看似简单,但有几个坑:

  • 不要重复释放:release() 调用多次会抛异常。可以用一个标志位判断是否已释放。
  • 不要在异步回调里释放:如果回调在子线程,而锁在主线程申请,release 可能不生效。我习惯用 Handler 切回主线程释放。
  • 注意引用计数:acquire() 和 release() 必须成对出现。多次 acquire 需要同样次数的 release。

最佳实践:

private PowerManager.WakeLock mWakeLock;

public void startTask() {
    if (mWakeLock == null) {
        PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE);
        mWakeLock = pm.newWakeLock(
            PowerManager.PARTIAL_WAKE_LOCK, 
            "MyApp:Task"
        );
        mWakeLock.setReferenceCounted(false); // 关闭引用计数
        mWakeLock.acquire(30 * 1000L); // 最多 30 秒
    }
}

public void stopTask() {
    if (mWakeLock != null && mWakeLock.isHeld()) {
        mWakeLock.release();
        mWakeLock = null;
    }
}

这里我用了 setReferenceCounted(false),这样不管 acquire 调用多少次,一次 release 就能释放。我个人比较喜欢这种方式,因为引用计数容易搞乱,尤其是在复杂的业务逻辑里。

3.6 内核层面的 wakeup_source

说完了应用层,咱们看看底层。Android 的 Wakelock 最终会映射到内核的 wakeup_source 机制。

内核里维护了一个全局链表,所有活跃的 wakeup_source 都挂在这个链表上。每次系统准备 suspend 时,会遍历这个链表:

// 内核伪代码
for_each_wakeup_source(ws) {
    if (ws->active) {
        // 还有活跃的锁,不能休眠
        return -EBUSY;
    }
}
// 可以休眠了
suspend_enter();

你可以通过 /sys/kernel/debug/wakeup_sources 查看当前所有 wakeup_source 的状态。我调试功耗问题时,第一步就是看这个文件:

adb shell cat /sys/kernel/debug/wakeup_sources | head -20

输出里会显示每个 wakeup_source 的 active_count、active_since 等信息。如果某个锁的 active_since 时间戳很久没变,那基本就是它导致的不休眠。

调试技巧:

我习惯写一个脚本,每隔 5 秒抓一次 wakeup_sources 的状态,然后对比变化。哪个锁一直 active,哪个就是罪魁祸首。这个方法帮我定位过至少 10 个功耗问题。

3.7 总结与避坑清单

最后,我列一个实战清单,你写代码时对照着检查:

  1. 能用前台服务就别用 Wakelock:前台服务自带 CPU 锁,而且用户可见,更规范。
  2. 必须设超时:acquire(timeout) 是底线,没有例外。
  3. 成对释放:acquire 和 release 要一一对应,最好用 try-finally。
  4. tag 要有意义:方便定位问题,格式建议 "包名:功能描述"。
  5. 废弃类型别用:SCREEN_DIM、SCREEN_BRIGHT、FULL 这些,忘掉它们。
  6. 测试要覆盖异常场景:进程被杀、网络断开、回调延迟,这些都要测到。

我曾经在一个项目里,就因为少设了一个超时参数,导致用户手机在夜间待机时无法进入深度休眠。那次的教训让我明白:Wakelock 是一把双刃剑,用好了是省电利器,用不好就是耗电元凶。嗯,希望你能用好它。