首页
社区
课程
招聘
[原创]《安卓逆向负基础之正向开发实践》03:MuzicBox 后台音乐播放与 Service
发表于: 2026-10-21 17:45 423

[原创]《安卓逆向负基础之正向开发实践》03:MuzicBox 后台音乐播放与 Service

2026-10-21 17:45
423

《安卓逆向负基础之正向开发实践》03:MuzicBox 后台音乐播放与 Service

项目仓库:D1screteWave/MuzicBox
本章对应版本:v0.3-background-playback
内容状态:初版开发笔记
开发语言:Java 17
界面方案:XML View
本章成果:使用 Media3 和 Service 实现后台播放、系统媒体控制与蓝牙断开暂停

0. 本章在整体路线中的位置

第二章由 MainActivity 管理播放器,并在 onStop() 中主动调用 releasePlayer() 释放它。因此,用户按下 Home 键或切换到其他应用、主页面完全不可见后,音乐随之停止:
第二章页面内播放关系总览

本章把播放器移入 PlaybackService,让播放不再依赖主页面;MainActivity 和系统媒体控件则通过 Media3 向它发送命令。调整后的整体关系如下:
第三章后台播放关系总览

图中各个名称的来源和作用会在第 2 节说明。除后台播放功能外,本章还借这次迁移理解以下通用问题:

  • 一个对象应该由谁创建、保存和清理;
  • 页面生命周期与长期任务的生命周期怎样分开;
  • 接口、具体实现和控制对象怎样分工;
  • 异步连接完成以前,代码怎样表示“结果还没准备好”;
  • 多个控制入口怎样共享同一份状态;
  • 系统事件怎样通过回调交给真正负责处理的对象。

1. 本章目标与架构变化

1.1 功能范围

本章完成以下功能:

  1. 用 PlaybackService 保存 Media3 ExoPlayer;
  2. 让 MainActivity 通过 MediaController 发送命令;
  3. 页面进入后台或锁屏后继续播放;
  4. 支持通知栏、锁屏和媒体按键控制;
  5. 接收设备断开广播并自动暂停。

本章不加入进度拖动、歌词、收藏、在线音乐和自定义通知样式。

1.2 播放器由谁管理:对象持有与生命周期

第二章与第三章的主要区别是播放器的所有者发生了变化。“所有者”是指对象引用保存在谁的成员变量中,并由谁负责创建、使用和清理它。

1.2.1 第二章:播放器保存在 MainActivity 中

第二章把 MediaPlayer 保存在 MainActivity 的成员变量中,播放、暂停和释放都由这个页面负责。页面完全不可见后,系统调用 MainActivity.onStop();第二章的代码再主动调用 releasePlayer(),由这个方法执行 MediaPlayer.release(),所以音乐停止。

对应的成员变量可以简化为:

// MainActivity.java
private MediaPlayer mediaPlayer;

用户按下 Home 键、切换到其他应用,或者打开覆盖主页面的全屏页面后,MainActivity 显示的主页面将不再出现在屏幕上。此时,Android Framework 会调用 MainActivity.onStop()。

问:Android Framework 怎么知道的屏幕上显示页面的切换情况?

答:Android Framework 并不是通过“观察屏幕画面”发现页面不见了,而是因为所有 Activity 的切换和窗口显示都由它统一管理。

程序接着执行这个方法中由我们编写的语句。

注:onStop() 由 Activity 基类提供,开发者可以在 MainActivity 中重写它;

这类随 Activity 生命周期状态变化而由系统调用的方法,称为生命周期回调。

onStop() 本身不会自动关闭播放器,但是第二章在这个onStop() 方法中主动调用 releasePlayer(),再由 releasePlayer() 执行 MediaPlayer.release();这相当于把“主页面完全不可见”设定为本次播放结束的时机。

相关代码

@Override
protected void onStop() {
    super.onStop();   //super.onStop()完成Activity原有的框架处理,下面才是新增的处理逻辑
    
    if (mediaPlayer != null) {
   	releasePlayer();   //releasePlayer()会调用MediaPlayer.release()销毁播放器
    	playPauseButton.setText(R.string.play);
	}
}

1.2.2 第三章:播放器保存在 PlaybackService 中

第三章把真正执行播放的 ExoPlayer 保存为 PlaybackService 的成员变量。这时可以说 PlaybackService 持有播放器,因为播放器对象的引用保存在成员变量 player 中。PlaybackService 通过 MediaSessionService 间接继承自 Service:

