← 返回蜂巢洞察

如何防止你的安卓应用程序因“唤醒锁定”功能而过度消耗电池电量

“唤醒锁”是Android系统中最简单的API之一,同时也是最容易被误用的功能之一。获取一个唤醒锁只需要一行代码;但如果忘记释放它,手机的CPU可能会持续工作数小时,从而导致电池在夜间被耗尽,同时你的应用程序也可能会在Google Play上收到警告标签。 大多数与唤醒锁相关的错误并不是由于对API本身的误解造成的,而是源于一些常见的控制流程问题:比如在调用 acquire() 和 release() 方法之间抛出了异常、程序过早退出、回调函数从未被执行,或者引用计数出现了失衡。 这些错误在代码审查过程中很难被发现,在常规测试中也无法显现出来,因为通常情况下设备都是处于连接电源且处于唤醒状态的

“唤醒锁”是Android系统中最简单的API之一,同时也是最容易被误用的功能之一。获取一个唤醒锁只需要一行代码;但如果忘记释放它,手机的CPU可能会持续工作数小时,从而导致电池在夜间被耗尽,同时你的应用程序也可能会在Google Play上收到警告标签。

大多数与唤醒锁相关的错误并不是由于对API本身的误解造成的,而是源于一些常见的控制流程问题:比如在调用acquire()和release()方法之间抛出了异常、程序过早退出、回调函数从未被执行,或者引用计数出现了失衡。

这些错误在代码审查过程中很难被发现,在常规测试中也无法显现出来,因为通常情况下设备都是处于连接电源且处于唤醒状态的状态。

本文将解释唤醒锁在系统层面上究竟起到了什么作用,分析应用程序最常见的导致唤醒锁泄漏的原因,并提供具体的预防和检测方法。同时,本文还会介绍一些现代API,这些API在大多数情况下已经消除了手动使用唤醒锁的必要性。

目录

先决条件

你应该能够熟练使用Kotlin编写Android应用程序,并且需要了解服务、广播接收器以及协程的基本概念。

此外,你还需要安装adb工具,并准备一台真实的设备用于调试。模拟器无法真实地模拟设备的电源状态,因此本文后面提到的一些命令在模拟器上运行时可能会产生错误的结果。

Android设备进入睡眠状态时会发生什么

当屏幕熄灭且没有其他程序需要使用CPU时,Android会将应用程序处理器置于低功耗待机状态。在这种状态下,CPU会停止执行指令,大部分RAM会保持自刷新状态,只有少数硬件模块(如调制解调器、实时时钟、传感器接口以及一些中断控制器)会继续运行。虽然设备看起来处于空闲状态,但一旦有中断信号到来,它就能立即恢复唤醒状态并开始工作。

Android采用了一种名为“自动睡眠”的机制。当系统没有继续运行的必要时,内核会尝试让系统进入休眠状态。任何需要CPU持续运行的进程,都必须通过某种方式明确告诉内核这一需求,这种方式就是持有“唤醒源”(在Android中,这种机制被称为“唤醒锁”)。只要至少有一个唤醒源处于激活状态,内核就不会让系统进入休眠模式。

 屏幕关闭
     |
     v
 有任何激活的唤醒源吗? == 是 ==> CPU保持运行状态
     |
     否
     |
     v
 内核使CPU进入休眠状态
     |
     v
 中断事件发生(闹钟、网络数据包、按钮被按下)
     |
     v
 CPU恢复运行,相应的驱动程序或框架会获取唤醒锁来处理该事件

上图展示了在屏幕关闭状态下,内核会反复做出的这些决策。如果存在任何激活的唤醒源,CPU就会继续运行;如果没有这样的唤醒源,系统就会进入休眠状态,直到有硬件中断发生。当设备恢复运行时,处理该中断事件的代码通常会获取短暂的唤醒锁,以便能够在内核再次尝试让系统进入休眠模式之前完成自己的工作。

需要重点注意的是,让CPU保持运行状态是需要应用程序主动选择的。除非有某种机制在为应用程序持有唤醒锁,否则在屏幕关闭状态下,应用程序是无法获得CPU资源的。

唤醒锁的作用

