广播是我学 Android 以来最熟悉的陌生人。onReceive 写过很多次,但直到这周做实验我才发现:我把一个自定义广播注册在 Manifest 里,然后它一次都没收到过。没有报错,没有警告,就是静默地收不到。
这就是 Android 8.0 的隐式广播限制。老教程里"Manifest 注册个 receiver 就能收广播"的写法,在 targetSdk 26 之后已经大面积失效,而大量二手资料至今没更新这一刀。这一周我把广播的完整机制跑了一遍:动态注册、静态注册的对照实验、有序广播的优先级和拦截、粘性广播仅存的正当用法,以及规划里要求的重点,为什么应用内通信不该用广播,该用 SharedFlow。标题里的"优化"指的是三件事:别收不该收的(冗余)、别忘了反注册(泄漏)、别用过时方案(正确性)。
实践
| 来源 | 说明 |
|---|---|
| Android Developers:后台执行限制(Android 8.0) | 隐式广播静态注册失效、豁免清单存在性、签名权限广播豁免、JobScheduler 迁移方向的依据 |
| Android Developers:Android 13/14 行为变更 | 动态注册导出标志(RECEIVER_EXPORTED/NOT_EXPORTED)从引入到强制、仅系统广播豁免、受限状态不投递 BOOT_COMPLETED 的依据 |
| OWASP MASTG:Android Broadcast Receivers | 广播作为 IPC 的攻击面、LocalBroadcastManager 废弃状态、优先级语义的交叉确认 |
广播到底是什么
广播(Broadcast)是 Android 的发布-订阅消息机制:发送方把消息(一个 Intent)交给系统,系统分发给所有声明"我在意这个消息"的接收器(BroadcastReceiver),发送方不关心谁在听。
这套机制有三个事实必须先钉死。
广播是 IPC(跨进程通信),不是普通的方法调用。一条广播从发出到接收要经过系统服务(ActivityManagerService)中转,Intent 里的数据要序列化。所以广播天然比直接调用贵,这个成本认知是后面"应用内通信别用广播"的底层原因。
接收广播可能会拉起你的进程。静态注册的接收器,即使你的应用完全没在运行,系统收到匹配广播时也会创建你的进程来投递。一个热门系统事件(比如网络切换)可能同时拉起几十个应用的进程。这就是 Android 8.0 下狠手限制隐式广播的动机:一次系统事件引发全设备范围的进程雪崩,电量和内存瞬间被打爆。
广播有三个正交的类型维度,别混:
| 维度 | 取值 | 区别 |
|---|---|---|
| 投递方式 | 普通广播 / 有序广播 / 粘性广播 | 同时到 / 按优先级依次到可拦截 / 发出后系统缓存最后一条 |
| 注册方式 | 静态(Manifest)/ 动态(代码) | 不运行也能收(但受 Android 8 限制)/ 活着才收 |
| 来源 | 系统广播 / 自定义广播 | 系统发出(BOOT_COMPLETED 等)/ 应用自己发出 |
系统的决策规则:谁能收到,谁收不到
机制型主题先讲规则。广播的分发决策可以压缩成三条。
规则一:隐式广播看 action 匹配,显式广播看组件定向。发送时不指定目标(只有一个 action 字符串)就是隐式广播,系统找出所有 intent-filter 匹配的接收器挨个投递;发送时指定了包名或组件名(setPackage / setComponent)就是显式广播,只投递给目标。这个区分就是 Android 8.0 限制的分界线。
规则二:targetSdk 26+ 的应用,Manifest 注册的接收器不再接收隐式广播(豁免清单除外)。官方的逻辑是,豁免的广播只有两类:一类是系统认为必须保证送达的(如 ACTION_BOOT_COMPLETED、ACTION_TIMEZONE_CHANGED),一类是签名权限保护的广播(只发给同签名应用,天然没有进程雪崩问题)。动态注册完全不受此限制。
规则三:动态注册必须声明导出性(Android 13 引入、14 强制)。targetSdk 33+ 注册非系统广播的接收器时,必须显式传入 RECEIVER_EXPORTED 或 RECEIVER_NOT_EXPORTED,否则抛 SecurityException。只监听系统广播的接收器豁免,不用传。这条规则的目的:老版本里任何应用都能向你动态注册的接收器发不受保护的广播,你的接收器成了公开攻击面。
版本演进是一条持续收口的线:
| 版本 | 规则 | 砍掉的旧写法 |
|---|---|---|
| Android 7.0(API 24) | CONNECTIVITY_ACTION 等三个广播不再静态投递 | Manifest 监听网络变化 |
| Android 8.0(API 26) | 隐式广播静态注册大面积失效 | Manifest 注册自定义广播收应用事件 |
| Android 12(API 31) | 带 intent-filter 的组件必须显式 exported | 不写 exported 靠默认值的模糊声明 |
| Android 13(API 33) | 动态注册引入导出标志(受限状态应用不投递 BOOT_COMPLETED) | 裸 registerReceiver 不设防 |
| Android 14(API 34) | 导出标志强制;缓存应用的动态广播排队延迟 | 依赖缓存状态实时收广播的逻辑 |
动态注册:唯一不受限制的通道
动态注册(代码里 registerReceiver)是 Android 8 之后收广播的主通道。标准写法:
// 接收器必须是成员变量,局部变量注册后无法反注册
private var dynamicReceiver: BroadcastReceiver? = null
dynamicReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
// 收到广播
}
}
val filter = IntentFilter("com.study.all.action.WEEK16_DYNAMIC_DEMO")
// ContextCompat 封装:旧版本也能跑同一套代码
// RECEIVER_NOT_EXPORTED = 只收本应用(和同 uid / 系统)发来的广播
ContextCompat.registerReceiver(
this, dynamicReceiver!!, filter, ContextCompat.RECEIVER_NOT_EXPORTED
)
这里用 ContextCompat.registerReceiver 而不是 context.registerReceiver,因为 AndroidX Core 1.9+ 的这个封装让导出标志 API 在旧版本上也能用同一套代码跑,不用自己写 if (Build.VERSION.SDK_INT >= 33) 分支。
RECEIVER_NOT_EXPORTED 是默认正确答案。你的接收器监听的是你自己发的广播,那就必须是 NOT_EXPORTED。否则任何应用都能伪造你的 action 给你发广播,onReceive 里的数据就是不可信输入。什么时候才用 EXPORTED?你明确要接收其他应用发来的广播时。
反注册的时机跟随注册用的 Context。用 Activity 的 context 注册,接收器就活到 Activity 销毁。但这不等于不用管:页面退到后台后还在收广播,一是浪费,收到也做不了什么;二是回调里如果碰了已销毁视图的引用,就是崩溃或泄漏。Demo 的做法是 onStop 反注册:
override fun onStop() {
super.onStop()
dynamicReceiver?.let {
unregisterReceiver(it)
dynamicReceiver = null
}
}
注意可空状态位不是装饰。unregisterReceiver 一个没注册过的接收器会直接抛 IllegalArgumentException,必须有保护。而不反注册的后果是:Activity 销毁了,系统还持有接收器,匿名内部类接收器又隐式持有 Activity,一条经典的泄漏链就这么成了。这也是为什么接收器不能是局部变量,注册完就丢了引用,想反注册都没办法。
实践
- 参考方向:内容 App 的"登录态变化"通知、播放器的"网络切换降级"提示
- 解决问题:这些场景的共同点是"只在用户使用期间关心"。动态注册加 onStop 反注册的生命周期绑定正好匹配;登录态广播用 NOT_EXPORTED 挡住外部伪造,这是账号安全的基本面
- 本节落点:Demo 区域① 的注册、发送、反注册完整流程加状态显示
- 取舍判断:动态注册的接收器只在进程活着时工作。"应用没运行也要响应事件"的需求它接不住,那是豁免清单广播(开机、闹钟)或 FCM 的领域,别用动态注册硬扛
相关技术清单
- API / 类:
registerReceiver()/unregisterReceiver()、ContextCompat.registerReceiver()、RECEIVER_EXPORTED/RECEIVER_NOT_EXPORTED、IntentFilter - 系统机制:注册 Context 决定接收器存活范围、导出标志强制(13 引入/14 强制)
- Jetpack / 三方库:AndroidX Core 1.9+ 的 ContextCompat 封装
- 常见坑:接收器写成局部变量无法反注册;onStop 不反注册导致泄漏;NOT_EXPORTED 误写成 EXPORTED 暴露攻击面
静态注册与隐式限制:本周的核心对照实验
静态注册是在 Manifest 里声明接收器:
<receiver
android:name=".Week16StaticReceiver"
android:exported="true">
<intent-filter>
<action android:name="com.study.all.action.WEEK16_STATIC_DEMO" />
</intent-filter>
</receiver>
exported="true" 是 Android 12 起的硬性要求:带 intent-filter 的组件必须显式声明 exported,不写的话安装直接失败。这里必须为 true 的原因是,显式定向广播从系统投递过来时,接收器得对系统可见。
然后做本周最重要的实验:同一个 action,两种发送方式。
// 发送方式一:隐式(只有 action,没有目标)
sendBroadcast(Intent(Week16StaticReceiver.ACTION_STATIC_DEMO))
// 发送方式二:显式(setPackage 定向到本应用)
sendBroadcast(Intent(Week16StaticReceiver.ACTION_STATIC_DEMO).apply {
setPackage(packageName)
})
在 Demo 区域③ 依次点两个按钮,观察日志区。
隐式发送:日志区没有 [Static] 输出,静态接收器静默了。没有崩溃,没有日志,没有任何提示。这是最容易让人以为"我代码写错了"的地方,实际上是系统按 Android 8.0 的规则拦截了投递。
setPackage 显式发送:[Static] 静态接收器收到广播! 立刻出现。
这个实验的结论值得记住:静态注册自定义广播,发送时必须显式定向,否则等于没注册。
那静态注册还剩下什么正当用途?两个。一是豁免清单里的系统广播(BOOT_COMPLETED 开机、ACTION_MY_PACKAGE_REPLACED 自身更新完成等),二是配合 android:permission 做跨应用的受控通信,发送方必须持有你声明的权限才能投递进来,签名级权限可以做到只有同签名的自家 App 能发。
实践
- 参考方向:应用自更新完成后的初始化(MY_PACKAGE_REPLACED)、开机后恢复闹钟或任务(BOOT_COMPLETED)
- 解决问题:这些场景必须"应用没运行也能收到",这是静态注册不可替代的价值。反过来说,凡是应用运行时才关心的事件,都不该用静态注册
- 本节落点:Demo 区域③ 隐式与显式对照实验加 Manifest 声明
- 取舍判断:
BOOT_COMPLETED不是无限开火权。Android 13 起被用户设为受限状态的应用收不到开机广播,Android 15 起从开机广播启动部分类型的前台服务也被禁,第15周的时间线在这里接上了。"开机拉起干活"的整条路每一环都在收紧
相关技术清单
- API / 类:
<receiver>Manifest 声明、android:exported、setPackage()、android:permission(受控投递) - 系统机制:隐式广播限制(8.0)、豁免清单、显式定向不受限、静态接收拉起进程
- 常见坑:自定义广播静态注册但隐式发送(静默收不到);带 intent-filter 不写 exported(安装失败);以为 BOOT_COMPLETED 永远可达(受限状态不投递)
有序广播与粘性广播:两个特殊形态
有序广播:按优先级排队,还能拦截
普通广播是所有接收器同时收到,顺序不定。有序广播(sendOrderedBroadcast)让接收器按 priority(-1000 到 1000,默认 0)从高到低依次执行,高优先级可以 abortBroadcast() 把广播链掐断。
Demo 区域④ 的实验:两个接收器 priority 10 和 5,开关控制高优先级是否拦截。
class Week16OrderedHighReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (shouldAbort) {
// 拦截:后续接收器收不到。只能在有序广播里调用
abortBroadcast()
}
}
}
// 发送侧
sendOrderedBroadcast(Intent(ACTION_ORDERED_DEMO), null)
开关关闭时日志依次出现 High、Low;开关打开时只有 High,Low 静默。注意 abortBroadcast 只能用在有序广播里,在普通广播的 onReceive 里调用会抛异常。
有序广播在今天还有意义吗?有,但场景很窄:同应用内多个模块要按确定顺序响应同一事件,或者你要给下一个接收器传递加工后的数据(setResultData)。跨应用的有序广播基本可以视为历史设计,我在真实项目里已经很多年没见过它了。
粘性广播:已废弃的发送方式,但留下了一个正当查询口
sendStickyBroadcast 家族已经废弃,它发出的广播会永久驻留系统,还没有任何访问控制,谁都能读。但系统给自己保留了几条粘性广播,于是有了粘性广播仅存的正当用法:查询系统当前状态,而不是监听变化。
// 传 null 接收器:不监听,只拿最后一次粘性广播的缓存 Intent
val batteryIntent = registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val level = batteryIntent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = batteryIntent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
// 电量百分比 = level * 100 / scale
ACTION_BATTERY_CHANGED 是粘性广播,系统一直缓存着最后一条。registerReceiver(null, filter) 的返回值就是这条缓存,不用常驻接收器,随时查电量。Demo 区域② 演示的就是这个:点按钮读出电量和充电状态,数据来自系统缓存而非实时监听。
这个用法的语义边界要说清:它是查询快照,不是监听。要监听电量变化过程,还是得正常注册接收器。
实践
- 参考方向:电量和充电状态查询(视频 App 决定是否在非充电时降低码率预加载)、应用市场类场景曾用有序广播做安装流程编排
- 解决问题:粘性查询把"启动时先知道当前电量"从"注册监听等第一条广播"变成一次同步调用,避免首屏逻辑挂在异步事件上
- 本节落点:Demo 区域② 粘性电量读取加区域④ 优先级拦截实验
- 取舍判断:有序广播的依次投递是串行的,高优先级接收器里做耗时操作会拖住整条链。
onReceive跑在主线程且有时间预算(前台广播约 10 秒,后台 60 秒,超时 ANR),重活必须goAsync()或转交其他组件
相关技术清单
- API / 类:
sendOrderedBroadcast()、abortBroadcast()、IntentFilter.setPriority()、registerReceiver(null, filter)粘性查询、goAsync() - 系统机制:优先级依次投递、结果数据链式传递、粘性广播系统缓存、onReceive 主线程时间预算
- 常见坑:普通广播里调 abortBroadcast 抛异常;onReceive 里做耗时操作 ANR;把粘性查询当成实时监听
广播优化:冗余、泄漏与 SharedFlow 替代
规划里点名了三个优化方向,逐个落。
冗余优化:只收你需要的,只在你需要时收。冗余有三个层次:订阅了不关心的事件(filter 写得太宽)、页面不可见时还挂着接收器(onStop 反注册解决)、用广播做应用内通信(每条都过系统服务,纯属浪费)。前两层在动态注册一节讲过,第三层引出替代方案。
泄漏解决:动态注册的接收器是必须成对出现的资源。注册路径(onCreate/onStart/onResume)和反注册路径(onStop/onDestroy)必须对称,且用状态位防止重复反注册抛异常。静态回调引用(Demo 里各接收器的 logListener)在宿主销毁时清零,这和第15周服务泄漏是同构问题。
静态广播的替代方案:应用内通信用 SharedFlow。LocalBroadcastManager(只限应用内的本地广播)已被官方废弃,推荐方向是 LiveData 或可观察数据持有器。在 Kotlin 协程体系里,更直接的选择是 SharedFlow:
// Week16EventBus.kt,一个 object 就是整条总线
object Week16EventBus {
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8)
val events: SharedFlow<String> = _events.asSharedFlow()
fun emit(event: String) { _events.tryEmit(event) }
}
// 订阅侧:repeatOnLifecycle(STARTED) 自动等价于"页面可见才收集"
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
Week16EventBus.events.collect { event ->
// 收到事件
}
}
}
// 发送侧:一行
Week16EventBus.emit("下载完成")
对比广播方案,SharedFlow 赢在每个方面:纯内存对象,不过 ActivityManagerService,零序列化;类型安全,编译期检查事件类型,不用解析 Intent extra 的字符串 key;生命周期自管理,repeatOnLifecycle 自动停止和恢复收集,天然免疫"忘了反注册";可测试,普通 Kotlin 对象,单测不用 Robolectric。extraBufferCapacity 给没有订阅者时的发送留缓冲,tryEmit 不挂起,这两个参数让"发送方先跑、订阅方后到"的场景不丢事件。
边界也要说诚实:SharedFlow 只能在本进程内工作。跨进程、跨应用、应用没运行也要被唤醒,这些仍然是广播(或 FCM)的地盘。方案选型看后面的矩阵。
实践
- 参考方向:成熟项目的事件总线迁移(EventBus 库和 LocalBroadcastManager 向 Flow 系方案迁移,是近年 Kotlin 项目的普遍演进方向)
- 解决问题:应用内事件通信走广播或总线库,性能开销和类型安全都差。Flow 方案把事件流纳入协程结构化并发,页面销毁自动停收,从机制上消掉一类泄漏
- 本节落点:Demo 区域⑤ 的
Week16EventBus发送和收集完整流程 - 取舍判断:SharedFlow 没有粘性语义,晚到的订阅者收不到历史事件。需要"订阅时先拿到当前状态"的场景用
StateFlow(带初始值、永远保留最新值),这是两者选型的唯一判断点
相关技术清单
- API / 类:
MutableSharedFlow、SharedFlow、tryEmit、extraBufferCapacity、repeatOnLifecycle、StateFlow - 系统机制:广播过系统服务 vs Flow 纯内存、序列化成本、生命周期绑定收集
- Jetpack / 三方库:kotlinx-coroutines(Flow)、被废弃的
LocalBroadcastManager - 常见坑:应用内通信用全局广播;SharedFlow 当 StateFlow 用(晚到订阅者拿不到当前值);
emit在无缓冲满时挂起导致发送方卡住
方案决策矩阵
| 通信需求 | 正确方案 | 理由 |
|---|---|---|
| 应用内组件通知(下载完成到刷新页面) | SharedFlow / StateFlow | 纯内存、类型安全、不过系统服务 |
| 监听系统事件,仅页面可见时关心 | 动态注册 + onStop 反注册 | 不受 8.0 限制,生命周期干净 |
| 监听系统事件,应用没运行也要响应 | 豁免清单广播静态注册(BOOT_COMPLETED 等) | 清单内广播不受隐式限制 |
| 应用内发广播给自己(静态接收) | 静态注册 + 发送时 setPackage 显式定向 | 显式不受隐式限制 |
| 跨应用受控通信 | 静态注册 + android:permission(签名级最佳) | 只有持权限或同签名发送方能投递 |
| 查询系统当前状态(电量等) | registerReceiver(null, filter) 粘性查询 | 同步拿快照,不挂监听 |
| 多模块按顺序响应 + 可拦截 | sendOrderedBroadcast + priority | 窄场景专用 |
| 延迟容忍的后台触发(充电时清理等) | JobScheduler / WorkManager | 官方迁移指南的指定方向 |
技术清零表
| 技术 | 它是什么 | Demo 落点 | 真实项目价值 | 常见坑 |
|---|---|---|---|---|
| 普通广播 | 所有匹配接收器同时收到的投递 | 区域① 自定义广播发送 | 一对多通知的基础形态 | 指望接收顺序 |
| 有序广播 | 按优先级依次投递、可拦截可传值 | 区域④ High/Low + abort 开关 | 同应用多模块顺序响应 | 普通广播里调 abortBroadcast 抛异常 |
| 粘性广播查询 | 系统缓存最后一条粘性广播,null 接收器读取 | 区域② 电量读取 | 同步查询系统状态快照 | 当成实时监听;自发粘性广播(已废弃) |
| 静态注册 | Manifest 声明接收器 | Week16StaticReceiver 声明 | 应用未运行也响应豁免事件 | 自定义隐式广播收不到(8.0 限制) |
| 动态注册 | 代码 registerReceiver | 区域① 完整流程 | 运行期监听的主通道 | 局部变量注册无法反注册 |
| 隐式/显式广播 | 是否定向到特定包/组件 | 区域③ 对照实验 | 理解 8.0 限制的钥匙 | 发送自定义广播忘 setPackage |
| 导出标志 | RECEIVER_EXPORTED / NOT_EXPORTED | ContextCompat.registerReceiver(..., NOT_EXPORTED) | 堵住外部伪造广播的攻击面 | 裸注册不传标志(targetSdk 33+ SecurityException) |
ContextCompat.registerReceiver | AndroidX 兼容封装 | 区域①④ 注册调用 | 一套代码兼容旧版本 | 自己写版本分支 |
android:exported(receiver) | 静态接收器对外可见性 | Manifest 显式声明 | Android 12+ 安装合规 | 带 intent-filter 不写 exported 安装失败 |
android:permission(receiver) | 要求发送方持权限才能投递 | 静态注册一节语义讲解 | 跨应用受控通信 | 不设权限的 exported 接收器被任意应用投递 |
| onReceive 时间预算 | 主线程执行,前台约 10s / 后台约 60s | 有序广播一节讲解 | 防广播 ANR | onReceive 里做 IO/网络 |
goAsync() | 把 onReceive 的耗时部分移交后台线程 | 有序广播一节提及 | 有序广播长任务保活 | goAsync 后忘了调 pendingResult.finish() |
BOOT_COMPLETED | 开机广播(豁免清单成员) | 静态注册一节讲解 | 开机恢复闹钟/任务 | 受限状态(13+)不投递;15 起部分 FGS 类型禁启 |
| 反注册纪律 | 注册/反注册对称 + 状态位保护 | onStop 反注册示范 | 防泄漏防空收 | 重复 unregisterReceiver 抛异常 |
LocalBroadcastManager | 已废弃的应用内本地广播 | SharedFlow 一节说明 | 历史代码识别 | 新项目继续引入 |
| SharedFlow 事件总线 | 协程体系的应用内事件流 | 区域⑤ Week16EventBus | 应用内通信的现代方案 | 跨进程场景误用(它只在进程内) |
| SharedFlow vs StateFlow | 事件流 vs 状态流 | SharedFlow 一节取舍 | 订阅时是否需要当前值 | 用 SharedFlow 承载"当前状态" |