// PlaybackService.java
private ExoPlayer player;

PlaybackService.onCreate() 创建播放器,onDestroy() 负责释放它。MainActivity 只保存用于发送命令的 MediaController:

// MainActivity.java
private MediaController controller;

用户离开页面时,MainActivity.onStop() 只释放 Controller,不会影响 Service 中的 ExoPlayer。播放器的所有者由此从 MainActivity 变成了 PlaybackService。

1.2.3 持有和释放分别是什么意思

“持有”是把对象引用保存在成员变量中,以便多个方法持续访问;“释放”是在不再使用资源时调用相应的清理方法。MediaPlayer.release() 归还的是底层播放、解码和输出资源,不是删除歌曲文件。不同资源采用的清理方式也不同:

对象或关系 常见清理方式
MediaPlayer、ExoPlayer 调用 release()
Cursor、文件输入流 调用 close()
动态注册的 BroadcastReceiver 调用 unregisterReceiver()
已注册的监听器 调用对应的 removeListener()

普通 Java 数据对象通常不需要手动释放;没有代码继续持有时,垃圾回收器会回收其内存。需要显式清理的通常是系统资源、底层资源或已注册的回调关系。

1.2.4 用生命周期关系总结迁移结果

第二章由 MainActivity 创建、保存并释放播放器;第三章改由 PlaybackService 负责,Activity 只保留控制连接。这样,“页面是否存在”和“播放是否继续”就不再绑定。
MuzicBox 播放架构迁移前后对比

下面的等号表示生命周期在本项目中相互绑定。第二章可以简写为:

MainActivity 生命周期 = 界面生命周期 = 播放器生命周期

第三章把它们拆开:

MainActivity 生命周期 = 界面生命周期
PlaybackService 生命周期 = 播放器生命周期
MediaController 生命周期 = 界面与播放服务的连接生命周期

Activity 负责界面,Service 负责播放器及状态,Controller 负责连接二者。Activity 停止时只断开 Controller,Service 仍可继续播放。

这套判断也适用于导航、运动记录或设备连接等长期任务:资源管理者的生命周期应当覆盖资源的实际使用时间,但答案不一定总是 Service。

1.3 版本与依赖

本章在 app/build.gradle 中把应用版本更新为:

defaultConfig {
    versionCode = 3
    versionName = "0.3.0"
}

同时加入 Media3 的播放模块和会话模块:

dependencies {
    implementation "androidx.media3:media3-exoplayer:1.11.1"
    implementation "androidx.media3:media3-session:1.11.1"
}

两项依赖承担不同职责:

  • media3-exoplayer 提供 ExoPlayer 播放能力;
  • media3-session 提供 MediaSession、MediaSessionService 和 MediaController。

2. 从 MediaPlayer 到 Media3:库、接口与实现

2.1 为什么需要迁移

第二章的 android.media.MediaPlayer 也能放进 Service。本章改用 Media3,是因为需求已经不只是直接播放文件:

  • 页面退出后仍要保存播放状态;
  • 通知栏和锁屏需要读取当前歌曲并发送控制命令;
  • 蓝牙耳机媒体键需要进入同一套控制链;
  • 后续在线音乐仍要复用当前播放架构。

Media3 已经组织好播放器、会话、后台 Service 和 Controller 的协作方式,更适合继续扩展。

2.2 先分清平台、扩展库和项目代码

迁移后,代码中会同时出现 Android 系统 API、AndroidX 库和项目代码:

层级 本身是什么 本章中的例子
Android 平台 随 Android 系统提供的框架 API,应用可以直接调用 MediaPlayer、Service、BroadcastReceiver
AndroidX / Jetpack Android 官方单独发布的扩展库,需要通过 Gradle 加入项目 Media3
MuzicBox 项目代码 本项目自己定义的 Java 类 MainActivity、PlaybackService、BecomingNoisyReceiver

