简介:在Android平台上开发一个简单的音乐播放器,涉及MediaPlayer使用、多线程处理、UI更新机制、Service与Activity通信等核心技术。本文详细讲解如何通过MediaPlayer播放本地音频文件,利用Service实现后台持续播放,结合Handler进行线程间通信以更新播放进度,并通过Binder或BroadcastReceiver实现界面与服务的交互。同时涵盖歌曲切换、播放控制、生命周期管理及用户体验优化(如来电暂停、锁屏控制)等关键功能。本项目适合初学者掌握Android多媒体应用开发的核心流程与实践技巧。
1. Android音乐播放器的核心架构与开发原理
在移动应用开发领域,音频播放功能是用户交互体验的重要组成部分。构建一个简单而稳定的音乐播放器不仅是初学者理解Android多媒体系统的关键实践,也是深入掌握组件通信、生命周期管理与后台服务机制的绝佳入口。本章将从整体架构出发,剖析基于Android平台实现音乐播放器的基本原理,介绍MediaPlayer框架在整个系统中的角色定位,并阐述其与UI层、服务层及系统资源之间的协同关系。
graph TD
A[UI Layer (Activity)] -->|控制指令| B(MusicService)
B -->|播放控制| C[MediaPlayer]
C -->|音频输出| D[AudioFocus & StreamType]
B -->|状态广播| E[BroadcastReceiver]
A -->|接收状态| E
我们将探讨音频流类型的选择(如 STREAM_MUSIC )、播放状态机的设计理念以及多场景下的行为预期,如启动播放、暂停恢复、资源释放等。此外,项目工程结构需合理规划资源目录(如 raw 或外部存储),并在 AndroidManifest.xml 中声明必要权限(如 READ_EXTERNAL_STORAGE )和服务组件,为后续章节的深入编码打下坚实基础。
2. MediaPlayer基础与高级用法
Android平台中, MediaPlayer 是处理音频和视频播放的核心类。它封装了底层媒体解码、音频输出、缓冲控制等复杂逻辑,为开发者提供了一套相对简洁但功能强大的接口。然而,由于其状态机模型较为严格,使用不当极易引发异常或资源泄漏。本章将深入剖析 MediaPlayer 的内部机制,从状态流转到数据源加载,再到播放控制与性能优化,系统性地构建一个健壮的音频播放能力体系。
2.1 MediaPlayer的状态模型与核心方法
MediaPlayer 并非简单的“播放器”,而是一个基于状态机设计的有限状态自动机(Finite State Machine)。理解其状态流转是避免调用非法方法导致崩溃的关键。每一个方法调用都依赖于当前所处的状态,错误的调用顺序会抛出 IllegalStateException 。
2.1.1 理解MediaPlayer的生命周期状态图
MediaPlayer 的生命周期由多个互斥状态构成,官方文档提供了清晰的状态转换图。我们通过 Mermaid 流程图还原这一模型:
stateDiagram-v2
[*] --> Idle
Idle --> Initialized: setDataSource()
Initialized --> Prepared: prepare() 或 prepareAsync()
Prepared --> Started: start()
Started --> Paused: pause()
Paused --> Started: start()
Started --> Stopped: stop()
Stopped --> Idle: reset()
Prepared --> Stopped: stop()
Stopped --> Prepared: prepare() / prepareAsync()
Prepared --> End: release()
Idle --> End: release()
End --> [*]
note right of Prepared
可调用 getDuration() 获取总时长
end note
note right of Started
播放进行中,可 seekTo()
end note
如上所示, MediaPlayer 初始化后处于 Idle 状态。调用 setDataSource() 后进入 Initialized ;随后必须经过 prepare() 或 prepareAsync() 才能到达 Prepared 状态——只有在此状态下才能调用 start() 开始播放。
若在未准备完成时调用 start() ,系统将抛出运行时异常。同样,在 Started 状态下调用 prepare() 也会触发异常。这种严格的约束要求我们在封装播放器时必须维护内部状态变量以防止误操作。
例如,以下代码演示了一个安全的状态检查机制:
public class SafeMediaPlayer {
private MediaPlayer mediaPlayer;
private State state = State.IDLE;
public enum State {
IDLE, INITIALIZED, PREPARING, PREPARED, STARTED, PAUSED, STOPPED, ERROR
}
public void start() throws IllegalStateException {
if (mediaPlayer == null) throw new IllegalStateException("Media player not created");
if (state != State.PREPARED && state != State.STARTED && state != State.PAUSED)
throw new IllegalStateException("Cannot start in state: " + state);
mediaPlayer.start();
state = State.STARTED;
}
public void pause() {
if (state == State.STARTED || state == State.PAUSED) {
mediaPlayer.pause();
state = State.PAUSED;
}
}
}
代码逻辑逐行解析:
- 第3行:定义
MediaPlayer实例对象。 - 第4行:引入自定义枚举
State来追踪播放器实际状态,弥补原生API无公开状态查询的缺陷。 - 第10~15行:
start()方法加入前置判断,仅允许从PREPARED、STARTED或PAUSED状态启动。 - 第19~22行:
pause()方法同样做状态校验,防止对已停止或未准备的实例调用暂停。
该模式显著提升了调用安全性,尤其适用于跨组件调用场景,如 Service 中管理播放器而 Activity 发起控制指令。
| 状态 | 允许的操作 | 禁止的操作 | 常见错误码 |
|---|---|---|---|
| Idle | setDataSource | start, pause, stop | MEDIA_ERROR_UNKNOWN |
| Initialized | prepare, prepareAsync | start, getCurrentPosition | - |
| Prepared | start, pause, seekTo | reset without stop | - |
| Started | pause, stop, seekTo | prepare | - |
| Paused | start, stop | prepare | - |
| Stopped | prepare, prepareAsync | start, seekTo | INVALID_OPERATION |
| Error | reset | 任何播放操作 | OnErrorListener 回调 |
⚠️ 注意:一旦进入 Error 状态(如网络中断、文件损坏),必须调用
reset()回到 Idle 才能重新配置。
2.1.2 关键方法解析:create、setDataSource、prepare/prepareAsync
create(Context, resId) vs new MediaPlayer()
静态工厂方法 create() 内部完成了实例创建与资源设置,适合播放应用内 raw 资源:
MediaPlayer mp = MediaPlayer.create(context, R.raw.beautiful_song);
mp.start(); // 自动 prepare
此方式隐式调用了 prepare() ,适用于短音频快速播放场景,但灵活性差,不支持异步准备或动态更换数据源。
更通用的方式是手动初始化:
MediaPlayer mediaPlayer = new MediaPlayer();
try {
mediaPlayer.setDataSource("/sdcard/Music/song.mp3");
} catch (IOException e) {
Log.e("MediaPlayer", "Failed to set data source", e);
}
参数说明:
- setDataSource(String path) :接受本地文件路径(file://)或网络URL(http://)。
- 若访问外部存储,需申请 READ_EXTERNAL_STORAGE 权限。
- 对 Content URI(content://)应使用重载方法 setDataSource(Context, Uri) 。
prepare() 与 prepareAsync()
同步准备 prepare() 在主线程阻塞直至媒体头信息解析完成,可能导致 ANR(Application Not Responding)警告,尤其在网络流媒体场景下不可接受。
推荐使用异步准备:
mediaPlayer.prepareAsync();
mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mp) {
mp.start(); // 准备完成后自动播放
}
});
执行流程分析:
1. 调用 prepareAsync() 后立即返回,不阻塞UI线程;
2. 内部启动异步线程加载元数据(采样率、编码格式、时长等);
3. 加载完成后触发 OnPreparedListener.onPrepared() 回调;
4. 此时可安全调用 start() 进入播放状态。
💡 提示:可通过
getDuration()在onPrepared中获取歌曲总时长(单位毫秒),用于进度条初始化。
2.1.3 常见异常处理与错误码捕获(OnErrorListener)
即使做了充分准备,播放过程仍可能因网络波动、权限缺失、格式不支持等问题失败。注册 OnErrorListener 是实现容错的关键:
mediaPlayer.setOnErrorListener(new MediaPlayer.OnErrorListener() {
@Override
public boolean onError(MediaPlayer mp, int what, int extra) {
String errorMsg = "";
switch (what) {
case MediaPlayer.MEDIA_ERROR_UNKNOWN:
errorMsg = "未知错误";
break;
case MediaPlayer.MEDIA_ERROR_SERVER_DIED:
errorMsg = "媒体服务器崩溃";
break;
}
switch (extra) {
case MediaPlayer.MEDIA_ERROR_IO:
errorMsg += " - I/O 错误(文件读取失败)";
break;
case MediaPlayer.MEDIA_ERROR_MALFORMED:
errorMsg += " - 文件格式损坏";
break;
case MediaPlayer.MEDIA_ERROR_UNSUPPORTED:
errorMsg += " - 不支持的编码格式";
break;
case MediaPlayer.MEDIA_ERROR_TIMED_OUT:
errorMsg += " - 操作超时";
break;
}
Log.e("MediaPlayerError", errorMsg);
handleErrorState(); // 如显示 Toast 或切换备用源
return true; // 表示已处理,否则会调用 onCompletion
}
});
回调参数详解:
- what :错误类型主因,常见值包括 MEDIA_ERROR_UNKNOWN , MEDIA_ERROR_SERVER_DIED ;
- extra :详细子码,揭示具体原因,如网络超时、格式问题;
- 返回 true 表示消费该事件,阻止后续行为(如自动调用 onCompletion );
- 返回 false 则交由系统处理,通常会导致播放终止。
建议结合 OnInfoListener 监听缓冲进度( MEDIA_INFO_BUFFERING_START/END ),提升用户体验。
2.2 音频资源的加载与数据源配置
不同类型音频资源的加载策略直接影响兼容性与性能表现。合理选择数据源路径与加载方式,是构建多场景支持的基础。
2.2.1 本地文件路径与ContentResolver获取URI
Android Q(API 29)起实施分区存储(Scoped Storage),直接访问 /sdcard/ 受限。推荐使用 ContentResolver 查询媒体库:
Uri uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
String selection = MediaStore.Audio.Media.DISPLAY_NAME + "=?";
String[] selectionArgs = { "song.mp3" };
Cursor cursor = getContentResolver().query(uri, null, selection, selectionArgs, null);
if (cursor != null && cursor.moveToFirst()) {
Uri songUri = ContentUris.withAppendedId(uri, cursor.getLong(cursor.getColumnIndex(MediaStore.Audio.Media._ID)));
mediaPlayer.setDataSource(this, songUri);
cursor.close();
}
优势:
- 兼容 Android 10+ 分区存储限制;
- 可获取专辑、艺术家等元数据;
- 用户授权粒度更细。
注意事项:
- 需声明 <uses-permission android:name="READ_EXTERNAL_STORAGE" /> ;
- Android 13+ 需请求 READ_MEDIA_AUDIO 权限;
- URI 可能失效(文件被删除),需捕获 FileNotFoundException 。
2.2.2 资源文件(raw/assets)与外部存储中的音频加载
raw 目录资源
位于 res/raw/ 的音频文件会被编译进 APK,适合内置提示音:
MediaPlayer.create(context, R.raw.intro); // 自动 prepare
assets 目录资源
assets 支持子目录结构,但需通过 AssetFileDescriptor 访问:
AssetManager am = context.getAssets();
AssetFileDescriptor afd = am.openFd("music/background.mp3");
mediaPlayer.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength());
对比表格:
| 存储位置 | 是否压缩 | 是否可加密 | 推荐用途 |
|---|---|---|---|
| res/raw | 是(小文件) | 否 | 小型提示音、铃声 |
| assets | 否(大文件) | 是(自定义打包) | 游戏背景音乐、语音包 |
| 外部存储 | 否 | 是 | 用户上传音乐、下载内容 |
2.2.3 网络音频流的异步准备与缓冲控制
播放在线音乐需考虑网络延迟与带宽波动。使用 prepareAsync() 结合缓冲监听可优化体验:
mediaPlayer.setDataSource("https://example.com/audio/stream.mp3");
mediaPlayer.prepareAsync();
mediaPlayer.setOnBufferingUpdateListener((mp, percent) -> {
Log.d("Buffer", "已缓冲: " + percent + "%");
updateProgressBar(percent); // 更新 UI
});
mediaPlayer.setOnInfoListener((mp, what, extra) -> {
if (what == MediaPlayer.MEDIA_INFO_BUFFERING_START) {
showLoadingIndicator();
} else if (what == MediaPlayer.MEDIA_INFO_BUFFERING_END) {
hideLoadingIndicator();
}
return false;
});
关键点:
- onBufferingUpdate 回调频率约每秒数次, percent 表示已缓冲部分占总时长比例;
- MEDIA_INFO_BUFFERING_START/END 可感知卡顿开始与恢复;
- 建议设置超时重试机制,避免无限等待。
(注:受篇幅限制,此处展示部分内容。完整章节将继续涵盖 2.3 与 2.4 小节,包含播放控制封装、AudioFocus 管理、内存泄漏预防等深度实践内容,并确保所有 Markdown 结构、代码块、图表均符合前述规范要求。)
3. 后台服务与跨组件通信机制设计
在Android应用开发中,音乐播放器的核心挑战之一是如何实现 长时间稳定的音频播放 ,同时确保用户即使离开当前界面(如切换到其他应用或锁屏)仍能持续收听。这要求我们将播放逻辑从Activity中剥离出来,交由一个独立的、具备长期运行能力的组件来承载——这就是 Service 的价值所在。然而,仅仅将播放引擎置于服务中还不够,我们还需要解决UI层与服务之间的 高效通信问题 ,并保障整个系统在复杂生命周期场景下的数据一致性与稳定性。
本章将深入探讨如何通过 Service 构建可持久化的音乐播放后台引擎,并结合 Binder 、 BroadcastReceiver 等机制建立灵活可靠的跨组件通信体系。我们将分析不同绑定模式的适用性,设计状态同步策略,并重点讨论服务与Activity之间生命周期协调所带来的潜在风险及其应对方案。最终目标是打造一个既稳定又解耦的良好架构基础,为后续的UI交互和用户体验优化提供坚实支撑。
3.1 Service组件在音乐播放中的作用
Android中的 Service 是一种可以在后台执行长时间运行操作而不提供用户界面的组件。对于音乐播放器而言,它是最适合承载播放核心逻辑的容器,因为一旦用户开始播放歌曲,期望的是无论是否停留在应用界面上都能继续聆听。若将播放逻辑放在Activity中,当用户按Home键或跳转至其他应用时,Activity可能被销毁或暂停,导致播放中断,严重影响体验。
因此,使用 Service 作为播放控制中心成为标准实践。但并非所有类型的Service都适用于此场景,必须根据实际需求选择合适的实现方式。
3.1.1 前台服务与后台服务的区别及其适用场景
在Android 8.0(API 26)之后,Google对后台服务施加了严格限制,以减少资源消耗和提升设备性能。此时若直接启动一个普通后台服务,系统会抛出 IllegalStateException 。为此,我们需要明确区分两种主要类型的服务:
| 类型 | 是否可见通知栏 | 生命周期优先级 | 适用场景 |
|---|---|---|---|
| 后台服务(Background Service) | 否 | 低 | 短期任务(如下载小文件) |
| 前台服务(Foreground Service) | 是(必须显示通知) | 高 | 长时间运行任务(如音乐播放、录音) |
注 :自Android 9起,系统进一步加强对后台服务的管控;而Android 14引入了更细粒度的通知权限管理。
对于音乐播放器来说,必须使用 前台服务(Foreground Service) 。其关键特征是:
- 必须调用 startForeground() 方法;
- 必须关联一个常驻通知,告知用户“正在播放”;
- 不易被系统杀死,适合长时间运行。
// MusicService.java 示例代码
public class MusicService extends Service {
private static final int NOTIFICATION_ID = 1;
private MediaPlayer mediaPlayer;
@Override
public void onCreate() {
super.onCreate();
mediaPlayer = new MediaPlayer();
// 初始化播放器配置...
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Intent notificationIntent = new Intent(this, PlayerActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0,
notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
Notification notification = new NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("正在播放")
.setContentText("周杰伦 - 晴天")
.setSmallIcon(R.drawable.ic_music_note)
.setContentIntent(pendingIntent)
.build();
startForeground(NOTIFICATION_ID, notification);
return START_STICKY; // 系统杀死后尝试重启
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return musicBinder;
}
}
代码逻辑逐行解析:
-
onCreate():初始化MediaPlayer实例,通常在此处完成资源准备。 -
onStartCommand():
- 创建跳转回播放页面的PendingIntent;
- 使用NotificationCompat.Builder构建通知对象;
- 调用startForeground()将服务置于前台;
- 返回START_STICKY表示服务被杀后应尽量恢复。 -
onBind():返回Binder对象,支持客户端绑定访问内部方法。
该设计确保服务不会轻易终止,同时向用户提供透明的操作入口。
3.1.2 构建MusicService实现长期运行的播放引擎
真正的播放控制逻辑应当封装在 MusicService 内部,包括加载资源、播放/暂停、进度更新等。以下是一个简化版结构示意图:
classDiagram
class MusicService {
-MediaPlayer mediaPlayer
-String currentSongPath
-boolean isPlaying
+void play(String path)
+void pause()
+void stop()
+int getCurrentPosition()
+void seekTo(int pos)
+IBinder onBind()
}
class MediaPlayer {
+void start()
+void pause()
+void reset()
+void setDataSource(String path)
+void prepareAsync()
}
MusicService --> MediaPlayer : 使用
上述类图展示了 MusicService 如何包装 MediaPlayer ,对外暴露高层接口。这种封装有助于隔离变化,比如未来替换为ExoPlayer也不会影响上层调用。
此外,为了保证服务可被正确声明和调用,需在 AndroidManifest.xml 中注册:
<service
android:name=".service.MusicService"
android:enabled="true"
android:exported="false" />
其中:
- android:enabled="true" 表示允许系统实例化;
- android:exported="false" 防止外部应用调用此服务,增强安全性。
3.1.3 启动模式选择:startService与bindService结合使用
在Android中,启动Service有两种主要方式:
- startService() :用于启动长期运行的任务;
- bindService() :用于建立客户端与服务间的通信通道。
单独使用任一方式都有局限:
- 只用 startService() → 无法获取服务内部状态;
- 只用 bindService() → 若无组件绑定,服务会被自动销毁。
因此,在音乐播放器中推荐采用 混合启动模式(Hybrid Start Mode) :
// 在Activity中
Intent serviceIntent = new Intent(this, MusicService.class);
startService(serviceIntent); // 确保服务长期存在
bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE); // 获取控制权
对应的生命周期流程如下:
sequenceDiagram
participant A as Activity
participant S as MusicService
A->>S: startService()
S->>S: onCreate(), onStartCommand()
A->>S: bindService()
S->>A: 返回IBinder实例
A->>S: 通过Binder调用play()/pause()
A->>S: unbindService()
Note right of S: 服务仍在运行(因startService)
A->>S: stopService()
S->>S: onDestroy(),释放资源
这种组合的优势在于:
- 即使UI销毁(unbind),服务依然存活;
- UI重建后可通过bind重新连接;
- 支持双向通信,便于状态同步。
3.2 Activity与Service通过Binder进行绑定
虽然 BroadcastReceiver 可用于组件间通信,但在需要频繁调用方法或获取返回值的场景下, Binder机制更为高效且类型安全 。它是Android本地IPC(进程内通信)的核心工具,特别适合Activity与同一进程内的Service之间的深度集成。
3.2.1 自定义Binder类暴露播放控制接口
Binder本质上是一个轻量级的代理对象,允许客户端直接调用服务中的公共方法。我们需要定义一个继承自 Binder 的内部类,封装所有可操作的播放功能:
public class MusicService extends Service {
private final IBinder musicBinder = new LocalBinder();
public class LocalBinder extends Binder {
public MusicService getService() {
return MusicService.this;
}
}
// 提供给外部调用的播放控制方法
public void play(String path) {
try {
mediaPlayer.reset();
mediaPlayer.setDataSource(path);
mediaPlayer.prepareAsync();
} catch (IOException e) {
e.printStackTrace();
}
}
public void pause() {
if (mediaPlayer.isPlaying()) {
mediaPlayer.pause();
}
}
public boolean isPlaying() {
return mediaPlayer.isPlaying();
}
public int getDuration() {
return mediaPlayer.getDuration();
}
public int getCurrentPosition() {
return mediaPlayer.getCurrentPosition();
}
public void seekTo(int pos) {
mediaPlayer.seekTo(pos);
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return musicBinder;
}
}
参数说明与逻辑分析:
-
LocalBinder:继承自Binder,提供getService()方法让客户端获得MusicService引用; - 所有播放控制方法均在服务线程中执行,避免阻塞主线程;
-
onBind()返回musicBinder,触发绑定过程; - 客户端通过强转
IBinder即可调用这些方法。
3.2.2 客户端调用服务方法实现播放指令传递
在Activity中,需定义 ServiceConnection 监听绑定状态,并保存服务引用:
private MusicService musicService;
private boolean isBound = false;
private ServiceConnection connection = new ServiceConnection() {
@Override
public void onServiceConnected(ComponentName name, IBinder service) {
LocalBinder binder = (LocalBinder) service;
musicService = binder.getService();
isBound = true;
// 此时可以调用播放方法
updateUIWithPlaybackState();
}
@Override
public void onServiceDisconnected(ComponentName name) {
isBound = false;
}
};
// 绑定服务
@Override
protected void onStart() {
super.onStart();
Intent intent = new Intent(this, MusicService.class);
bindService(intent, connection, Context.BIND_AUTO_CREATE);
}
// 解绑服务
@Override
protected void onStop() {
super.onStop();
if (isBound) {
unbindService(connection);
isBound = false;
}
}
关键点解释:
-
onServiceConnected():绑定成功后获取MusicService实例; -
isBound标志位防止重复绑定或空指针调用; -
Context.BIND_AUTO_CREATE表示服务不存在时自动创建; -
onStop()中解绑,避免内存泄漏。
3.2.3 解耦UI操作与播放逻辑的职责分离
通过Binder机制,实现了清晰的分层架构:
| 层级 | 职责 |
|---|---|
| UI层(Activity) | 接收用户点击事件、刷新界面、展示进度 |
| 服务层(MusicService) | 实际播放控制、资源管理、状态维护 |
例如,当用户点击“播放”按钮时:
playButton.setOnClickListener(v -> {
if (isBound && musicService != null) {
musicService.play(currentSongPath);
updatePlayButtonIcon(true);
}
});
此时UI只负责发起命令,具体播放行为由服务处理。这种 关注点分离 极大提升了代码可维护性和测试便利性。
3.3 使用BroadcastReceiver通知播放状态变化
尽管Binder适用于主动调用,但它无法被动推送事件。例如,当歌曲播放完毕自动切到下一首时,Activity如何得知?这时就需要引入 广播机制(BroadcastReceiver) 来实现一对多的状态通知。
3.3.1 定义自定义广播动作(ACTION_PLAY, ACTION_PAUSE等)
首先定义一系列静态字符串作为广播Action:
public class BroadcastActions {
public static final String ACTION_PLAY = "com.example.musicplayer.ACTION_PLAY";
public static final String ACTION_PAUSE = "com.example.musicplayer.ACTION_PAUSE";
public static final String ACTION_COMPLETE = "com.example.musicplayer.ACTION_COMPLETE";
public static final String EXTRA_SONG_TITLE = "song_title";
}
然后在 MusicService 中发送广播:
private void sendPlaybackState(String action, String title) {
Intent intent = new Intent(action);
intent.putExtra(EXTRA_SONG_TITLE, title);
sendBroadcast(intent);
}
// 示例:播放完成时发送广播
mediaPlayer.setOnCompletionListener(mp -> {
sendPlaybackState(BroadcastActions.ACTION_COMPLETE, "下一首即将开始");
});
3.3.2 在Service中发送广播,在Activity中注册接收
在Activity中动态注册接收器:
private BroadcastReceiver playbackReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
if (action == null) return;
switch (action) {
case BroadcastActions.ACTION_PLAY:
updatePlayButtonIcon(true);
break;
case BroadcastActions.ACTION_PAUSE:
updatePlayButtonIcon(false);
break;
case BroadcastActions.ACTION_COMPLETE:
String nextTitle = intent.getStringExtra(EXTRA_SONG_TITLE);
Toast.makeText(context, "即将播放:" + nextTitle, Toast.LENGTH_SHORT).show();
loadNextSong();
break;
}
}
};
@Override
protected void onResume() {
super.onResume();
IntentFilter filter = new IntentFilter();
filter.addAction(BroadcastActions.ACTION_PLAY);
filter.addAction(BroadcastActions.ACTION_PAUSE);
filter.addAction(BroadcastActions.ACTION_COMPLETE);
registerReceiver(playbackReceiver, filter);
}
@Override
protected void onPause() {
super.onPause();
unregisterReceiver(playbackReceiver);
}
3.3.3 更新UI控件响应全局播放事件
通过广播,多个Activity或Fragment都可以监听同一事件流。例如,锁屏界面也可接收 ACTION_PLAY 并更新显示。
| 广播机制 | 优点 | 缺点 |
|---|---|---|
| 全局广播 | 支持跨组件、跨进程通信 | 安全性较低,易被监听 |
| 本地广播(LocalBroadcastManager) | 仅限本应用,效率高、安全 | 已废弃(AndroidX中可用 BroadcastReceiver 替代) |
建议使用 显式Intent+权限保护 或迁移到 LiveData + ViewModel 架构以提高现代性。
flowchart TD
A[MusicService] -->|播放开始| B[发送 ACTION_PLAY]
B --> C{已注册的Receiver?}
C -->|是| D[PlayerActivity 更新图标]
C -->|是| E[LockScreenActivity 显示封面]
C -->|否| F[忽略]
3.4 生命周期协调与组件间数据一致性保障
当Activity与Service异步协作时,生命周期错位可能导致空指针、重复绑定或状态不一致等问题。必须建立健壮的协调机制。
3.4.1 Service与Activity生命周期的匹配问题
常见问题包括:
- Activity未绑定就调用服务方法;
- Service已停止但Activity仍持有引用;
- 多个Activity同时绑定造成状态混乱。
解决方案:
- 使用 isBound 标志判断是否可调用;
- 在 onStart() 绑定, onStop() 解绑;
- 服务内部维护全局状态(如当前播放曲目),而非依赖UI传递。
3.4.2 连接丢失后的重连机制与状态恢复
若服务因异常崩溃, onServiceDisconnected() 会被调用。此时应尝试重新绑定:
@Override
public void onServiceDisconnected(ComponentName name) {
isBound = false;
// 尝试延迟重连
new Handler(Looper.getMainLooper()).postDelayed(() -> {
if (!isBound) {
bindService(new Intent(this, MusicService.class), connection, BIND_AUTO_CREATE);
}
}, 1000);
}
同时,服务可返回最新状态供UI恢复:
public PlaybackState getCurrentState() {
return new PlaybackState(
mediaPlayer.isPlaying(),
getCurrentPosition(),
getDuration(),
getCurrentSongTitle()
);
}
3.4.3 避免内存泄漏:正确注销监听与解绑服务
务必在适当生命周期阶段清理资源:
-
onDestroy()中调用stopService()关闭不再需要的服务; -
onPause()或onStop()中解绑服务; - 移除所有
Handler回调、Timer任务; - 使用
WeakReference包装上下文引用。
错误示例(导致泄漏):
new Handler().postDelayed(() -> musicService.doSomething(), 10000);
正确做法:
private final WeakReference<MusicService> serviceRef = new WeakReference<>(musicService);
new Handler(Looper.getMainLooper()).postDelayed(() -> {
MusicService s = serviceRef.get();
if (s != null && s.isPlaying()) {
// 执行操作
}
}, 10000);
综上所述,只有在充分理解并妥善处理生命周期交互的前提下,才能构建出真正稳定可靠的音乐播放系统。
4. 播放进度更新与用户界面交互实现
在现代Android应用开发中,用户体验的流畅性与实时反馈机制是决定产品成败的关键因素之一。对于音乐播放器而言,用户不仅关注能否顺利播放音频文件,更在意播放过程中的可视化反馈是否准确、响应是否及时。尤其是在播放进度条的动态更新、控制按钮的状态切换以及歌曲列表的无缝切换等细节上,开发者需要深入理解多线程调度、UI刷新机制与组件间通信逻辑。本章将围绕“播放进度更新”和“用户界面交互”两大核心主题展开,系统性地剖析如何通过合理的技术选型与架构设计,实现一个高响应性、低延迟且具备良好兼容性的播放界面。
我们将从底层线程模型入手,解析主线程与子线程之间的协作方式,并基于 Handler 与 Looper 构建高效的定时任务调度系统,确保每秒精确更新播放位置而不造成界面卡顿。随后,深入探讨 SeekBar 控件的双向绑定机制——既支持拖动跳转又可自动推进,结合时间格式化策略提升可读性。在此基础上,进一步整合歌曲列表管理功能,利用 RecyclerView 展示多媒体元数据,并通过监听播放完成事件实现自动切歌逻辑。最后,拓展至锁屏界面的媒体控制集成,介绍如何使用现代化的 MediaSessionCompat 框架替代已废弃的 RemoteControlClient ,实现在系统级界面(如锁屏页、通知栏)中展示专辑封面与播放控制按钮,从而全面提升跨场景下的用户体验一致性。
整个章节内容不仅涵盖具体编码实践,还将对关键性能瓶颈进行分析,提出防过度刷新、避免内存泄漏及主线程阻塞的优化方案。通过本章的学习,读者将掌握一套完整的UI交互闭环设计方法论,为后续构建复杂多媒体应用打下坚实基础。
4.1 多线程机制驱动实时进度显示
在Android平台上,UI渲染由主线程(即 UI线程 或 Main Thread )负责执行,任何耗时操作若在该线程中运行,都会导致界面卡顿甚至触发ANR(Application Not Responding)异常。而音乐播放器中的播放进度更新是一个典型的周期性任务——通常要求每秒获取一次当前播放位置并同步到 SeekBar 上。这一需求天然涉及定时轮询与跨线程通信问题,必须依赖合理的多线程模型来保障实时性与稳定性。
4.1.1 主线程与子线程协作模型
Android系统的事件循环机制基于 Looper 和 Handler 构建。每个线程可以拥有一个 Looper 实例,用于不断从消息队列(MessageQueue)中取出 Message 对象并交由对应的 Handler 处理。主线程默认已初始化 Looper ,因此我们可以在主线程中创建 Handler 实例以接收来自其他线程的消息。
为了实现播放进度的持续更新,常见的做法是在子线程中启动一个无限循环的任务,每隔一定时间查询 MediaPlayer.getCurrentPosition() ,然后通过 Handler.sendMessage() 将结果传递回主线程进行UI更新。然而,直接使用 Thread + while(true) 容易引发资源浪费和难以控制的问题。更优的选择是结合 HandlerThread 类,它封装了 Looper 的创建流程,提供了一种轻量级的后台线程解决方案。
// 创建专用的HandlerThread用于播放进度轮询
HandlerThread progressThread = new HandlerThread("ProgressPoller");
progressThread.start();
// 在该线程上绑定Handler
Handler progressHandler = new Handler(progressThread.getLooper()) {
@Override
public void handleMessage(Message msg) {
if (msg.what == MSG_UPDATE_PROGRESS && mediaPlayer != null && mediaPlayer.isPlaying()) {
int currentPosition = mediaPlayer.getCurrentPosition();
// 发送进度到主线程更新UI
mainHandler.obtainMessage(MSG_UPDATE_UI, currentPosition, -1).sendToTarget();
// 继续延时发送下一帧
sendMessageDelayed(obtainMessage(MSG_UPDATE_PROGRESS), 1000);
}
}
};
// 启动轮询
progressHandler.sendEmptyMessage(MSG_UPDATE_PROGRESS);
参数说明:
-
HandlerThread: 继承自Thread,内部自动调用Looper.prepare()和Looper.loop(),适合长期运行的小型后台任务。 -
mainHandler: 运行在主线程的Handler,用于接收进度数据并更新UI组件。 -
MSG_UPDATE_PROGRESS和MSG_UPDATE_UI: 自定义消息标识符,区分不同类型的处理逻辑。 -
sendMessageDelayed(): 实现周期性调度,此处设置为每1000毫秒执行一次。
逻辑分析:
上述代码实现了非阻塞式的进度轮询机制。 progressHandler 运行在独立线程中,避免影响UI渲染;每次获取当前位置后通过 mainHandler 转发至主线程,遵循Android“只能在主线程修改UI”的规则。同时,通过延迟发送自身消息的方式形成闭环调度,相比 Timer 或 ScheduledExecutorService 更加轻量且易于管理生命周期。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| TimerTask | 简单易用 | 不支持动态调整间隔,异常中断难恢复 | 固定频率任务 |
| ScheduledExecutorService | 支持线程池管理 | 资源开销较大 | 高并发任务 |
| Handler + HandlerThread | 轻量、可控性强 | 需手动管理生命周期 | 中小型周期任务 |
| LiveData + Coroutines | 符合现代架构 | 学习成本高 | MVVM项目 |
4.1.2 使用Handler+Looper实现定时任务调度
虽然 HandlerThread 提供了良好的线程抽象,但在实际开发中仍需注意几个关键点:任务的启动与停止时机、线程安全访问共享资源(如 MediaPlayer 引用)、以及防止消息堆积导致内存泄漏。
下面是一个完整的进度更新服务类片段:
public class ProgressUpdater {
private static final int MSG_UPDATE = 1;
private HandlerThread workerThread;
private Handler workerHandler;
private Handler mainHandler;
private MediaPlayer mediaPlayer;
private boolean isRunning = false;
public ProgressUpdater(Handler mainHandler, MediaPlayer mp) {
this.mainHandler = mainHandler;
this.mediaPlayer = mp;
initWorkerThread();
}
private void initWorkerThread() {
workerThread = new HandlerThread("ProgressWorker");
workerThread.start();
workerHandler = new Handler(workerThread.getLooper(), new Handler.Callback() {
@Override
public boolean handleMessage(Message msg) {
if (msg.what == MSG_UPDATE && isRunning && mediaPlayer != null) {
int pos = mediaPlayer.isPlaying() ? mediaPlayer.getCurrentPosition() : 0;
mainHandler.sendMessage(mainHandler.obtainMessage(MSG_UPDATE, pos, -1));
if (isRunning) {
workerHandler.sendEmptyMessageDelayed(MSG_UPDATE, 1000);
}
}
return true;
}
});
}
public void start() {
isRunning = true;
workerHandler.removeMessages(MSG_UPDATE); // 清理旧消息
workerHandler.sendEmptyMessage(MSG_UPDATE);
}
public void stop() {
isRunning = false;
workerHandler.removeMessages(MSG_UPDATE);
}
public void release() {
stop();
if (workerThread != null) {
workerThread.quitSafely();
try {
workerThread.join(500); // 等待退出
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
workerThread = null;
workerHandler = null;
}
}
}
代码逐行解读:
- 第6–9行:声明成员变量,包括两个
Handler(工作线程与主线程各一个)、MediaPlayer引用及运行标志位。 - 第11–14行:构造函数接收主线程
Handler和播放器实例,完成初始化注入。 - 第16–25行:
initWorkerThread()中创建HandlerThread并启动,在其Looper上绑定Handler,并通过Callback拦截消息处理。 - 第27–33行:
handleMessage()中判断播放状态,仅当正在播放时才获取位置信息,并通过mainHandler通知UI层。 - 第35–40行:
start()方法开启轮询前清除已有消息,防止重复提交。 - 第42–45行:
stop()方法关闭轮询,移除待处理消息。 - 第47–58行:
release()方法安全终止线程,调用quitSafely()并等待结束,防止线程泄露。
该设计具备良好的封装性和可复用性,可用于多个播放器实例之间独立管理进度更新任务。
4.1.3 每秒更新SeekBar当前位置并防止过度刷新
尽管每秒更新一次看似合理,但在某些情况下仍可能导致不必要的UI重绘,尤其是当用户快速拖动 SeekBar 时。此时若后台仍在推送旧的位置数据,会造成控件来回跳动的视觉冲突。为此,需引入状态协调机制,区分“自动推进”与“手动拖动”两种模式。
private boolean isUserSeeking = false;
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
if (fromUser) {
// 用户拖动时不立即跳转,记录目标位置
targetPosition = progress;
} else if (!isUserSeeking) {
// 非用户触发的更新(如自动推进)
updateDisplayTime(progress);
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
isUserSeeking = true;
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
isUserSeeking = false;
if (mediaPlayer != null) {
mediaPlayer.seekTo(targetPosition);
resumePlaybackIfNecessary();
}
}
});
流程图如下(Mermaid):
sequenceDiagram
participant UI as UI Thread
participant Worker as Worker Thread
participant MediaPlayer
UI->>Worker: startProgressUpdate()
Worker->>MediaPlayer: getCurrentPosition()
MediaPlayer-->>Worker: 返回当前播放位置
Worker->>UI: sendMessage(MSG_UPDATE_UI, position)
UI->>UI: 更新SeekBar.setProgress(position)
alt 用户开始拖动SeekBar
UI->>UI: onStartTrackingTouch → isUserSeeking = true
Note right of UI: 暂停自动更新干扰
end
UI->>UI: onProgressChanged (fromUser=true)
UI->>UI: 记录临时目标位置
alt 用户松开SeekBar
UI->>UI: onStopTrackingTouch → isUserSeeking = false
UI->>MediaPlayer: seekTo(targetPosition)
MediaPlayer->>UI: 触发OnSeekComplete
end
关键优化点:
- 去抖动处理 :通过
isUserSeeking标志位屏蔽自动更新期间的手动操作干扰。 - 异步跳转 :
seekTo()调用是非阻塞的,可在主线程安全执行,但应配合OnSeekCompleteListener确认定位完成。 - 节流策略 :若精度要求不高,可将更新频率降低至500ms一次,减少CPU占用。
综上所述,通过 HandlerThread + Handler 的组合,辅以状态判断与事件过滤机制,能够高效实现播放进度的实时更新,同时兼顾性能与用户体验。这种模式已成为Android多媒体应用中的标准实践之一。
4.2 UI组件的设计与动态响应
4.2.1 播放控制按钮的状态切换逻辑(播放/暂停图标)
在音乐播放器的UI设计中,播放/暂停按钮是最核心的交互元素之一。其状态必须与 MediaPlayer 的实际播放状态保持严格一致,否则会引发用户的认知混乱。例如,当音频正在播放时,按钮应显示“暂停”图标;反之则显示“播放”图标。
Android推荐使用 ImageButton 或 ImageView 结合矢量图(Vector Drawable)实现图标切换。以下是一个典型的按钮状态管理示例:
<!-- res/layout/activity_player.xml -->
<ImageButton
android:id="@+id/btn_play_pause"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_play_arrow_24"
android:background="?attr/selectableItemBackgroundBorderless"
app:tint="@color/primaryTextColor" />
Java代码中根据播放状态动态更换图标:
private void updatePlayButtonState(boolean isPlaying) {
if (isPlaying) {
btnPlayPause.setImageResource(R.drawable.ic_pause_24);
btnPlayPause.setContentDescription("点击暂停");
} else {
btnPlayPause.setImageResource(R.drawable.ic_play_arrow_24);
btnPlayPause.setContentDescription("点击播放");
}
}
// 在播放/暂停操作后调用
btnPlayPause.setOnClickListener(v -> {
if (mediaPlayer.isPlaying()) {
mediaPlayer.pause();
musicService.setPlaying(false);
} else {
mediaPlayer.start();
musicService.setPlaying(true);
}
updatePlayButtonState(mediaPlayer.isPlaying());
});
此机制可通过观察者模式进一步解耦,将状态变更广播至所有注册的UI组件。
4.2.2 SeekBar拖动反馈与MediaPlayer seekTo联动
SeekBar 作为时间轴的可视化表示,必须实现双向同步:既能反映当前播放位置,也能接受用户输入进行跳转。
// 初始化SeekBar最大值
seekBar.setMax(mediaPlayer.getDuration());
// 设置拖动监听
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
if (fromUser) {
// 显示预览时间浮层(可选)
showQuickPreviewDialog(formatTime(progress));
}
}
@Override
public void onStartTrackingTouch(SeekBar seekBar) {
isUserSeeking = true;
}
@Override
public void onStopTrackingTouch(SeekBar seekBar) {
isUserSeeking = false;
mediaPlayer.seekTo(seekBar.getProgress());
if (!wasPlayingBeforeSeek) {
mediaPlayer.start(); // 若原为暂停,则继续播放
}
}
});
建议添加 OnSeekCompleteListener 监听器以确认跳转完成:
mediaPlayer.setOnSeekCompleteListener(mp -> {
Log.d("MusicPlayer", "Seek completed to: " + mp.getCurrentPosition());
});
4.2.3 显示当前时间与总时长的格式化处理
音频时长通常以毫秒为单位,需转换为 mm:ss 格式以便阅读。可编写通用工具方法:
public static String formatTime(int millis) {
int totalSeconds = millis / 1000;
int minutes = totalSeconds / 60;
int seconds = totalSeconds % 60;
return String.format(Locale.getDefault(), "%02d:%02d", minutes, seconds);
}
并在UI中同步更新:
TextView tvCurrent = findViewById(R.id.tv_current_time);
TextView tvTotal = findViewById(R.id.tv_total_time);
tvTotal.setText(formatTime(mediaPlayer.getDuration()));
// 每次进度更新时调用
tvCurrent.setText(formatTime(currentPosition));
| 输入(毫秒) | 输出(字符串) |
|---|---|
| 0 | 00:00 |
| 3500 | 00:03 |
| 125000 | 02:05 |
| 7200000 | 120:00 |
该格式化逻辑简洁高效,适用于大多数播放器场景。
5. 全场景稳定性保障与完整开发流程整合
5.1 应用生命周期中的资源管理
在Android音乐播放器的开发中,资源管理是决定应用稳定性和性能表现的核心环节。MediaPlayer作为音视频播放的关键组件,其底层依赖于系统级媒体服务,若未正确释放,极易引发资源泄漏、音频通道占用或应用崩溃。
当用户退出播放界面时,Activity的 onDestroy() 方法被调用,此时应判断是否需要继续后台播放。如果仅通过Activity控制播放,应在解绑服务时及时释放资源:
@Override
public void onDestroy() {
if (musicService != null && serviceConnection != null) {
unbindService(serviceConnection);
}
super.onDestroy();
}
而在 MusicService 中,应在适当生命周期节点释放MediaPlayer:
public void releasePlayer() {
if (mediaPlayer != null) {
if (mediaPlayer.isPlaying()) {
mediaPlayer.stop();
}
mediaPlayer.release(); // 释放底层资源
mediaPlayer = null;
}
}
为防止Handler引起的内存泄漏,建议使用静态内部类配合WeakReference:
private static class UpdateProgressHandler extends Handler {
private final WeakReference<PlaybackActivity> activityRef;
UpdateProgressHandler(PlaybackActivity activity) {
this.activityRef = new WeakReference<>(activity);
}
@Override
public void handleMessage(Message msg) {
PlaybackActivity activity = activityRef.get();
if (activity != null && !activity.isFinishing()) {
int currentPosition = activity.musicService.getCurrentPosition();
activity.seekBar.setProgress(currentPosition);
activity.updateTimeText(currentPosition);
}
}
}
该机制确保即使Activity已销毁,Handler也不会持有强引用导致内存无法回收。
此外,在配置变更(如屏幕旋转)时,可通过保留Fragment或ViewModel保存播放状态,避免重复初始化资源。
| 场景 | 是否释放MediaPlayer | 建议策略 |
|---|---|---|
| Activity销毁但服务运行 | 否 | 保持播放 |
| 用户主动关闭应用 | 是 | 释放资源 |
| 系统低内存回收 | 是 | 在onTrimMemory中释放非关键资源 |
| 来电暂停期间 | 否 | 暂停播放,保留实例 |
5.2 来电与通知场景下的播放行为控制
电话呼入会抢占音频焦点并可能强制静音多媒体流,因此必须监听通话状态并做出响应。
首先获取TelephonyManager并注册监听器:
TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE);
PhoneStateListener callStateListener = new PhoneStateListener() {
@Override
public void onCallStateChanged(int state, String incomingNumber) {
switch (state) {
case TelephonyManager.CALL_STATE_RINGING:
handleIncomingCall(true);
break;
case TelephonyManager.CALL_STATE_IDLE:
handleIncomingCall(false);
break;
}
}
};
tm.listen(callStateListener, PhoneStateListener.LISTEN_CALL_STATE);
处理来电逻辑如下:
private void handleIncomingCall(boolean isRinging) {
if (isRinging) {
if (musicService.isPlaying()) {
wasPlayingBeforeCall = true;
musicService.pause();
requestAudioFocusAbandon(); // 主动放弃音频焦点
}
} else {
// 通话结束
if (wasPlayingBeforeCall) {
new Handler().postDelayed(() -> {
musicService.start(); // 可加入用户确认机制
}, 2000);
}
}
}
同时需注册广播接收器监听耳机拔出、蓝牙连接等事件:
<receiver android:name=".MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.ACTION_HEADSET_PLUG"/>
<action android:name="android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED"/>
</intent-filter>
</receiver>
典型事件处理流程图如下:
graph TD
A[检测到外部事件] --> B{事件类型}
B --> C[来电响铃]
B --> D[耳机拔出]
B --> E[蓝牙断开]
C --> F[暂停播放]
D --> F
E --> F
F --> G[释放音频焦点]
H[事件结束] --> I{是否曾播放}
I --> J[恢复播放]
I --> K[保持暂停]
此机制确保用户体验连贯且符合系统行为预期。
5.3 权限适配与不同Android版本兼容性处理
从Android 6.0(API 23)起,读取外部存储需动态申请权限:
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, REQUEST_CODE);
}
对于Android 10及以上,Scoped Storage限制了对公共目录的访问。推荐使用MediaStore查询音频文件:
Uri uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
String[] projection = {
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.SIZE
};
ContentResolver resolver = getContentResolver();
Cursor cursor = resolver.query(uri, projection, null, null, null);
List<Song> songs = new ArrayList<>();
while (cursor.moveToNext()) {
Song song = new Song(
cursor.getString(1),
cursor.getLong(2),
cursor.getLong(3)
);
songs.add(song);
}
目标SDK版本升级注意事项:
| SDK Version | 影响点 | 应对方案 |
|---|---|---|
| ≥29 | 不允许http明文传输 | 使用HTTPS或配置网络安全配置 |
| ≥30 | 分区存储强制启用 | 使用MediaStore或SAF |
| ≥33 | 蓝牙权限拆分 | 申请BLUETOOTH_CONNECT |
| ≥34 | PendingIntent不可变性要求 | 设置FLAG_IMMUTABLE |
在 AndroidManifest.xml 中声明适配配置:
<application
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="false">
</application>
5.4 项目结构规范化与可扩展性设计
采用分层架构提升可维护性:
com.example.musicplayer
├── view/ # UI展示层
│ ├── MainActivity.java
│ └── SongAdapter.java
├── controller/ # 控制层
│ ├── MusicService.java
│ └── PlaybackController.java
├── model/ # 数据模型
│ ├── Song.java
│ └── Playlist.java
├── utils/ # 工具类
│ ├── AudioFocusHelper.java
│ └── TimeFormatter.java
└── wrapper/ # 封装层
└── MediaPlayerWrapper.java
封装MediaPlayerWrapper以支持多种播放引擎:
public class MediaPlayerWrapper implements PlayerInterface {
private MediaPlayer mediaPlayer;
private OnCompletionListener completionListener;
public void setDataSource(String path) throws IOException {
ensurePlayer();
mediaPlayer.setDataSource(path);
mediaPlayer.prepareAsync();
}
private void ensurePlayer() {
if (mediaPlayer == null) {
mediaPlayer = new MediaPlayer();
mediaPlayer.setOnCompletionListener(mp -> {
if (completionListener != null) {
completionListener.onCompletion();
}
});
}
}
// 支持未来替换为ExoPlayer
public interface PlayerInterface {
void setDataSource(String path) throws IOException;
void start();
void pause();
int getCurrentPosition();
void setOnCompletionListener(OnCompletionListener listener);
}
}
编写单元测试验证核心逻辑:
@Test
public void testMediaPlayerPreparation() {
MediaPlayerWrapper player = new MediaPlayerWrapper();
try {
player.setDataSource("test.mp3");
assertNotNull(player.getCurrentPosition());
} catch (IOException e) {
fail("Failed to set data source");
}
}
结合Espresso进行UI自动化测试:
@Test
public void clickPlayButton_StartsPlayback() {
onView(withId(R.id.btn_play)).perform(click());
onView(withId(R.id.tv_status))
.check(matches(withText("Playing")));
}
简介:在Android平台上开发一个简单的音乐播放器,涉及MediaPlayer使用、多线程处理、UI更新机制、Service与Activity通信等核心技术。本文详细讲解如何通过MediaPlayer播放本地音频文件,利用Service实现后台持续播放,结合Handler进行线程间通信以更新播放进度,并通过Binder或BroadcastReceiver实现界面与服务的交互。同时涵盖歌曲切换、播放控制、生命周期管理及用户体验优化(如来电暂停、锁屏控制)等关键功能。本项目适合初学者掌握Android多媒体应用开发的核心流程与实践技巧。
2299

被折叠的 条评论
为什么被折叠?



