Skip to content

广播:队列、注册时机与 Android 8 之后的限流世界 ​

本页导读:广播是 Android 最古老也最被滥用的 IPC。本页讲广播在系统侧的真实投递模型(有序/无序、现代广播队列)、注册时机的合法窗口、Android 8 隐式广播禁令的影响面,以及 App Standby Bucket 与 Doze 如何把「秒达」变成「择机送达」。前置:进程模型、Service。

一、投递模型:一对多的 Binder 扇出 ​

广播的发送端只有一个入口(sendBroadcast/sendOrderedBroadcast),但系统侧把它拆成一场精细的投递工程:

  • 入队:ActiveServices 校验(发送者权限、接收者 android:exported/权限要求)后生成 BroadcastRecord,进 BroadcastQueue;Android 10+ 默认走现代广播队列(BroadcastQueueModern + AnrHelper),前台/后台分队列、按进程 adj 与优先级排序。
  • 提权投递:广播正在发给某进程时,该进程 adj 被临时提升(量级为 perceptible 档);投递给 receiver 时给一个短暂执行窗口用于起服务/FGS(约 10s 量级,BroadcastQueue 的 FGS 提权逻辑,以 AOSP 为准)。
  • 计时:每个 receiver 挂独立超时(历史口径前台 10s、后台 60s;现代队列为动态 anrTimeout,随进程状态调整,以 AOSP 为准);超时即 ANR(见 ANR 页)。
  • 合并与去重:对「同 action 同数据」的连发(历史上有 throttle 白名单机制),以及对 cached 接收者的攒批,都是为了对抗当年闹钟类 App 的广播风暴。
java
// frameworks/base/core/java/android/content/BroadcastReceiver.java(机制注释大意)
// onReceive() 运行于主线程,且被系统视为「前台承诺」:
// 超过超时未返回 → 广播 ANR。
public final PendingResult goAsync() { ... }
// goAsync() 把回执权交给你:返回的 PendingResult 必须最终 finish(),
// 期间广播队列继续投递后续 receiver —— 这是唯一合法的「onReceive 里跑异步」姿势。

onReceive 里没有「稍后」的概念:主线程执行、不能保证进程活着(后台 receiver 随起随杀)、禁止起子线程后撒手——要干重活就交给 goAsync 里完成回执、或立刻转投 WorkManager/JobScheduler。

队列内部状态可观测(现代队列把「排队-派发-回执」全暴露了出来):

bash
adb shell dumpsys activity broadcasts | head -40
  # 典型关注点(字段名以版本为准):
  #   Modern Queue / Historical Broadcasts 分段
  #   BroadcastRecord{... act=... dispatchTime=... anrTimeout=...}
  #   receiver 进程不在时是否被标记为「延迟派发」

二、有序 vs 无序:两条投递语义 ​

维度普通广播(无序)有序广播
APIsendBroadcastsendOrderedBroadcast
到达顺序系统按队列/adj 批量投,无保证严格按 receiver priority(高→低)逐个投
可修改/可截断否是:resultData/resultExtras 传递,abortBroadcast() 截断
回执链无resultTo 指定「链尾」接收方
典型场景刷新缓存、进程内解耦系统广播的优先级让渡(如 NEW_PICTURE、短信类)
  • priority 范围 -1000~1000,默认 0;只对有序广播有意义——把它用在普通广播上指望抢顺序,是面试与线上双重翻车点。
  • 有序广播链上每个 receiver 的超时不累计,但链整体拉长会放大首末端的感知延迟;系统自身很少再用有序广播做新功能。
  • 应用间自造的有序广播请立刻换掉:优先级可以被别人抢,链可能被中途 abort,2026 年它是遗产而非方案。

有序链的最小示例(含链上信箱的正确用法):

kotlin
// 注册端:priority 只在有序广播里起作用
val filter = IntentFilter("com.example.ACTION_SCORE").apply { priority = 100 }
registerReceiver(scoreReceiver, filter, Context.RECEIVER_EXPORTED)

class ScoreReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val score = intent.getIntExtra("score", 0)
        resultData = "score=$score"          // 写回给链上下游
        if (intent.hasExtra("terminate")) abortBroadcast()  // 截断后续 receiver
    }
}