Media3 属于 AndroidX / Jetpack,其中 media3-common 模块提供核心接口和数据对象,media3-exoplayer 模块提供播放能力,media3-session 模块负责连接播放器、后台服务和各类控制端:

  • Android 平台
    • MediaPlayer:平台提供的播放器类,第二章由 MainActivity 直接使用,第三章迁移到 Media3 后由 ExoPlayer 取代;
    • Service:无独立界面的 Android 应用组件,本章的 PlaybackService 通过 MediaSessionService 间接继承自 Service;
    • BroadcastReceiver:接收系统或应用广播的 Android 应用组件,本章用它接收设备断开广播,并调用 PlaybackService 中的 player.pause()。
  • AndroidX / Jetpack
    • Media3:一组媒体库和 API 的总称
      • media3-common 模块
        • Player:Java 接口,统一规定 play()、pause() 和状态查询等能力;
      • media3-exoplayer 模块
        • ExoPlayer:扩展了 Player 的接口,Builder 返回它的实现对象;
      • media3-session 模块
        • MediaSession:连接 Player、应用界面和系统媒体控件的类;
        • MediaSessionService:用于承载 Session 和 Player 的抽象 Service 类;
        • MediaController:Activity 使用的控制类,负责把命令发送给 Session。
  • MuzicBox 项目代码
    • PlaybackService:继承 MediaSessionService,保存 Player 和 Session;
    • MainActivity:创建并持有 MediaController,不再直接保存播放器。

项目直接依赖 media3-exoplayer 和 media3-session;它们共同依赖的 media3-common 会由 Gradle 一并加入。从 Java 语法看,Player 和 ExoPlayer 是接口,MediaSessionService 是抽象类,MediaPlayer、MediaSession 和 MediaController 是类。new ExoPlayer.Builder(this).build() 创建的实现对象可以保存在 ExoPlayer 接口类型的变量中。

2.3 Media3 中的对象怎样连接

下图把 Media3 的模块与本章实际创建的对象放在一起:
Media3 中几个核心名称的关系

ExoPlayer 和 MediaController 都遵循 Player 接口,但前者执行播放,后者转发命令。因此,同样是 play(),调用路径并不相同:

Service 中:ExoPlayer.play() → 播放器直接执行
Activity 中:MediaController.play() → MediaSession → ExoPlayer 执行

这是面向接口编程的一种常见结构。阅读其他框架代码时,也可以依次确认:

  1. 接口统一规定了哪些能力;
  2. 哪个具体对象真正实现这些能力;
  3. 当前变量实际指向的是执行对象,还是转发命令的控制对象。

3. Service:让任务不再依赖页面生命周期

3.1 Service 是什么

Service 既是 Android 四大组件之一,从 Java 语法看也是 Android 平台提供的抽象类。应用先编写它的子类并在 Manifest 中登记;需要运行时,系统创建这个子类的对象,并调用 onCreate()、onDestroy() 等生命周期方法。

Service 没有独立界面,适合管理不应依赖某个页面的任务:

  • Activity 负责可见页面和用户操作;
  • Service 负责不应依赖某个页面是否存在的任务。

安装在手机中的 MuzicBox 是应用文件;启动应用后,Android 为它创建进程,把代码和数据装入内存。MainActivity、PlaybackService 等组件对象运行在这个进程中,线程则负责逐条执行它们的代码。

启动 MuzicBox
    ↓
创建应用进程,提供运行所需的内存空间
    ├─ MainActivity 组件对象
    ├─ PlaybackService 组件对象
    └─ 主线程执行两个组件的生命周期回调

三者可以这样区分:

概念 常见理解 在 Android 中表示什么 本章中的情况
Service(组件) 应用运行后由系统创建的无界面对象,可在 Activity 离开后继续承担任务 Service 子类的实例,生命周期由系统管理 PlaybackService 在页面离开后继续管理播放器
进程 程序运行起来后的实例和运行容器,通常也是资源隔离和管理的基本单位 Android 创建的 Linux 进程,负责装载代码、数据并提供内存空间 Activity 和 Service 默认位于同一应用进程
线程 进程中真正逐条执行代码的工作路线,也是 CPU 调度的基本单位 每个进程至少有一条主线程,也可以创建其他工作线程 Service 的生命周期回调默认由应用主线程执行

“程序对应进程”是一种入门简化:一个 Android 应用也可以配置多个进程,而一个进程中又可以存在多条线程。本章没有这样拆分,因此 Activity 和 Service 位于同一个进程,它们的生命周期回调也默认由同一条主线程执行。把播放器移入 Service,改变的是负责管理播放器的组件及其生命周期,并没有自动创建新进程或新线程。

如果在 Manifest 中设置 android:process=":playback",系统会为 Service 创建应用私有的独立进程,但回调仍运行在新进程的主线程。独立进程还会增加内存和通信成本,本项目没有这样配置。使用 Service 的目的只是让播放器拥有独立于页面的组件生命周期。

