资讯详情

资讯详情

Android电量监测:BroadcastReceiver广播机制与adb模拟实战

前两天接了个需求设备上电后启动一个检测模块插上充电器要在 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的 ReceiveronReceive会马上被调用一次参数里的 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 是 34Android 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) } }布局文件很简单就是一个居中的 TextViewid 为batteryInfoText。这个 Demo 不需要申请任何权限电池广播是系统保护广播动态注册即可接收运行时就只有一个 ActivityManifest 里给 MainActivity 加android:exportedtrue因为带 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_CHANGEDdumpsys battery set level 5跳到 5%跨过低阈值ACTION_BATTERY_LOWdumpsys battery set level 40恢复到 40%ACTION_BATTERY_OKAYdumpsys battery set ac 1模拟插入 AC 电源ACTION_POWER_CONNECTEDdumpsys battery set ac 0模拟断开 AC 电源ACTION_POWER_DISCONNECTEDdumpsys battery set status 2强制状态为“充电中”ACTION_BATTERY_CHANGEDdumpsys 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 里收到事件后把状态和电量转换成字符串或数据类写入MutableStateFlowUI 层通过collect观察这个流。这样日志打印、UI 更新、业务判断都解耦了以后想加通知提醒只需要再 collect 一份就行。private val _batteryState MutableStateFlow(未知) val batteryState: StateFlowString _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里注册 ReceiveronDestroy里注销。因为前台服务保证了进程存活优先级动态注册的 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第二天设备一直用着模拟电量数据害得我排查了半天才发现是数据没复位。电池监测本身不复杂但“入口选对广播、退出记得注销、验证用对命令”这三个习惯能帮你少走很多弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →