如何解决 Android 应用 Wake Locks 导致的电池耗电问题
Wake lock(唤醒锁)是 Android 系统中最简单的 API 之一,也是最容易被误用的。获取它只需一行代码,但忘记释放它可能导致手机 CPU 持续数小时保持唤醒状态,夜间耗尽电池,甚至让应用因耗电问题在 Google Play 上收到警示标签。
大多数 Wake lock 错误并非源于对 API 本身的误解,而是由常规控制流问题引起:例如在 acquire() 和 release() 之间抛出异常、提前返回、回调永远未触发,或者引用计数失衡。
这些 Bug 在代码审查中难以察觉,在日常测试中也几乎不可见,因为测试设备通常一直处于插电和唤醒状态。
本文将在系统层面解释 Wake lock 的实际作用,梳理应用泄漏 Wake lock 的常见场景,并展示防止和检测泄漏的具体模式与工具。文章还会介绍现代 API,说明在大多数情况下它们如何替代手动管理 Wake lock。
目录
前置要求
你应该熟悉使用 Kotlin 编写 Android 应用,并了解 Service、Broadcast Receiver(广播接收器)以及 Coroutines(协程)的基础知识。
调试部分还需要安装 adb 并使用真机。模拟器对电源状态的建模不够真实,文中后面的部分命令在模拟器上会给出误导性的结果。
Android 设备休眠时发生了什么
当屏幕熄灭、也没有其他任务需要 CPU 时,Android 会把应用处理器切换到低功耗挂起状态。此时 CPU 停止执行指令,大部分内存进入自刷新模式,只有少数硬件模块保持运行(基带、实时时钟、传感器中枢和少数中断控制器)。设备看起来处于空闲状态,但能在中断到来时迅速唤醒。
Android 通过 autosleep 机制实现这一点:只要没有理由保持唤醒,内核就会尽快挂起系统。任何需要 CPU 持续运行的任务,都必须显式地持有一个 wakeup source(内核术语,对应 Android 里的 wake lock)。只要还有至少一个 wakeup source 处于激活状态,内核就会拒绝挂起。
Screen off
|
v
Any active wakeup sources? == yes ==> CPU stays awake
|
no
|
v
Kernel suspends CPU
|
v
Interrupt (alarm, network packet, button press)
|
v
CPU resumes, driver or framework takes a wake lock to handle the event
上面的流程图展示了屏幕熄灭期间内核反复执行的判断逻辑:只要有 wakeup source 被持有,CPU 就继续运行;一个都没有,系统就挂起,直到硬件中断到来。设备恢复运行后,处理中断的代码通常会先获取一个短暂的 wake lock,以便在内核再次挂起之前完成自己的工作。
关键在于:保持唤醒是需要主动声明的。屏幕熄灭后,如果没有东西替你的代码持有 wake lock,它就拿不到 CPU 时间。
Wake Lock 的作用
应用级 Wake Lock(唤醒锁)本质上是向 PowerManagerService 发起的请求,这是一个运行在 system_server 内部,属于系统级的服务。当应用调用 acquire() 时,该请求通过 Binder 机制传递至该服务。服务方会记录此请求,关联数据包括应用的 UID、标签字符串以及锁类型。PowerManagerService 会聚合所有应用的请求,并代表所有应用持有单一的 Kernel 级唤醒源。一旦最后的应用级锁被释放,Kernel 唤醒源随之解除,设备即可进入休眠。
这种设计带来两个值得理解的特性。首先,系统能精确掌握每个应用持有锁的对象及持有时长。这也是电池统计数据和 Play Console 中的 Vitals(应用健康指标)能够将耗电问题归咎于应用的原因。
其次,PowerManagerService 通过 Binder 的死亡通知机制,将每个锁与应用进程绑定。一旦进程意外终止,系统会自动释放其持有的锁。因此,锁的泄漏风险受限于进程的生命周期。但请注意,运行前台服务或被系统保活的进程可能存活数小时。
Wake Lock(唤醒锁)的类型
PowerManager 类定义了多种锁级别。其中大部分已弃用,对应用开发者而言,实际常用的只有哪一种。
| 级别 | 保持 CPU 活跃 | 保持屏幕亮起 | 状态 |
|---|---|---|---|
PARTIAL_WAKE_LOCK |
是 | 否 | 当前主流,大多数应用应使用此类型 |
SCREEN_DIM_WAKE_LOCK |
是 | 是,亮度降低 | 自 API 17 起弃用 |
SCREEN_BRIGHT_WAKE_LOCK |
是 | 是,全亮度 | 自 API 13 起弃用 |
FULL_WAKE_LOCK |
是 | 是,含键盘背光 | 自 API 17 起弃用 |
PROXIMITY_SCREEN_OFF_WAKE_LOCK |
否 | 检测距离传感器时关闭屏幕 | 当前可用,多用于通话应用 |
表格首列是调用 newWakeLock() 时需要传入的常量,后续两列则说明了该锁会维持哪些硬件处于供电状态。与屏幕相关的锁级别之所以被弃用,是因为应用往往在用户离开界面后仍持续持有这些锁,导致屏幕长亮、电池快速耗光。
保持屏幕常亮需要替代方案,即使用 FLAG_KEEP_SCREEN_ON 窗口标志(或在布局文件中使用 android:keepScreenOn)。窗口管理器会将该标志与窗口可见性绑定,因此不会出现泄漏问题。距离传感器锁是通话界面的特殊情况。对于其他所有场景,现代 Android 中的“唤醒锁”指的就是 PARTIAL_WAKE_LOCK,本文将重点探讨这一类型。
如何获取和释放唤醒锁
使用唤醒锁需要在 Manifest 文件中声明 WAKE_LOCK 权限。这是一个普通权限,在应用安装时会自动授予,无需用户交互。
<uses-permission android:name="android.permission.WAKE_LOCK" />
这行代码声明应用可以持有唤醒锁。若未声明,调用 acquire() 时将抛出 SecurityException。由于权限是静默授予的,用户在看不到电池使用情况之前,完全不知道应用在使用唤醒锁,因此更要谨慎使用。
以下是最基本的使用示例。
val powerManager = context.getSystemService(PowerManager::class.java)
val wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"myapp:upload"
)
wakeLock.acquire(10 * 60 * 1000L) // 10 分钟
uploadPendingFiles()
wakeLock.release()
代码通过获取 PowerManager 系统服务来创建 WakeLock 对象,并指定 partial 级别和标签。仅创建对象并不会产生实际作用,CPU 只在 acquire() 和 release() 之间保持唤醒状态。
标签 "myapp:upload" 会出现在 dumpsys、Battery Historian 和 Play Console 中,因此应能清晰识别应用及具体任务。
app:component 是 Google 推荐的命名约定,在调试过程中能节省大量时间。acquire() 的参数是以毫秒为单位的超时时间。若 10 分钟后锁仍被持有,系统会自动释放它。
这个示例看似正确,但存在实际 Bug:若 uploadPendingFiles() 抛出异常,release() 将不会执行。超时机制将损害限制在 10 分钟内,但每次失败仍会导致持续 10 分钟的电量消耗。后续章节将解释如何修复此问题及其他潜在陷阱。
引用计数的工作原理
Wake lock 默认采用引用计数机制。每次调用 acquire() 会让内部计数器加一,每次调用 release() 让它减一,只有计数归零时锁才会真正释放。
wakeLock.acquire(60_000L) // count = 1, lock held
wakeLock.acquire(60_000L) // count = 2
wakeLock.release() // count = 1, still held
wakeLock.release() // count = 0, lock released
wakeLock.release() // throws RuntimeException: WakeLock under-locked
前两次 acquire 把计数推到了 2。第一次 release 只把计数减回 1,所以虽然代码"释放"了锁,锁其实还在持有状态。
真正释放发生在第二次 release。如果再调用第三次,已经没有可释放的锁,就会抛出消息为 "WakeLock under-locked" 的 RuntimeException。这在生产环境属于崩溃,所以有些开发者会用 try 包住 release(),或者先检查 isHeld。
引用计数适合多段独立代码共享同一个 WakeLock 对象的场景。但如果 acquire 和 release 的次数不一致,它就会出问题——比如某个函数被调用了两次,完成回调却只触发了一次。
可以通过 setReferenceCounted(false) 关闭引用计数,之后无论 acquire 了多少次,任何一次 release() 都会直接释放锁。当锁只由单个组件持有时,这通常是更简单的模型。
App 泄漏 Wake Lock 的常见方式
Wake lock 泄漏几乎都可以归为几种固定模式。识别出这些模式能让代码审查的效率大大提升。
异常与提前返回
上面的基础示例就是最常见的泄漏。任何在 acquire() 和 release() 之间退出函数却没执行 release 的代码路径,都是泄漏来源。包括抛出的异常、后来有人没注意到锁的存在而加上的 return 语句,以及循环里的 break 或 continue。
fun syncContacts() {
wakeLock.acquire(5 * 60 * 1000L)
val account = accountStore.current() ?: return
api.pushContacts(account)
wakeLock.release()
}
如果当前没有账户,这个函数会因早期 return 跳过释放逻辑而导致锁泄漏。当 pushContacts() 抛出网络异常时,也会出现同样的泄漏。单看这两处代码似乎无害,超时机制能防止泄漏永久存在,但每次同步失败仍会白白消耗 5 分钟的唤醒时间。
生命周期超出调用者的异步任务
Wake lock 通常在一个位置获取,却在另一处的回调中释放。如果回调从未执行,锁也永远不会被释放。
fun startDownload(url: String) {
wakeLock.acquire()
downloader.enqueue(url, object : Callback {
override fun onComplete() {
wakeLock.release()
}
override fun onError(e: Throwable) {
log("download failed", e)
}
})
}
这里有两处错误。错误回调忘记释放锁,导致每次下载失败都会造成泄漏。而且获取锁时没有设置任何超时,因此一旦泄漏,将持续到进程终止。
还有一个较不明显的风险:如果 downloader 被取消或丢弃请求而未调用任何回调,锁同样会泄漏。只要释放逻辑依赖于第三方回调,就必须设置超时作为兜底措施。
引用计数不平衡
当共享的引用计数锁获取次数多于释放次数时,即使所有代码路径看似都执行了释放,锁仍会被持有。
class LocationTracker(private val wakeLock: PowerManager.WakeLock) {
fun onStart() {
wakeLock.acquire(30 * 60 * 1000L)
startUpdates()
}
fun onStop() {
stopUpdates()
wakeLock.release()
}
}
这个类看起来是平衡的。问题出现在 onStart() 被调用两次(例如配置变更或重复 intent)而 onStop() 只被调用一次时。计数最终停留在一,锁会一直被持有直至超时失效。关闭引用计数,或在获取前用 if (!wakeLock.isHeld) 加以保护,都能修复此特定缺陷。
触发后台工作的广播接收器
系统在 onReceive() 执行期间会代为持有 wake lock。一旦 onReceive() 返回,该锁随即释放,进程可能被判定为空闲。
一个常见错误是在 onReceive() 中获取一个独立的 wake lock,启动线程,然后从线程中释放锁。如果进程被杀死或线程挂起,释放操作就永远不会发生。
较旧的 WakefulBroadcastReceiver 辅助类正是为这种模式设计的,但由于存在上述问题,它如今已被弃用。本文稍后将介绍现代化的替代方案。
在无界操作期间持有锁
没有设置超时的网络请求、没有截止时间的 CountDownLatch 等待、以及从 Bluetooth socket 进行的阻塞读取,这些都是可能永久挂起的操作。如果 wake lock 围绕其中一项操作持有,锁也会随之挂起。代码在每条路径上都在技术层面上释放了锁,但由于操作永远无法完成,释放逻辑永远无法被执行到。
防止泄漏的模式
修复这些泄漏关键在于养成一些易于一致执行的简单习惯。
始终使用超时
每次调用 acquire() 都应传入超时参数。Android Lint 通过 WakelockTimeout 检查项强制执行此规则,它会标记那些不带参数的 acquire() 调用。
选择一个超时值:它要足够长,能舒适地覆盖任务正常完成所需的时间;但也要足够短,确保即使发生泄漏也是可承受的。对于通常 20 秒内完成的同步任务,设置几分钟的超时是合理的,而一小时则不是。
在 Finally 块中释放
结构化释放是最有效的方法。在 Kotlin 中,一个小小的扩展函数就能让这件事变得难以出错。
inline fun <T> PowerManager.WakeLock.withLock(
timeoutMs: Long,
block: () -> T
): T {
acquire(timeoutMs)
try {
return block()
} finally {
if (isHeld) release()
}
}
该函数获取带有强制超时的锁,运行调用方的代码块,并在 finally 子句中释放锁。得益于 finally,释放操作会在正常完成、代码块提前返回以及异常传播时执行。
isHeld 检查很关键:如果代码块执行时间过长,超时可能已经自动释放了锁。不加这个检查,release() 就会抛出 "WakeLock under-locked" 异常,把代码块原本抛出的异常给掩盖掉。把函数标记为 inline 后,调用方可以在代码块里用 return 直接退出外层函数,而 finally 子句依然会执行。
有了这个辅助函数,前面提到的联系人同步函数就安全了。
fun syncContacts() = wakeLock.withLock(timeoutMs = 5 * 60 * 1000L) {
val account = accountStore.current() ?: return
api.pushContacts(account)
}
提前返回和 pushContacts() 抛出的任何异常,现在都会经过 withLock 里的 finally 块,锁在所有路径上都能被释放。调用处代码也更简洁,reviewer 一眼就能确认逻辑正确,不用逐条分支去追踪。
谨慎地把锁作用到协程
同样的模式在协程里也适用,因为 Kotlin 的取消机制通过 CancellationException 实现,协程被取消时 finally 块仍会执行。
suspend fun uploadAll(files: List<File>) {
wakeLock.acquire(10 * 60 * 1000L)
try {
withTimeout(9 * 60 * 1000L) {
files.forEach { api.upload(it) }
}
} finally {
if (wakeLock.isHeld) wakeLock.release()
}
}
锁在任务开始前获取,在 finally 中释放——无论正常完成、失败还是外层作用域被取消,这段代码都会执行。withTimeout 给上传本身加了一个硬性截止时间,设得比 wake lock 超时略短,让协程在系统强制释放锁之前主动放弃。这样就解决了前面提到的无上限操作问题。
有一点需要说明:上一节的 inline 辅助函数在这里同样可用,因为从 suspend 上下文调用时,inline lambda 内部可以调用 suspend 函数。
为每个任务单独创建锁
让不相关的功能共用一个 WakeLock 对象,会让引用计数变得难以推断,也让电池统计失去意义——因为所有功能都挂在同一个 tag 上。为每个任务单独创建一个带描述性 tag 的锁几乎没有任何成本,却能让泄漏问题容易定位得多。
如果多个组件共享同一把锁,请禁用引用计数,或确保只有一个所有者负责管理它。
正确归属工作任务
如果你的代码代表其他应用或 UID 获取 Wake Lock(常见于系统服务和 SDK 场景),请调用 setWorkSource(),以便将电量消耗准确计费给对应的责任方。普通应用很少需要用到这一功能,但平台层和 OEM 工程师应当了解其存在。
何时完全不需要 Wake Lock
避免 Wake Lock 泄漏的最佳方式就是根本不去持有它。在现代 Android 系统中,大多数后台任务都应使用更高级别的 API,这些 API 会在内部自动管理 Wake Lock。
| 需求 | 应替代手动 Wake Lock 的 API |
|---|---|
| 可延后的后台任务(同步、上传、清理) | WorkManager 或 JobScheduler |
| 必须在精确时刻执行的任务 | AlarmManager.setExactAndAllowWhileIdle() 配合一个短任务 |
| 广播后的短时后续任务 | BroadcastReceiver.goAsync() |
| 用户有明确感知的长时运行任务(音乐、导航、健身) | 具有相应类型的前台服务 |
| 在活动界面可见时保持屏幕常亮 | FLAG_KEEP_SCREEN_ON |
| 用户主动发起的数据传输 | 用户主动发起的数据传输任务(API 34 及以上版本) |
左列描述了应用希望完成的具体动作,右列则列出了覆盖该需求的对应 API。这些 API 的共同特点是:它们会代应用获取 Wake Lock,并在任务结束或超时后自动释放。
WorkManager 和 JobScheduler 会在 doWork() 或 onStartJob() 的执行期间持有 Wake Lock,并强制执行时限限制。goAsync() 会将系统的广播 Wake Lock 延长约 10 秒,供你完成收尾工作。FLAG_KEEP_SCREEN_ON 与窗口可见性绑定,用户离开界面时即自动失效。
下面是一个 WorkManager Worker 示例,用于替代上述上传示例。
class UploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
uploadPendingFiles()
Result.success()
} catch (e: IOException) {
Result.retry()
}
}
}
CoroutineWorker 在执行 doWork() 时,由 JobScheduler 为其持有唤醒锁。无论结果成功、失败还是重试,一旦 doWork() 返回,唤醒锁就会释放。如果任务运行时间过长,系统会将其终止并取消协程。代码中不涉及 acquire() 或 release() 调用,因此不存在泄漏风险。在 I/O 失败时返回 Result.retry(),WorkManager 会按照退避策略调度下一次尝试,而不是让应用持续唤醒 CPU 在循环中重试。
手动管理唤醒锁仍有其适用场景。媒体播放器、VoIP 协议栈以及通过蓝牙从连接设备流式传输数据的应用,通常需要在前台服务激活期间保持 CPU 运行。在这些情况下,服务拥有生命周期,应在 onCreate() 或播放开始时获取唤醒锁,并在对应的停止路径及 onDestroy() 中释放。
了解 Doze 模式如何限制唤醒锁的作用也很有帮助。当设备进入 Doze 模式时,系统会忽略电池优化白名单外应用持有的部分唤醒锁。但这并不意味着可以放任泄漏,因为在维护窗口期及设备活跃期间,该锁仍会被计入统计。这也解释了为何仅持有唤醒锁并不能保证代码在夜间持续运行。
如何检测唤醒锁泄漏
在开发阶段很少发现泄漏,因为设备通常插着电源、屏幕亮着,且有其他进程保持唤醒状态。你需要主动查找它们。
使用 dumpsys power 查看当前持有的锁
最快的方法是查询 PowerManagerService 当前正在持有哪些锁。
adb shell dumpsys power | grep -A 20 "Wake Locks:"
此命令会转储电源管理器的完整状态,并过滤出唤醒锁部分。输出结果会逐行列出当前持有的每个应用级唤醒锁。
Wake Locks: size=2
PARTIAL_WAKE_LOCK 'myapp:upload' ACQ=-14m32s110ms (uid=10245 pid=8123)
PARTIAL_WAKE_LOCK 'AudioMix' ACQ=-3s204ms (uid=1041 ws=WorkSource{10112})
每一行显示了锁级别、标签、获取时间(ACQ)以及拥有者的 UID 和进程。
在这个例子里,myapp:upload 已经持有了超过 14 分钟——如果上传通常只需几秒,这几乎可以断定是泄漏。第二条记录由音频服务器代表另一个应用持有,从它的 work source 可以看出来。关掉屏幕,等一分钟,再执行一次这条命令。凡是你的应用持有且 ACQ 时间还在增长的锁,都值得排查。
用 batterystats 统计总量
dumpsys power 只反映当前时刻的状态。想看一段时间内累计的 wake lock 时长,得用电池统计。
adb shell dumpsys batterystats --reset
# 拔掉电源,正常使用设备,或者闲置一段时间
adb shell dumpsys batterystats com.example.myapp | grep -i "wake lock"
第一条命令清空已收集的统计数据,让测量从零开始。让设备用电池跑一段时间后(至少一小时,数据才有参考价值),第二条命令会输出你的包的统计信息,并只筛选出 wake lock 相关的行。
你会看到每个 tag 的总持有时长和获取次数。如果某个 tag 总时长很大而获取次数很少,通常说明锁被长时间持有,没有及时释放。
想要可视化的时间线,可以用 adb bugreport 抓取 bug report 后导入 Battery Historian,或者开启 android.power 数据源录制 Perfetto trace。两者都会把 wake lock 显示为时间轴上的条形,与屏幕状态、网络活动和 CPU 频率并列展示,很容易发现那种在相关活动早已结束后仍被持有的锁。
在 Play Console 中关注 Android Vitals
Google Play 会把过度的 partial wake lock 使用作为 Android vitals 指标来跟踪。当应用在后台长时间累计持有非豁免的 partial wake lock(当前定义为 24 小时窗口内累计 2 小时及以上),该用户会话就会被记为过度使用。由部分系统托管的工作(如音频播放或某些类型的 job)持有的 wake lock 则不在统计范围内。
Play 已将此项列为核心关键指标(vitals),这意味着一旦应用超出不良行为阈值,其在商店中的曝光度将受到限制,并且可能在应用详情页显示警告。具体的阈值和豁免规则会随时间变化,请以最新的 Android vitals 文档为准。但有一个实际结论是稳定的:漏掉的 wake lock 现在不仅是电池投诉问题,更直接影响商店排名。
启用 Lint 检查
Android Lint 包含两个相关的检查规则。Wakelock 会标记那些获取了锁但可能未释放的代码路径,而 WakelockTimeout 则标记没有设置超时的获取操作。这两项默认均作为警告启用。在 lint.xml 或 Gradle 配置中将其提升为错误(errors),可以阻止新的泄漏被合并到代码库中。
android {
lint {
error += listOf("Wakelock", "WakelockTimeout")
}
}
这段 Gradle 配置将上述两项检查从警告升级为错误,因此当出现任一模式时构建将直接失败。Lint 是静态分析工具,无法追踪所有异步路径,因此它无法捕捉前文所示的回调泄漏,但它可以低成本地识别出简单的泄漏案例。
如何测试 Wake Lock 行为
使用 Robolectric 进行单元测试可以轻松覆盖 wake lock 的处理逻辑,因为它提供了 PowerManager 的 shadow 实现。
@RunWith(RobolectricTestRunner::class)
class ContactSyncTest {
@Test
fun releasesWakeLockWhenSyncFails() {
val context = ApplicationProvider.getApplicationContext<Context>()
val syncer = ContactSyncer(context, api = FailingApi())
runCatching { syncer.syncContacts() }
val lock = ShadowPowerManager.getLatestWakeLock()
assertThat(lock).isNotNull()
assertThat(lock.isHeld).isFalse()
}
}
该测试使用一个总是抛出异常的假 API 来创建被测组件。执行同步并忽略异常后,向 Robolectric 查询最近创建的 wake lock。
这两条断言验证了锁确实被使用过,且在失败后不再被持有。编写此类测试的成本很低,适用于每一个错误路径,并且能精确捕捉到那些在代码审查中最难发现的异常处理和提前返回导致的泄漏。将 WakeLock(或围绕它的一个小包装接口)注入到你的类中,会使测试更加简单,因为这样就可以使用普通的 fake 对象,无需依赖 Robolectric。
框架层以下的 Wake Locks
如果你的工作内容涉及平台代码、HAL(硬件抽象层)或原生守护进程,那么你处理 Wake Lock 的层级更低。原生代码可以从 libpower 调用 acquire_wake_lock() 和 release_wake_lock()。在当前的 Android 版本中,这些调用会通过 SystemSuspend 服务,该服务管理着内核中 /sys/power/wake_lock 文件的接口。
SystemSuspend 的 AIDL 接口在调用 acquireWakeLock() 时会返回一个 IWakeLock 对象。只要该对象存活,锁就会被持有;当最后一个引用被释放时,锁随之释放。这种机制赋予了原生代码与 Kotlin 中 finally 块模式相同的范围式生命周期特性。
在新版内核中,完整的唤醒源列表(包括驱动程序持有的唤醒源,而不仅仅是 PowerManagerService 持有的那个)可通过 /sys/class/wakeup/ 查看;在较旧的内核中,若启用了 debugfs,则需查看 /sys/kernel/debug/wakeup_sources。读取这些文件需要提升权限。如果 dumpsys power 显示没有应用 Wake Lock 但设备仍拒绝进入休眠状态,通常是因为某个驱动程序或原生守护进程正持有内核唤醒源,上述文件能帮助定位具体对象。
总结
Wake Lock 用于告知系统,即使屏幕关闭,某些代码也需要 CPU 保持清醒。应用级 Wake Lock 由 PowerManagerService 管理,它通过 UID 和标签追踪每一个锁,并为所有锁维护一个内核唤醒源。由于保持清醒是一种“主动选择”(opt-in),未被释放的锁会阻止设备休眠,耗尽电池电量,直到超时或进程终止。
大多数泄漏源于常规控制流:跳过 release() 调用的异常和提前返回、忘记释放锁的错误回调、失去平衡的引用计数,以及围绕永远不会完成的操作持有的锁。
解决方案同样常规且直接:始终设置超时时间、在 finally 块中释放锁(理想情况下通过 withLock 等小型辅助函数实现)、为每个任务分配独立的带标签锁,并为锁内的操作设置截止时间。
大多数情况下,更好的选择是完全避免手动使用 wake lock。WorkManager、JobScheduler、goAsync()、前台服务和 FLAG_KEEP_SCREEN_ON 都会自动管理锁,并在工作结束后释放。如果确实需要手动使用,可以通过 dumpsys power、batterystats、Perfetto、Battery Historian、Lint,以及覆盖失败路径的单元测试来验证锁的行为。
如今 Play 商店已把过度使用 wake lock 列为核心健康指标,因此在发布前及时发现这些泄漏,既关系到用户的电池续航,也影响你的应用在商店中的曝光。