前两天接了个需求:设备上电后启动一个检测模块,插上充电器要在 logcat 里看到“正在充电”,电量掉到系统阈值要打印当前电量,电量恢复也要打印。需求本身不复杂,但真正写起来就会发现里面全是细节——广播事件怎么选、粘性广播怎么处理、低电量阈值到底是谁说了算、以及怎么在办公室里模拟“没电了”这种状态。这篇文章把我从接到需求到跑通验证的完整过程写出来,从事件选型到完整代码再到 adb 模拟测试,一条龙讲清楚。适合刚接触 BroadcastReceiver 的开发者,也适合想快速在项目里加电量监测模块的老手直接参考。
1. 需求拆解:把“充电”“低电量”翻译成具体的广播事件
1.1 先搞清楚 Android 在什么时候会主动告诉你电池状态
Android 的电池状态变化是系统级事件,不是某个 App 自己算出来的。系统里跑着一个 BatteryService,它监听电池驱动上报的数据,维护当前电量、充电状态、温度、电压这些信息,并且在外界可感知的状态发生变化时发送广播。我们做应用层的监测,本质上就是去订阅这些系统广播。
针对这个需求,真正要监听的 Action 只有四个,整理出来是这样的:
| 事件 | Action 常量 | 触发时机 | 事件本身携带的信息 |
|---|---|---|---|
| 开始充电 | ACTION_POWER_CONNECTED | 插入 AC/USB/无线充电器 | 只有“插上了”这个信号 |
| 停止充电 | ACTION_POWER_DISCONNECTED | 拔掉充电器 | 只有“拔掉了”这个信号 |
| 电量低 | ACTION_BATTERY_LOW | 电量低于系统阈值 | 只有“进入低电量”这个信号 |
| 离开电量低 | ACTION_BATTERY_OKAY | 电量恢复到阈值以上 | 只有“离开低电量”这个信号 |
这里有个很容易忽略的点:这四个广播事件本身都不带具体电量数值。ACTION_POWER_CONNECTED的 Intent 里没有“当前 37%”这种附加数据,ACTION_BATTERY_LOW也只是告诉你“低了这个事实”。所以在写业务逻辑时,收到事件之后必须自己去查一次当前电量,才能把“正在充电”“电量低”和具体百分比拼成一条完整日志。
1.2 为什么监听广播而不是自己写定时器轮询
我看过不少同事的第一版实现是开一个 Handler,每 3 秒读一次BatteryManager的容量属性,然后自己比较上一次的值判断“是不是充电了”“是不是低电量了”。这种轮询方式在功能上能用,但它有三个明显问题。
第一是功耗和无效唤醒,App 在后台时每 3 秒醒一次去读电池,完全是在浪费资源;第二是会丢事件,假如从 11% 掉到 9%,跨越了系统低电量阈值,你的轮询如果正好在 10.5% 到 8.5% 之间没采样,这个“进入低电量”的转折点就永远检测不到了;第三是自己重新实现了一遍系统的状态机,但“低”到底按多少算?系统阈值是多少?你根本拿不到一个稳定的公共常量。
系统的广播机制是事件驱动的,BatteryService 在状态变化的那一瞬间把广播发出来,应用收到再处理,既不会漏,也不会多做无用功。所以正确姿势是:启动时手动读一次当前状态作为初始快照,之后完全依赖广播事件驱动,而不是轮询。
2. 一个绕不开的细节:ACTION_BATTERY_CHANGED 是粘性广播
2.1 粘性广播:为什么一注册就会立刻回调一次
Android 里有一种特殊广播叫粘性广播(sticky broadcast),系统会把最后一个发出的 Intent 一直保存着。当你新注册一个匹配的 Receiver 时,系统不是等着下一个广播,而是先把历史里保存的最后一个 Intent 立刻投递给你。
电池相关的广播里,ACTION_BATTERY_CHANGED就是典型的粘性广播,它会在电量每变化一次就更新一次,系统始终保存着最新状态。这意味着你一旦注册监听ACTION_BATTERY_CHANGED的 Receiver,onReceive会马上被调用一次,参数里的 Intent 就是当前电池状态。
这个特性有两个层面的影响。
往好了说,它天然适合做“初始快照”,你不需要额外查一次电量,注册完回调里拿到的数据就是当前实时状态。往坑里说,ACTION_BATTERY_CHANGED只能通过动态注册接收,在 AndroidManifest 里声明它的<receiver>是收不到任何回调的,系统设计上就不允许静态注册这个 Action。另外,ACTION_BATTERY_LOW在官方文档里也标注了是粘性广播,后面我会单独讲它带来的误触发问题。
2.2 电量百分比的两条读取路线
拿到电量的方式主要有两条路线,我建议在动手前想清楚用哪条,因为它们取到的数据粒度不一样。
第一条是从ACTION_BATTERY_CHANGED的 Intent 里取EXTRA_LEVEL和EXTRA_SCALE,然后自己计算百分比。EXTRA_LEVEL是当前电量,单位是微安时相关的原始值,EXTRA_SCALE是满电时对应的值,百分比就是level / scale * 100。这条路线数据粒度最细,因为系统就是靠这两个值算的所有展示,包括通知栏图标。如果你要做电量曲线、趋势记录,用这条路线更合适。
第二条是直接调BatteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY),它直接返回一个 0 到 100 的整数百分比,API 21 之后都能用。返回值已经是四舍五入之后的整型,做日志打印和 UI 展示都够用,代码也更简洁。我这次的需求只需要在事件发生时打印一条百分比,所以用的是这条路线。
顺带提一句,老项目里常见的registerReceiver(null, IntentFilter(ACTION_BATTERY_CHANGED))这种“注册空 Receiver 拿粘性广播”的写法,在 API 33 之后已经标记废弃,不建议再用,直接用BatteryManager的属性和方法是更长寿的写法。
2.3 高版本系统要求:动态注册必须声明 exported 标志
如果你的 targetSdk 是 34(Android 14)及以上,调用registerReceiver(receiver, filter)时必须显式传入RECEIVER_EXPORTED或RECEIVER_NOT_EXPORTED,否则直接抛异常。这里有个常见的纠结:电池广播到底该用哪个标志?
我的结论是,只要监听的是系统自己发出的受保护广播,一律用RECEIVER_NOT_EXPORTED。原因很简单,这类广播只有系统能发,普通应用伪造不了,你根本不需要接收来自其他 App 的同类广播;用NOT_EXPORTED反而能挡住恶意应用尝试投递伪广播的行为。写成代码的时候注意版本判断,因为Context.RECEIVER_NOT_EXPORTED这个常量是 API 33 才有的:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) } else { registerReceiver(receiver, filter) }如果你项目里已经引入了 AndroidX Core,也可以直接用ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED),库内部会帮你做好版本适配,代码看起来更干净。
3. 完整实现:一个 Receiver 搞定充电状态与电量边界打印
3.1 核心代码:注册、回调、注销一个都不缺
下面是我这次跑通的完整实现,一个 Activity,一个动态注册的 Receiver,再加一个 TextView 同步显示状态。代码里我特意把“查询当前电量”抽成了一个方法,这样不管是哪个事件触发,打出来的日志都能带上实时电量。
package com.example.batterymonitor import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.BatteryManager import android.os.Build import android.os.Bundle import android.util.Log import android.widget.TextView import androidx.activity.ComponentActivity class MainActivity : ComponentActivity() { companion object { private const val TAG = "BatteryMonitor" } private var batteryInfoText: TextView? = null private val batteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val capacity = getBatteryCapacity(context) when (intent.action) { Intent.ACTION_POWER_CONNECTED -> { Log.i(TAG, "正在充电: 当前电量 $capacity%") batteryInfoText?.text = "正在充电: $capacity%" } Intent.ACTION_POWER_DISCONNECTED -> { Log.i(TAG, "已断开充电: 当前电量 $capacity%") batteryInfoText?.text = "已断开充电: $capacity%" } Intent.ACTION_BATTERY_LOW -> { Log.w(TAG, "电量低: 当前电量 $capacity%") batteryInfoText?.text = "电量低: $capacity%" } Intent.ACTION_BATTERY_OKAY -> { Log.i(TAG, "离开电量低: 当前电量 $capacity%") batteryInfoText?.text = "离开电量低: $capacity%" } } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) batteryInfoText = findViewById(R.id.batteryInfoText) registerBatteryReceiver() val batteryManager = getSystemService(BatteryManager::class.java) val capacity = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) Log.i(TAG, "初始快照: 电量=$capacity%, 充电中=${batteryManager.isCharging}") } private fun registerBatteryReceiver() { val filter = IntentFilter().apply { addAction(Intent.ACTION_POWER_CONNECTED) addAction(Intent.ACTION_POWER_DISCONNECTED) addAction(Intent.ACTION_BATTERY_LOW) addAction(Intent.ACTION_BATTERY_OKAY) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(batteryReceiver, filter, Context.RECEIVER_NOT_EXPORTED) } else { registerReceiver(batteryReceiver, filter) } } private fun getBatteryCapacity(context: Context): Int { val batteryManager = context.getSystemService(BatteryManager::class.java) return batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } override fun onDestroy() { super.onDestroy() unregisterReceiver(batteryReceiver) } }布局文件很简单,就是一个居中的 TextView,id 为batteryInfoText。这个 Demo 不需要申请任何权限,电池广播是系统保护广播,动态注册即可接收,运行时就只有一个 Activity,Manifest 里给 MainActivity 加android:exported="true"(因为带 LAUNCHER intent-filter)就能直接跑。
3.2 日志需要用心设计:Tag、级别、附带电量
同样一句话,有人直接Log.d("tag", "来充电啦"),有人会打成Log.i("BatteryMonitor", "正在充电: 当前电量 37%"),这两者在排查问题时的效率差别很大。
Tag 我固定用一个常量BatteryMonitor,整个项目里监测相关的日志都往这个 Tag 上打,这样adb logcat -s BatteryMonitor一下就能把所有相关日志筛出来,不会混进其他模块的日志。级别上,正常的状态切换用Log.i,低电量这种需要重点关注的用Log.w,这样在 Logcat 里按级别过滤时,低电量事件会比其他信息更醒目。
日志文本一定要带上当前电量。收到ACTION_POWER_CONNECTED时插上充电器,当时的电量是 37% 还是 80%,对分析问题完全不是一回事。你可以在回调里用getBatteryCapacity(context)现查一遍,这样每条日志都是完整的一句话,不用再翻第二行日志来推断状态。
3.3 生命周期和防泄漏细节
动态注册的 Receiver 一定要和注销成对出现。我的习惯是在onCreate里注册、onDestroy里注销,对应 Activity 的整个生命周期。如果你是在onStart注册,那就要在onStop注销,这样 App 退到后台时 Receiver 会跟着注销,避免潜在的内存泄漏。
这里要提醒一个新手高频踩的异常:调unregisterReceiver时如果这个 Receiver 根本没有注册过,系统会抛IllegalArgumentException。比如 Activity 因为某些原因走了两次onDestroy,第二次注销就会崩。我见过很多项目用 try-catch 包住注销代码来兜底,虽然不是最优雅的解法,但如果你的代码里存在多路径销毁的可能,保底一下也不丢人。
还要注意,动态注册的 Receiver 生命周期是绑定在进程上的,进程一旦被系统杀掉,Receiver 就没了。所以这个方案适合 App 在前台运行、或者进程还活着时的场景。真正需要“App 被杀也能持续监测”的业务,后面第 6 节会说怎么扩展。
4. 低电量场景的两个坑:粘性回放和制造商阈值
4.1 迟到的 ACTION_BATTERY_LOW:粘性广播的重复投递
ACTION_BATTERY_LOW被官方文档标注为 sticky broadcast。这句话在正常使用中没人在意,但一旦你的 App 是在“设备已经处于低电量状态之后”才启动的,坑就来了。
举个实际例子:设备当前电量 8%,已经处于系统认定的低电量状态。此时你启动 App,注册了一个包含ACTION_BATTERY_LOW的 IntentFilter。因为粘性广播会把历史上最后一次低电量广播保存着,你的 Receiver 会在注册的瞬间立刻收到一次ACTION_BATTERY_LOW。从用户角度来说,这个时间是进入低电量之后的某个任意时刻,事件本身早发过了,这是系统在“回放历史”,不是又一次真正进入低电量。
如果你拿ACTION_BATTERY_LOW的次数去统计“这轮放电周期里低电量发生了多少次”,就一定会多记一次。我的处理方式有两个:要么完全信任事件语义,把它当作系统状态信号,统计时按时间去重;要么在收到事件后查询一下当前电量,结合自己的业务状态判断这次要不要响应。具体用哪种,取决于你的业务是把广播当“事件”还是当“状态”。
4.2 低电量阈值不是代码里写死的 15%
很多开发者的第一直觉是“低电量 = 15%”,然后自己在代码里写if (level <= 15)。这个做法的问题在于,系统广播的触发阈值根本不是你定的,不同厂商、不同机型差别很大。我见过的设备里,低电量阈值有定在 10% 的,有定在 15% 的,也有定在 20% 的,而且同一个厂商的不同系统版本都可能改。
Android 没有提供公开的 API 去查询这个低电量阈值,所以你不要试图在代码里获取一个“系统阈值常量”然后自己去比对。正确做法是直接使用ACTION_BATTERY_LOW和ACTION_BATTERY_OKAY这两个广播,让系统替你判断“低”的边界,你只负责在收到事件后打印日志。你只需要知道“系统认为现在低了”,不用管它到底是多少。
反过来,如果你的业务需求是“电量低于 18% 就预警”,这种自定义阈值需求就不能依赖系统广播了,因为系统广播根本不知道 18% 这个数。你要么接受系统阈值,要么自己监听ACTION_BATTERY_CHANGED来算百分比并和业务阈值做比较。这个方案我会在第 6 节给出完整做法。
4.3 插着电时的低电量广播可能被收敛
这是我实测中遇到的实际情况:在部分设备上,当电源还插着的时候,系统对低电量事件的处理会和放电状态不一样,ACTION_BATTERY_LOW不会像纯放电时那样准时触发,有些 ROM 会把低电量状态合并到ACTION_BATTERY_CHANGED的 status 字段里,而不是单独广播一次。
如果你的业务逻辑完全依赖ACTION_BATTERY_LOW,就可能出现“插着充电器、电量已经只有 5%,但应用里一条低电量日志都没有”的诡异现象。排查方向倒不复杂:把ACTION_BATTERY_CHANGED也注册进来,自己做一个兜底判断,在收到每次电量变化时检查百分比,低于业务认可的兜底值就补一条日志。这样即使系统广播行为不同,你也不会完全瞎掉。
另外提醒一点,ACTION_BATTERY_CHANGED触发频率非常高,电量每变化 1% 就会来一次,充电过程中可能一分钟内来几十次。如果直接Log.d全量打出来,logcat 会被刷屏。我的做法是只在跨过阈值时记录,或者降级到Log.v,平时不常开。
5. 实测环节:用 adb 模拟充电与电量跳变
5.1 常用模拟命令与触发关系
写代码是一回事,验证是另一回事。真机上要复现“电量从 20% 掉到 5%”“插入充电器”这些场景,得等放电、找充电器,效率太低。Android 系统调试器内置了一套电池模拟功能,可以直接篡改模拟电池数据,命令都挂在adb shell dumpsys battery下面。
| adb 命令 | 模拟效果 | 预期收到的广播 |
|---|---|---|
dumpsys battery set level 20 | 强制跳到 20% 电量 | ACTION_BATTERY_CHANGED |
dumpsys battery set level 5 | 跳到 5%,跨过低阈值 | ACTION_BATTERY_LOW |
dumpsys battery set level 40 | 恢复到 40% | ACTION_BATTERY_OKAY |
dumpsys battery set ac 1 | 模拟插入 AC 电源 | ACTION_POWER_CONNECTED |
dumpsys battery set ac 0 | 模拟断开 AC 电源 | ACTION_POWER_DISCONNECTED |
dumpsys battery set status 2 | 强制状态为“充电中” | ACTION_BATTERY_CHANGED |
dumpsys battery reset | 恢复真实电池数据 | 状态重置产生的广播 |
命令前面都要加上adb shell。这里的关键是理解命令作用在的是“模拟电池”,它和真实电池驱动是并列的调试通道,不会真的伤害硬件。测试完必须执行dumpsys battery reset恢复,否则设备会一直用你伪造的数据运行,甚至可能出现系统 UI 弹出异常电量提醒。
5.2 logcat 过滤与完整复现步骤
先把 logcat 窗口过滤到只显示我们的 Tag。命令行下可以这样:
adb logcat -s BatteryMonitor:I *:SAndroid Studio 的 Logcat 窗口也一样,加一个包名或 Tag 过滤器,否则调试信息太多会干扰判断。
完整的复现步骤我建议按下面的顺序走:
- 安装并启动 App,此时应该先看到一行“初始快照: 电量=xx%, 充电中=false”。
- 执行
adb shell dumpsys battery set level 60,验证基础的电量变化广播能通。 - 执行
adb shell dumpsys battery unplug,把设备从“充电状态”切到模拟的放电状态。 - 执行
adb shell dumpsys battery set level 5。正常情况下这里会出现“电量低: 当前电量 5%”,用的Log.w级别,在 filtered 日志里会有 W 前缀。 - 执行
adb shell dumpsys battery set level 40。此时电量回到阈值以上,应该出现“离开电量低: 当前电量 40%”。 - 执行
adb shell dumpsys battery set ac 1。模拟插电,应出现“正在充电: 当前电量 40%”。 - 执行
adb shell dumpsys battery set ac 0。模拟拔电,应出现“已断开充电: 当前电量 40%”。 - 全部验证完执行
adb shell dumpsys battery reset,恢复真实电池数据。
第 3 步的unplug容易被漏掉。如果不先模拟拔电,设备可能一直处于“已插电”的模拟状态,这时候你 set level 5,低电量事件的行为会和纯放电场景不一致,可能复现不出第 4 步的效果。先unplug再调 level,流程才完整。
5.3 实机验证注意事项
模拟数据毕竟不是真实硬件状态,有几件事我必须提醒一下。
第一,模拟命令不会真的改变物理电池,但系统的低电量弹窗、功耗统计、甚至个别设备的关机逻辑都可能被模拟数据触发。测试时插着电源线最好,避免设备因为模拟数据以为电量耗尽而关机,虽然概率极低但不是没有。
第二,如果你在同一台设备上反复测试,建议每次开始前先dumpsys battery reset并等待几秒,让真实数据重新生效,再开始新的模拟流程。不然上一次测试遗留的模拟状态会直接影响下一次结果,你排查半天才发现是数据没重置。
第三,模拟命令在模拟器上同样生效。如果你手头没有真机,直接在模拟器里跑这套流程也能验证广播注册、日志输出这些核心逻辑,只是模拟器对充电状态的模拟方式偶尔和真机有差异,最终上线前还是要在真机上插拔一次充电器做最终确认。
6. 这个监听逻辑还能怎么扩展
6.1 改造数据源:把 BroadcastReceiver 包成 StateFlow
实际项目里很少有需求是“只在 logcat 里打印日志”,更多是 UI 要跟着变、业务逻辑要做出反应。把 Receiver 直接写在 Activity 里的做法在模块多了以后会很乱,我习惯把电量状态封装成一个数据源,用 StateFlow 对外暴露。
思路很简单:Receiver 里收到事件后,把状态和电量转换成字符串或数据类,写入MutableStateFlow,UI 层通过collect观察这个流。这样日志打印、UI 更新、业务判断都解耦了,以后想加通知提醒,只需要再 collect 一份就行。
private val _batteryState = MutableStateFlow("未知") val batteryState: StateFlow<String> = _batteryState.asStateFlow() private val batteryReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val capacity = getBatteryCapacity(context) _batteryState.value = when (intent.action) { Intent.ACTION_POWER_CONNECTED -> "正在充电: $capacity%" Intent.ACTION_POWER_DISCONNECTED -> "已断开充电: $capacity%" Intent.ACTION_BATTERY_LOW -> "电量低: $capacity%" Intent.ACTION_BATTERY_OKAY -> "离开电量低: $capacity%" else -> return } Log.i(TAG, _batteryState.value) } }Activity 在onCreate里 collect,配合repeatOnLifecycle使用,退出时自动取消收集。这一套改下来,后面业务再怎么扩展都不会动 Receiver 的代码。
6.2 后台持续监控:前台服务加动态注册
如果你要的是进程被杀之后还能监测充电状态,动态注册就满足不了了。但直接去 Manifest 里静态注册那几个电池广播,在 Android 8 之后的隐式广播限制下行为很飘,不同版本表现不一致。与其和系统限制较劲,我推荐用“前台服务 + 动态注册”的组合。
具体做法是起一个前台服务,在服务的onCreate里注册 Receiver,onDestroy里注销。因为前台服务保证了进程存活优先级,动态注册的 Receiver 就能一直工作。targetSdk 34 之后前台服务需要声明类型,你可以按用途选dataSync或specialUse,并配上对应权限。这套方案的优点是行为完全可控,注册注销时机都掌握在你自己手里。
6.3 自定义低电阈值:自己监听 BATTERY_CHANGED 并加滞回
如果你的业务非要“低于 18% 预警”,那就要放弃系统广播,自己监听ACTION_BATTERY_CHANGED做判断。这里有个很容易踩的细节:阈值边界反复横跳的问题。电量在 18% 到 19% 之间来回波动时,如果代码写成“低于 18% 预警、高于 18% 解除”,日志会疯狂交替刷屏。
我的做法是引入滞回区间:进入预警的阈值低一点,解除预警的阈值高一点。比如进入预警在 18%,解除要回到 23%,这样两个阈值之间留出了缓冲带,电量小幅波动不会造成状态翻转。
private var lowWarningActive = false private val batteryChangedReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1) if (level <= 0 || scale <= 0) return val percent = (level * 100f / scale).toInt() val status = intent.getIntExtra(BatteryManager.EXTRA_STATUS, -1) val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL if (!lowWarningActive && percent <= 18 && !charging) { lowWarningActive = true Log.w(TAG, "业务阈值进入低电量: $percent%") } else if (lowWarningActive && percent >= 23) { lowWarningActive = false Log.i(TAG, "业务阈值离开低电量: $percent%") } } }这套逻辑同样要注册在动态 Receiver 里。注意它和系统广播方案不是互斥的,如果同时订阅了两套,日志里会出现两条不同来源的低电量记录,一条来自系统阈值,一条来自业务阈值,这是正常的,关键在于你要知道哪条是给谁用的。
最后分享一个我自己的排查习惯:每次写完这种监听逻辑,我都会先确认动态注册和注销是成对出现的,再用dumpsys battery把完整流程模拟一遍,最后一定记得reset。我吃过一次亏,测试完忘掉 reset,第二天设备一直用着模拟电量数据,害得我排查了半天才发现是数据没复位。电池监测本身不复杂,但“入口选对广播、退出记得注销、验证用对命令”这三个习惯,能帮你少走很多弯路。