Binder、AIDL 与本章的边界

MuzicBox 没有为 PlaybackService 配置 android:process,所以本章的 Activity 和 Service 默认位于同一个应用进程。本章实现的是组件职责与生命周期的分离,不是把播放器拆到另一个进程。

不同进程不能直接共享普通 Java 对象,需要通过 Binder 等跨进程通信机制传递请求和数据;AIDL 是描述 Binder 接口的一种方式。当前代码没有编写 AIDL。

3.2 Service、前台 Service 与 MediaSessionService 有什么区别

这三个名称不在同一分类层级,需要分别看 Java 继承关系和 Service 的运行方式。

Java 继承关系如下:

PlaybackService → 继承自 MediaSessionService → 继承自 Service
名称 本身是什么 在本章中的作用
Service Android 平台提供的抽象基类 为 Service 组件提供基础生命周期
MediaSessionService Media3 提供的抽象类,继承自 Service 托管 MediaSession 和 Player,并配合系统媒体控制
PlaybackService MuzicBox 定义的具体类,继承自 MediaSessionService 真正保存 ExoPlayer,并由系统创建和运行
前台 Service 进入前台运行状态的 Service 组件 播放开始后显示持续通知,让用户知道任务仍在运行

因此,前台 Service 可以理解为 Service 的一种特殊运行方式;MediaSessionService 是面向媒体场景的 Service 抽象基类,而 PlaybackService 才是本项目实际使用的具体 Service。用户按下 Home 键后,MainActivity 已经离开屏幕,但只要音乐和媒体通知仍在继续,PlaybackService 仍然属于前台 Service。这里的“前台”指任务通过通知持续向用户公开,不是指 App 页面仍显示在屏幕上。

3.3 普通 Service 的启动与绑定

普通 Service 常见的使用关系有两种。它们可以单独使用,也可以同时存在:

方法 谁调用 Framework 回调 Service 的方法 主要目的
startService(intent) Activity 等 Context 对象 onStartCommand() 请求 Service 开始执行任务
bindService(...) Activity 等 Context 对象 onBind() 建立连接并取得通信接口

调用链可以简化为:

启动:Activity → startService() → Framework → Service.onStartCommand()
绑定:Activity → bindService()  → Framework → Service.onBind() → IBinder

对应关系如下图:
普通 Service 的启动与绑定方式

bindService() 是外部组件发出的绑定请求;onBind() 是 Service 收到请求后由 Framework 调用的回调方法。前者负责“请求连接”,后者负责“返回通信接口”。

3.4 MuzicBox 的真实连接方式

MuzicBox 没有直接调用 startService() 或 bindService(),也没有在 PlaybackService 中重写 onBind()。MainActivity 创建 SessionToken 指定目标 Service,再异步构建 MediaController:

SessionToken sessionToken = new SessionToken(
        this,
        new ComponentName(this, PlaybackService.class)
);

ListenableFuture<MediaController> controllerFuture =
        new MediaController.Builder(this, sessionToken).buildAsync();

Media3 内部仍会绑定 Service,但把 bindService()、IBinder 和 onBind() 等底层细节封装了起来。本项目能直接观察到的连接过程是:

MainActivity 创建 SessionToken     →    MediaController.Builder.buildAsync()
→   Media3 绑定 PlaybackService     →    MediaSessionService.onBind()
→   PlaybackService.onGetSession() 返回 MediaSession    →   MainActivity 得到 MediaController

这条链路可以分成四步理解:

  1. SessionToken 根据 ComponentName 指出目标是 PlaybackService,此时还没有完成连接;
  2. buildAsync() 请求创建 Controller,并异步等待连接结果;
  3. Media3 在内部绑定 Service,继承自 MediaSessionService 的 onBind() 负责处理请求,再调用本项目重写的 onGetSession();
  4. onGetSession() 返回 Service 中已有的 MediaSession。连接成功后,Future 的成功回调才把 MediaController 交给 MainActivity。

Activity 得到的是控制端 MediaController,不是 Service 中的 ExoPlayer。后续的播放、暂停和切歌命令还要经过 MediaSession,最终才由 ExoPlayer 执行。

Controller → Session → ExoPlayer 的完整命令链如下图:
MuzicBox 通过 Media3 连接 PlaybackService