应用层级的唤醒锁实际上是向PowerManagerService发出的请求,而这个系统服务是运行在system_server进程中的。当你的应用程序调用acquire()方法时,这个请求会通过Binder传递给该服务,服务会记录下这个请求,同时还会记录下你的UID、一个标识字符串以及唤醒锁的类型。PowerManagerService会将所有应用程序的这些请求汇总起来,然后代表所有这些应用程序持有一个内核级别的唤醒源。当最后一个应用层级的唤醒锁被释放时,该内核唤醒源也会被取消,此时设备就可以进入休眠状态了。

这种设计有两个值得注意的地方。首先,系统能够清楚地知道是哪个应用程序持有了哪个唤醒锁,以及持有的时间长度;因此,电池使用统计数据和Play Console的监控数据才能将特定的问题归咎于某个具体的应用程序。

其次,PowerManagerService会通过Binder的通知机制,将每个唤醒锁与对应的应用程序进程关联起来。如果某个应用程序进程终止了,系统会自动释放它所持有的所有唤醒锁。因此,这些唤醒锁的存在时间是受限于相应应用程序进程的生命周期的;不过,那些正在运行前台服务或者被系统强制保持活跃状态的进程,其进程本身可能会持续运行数小时之久。

唤醒锁的类型

PowerManager类定义了几种不同的唤醒锁级别。其中大多数已经被弃用,在实际开发中,对于应用程序开发者来说,只有其中一种类型是真正需要关注的。

级别 是否使CPU保持运行状态 是否使屏幕保持亮起状态 当前状态
PARTIAL_WAKE_LOCK 是 否 目前大多数应用程序都应该使用这种级别
SCREENDIMWake_LOCK 是 是,但屏幕亮度会降低 自API 17版本起已被弃用
SCREEN_BRIGHT_WAKELOCK 是 是,屏幕亮度为最高值 自API 13版本起已被弃用
FULLWake_LOCK 是 是,同时键盘背光也会保持亮起状态 自API 17版本起已被弃用
PROXIMITY_SCREEN_OFF_WAKE_LOCK 否 当接近传感器检测到有人靠近时,屏幕会关闭 目前仍在被一些应用程序使用

第一列是您传递给`newWakeLock()`的参数,接下来的两列则说明了这种锁会保持哪些设备处于工作状态。与屏幕相关的设置已经被弃用,因为用户在离开设备后,这些设置仍会使屏幕保持开启状态,从而导致电池电量被浪费。

用来保持屏幕开启状态的替代方法是使用`FLAG_KEEP_SCREEN_ON`窗口标志(或者在布局文件中设置`android:keepScreenOn`),窗口管理器会将这个标志与窗口的可见性关联起来,这样就可以防止屏幕在用户离开后仍然处于开启状态从而继续消耗电池电量。而在接听电话时,接近传感器锁会自动发挥作用。对于其他情况来说,在现代Android系统中,“唤醒锁”实际上指的是`PARTIAL_WAKE_LOCK`,本文接下来的内容也将主要围绕这一类型锁定机制进行讲解。

如何获取和释放唤醒锁

要使用唤醒锁,您的应用在清单文件中必须声明拥有`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`对象,指定了部分唤醒模式,并设置了一个标识标签。“myapp:upload”这个标签会在`dumpsys`工具、电池使用记录以及Play Console中显示出来,因此它能够准确识别出是您的应用以及具体正在执行的操作。

Google推荐使用“app:component”这种格式作为标签,因为在调试过程中使用这种格式可以更方便地获取实时信息。传递给`acquire()`方法的参数是一个以毫秒为单位的超时时间值。如果10分钟后锁仍然没有被释放,系统会自动将其解除。

虽然上面的代码看起来没有问题,但实际上存在一个漏洞:如果`uploadPendingFiles()`方法抛出异常,那么`release()`方法就永远不会被执行。尽管这个超时设置可以将造成的损害限制在10分钟内,但每次出现异常,仍然会有10分钟的电池电量被浪费掉。接下来的章节会解释如何解决这个问题,以及还有哪些其他需要注意的地方。

引用计数机制的工作原理

默认情况下,唤醒锁是使用引用计数机制来管理的。每次调用`acquire()`方法时,系统内部的计数器值会增加;而每次调用`release()`方法时,计数器值会减少。只有当计数器的值为0时,锁定状态才会被真正解除。

