-
-
[原创]《安卓逆向负基础之正向开发实践》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 功能范围
本章完成以下功能:
- 用
PlaybackService保存 Media3 ExoPlayer; - 让
MainActivity通过MediaController发送命令; - 页面进入后台或锁屏后继续播放;
- 支持通知栏、锁屏和媒体按键控制;
- 接收设备断开广播并自动暂停。
本章不加入进度拖动、歌词、收藏、在线音乐和自定义通知样式。
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 只保留控制连接。这样,“页面是否存在”和“播放是否继续”就不再绑定。
下面的等号表示生命周期在本项目中相互绑定。第二章可以简写为:
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。
- Media3:一组媒体库和 API 的总称
- 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 的模块与本章实际创建的对象放在一起:
ExoPlayer 和 MediaController 都遵循 Player 接口,但前者执行播放,后者转发命令。因此,同样是 play(),调用路径并不相同:
Service 中:ExoPlayer.play() → 播放器直接执行
Activity 中:MediaController.play() → MediaSession → ExoPlayer 执行
这是面向接口编程的一种常见结构。阅读其他框架代码时,也可以依次确认:
- 接口统一规定了哪些能力;
- 哪个具体对象真正实现这些能力;
- 当前变量实际指向的是执行对象,还是转发命令的控制对象。
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
对应关系如下图:
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
这条链路可以分成四步理解:
SessionToken根据ComponentName指出目标是PlaybackService,此时还没有完成连接;buildAsync()请求创建 Controller,并异步等待连接结果;- Media3 在内部绑定 Service,继承自
MediaSessionService的onBind()负责处理请求,再调用本项目重写的onGetSession(); onGetSession()返回 Service 中已有的MediaSession。连接成功后,Future 的成功回调才把MediaController交给MainActivity。
Activity 得到的是控制端 MediaController,不是 Service 中的 ExoPlayer。后续的播放、暂停和切歌命令还要经过 MediaSession,最终才由 ExoPlayer 执行。
Controller → Session → ExoPlayer 的完整命令链如下图:
因此,普通写法由应用直接调用 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 登记后,运行过程可以概括为:
- Activity 创建 Controller,系统创建 Service;
- Service 创建 ExoPlayer 和
MediaSession; - 播放开始后,Service 进入前台状态;
- Activity 离开并释放 Controller,Service 继续播放;
- 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,不会各自创建播放器:
这种结构把界面与执行对象分开。以后更换控制界面时,只要继续向同一个 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。
蓝色箭头表示元数据和状态向外发布,紫色箭头表示控制命令发送给 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 执行暂停。
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(...) 遵循三个规则:
- 准备播放且尚未注册时,调用
registerReceiver(); - 暂停播放且已经注册时,调用
unregisterReceiver(); - 使用布尔变量避免对同一 Receiver 重复注册或重复注销。
Android 13 及以上使用带标志的重载方法,旧版本调用原有方法。动态注册与静态注册没有脱离场景的固定优劣,应根据事件需要在哪段生命周期内被接收来选择。
8. 真机结果、边界与小结
8.1 构建与真机测试
Debug APK 构建通过,Android Lint 检查为 0 个错误。应用覆盖安装到测试手机后完成了以下测试:
| 测试项目 | 结果 |
|---|---|
| 前台播放、暂停、上一首和下一首 | 通过 |
| 回到桌面后继续播放 | 通过 |
| 通知栏媒体控制 | 通过 |
| 重新打开页面后恢复当前播放状态 | 通过 |
| 蓝牙耳机断开后自动暂停 | 通过 |
8.2 当前范围与已知限制
当前尚未加入以下功能:
- 进度条和拖动定位;
- 歌词、收藏和播放模式界面;
- 自定义通知样式和专辑封面;
- 保存进程被系统终止前的播放位置;
- 在线音乐和网络错误处理。
8.3 代码阅读与运行观察练习
- 比较第二章与第三章的
onStop(),说明释放对象为什么不同。 - 跟踪一次
MediaController.pause()怎样到达 ExoPlayer。 - 从通知栏切歌后重新打开页面,观察界面怎样读取当前状态。
- 找出本章成对出现的注册、移除、创建和释放操作。
- 假设增加桌面小组件,判断它应直接持有播放器还是复用 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。
参考资料
- Android Services 概览
- Android 应用的进程与线程
<service>元素与android:process属性- Android 绑定服务与 Binder
- Android 接口定义语言 AIDL
- 管理 MediaPlayer 状态和资源
- 使用 MediaSessionService 在后台播放
- 使用 Media3 ExoPlayer 创建基本媒体播放器
- Media3 Player 接口
- ExoPlayer API
- MediaSession API
- MediaController API
- 处理音频输出变化
- BroadcastReceiver 与动态注册
- Media3 版本说明