因此,普通写法由应用直接调用 startService() 或 bindService();MuzicBox 则调用 MediaController.buildAsync(),由 Media3 完成内部绑定。音乐开始后,MediaSessionService 会配合媒体通知以前台 Service 方式继续运行。

3.5 在 Manifest 中登记播放服务

AndroidManifest.xml 先声明前台 Service 权限:

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />

然后在 <application> 中登记 PlaybackService:

<service
    android:name=".player.PlaybackService"
    android:exported="true"
    android:foregroundServiceType="mediaPlayback">
    <intent-filter>
        <action android:name="androidx.media3.session.MediaSessionService" />
    </intent-filter>
</service>

两项权限允许使用媒体播放类型的前台 Service;android:name 指向项目类,foregroundServiceType 声明用途,exported 表示允许应用外部的客户端连接,intent-filter 让 Media3 客户端识别该服务。前台状态和默认媒体通知由 MediaSessionService 根据播放状态管理。

3.6 Service 与 Activity 的生命周期不再相同

完成 Manifest 登记后,运行过程可以概括为:

  1. Activity 创建 Controller,系统创建 Service;
  2. Service 创建 ExoPlayer 和 MediaSession;
  3. 播放开始后,Service 进入前台状态;
  4. Activity 离开并释放 Controller,Service 继续播放;
  5. Service 销毁时,由 onDestroy() 释放 Session 和 Player。

如果整个进程被系统直接终止,onDestroy() 不保证执行。选择组件也不是生命周期越长越好,而要判断任务是否需要脱离页面、由谁保存状态、何时停止以及由谁清理。

4. PlaybackService 怎样真正接管播放器

第 3 节解决了“Activity 怎样找到播放服务”,这一节只看 Service 内部最重要的责任:创建播放器、保存同一个播放状态,并在结束时完成清理。代码细节很多,但主线只有“创建”和“释放”两端。

4.1 Service 创建播放器和 Session

系统首次创建 PlaybackService 时会调用 onCreate()。Service 先创建真正执行播放的 ExoPlayer,再创建连接播放器与各类控制端的 MediaSession:

player = new ExoPlayer.Builder(this).build();
mediaSession = new MediaSession.Builder(this, player).build();

把 player 传给 MediaSession.Builder,表示 Session 收到的命令最终都交给这个播放器。项目还设置了循环模式和状态监听器,但它们不会改变这条核心关系。

前面出现的 onGetSession() 也不会每连接一次就创建新 Session,而是返回成员变量 mediaSession。因此,Activity、通知栏和锁屏连接的是同一个 Session,看到的也是同一份播放状态。

4.2 Service 结束时统一清理

Service 可以比 Activity 存在得更久,但不会永久运行。PlaybackService.onDestroy() 负责结束仍由它管理的关系和资源,核心操作可以缩写为:

updateNoisyReceiverRegistration(false);
mediaSession.release();
player.removeListener(playerListener);
player.release();

实际代码还会先判断对象是否为 null,释放后再把成员变量设为 null。这里更值得记住的是几组成对操作:

  • 创建对象,对应释放对象;
  • addListener(),对应 removeListener();
  • registerReceiver(),对应 unregisterReceiver();
  • 建立连接,对应断开连接。

这些操作由同一个管理者负责,阅读代码时就可以沿着“在哪里获得,就在哪里归还”检查遗漏。播放器既然由 PlaybackService 创建和持有,也应由它完成最后的释放。

5. MainActivity 只控制,不再直接播放

真正的播放器移入 Service 后,MainActivity 的角色也发生了变化:它仍负责显示歌曲和接收点击,但手里只有 MediaController。可以把 Controller 理解为控制入口,它能发送播放命令和读取状态,却不负责解码或输出音频。

5.1 连接需要等待,也会随页面断开

Activity 进入 onStart() 后开始连接播放服务。系统可能需要创建 Service、准备 Session,再返回 Controller,所以这个结果不能像普通 new 对象一样立即取得:

onStart()
   ↓
buildAsync() 开始连接
   ↓
连接成功:保存 Controller、注册监听器并刷新界面
连接失败:记录错误并保持按钮不可用

buildAsync() 返回的 ListenableFuture<MediaController> 代表“将来才可能取得的 Controller”。这种先发起操作、稍后接收结果的异步结构,也常见于网络请求、数据库查询和设备连接。