wakeLock.acquire(60_000L)  // 计数变为2,锁被持有
wakeLock.acquire(60_000L)  // 计数仍为2
wakeLock.release()         // 计数变为1,锁仍然被持有
wakeLock.release()         // 计数为0,锁被释放
wakeLock.release()         // 抛出RuntimeException:WakeLock未完全释放

前两次调用会使计数变为2。第一次释放后计数会变回1,因此即使代码执行了“释放”操作,锁仍然会被持有。

直到第二次释放时,锁才会真正被释放。如果第三次尝试释放时已经没有锁可释放了,就会抛出RuntimeException,错误信息为“WakeLock未完全释放”。在生产环境中,这种情况会导致程序崩溃,因此有些开发者会将release()方法放在try块中执行,或者先检查isHeld属性。

当多段独立的代码共享同一个WakeLock对象时,引用计数机制非常有用。然而,如果获取锁和释放锁的操作次数不一致,就会出现问题。例如,某个函数被调用了两次,但其完成回调只执行了一次,就会导致锁泄漏。

setReferenceCounted(false)这个方法可以关闭引用计数功能。这样一来,无论该锁被获取过多少次,每次调用release()方法都会使锁被释放。当某个组件独自拥有这个锁时,这种处理方式通常更为简单。

应用程序中导致Wake Lock泄漏的常见原因

Wake Lock泄漏几乎总是遵循某些固定的模式。了解这些模式可以让代码审查更加有效。

fun syncContacts() { wakeLock.acquire(5 * 60 * 1000L) val account = accountStore.current() ?: return api.pushContacts(account) wakeLock.release() }

当当前没有可使用的账户时,这个函数就会导致锁泄漏,因为return语句会直接跳过释放锁的操作。另外,如果pushContacts()方法抛出网络异常,也会导致锁泄漏。这两种情况单独来看似乎都不会造成严重问题,但每次同步操作失败都会浪费5分钟的时间。

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()被调用两次(例如在配置发生变化或者收到重复的意图信号时),而onStop()只被调用一次的情况下。此时引用计数会变为1,锁就会一直保持被占用的状态,直到超时时间结束。关闭引用计数机制,或者在获取锁之前添加if (!wakeLock.isHeld)这样的判断语句,就可以解决这个bug。

会启动后台任务的广播接收器

在onReceive()方法执行期间,系统会为你保持这个唤醒锁。一旦onReceive()方法执行完毕,这个锁就会被释放,进程也会被视为处于空闲状态。

一个常见的错误是在onReceive()方法中获取一个单独的唤醒锁,然后启动一个线程,在线程中再释放这个锁。如果进程被终止或者线程出现挂起的情况,那么锁就永远不会被释放了。

旧版本的WakefulBroadcastReceiver》辅助类就是为了解决这类问题而设计的,但由于上述这些问题,它现在已经过时了。本文后面会介绍一些现代的替代方案。

在无限制的操作中持续保持锁的状态

那些没有设置超时的网络请求、无限期地等待CountDownLatch的结果,以及从蓝牙套接字中进行阻塞式读取操作,都可能导致程序永远处于挂起状态。如果在这些操作中持续保持唤醒锁,那么整个系统也会陷入停滞。虽然从技术上讲,每个代码路径都会尝试释放这个锁,但由于这些操作永远不会完成,因此锁实际上永远不会被真正释放。

防止泄漏的模式

解决这些泄漏问题的方法,归根结底就是养成一些能够始终得到严格遵守的习惯。

始终设置超时时间

每次调用acquire()方法时,都应当指定一个超时时间。Android Lint工具会通过WakelockTimeout检查来强制要求开发者这样做,该检查会标记那些没有传入超时参数的acquire()调用。

选择一个远远长于任务实际所需时间的超时时间,但又要短到足以保证在出现异常时系统仍能正常运行。对于通常需要20秒才能完成的同步操作来说,设置几分钟的超时时间是合理的;而设置1小时的超时时间则不合适。

在“finally”块中释放锁

结构化地管理锁的释放是解决这个问题的最有效方法。在Kotlin中,通过添加一个简单的扩展函数,就可以轻松实现这一目标。

inline fun  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”块中,因此锁肯定会在所有情况下都被释放。此外,代码的结构也更加简洁了,审查人员只需一眼就能确认其正确性,而无需逐一检查每一个执行路径。

谨慎地为协程使用作用域锁

这种模式在协程中同样适用,因为Kotlin中的取消操作会以“CancellationException”异常的形式体现出来,而当协程被取消时,“finally”块也会被执行。