链上语义提醒:resultData/resultExtras 是链上共享的可变信箱;任何一个 receiver 抛异常都会让后续 dispatch 变得不可预期,链式设计的脆弱性可见一斑。

三、注册时机与 exported:三档位、一条红线 ​

注册方式存活期Android 8 后能收的隐式广播注意
manifest 静态 receiver随安装几乎全部被禁(豁免清单除外)android:exported 必须显式(有 filter 时,target 31+)
registerReceiver 动态进程存活期可以target 34+ 非系统广播必须声明 RECEIVER_EXPORTED/NOT_EXPORTED
RECEIVER_VISIBLE / 系统特权类特殊特殊device owner、系统应用专属,三方勿念
  • 红线一:manifest receiver 的 onReceive 只有约 10s 生命周期窗口,进程可能随广播结束立刻回收;在里面 bindService、开线程池、读大文件都是事故源。
  • 红线二:动态 receiver 泄漏是内存泄漏重灾区——注册在 onCreate 就必须在 onDestroy 解绑,注册在 Fragment 生命周期里最容易漏。
  • 系统广播 vs 应用自定义广播:前者由 AOSP AndroidManifest 声明 protected-broadcast,别人伪造不进来;你 App 的 action 谁都能 sendBroadcast,默认不可信(第四节细说)。

动态注册的标准姿势(target 34+ 少了 flags 直接抛异常):

kotlin
class MainActivity : AppCompatActivity() {
    private val screenOff = object : BroadcastReceiver() {
        override fun onReceive(c: Context, i: Intent) { pausePlayback() }
    }
    override fun onStart() {
        super.onStart()
        registerReceiver(screenOff, IntentFilter(Intent.ACTION_SCREEN_OFF),
                         Context.RECEIVER_NOT_EXPORTED) // 只收系统广播也要显式标记
    }
    override fun onStop() {
        unregisterReceiver(screenOff)   // 漏解注册 = Activity 泄漏经典款
        super.onStop()
    }
}

四、Android 8:隐式广播的「断头铡」 ​

Android 8.0(API 26)起,manifest 注册的 receiver 收不到绝大多数隐式广播——这是当年最大的行为变更,动机是整机广播风暴(几十个 App 同时为每一条网络变化开机)。

  • 仍然可收:官方豁免清单内的系统广播(开机、时区、USB 附件、锁屏相关等,完整清单见官方「广播限制」页);显式广播(setPackage/setComponent 指定目标)不受限。
  • 替代路径按需求选:动态注册(进程活着)、WorkManager(数据类)、JobScheduler 网络约束任务(网络变化早已不靠广播)、或让触发方改为显式投递。
  • ACTION_TIME_TICK:从来不在豁免清单神话里,但 Android 8+ 它变成仅系统可发的显式定向投递(给注册了 filter 的活跃进程),别把它当闹钟。
  • 验证手段:adb shell cmd deviceidle 推进状态 + cmd appops set ... 之后观察投递;静态 receiver 收不到时先 dump BroadcastQueueModern 的队列(dumpsys activity broadcasts,输出格式以版本为准)。
bash
  # 对照实验:显式定向必达,隐式广播看桶脸色
adb shell am broadcast -a com.example.ACTION_PING -p com.example
adb shell am broadcast -a com.example.ACTION_PING        # 只有动态 receiver 能收到
  # 设备空闲态对照:
adb shell cmd deviceidle whitelist -f com.example         # 解除豁免,恢复限流

五、App Standby Bucket:投递被「看人下菜」 ​

Bucket(Android 9 引入模型,API 30 提供只读公开常量)是系统对每个 App 的 usage-stats 画像:

Bucket判定画像对广播/后台工作的影响
ACTIVE正在用基本无限流
WORKING_SET常用但非当下轻限频
FREQUENT每天一两次中限频
RARE很少用隐式广播延迟投递、Job 配额收紧
RESTRICTED异常行为/用户设定广播/任务显著延迟,接近「拒付」