页面进入 onStop() 后,Activity 会释放 Controller;页面再次显示时再重新连接。这里断开的只是界面一侧的控制连接,Service 中的 ExoPlayer 仍可继续播放:

Activity 可见:连接 Controller
Activity 不可见:释放 Controller
PlaybackService:继续持有 ExoPlayer

5.2 两套数据之间需要一次转换

Activity 从系统媒体库得到的是项目自己的 Song 对象,而 Media3 接收的是 MediaItem。两者描述的是同一首歌曲,但服务于不同代码层,因此需要在边界处转换一次:

Song 中的数据 转换到 MediaItem 后的用途
歌曲 ID mediaId,用于标识歌曲
Uri 指出 ExoPlayer 要读取的音频
标题和歌手 放入 MediaMetadata,供页面和系统控件显示

这种转换并不是重复保存两首歌曲,而是把项目的数据格式整理成 Media3 能理解的格式。网络接口数据转换为页面模型时,也会遇到类似情况。

5.3 点击按钮只是发出命令

用户点击暂停后,Activity 调用 MediaController.pause(),但 Activity 本身并不暂停音频。命令还要经过 Session,最后由 Service 中的 ExoPlayer 执行:
点击暂停按钮后的命令路径

MediaController 和 ExoPlayer 都遵循 Player 接口,所以都有 play()、pause() 等方法;区别是 Controller 负责转发,ExoPlayer 负责执行。上一首和下一首也采用相同路径。

通知栏、锁屏和媒体按键同样通过 MediaSession 控制这个 ExoPlayer,不会各自创建播放器:
不同控制入口到 ExoPlayer 的命令链

这种结构把界面与执行对象分开。以后更换控制界面时,只要继续向同一个 Session 发送命令,就不需要搬动播放器和播放状态。

5.4 状态变化后再刷新界面

播放状态不只会被 App 按钮改变,也可能被通知栏、锁屏或耳机按键改变。如果界面只在按钮点击后修改文字,很快就会与真实状态不一致。因此 Activity 通过 Player.Listener 观察 Controller:

private final Player.Listener controllerListener = new Player.Listener() {
    @Override
    public void onEvents(Player player, Player.Events events) {
        updatePlaybackUi();
    }
};

这里可以记住两个方向:点击按钮负责发送命令,监听器负责感知状态变化并刷新界面。下载进度、设备连接和后台任务状态也常采用这种“状态变化后重新渲染”的方式。

6. 多个控制入口怎样共享同一份状态

6.1 MediaSession 对外发布播放状态

ExoPlayer 负责播放,MediaSession 则为通知栏、锁屏和外接设备提供统一入口。它对外发布:

  • 当前播放的 MediaItem;
  • 标题和歌手等元数据;
  • 当前是播放、暂停还是缓冲状态;
  • 当前支持的播放、暂停和切歌命令。

系统据此显示媒体控件,并把用户命令通过 Session 交给 ExoPlayer。
MediaSession 连接系统媒体控件的技术架构图

蓝色箭头表示元数据和状态向外发布,紫色箭头表示控制命令发送给 Session。new MediaSession.Builder(this, player).build() 把 Session 与 ExoPlayer 绑定。Activity、通知栏和锁屏都围绕同一个执行对象工作,避免各自保存状态副本而产生不一致。

6.2 为什么没有手写通知代码

MediaSessionService 根据 Session 和 Player 状态生成默认媒体通知,并读取 MediaItem 中的标题和歌手。本章保留默认样式,因为它已经支持基本控制;以后可在同一份 Session 状态上扩展封面和按钮。

7. 用 BroadcastReceiver 接收系统事件

7.1 本章监听的系统广播

有线耳机拔出或蓝牙音频设备断开时,Android 发送 AudioManager.ACTION_AUDIO_BECOMING_NOISY 广播。Intent 携带事件信息,Receiver 判断事件,真正管理状态的 Service 执行暂停。
系统广播到 Service 的事件链

7.2 BroadcastReceiver 与回调

BroadcastReceiver 是接收系统或应用广播的 Android 组件。项目定义的 BecomingNoisyReceiver 继承它,并通过构造方法接收一个保存待执行操作的 Runnable:

public final class BecomingNoisyReceiver extends BroadcastReceiver {

    private final Runnable pausePlayback;

    public BecomingNoisyReceiver(Runnable pausePlayback) {
        this.pausePlayback = pausePlayback;
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        if (AudioManager.ACTION_AUDIO_BECOMING_NOISY.equals(intent.getAction())) {
            pausePlayback.run();
        }
    }
}