suspend fun uploadAll(files: List) {
    wakeLock.acquire(10 * 60 * 1000L)
    try {
        withTimeout(9 * 60 * 1000L) {
            files.forEach { api.upload(it) }
        }
    } finally {
        if (wakeLock.isHeld) wakeLock.release()
    }
}

锁会在操作开始之前被获取,然后在“finally”块中释放。无论操作是正常完成、失败,还是协程的范围被取消,“finally”块都会被执行。“withTimeout”方法为文件上传操作设置了一个明确的截止时间,这个时间略短于锁的超时时间,这样协程就能在系统强制释放锁之前完成任务,从而避免了之前提到的无限等待问题。

需要注意的一点是:前面章节中介绍的inline辅助函数在这里也同样可以使用,因为内联lambda函数在从暂停上下文被调用时,是可以调用暂停函数的。

为每项任务分配独立的锁

如果让多个不相关的功能共享同一个WakeLock对象,那么引用计数就会变得难以理解,同时电池使用情况的统计数据也会失去参考价值,因为所有功能都会报告相同的标签。为每项任务创建一个带有描述性标签的独立锁,几乎不会增加任何成本,而且也能更容易地追踪锁的使用情况。

如果确实需要共享锁,那么就应该禁用引用计数机制,或者确保只有某个特定的组件来管理这个锁。

正确地为任务分配锁

如果你的代码是代表其他应用程序或UID获取唤醒锁的(这种情况在系统服务及SDK中很常见),那么请调用setWorkSource()方法,这样电池消耗的费用就能被正确地记到相应的应用程序或UID上。虽然大多数应用程序并不需要使用这个功能,但平台工程师和OEM厂商应该了解它的存在。

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会在JobScheduler保持唤醒锁的情况下执行doWork()方法。无论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和进程ID。

在这个例子中,myapp:upload这个锁已经被持有超过了14分钟,如果正常情况下上传操作只需要几秒钟,那么这就很可能是泄漏现象。而第二个记录显示,这个锁是由音频服务器代表另一个应用程序持有的,这一点可以从其工作源信息中看出来。你可以关闭屏幕,等待一分钟后再运行这条命令。如果仍然发现有某个应用程序持有的锁的ACQ时间在不断增加,那就需要进一步调查了。

使用batterystats工具统计总耗电量

dumpsys power 可以显示当前时刻的信息。若想了解某段时间内设备处于“唤醒锁定”状态的时间长度,可以使用电池统计信息来进行查询。

adb shell dumpsys batterystats --reset
# 断开设备连接,正常使用设备或让其闲置一段时间
adb shell dumpsys batterystats com.example.myapp | grep -i "wake lock"

第一个命令会清除之前收集的统计数据,从而确保测量结果能够从新开始。让设备在仅依靠电池供电的情况下运行一段时间后(至少一小时才能获得有用的数据),再执行第二个命令,该命令会输出与你的应用程序相关的统计信息,并且只会显示那些与“唤醒锁定”状态相关的记录。

你会看到每个相关标签以及它们被持有的总时间长度,还有它们被触发了多少次。如果某个标签的累计持有时间很长,但被触发的次数却很少,那么这通常意味着设备处于“唤醒锁定”状态的时间过长,且没有及时恢复到正常状态。

如果你想要以可视化的形式查看这些数据,可以使用 adb bugreport 命令生成故障报告,并将其导入到 Battery Historian 中;或者启用 android.power 数据源来记录 Perfetto 跟踪信息。这两种方法都能在时间轴上用条形图的形式显示“唤醒锁定”状态,同时还会展示屏幕状态、网络活动以及 CPU 使用频率等信息,这样就能很容易地发现那些在相关操作停止后仍然持续存在的“唤醒锁定”现象。

在 Play 控制台中查看 Android 生命体征数据

Google Play 将过度使用“部分唤醒锁定”功能视为一种影响 Android 设备性能的指标。当应用程序在后台运行时,如果长时间持续处于这种状态(目前的规定是 24 小时内累计达到 2 小时或更长时间),那么就会被视为存在过度使用的现象。不过,一些由系统自动管理的操作,比如音频播放或某些类型的任务所导致的“唤醒锁定”行为,则不在这一检测范围内。