关键认知:

  1. Bucket 限制的不只是广播——JobScheduler 配额、Alarm 精度、FGS 豁免窗口一起收紧(具体阈值以 AOSP 与 OEM 为准);

  2. 对显式定向广播与自身 App 内广播,系统仍会投,但 cached 接收方可能被攒批;「延迟投递」的真实含义是:你的 receiver 会在系统认为划算的时刻醒来,而不是被丢弃;

  3. 用户可在 设置→电池→详细用量 里把 App 设为 RESTRICTED(或在开发者选项看 bucket)——测试限流行为的最快方法是 adb shell am set-standby-bucket(需特权);

  4. Doze 是另一维:深度 Doze 下标准广播批量积压到维护窗口释放(网络类、闹钟类系统广播有豁免),Bucket 与 Doze 叠乘,这就是「后台广播看起来玄学」的根源。

  5. Bucket 的「延迟」在 ANR 视角有副作用:被攒批的广播集中释放时,多个 receiver 同刻苏醒,低端机瞬间卡顿甚至广播超时——「广播风暴复发」的现代版本。

text
示例广播 ANR(logcat 文案随版本变化):
ANR in com.example
Reason: Broadcast of Intent { act=com.example.ACTION_UPLOAD flg=... }
Error: android.app.RemoteServiceException: Context.startForegroundService() ...
排查顺序:dumpsys activity broadcasts 找该 BroadcastRecord
          → 比对 dispatch 与 finish 时间差 → 定位卡在哪个 receiver 的主线程

六、应用侧的正确用法 ​

  • 跨进程通知:优先显式 Intent(setPackage(自己) 或 setComponent),把 action 当字符串协议而不是广播——事实上 LocalBroadcastManager 早在 API 29 就被弃用,官方推荐 Flow/LiveData 做进程内。

  • 高频事件(进度、传感器):广播扇出是 O(接收者数) 的 Binder 轰炸,改成共享 ViewModel/Flow 或回调注册。

  • 安全三件套:接收方校验来源(isOrderedBroadcast/uid 校验)、发送方带权限(sendBroadcast(intent, permission) 要求接收方持有)、自定义广播绝不回传敏感数据。

  • 降级路径:静态入口的 App(如扫码唤醒)要考虑「隐式收不到」的机型/版本差异,留显式深链兜底。

  • 发送方也要带权限:sendBroadcast(intent, RECEIVER_PERMISSION) 让没该权限的 receiver 收不到,自定义 action 的安全边界要两头都设。

kotlin
// 自定义广播的双保险
context.sendBroadcast(
    Intent("com.example.ACTION_DATA_READY").setPackage("com.example"),
    "com.example.permission.RECEIVE_DATA")   // 接收方必须在 manifest 声明该 permission

七、常见误区问答 ​

Q1:registerReceiver 之后进程退了,广播会丢吗? 会。动态 receiver 的存活期就是进程存活期;cached 进程被冻结时还要等解冻。要「必达」就走系统级通道(WorkManager/JobScheduler)或让对方以 bound service 长期在场。

Q2:ACTION_SCREEN_OFF/USER_PRESENT 还有必要 manifest 注册吗? 它们属于少数仍在豁免清单常客名单上的广播(以官方清单为准);但新代码更推荐 Activity 生命周期 + ProcessLifecycleOwner,别再为亮灭屏养常驻 receiver。

Q3:广播传大数据有什么问题? Binder 缓冲区 1MB-8KB 是硬墙,extras 超阈值直接 TransactionTooLargeException。传引用(content URI、文件路径),不传载荷。

Q4:能给别的 App 发广播并确认它收到吗? 确认不了——显式定向只保证「投递」,不保证对方被拉活、receiver 没崩。要闭环改用 bound service/AIDL 同步调用。

版本注记:基于 Android 16 / API 36;Android 8 差异:隐式广播 manifest 禁令生效;Android 10 差异:现代广播队列灰度;Android 13 差异:RECEIVER_EXPORTED/NOT_EXPORTED 对 target 33+ 强制;Android 14~16 差异:广播触发的 FGS 提权窗口持续收紧(以行为变更文档为准)。

本页小结:广播的现代画像——投递是带优先级的系统级调度,receiver 是 10s 的临时工;manifest 静态收隐式广播的路线在 Android 8 就基本封死,Bucket/Doze 又把「何时到」变成系统裁量;剩下的正当用法是低频、显式、带权限校验的定向通知,进程内解耦请回到 Flow。

参考链接 ​

基于 Android 16 / API 36 · 内容为原创整理,转载请注明出处