第16周:Broadcast 全功能 + 广播优化

2 阅读18分钟

广播是我学 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_COMPLETEDACTION_TIMEZONE_CHANGED),一类是签名权限保护的广播(只发给同签名应用,天然没有进程雪崩问题)。动态注册完全不受此限制。

规则三:动态注册必须声明导出性(Android 13 引入、14 强制)。targetSdk 33+ 注册非系统广播的接收器时,必须显式传入 RECEIVER_EXPORTEDRECEIVER_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_EXPORTEDIntentFilter
  • 系统机制:注册 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:exportedsetPackage()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 / 类:MutableSharedFlowSharedFlowtryEmitextraBufferCapacityrepeatOnLifecycleStateFlow
  • 系统机制:广播过系统服务 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_EXPORTEDContextCompat.registerReceiver(..., NOT_EXPORTED)堵住外部伪造广播的攻击面裸注册不传标志(targetSdk 33+ SecurityException)
ContextCompat.registerReceiverAndroidX 兼容封装区域①④ 注册调用一套代码兼容旧版本自己写版本分支
android:exported(receiver)静态接收器对外可见性Manifest 显式声明Android 12+ 安装合规带 intent-filter 不写 exported 安装失败
android:permission(receiver)要求发送方持权限才能投递静态注册一节语义讲解跨应用受控通信不设权限的 exported 接收器被任意应用投递
onReceive 时间预算主线程执行,前台约 10s / 后台约 60s有序广播一节讲解防广播 ANRonReceive 里做 IO/网络
goAsync()把 onReceive 的耗时部分移交后台线程有序广播一节提及有序广播长任务保活goAsync 后忘了调 pendingResult.finish()
BOOT_COMPLETED开机广播(豁免清单成员)静态注册一节讲解开机恢复闹钟/任务受限状态(13+)不投递;15 起部分 FGS 类型禁启
反注册纪律注册/反注册对称 + 状态位保护onStop 反注册示范防泄漏防空收重复 unregisterReceiver 抛异常
LocalBroadcastManager已废弃的应用内本地广播SharedFlow 一节说明历史代码识别新项目继续引入
SharedFlow 事件总线协程体系的应用内事件流区域⑤ Week16EventBus应用内通信的现代方案跨进程场景误用(它只在进程内)
SharedFlow vs StateFlow事件流 vs 状态流SharedFlow 一节取舍订阅时是否需要当前值用 SharedFlow 承载"当前状态"