Play Store 将这一指标视为核心的生命体征数据之一,因此那些超过规定阈值的应用程序在商店中的显示优先级可能会降低,其列表页面上也可能会出现警告信息。具体的阈值和例外情况会随着时间的推移而发生变化,因此建议查阅最新的 Android 生命体征数据文档。但可以肯定的是:现在,“泄露的唤醒锁定”现象已经不再仅仅是影响电池使用效率的问题,而是会直接影响应用程序在商店中的排名。

启用 Lint 检查功能

Android Lint 包含两项与“唤醒锁定”行为相关的检查机制。Wakelock 会标记那些获取了“唤醒锁定”状态但未能及时释放的代码路径,而 WakelockTimeout 则会标记那些在获取“唤醒锁定”状态时没有设置超时的情况。这两种检查功能默认都是以警告的形式出现的。你可以在 lint.xml 文件或 Gradle 配置中将它们的警告级别提升为错误级别,这样新的违规行为就不会被允许被合并到最终构建结果中。

android {
    lint {
        error += listOf("Wakelock", "WakelockTimeout")
    }
}

通过上述 Gradle 配置,这两种检查功能都会从警告级别提升为错误级别,因此一旦出现相关的违规情况,构建过程就会失败。需要注意的是,Lint 工具只能检测那些同步执行的代码路径,因此它无法捕捉到之前提到的那种回调函数导致的“唤醒锁定”泄漏问题,但能够有效地检测出一些较为简单的违规情况。

如何测试“唤醒锁定”行为

使用Robolectric进行单元测试,可以很容易地处理与唤醒锁相关的问题,因为Robolectric提供了PowerManager的模拟实现。

@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获取最近生成的唤醒锁。

这两个断言用于验证是否确实使用了唤醒锁,以及在同步失败后该锁是否已经被释放。对于每一个可能出现的错误路径,编写这样的测试都非常简单,而且它们能够准确地捕获那些在代码审查过程中最难被发现的异常情况。如果将WakeLock(或者围绕它实现的简单封装接口)直接注入到你的代码中,那么编写这类测试会变得更加容易,因为这时你甚至不需要使用Robolectric来辅助测试。

框架层以下的唤醒锁机制

如果你负责开发平台级代码、HAL模块或原生守护进程,那么你会在更低的层次上处理与唤醒锁相关的问题。原生代码可以通过libpower调用acquire_wake_lock()和release_wake_lock()方法,在当前的Android版本中,这些操作会通过SystemSuspend服务来执行,而SystemSuspend服务负责提供对内核文件/sys/power/wake_lock的访问接口。

SystemSuspend的AIDL接口通过acquireWakeLock()方法返回一个IWakeLock对象。只要这个对象还存在,唤醒锁就会保持激活状态;而当最后一个引用被释放时,该锁才会被释放。这种机制使得原生代码能够像使用Kotlin语言中的finally语句一样,确保唤醒锁在特定作用域内被正确管理。

在较新的Android内核中,所有可以触发设备唤醒的源都会被列在/sys/class/wakeup/目录下;而在那些配备了debugfs功能的旧版本内核中,这些信息则存储在/sys/kernel/debug/wakeup_sources目录中。无论是访问哪个目录,都需要具备相应的权限。当使用dumpsys power命令检查后发现没有应用程序在持有唤醒锁,但设备仍然无法进入休眠状态时,通常是因为有某个驱动程序或原生守护进程正在占用内核的唤醒资源,而这些文件可以帮助我们确定到底是哪一个组件导致了这个问题。

总结

唤醒锁的作用是告诉系统:即使屏幕已经关闭,某些代码仍然需要让CPU保持处于活跃状态。应用层级别的唤醒锁是由PowerManagerService来管理的,该服务会根据每个进程的UID和标签来记录所有被持有的唤醒锁,并且只会使用一个内核级别的唤醒资源来满足所有这些需求。由于是否启用这种功能是用户可以选择的,因此如果某个唤醒锁一直没有被释放,那么它就会阻止设备进入休眠状态,从而导致电池电量不断被消耗,直到该进程被终止或超时为止。