这段代码同时体现了 Android 组件和 Java 回调:

  • onReceive() 是系统触发的回调方法;
  • Intent 保存本次广播的动作信息;
  • Runnable 保存由 Service 传入的暂停操作;
  • Receiver 只判断事件类型,具体暂停操作由创建它的 Service 提供。

Service 创建 Receiver 时,把 player.pause() 包装成 Lambda 表达式传入:

noisyReceiver = new BecomingNoisyReceiver(() -> {
    Log.d(TAG, "Audio output disconnected; pausing playback");
    player.pause();
});

Lambda 相当于一个简短的 Runnable 实现。Receiver 只识别事件,Service 提供处理动作,二者通过回调连接。这种“事件来源与业务处理分离”的方式也适用于网络变化、定时任务或权限结果。

7.3 为什么使用动态注册

广播接收器有两种常见登记方式:

  • 静态注册:在 Manifest 中声明,由系统发现;
  • 动态注册:代码调用 registerReceiver(),不再需要时调用 unregisterReceiver()。

本章只在准备播放或正在播放时需要 Receiver,因此由 Service 动态注册。监听器根据 getPlayWhenReady() 决定注册或注销:

private final Player.Listener playerListener = new Player.Listener() {
    @Override
    public void onEvents(Player player, Player.Events events) {
        updateNoisyReceiverRegistration(player.getPlayWhenReady());
    }
};

updateNoisyReceiverRegistration(...) 遵循三个规则:

  1. 准备播放且尚未注册时,调用 registerReceiver();
  2. 暂停播放且已经注册时,调用 unregisterReceiver();
  3. 使用布尔变量避免对同一 Receiver 重复注册或重复注销。

Android 13 及以上使用带标志的重载方法,旧版本调用原有方法。动态注册与静态注册没有脱离场景的固定优劣,应根据事件需要在哪段生命周期内被接收来选择。

8. 真机结果、边界与小结

8.1 构建与真机测试

Debug APK 构建通过,Android Lint 检查为 0 个错误。应用覆盖安装到测试手机后完成了以下测试:

测试项目 结果
前台播放、暂停、上一首和下一首 通过
回到桌面后继续播放 通过
通知栏媒体控制 通过
重新打开页面后恢复当前播放状态 通过
蓝牙耳机断开后自动暂停 通过

8.2 当前范围与已知限制

当前尚未加入以下功能:

  • 进度条和拖动定位;
  • 歌词、收藏和播放模式界面;
  • 自定义通知样式和专辑封面;
  • 保存进程被系统终止前的播放位置;
  • 在线音乐和网络错误处理。

8.3 代码阅读与运行观察练习

  1. 比较第二章与第三章的 onStop(),说明释放对象为什么不同。
  2. 跟踪一次 MediaController.pause() 怎样到达 ExoPlayer。
  3. 从通知栏切歌后重新打开页面,观察界面怎样读取当前状态。
  4. 找出本章成对出现的注册、移除、创建和释放操作。
  5. 假设增加桌面小组件,判断它应直接持有播放器还是复用 Session。

8.4 本章小结

本章把单页面播放器改成了由后台组件承载的结构:

控制关系:
MainActivity → MediaController → MediaSession → ExoPlayer
通知栏 / 锁屏 / 媒体键 ─────────→ MediaSession → ExoPlayer

保存关系:
PlaybackService 持有 → MediaSession + ExoPlayer

从这次改造可以提炼出六组可迁移的机制:

本章代码 通用机制 还可能出现在哪里
Player 从 Activity 移入 Service 让资源管理者的生命周期匹配任务 导航、运动记录、设备连接
Player、ExoPlayer、Controller 分工 区分接口、实现和控制入口 数据源接口、下载器、硬件控制层
ListenableFuture<MediaController> 表示尚未完成的异步结果 网络、数据库、设备连接
Listener 与 updatePlaybackUi() 状态变化后重新渲染界面 下载进度、同步状态、连接状态
多个入口连接同一个 Session 多个界面共享一份权威状态 页面、通知、小组件共同控制任务
Receiver 接收事件,Service 执行动作 分离事件来源与业务处理 网络变化、定时事件、系统状态变化

下一章将在这套播放架构上加入网络请求和在线音乐,不需要重新把播放器塞回 Activity。

参考资料


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
点赞 ・0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回