大多数泄漏问题都源于普通的控制流程:异常处理或提前返回导致`release()`方法未被调用,错误回调函数忘记释放锁,引用计数失衡,以及某些操作长时间未完成却仍然持有锁。 解决这些问题的方法也相当简单。只需为所有操作设置超时时间,在`finally`块中释放锁(最好使用像`withLock`这样的辅助工具),为每个任务分配独立的锁,并为锁内的操作设定截止时间即可。 在大多数情况下,更好的做法是完全避免使用手动持有的锁。WorkManager、JobScheduler、`goAsync()`函数、前台服务以及`FLAG_KEEP_SCREEN_ON`机制都会自动管理锁的释放,确保任务完成后锁会被及时释放。只有在你确实需要使用锁时,才需要通过`dumpsys power`、`batterystats`、Perfetto或Battery Historian等工具来检查是否真的有必要使用锁。 由于Play Store现在将过度使用锁的行为视为严重问题,因此在应用发布前及时发现并修复这些泄漏问题,对于保护用户的电池寿命以及提升应用在商店中的排名都具有重要意义。

相关文章

技术实践

如何使用Python中的Gradio:一本从初学者到高级用户的完整指南

Gradio就是这样一种Python库,它让你不禁思考:为什么构建Web界面一开始就会变得如此复杂呢? 你可能已经有过这样的经历:你编写了一个Python程序,它运行得很好;你的机器学习模型能够生成预测结果;你的AI应用程序也能给出相当不错的答案;你的数据处理脚本也完全按照你的预期完成了工作。 然后,有人想要使用这个程序。 你把Python文件发给他们,他们询问如何运行这个程序,你告诉他们需要安装Python环境。 接着他们又发现需要特定版本的Python,还需要相关的依赖库,之后还得执行`pip install`命令……然而,最终还是会出现各种问题。 于是,原本让你充满期待想要分享的这个应用

阅读全文
技术实践

如何负责任地使用Lovable产品

过去,开发应用程序往往就像在没有任何说明书的情况下组装家具,而且还会缺少一半的螺丝。如今,像Lovable这样的人工智能工具可以帮助你用简单明了的语言描述自己的需求,从而将一个想法转化为可运行的网页应用。 这确实很令人兴奋——但同时也意味着一种责任。 Lovable能帮助你快速行动、尝试各种想法,并创造出实用的软件。不过,速度绝不能取代周密的思考。由人工智能生成的程序可能会存在安全问题、导致用户使用体验混乱、包含不准确的信息,或者其代码在演示环境中可以正常运行,但在实际使用中却会出故障。 在这份指南中,你将学习到如何在实际使用Lovable的过程中兼顾安全性、隐私性、可访问性以及用户的安全。我

阅读全文
技术实践

人工智能如何改变补丁更新流程,以及开发人员需要了解哪些关于漏洞暴露管理的相关知识

当漏洞扫描工具报告你的应用程序存在23个安全漏洞时,其中4个属于严重等级,7个为较高风险等级,剩下的12个则为中等风险等级,乍一看,解决办法似乎很明确:立即开始修补这些漏洞。但究竟应该先修复哪一个呢? 这在漏洞管理中一直是个棘手的问题。虽然安全团队可能会及时发现存在漏洞的代码依赖项,但并不总能立刻对其进行修复。开发人员需要确保这些有漏洞的代码仍在被使用中,并在将修复方案部署到生产环境之前完成所有必要的测试。 不过,最近在利用人工智能来检测软件漏洞及攻击手段方面确实取得了一些显著的进展。例如,这篇 研究 就详细介绍了相关的研究成果以及未来的发展方向。 但这一切究竟如何才能真正帮助开发社区呢?我们

阅读全文
技术实践

如何使用Next.js、AWS以及沙箱环境来构建属于自己的、类似于Lovable这样的AI应用开发工具

像Lovable、Bolt这样的工具,第一次使用的时候真的会让人觉得像是魔法一样。说实话,我第一次看到这类东西的时候简直惊呆了……而且这一切都是通过聊天窗口来完成的!真是太不可思议了。第一次尝试使用Lovable的时候,我甚至产生了一场关于“存在意义”的哲学思考。 但你们有没有想过,其实这些工具内部到底发生了什么?比如说,究竟是怎么让一个应用程序能够生成另一个可以用于测试、分享或下载的应用程序的呢? 显然,在某个地方有一个AI模型在编写代码。但这个过程具体是如何进行的呢?那些代码需要被安装、编译并运行才行。而我有点怀疑的是,现在似乎没有人会在这些代码运行之前去仔细检查它们。这些代码可能会出现错

阅读全文