Android音乐播放器源码解析:MediaPlayer状态机与工程迁移
2026/9/13 19:52:58 网站建设 项目流程

简介:安卓MusicPlayer音乐播放器完整源码包面向Android入门开发者,以可直接编译运行的工程为载体,完整演示多媒体播放、界面交互与后台服务等核心知识点。压缩包共36个文件,包含5个java核心源文件、18个class编译产物、xml界面布局、apk可运行包及dex文件,另有源码说明txt与应用截图,整体仅126KB,结构精简便于逐行对照研读。源码清晰呈现MediaPlayer从setDataSource到start/pause/seekTo的完整调用链,并配合ListView/RecyclerView等列表组件构建歌曲列表,还通过Foreground Service结合通知栏常驻通知实现离开界面仍可后台播放。已有776人学习下载,配套的源码说明可帮助快速定位主流程与关键类,适合在真实工程中理解Android多媒体框架与后台任务处理。

1. 解压这份 Eclipse 时代的 MusicPlayer,先看怎么让它跑起来

解压MusicPlayer源码.zip后,你会看到srcgenresassetsAndroidManifest.xmldefault.properties,这是一个非常典型的 Eclipse ADT 老工程结构,而不是现在 Android Studio 期望的 Gradle 工程。目录里还有一份源码说明.txt和一张1_120828192818_1.png界面截图,前者是这个项目的“入门钥匙”,后者能直接看到播放器界面的最终长相。

这个工程里没有引入任何第三方框架或播放器内核,核心全靠 Android 自带的MediaPlayer类把音频资源从装载到播放串起来。正因为它足够老、足够直白,反而适合拿来拆解 Android 多媒体框架的底层状态机、UI 线程刷新和后台 Service 生命周期。对刚接触安卓开发的人,这是一份可以对着adb install跑通的完整可运行源码;对工作五年以上的人,也可以借这个干净工程快速回顾MediaPlayerIllegalStateException边界,或者直接把它迁移成高版本的 Android Studio 工程,省去从零搭建的时间。

2. 播放核心:MediaPlayer 状态机与 setDataSource、seekTo 的正确姿势

许多人在拿到这份源码后,第一件事就是去src目录里找播放按钮的逻辑。你会看到MediaPlayer被封装在 Activity 或者一个工具类里,代码极其精简,但这精简背后是 Android 多媒体框架里最容易踩坑的状态机约束。

2.1 初始化与装载:为什么源码里反复出现 reset()

src里的播放方法,很可能长这样:

private MediaPlayer mediaPlayer; public void playSong(String path) { if (mediaPlayer == null) { mediaPlayer = new MediaPlayer(); } try { // 核心:把播放器重置到 Idle 状态,避免上次播放的残留状态干扰 mediaPlayer.reset(); // 设定数据源,整个过程从 Initialized 状态开始 mediaPlayer.setDataSource(path); // 同步准备,进入 Prepared 状态 mediaPlayer.prepare(); // 真正开始出声,进入 Started 状态 mediaPlayer.start(); } catch (IOException e) { e.printStackTrace(); } }

这里的reset()不是可有可无的防御代码。MediaPlayer是一个严格状态机,如果你在Started状态直接调用setDataSource(),系统会直接抛出IllegalStateException,整个应用当场崩溃。每次切换歌曲之前先reset(),是让播放器从任意状态退回到Idle,再重新走一遍setDataSource -> prepare -> start的完整链路,这套写法在老源码里出现频率极高。

prepare()是一个阻塞操作,它会把音频文件装载进内存并解析头信息。在这份源码的同步调用方式下,如果加载的是网络流或超大本地文件,主线程会有明显卡顿。源码这么设计是因为assets目录下的测试音乐文件都很小,完整解析在毫秒级,并不会影响点击播放后的即时反馈。如果你要改成网络播放,需要换成prepareAsync()配合setOnPreparedListener,这属于将源码现代化时必改的第一处。

2.2 播放暂停与拖动:seekTo() 不是简单跳转

播放和暂停逻辑在源码里是成对出现的:

// 暂停方法 public void pause() { if (mediaPlayer != null && mediaPlayer.isPlaying()) { mediaPlayer.pause(); // 进入 Paused 状态 } } // 恢复播放 public void resume() { if (mediaPlayer != null && !mediaPlayer.isPlaying()) { mediaPlayer.start(); // 从 Paused 回到 Started } } // 进度条拖动 public void seekTo(int progress) { if (mediaPlayer != null) { mediaPlayer.seekTo(progress); } }

seekTo()在状态机上比许多人想象得更宽容,只要MediaPlayer处于PreparedStartedPausedPlaybackCompleted状态,它都能安全工作。你唯一要注意的是,如果在Idle状态直接调它,IllegalStateException照样会砸到脸上。

源码中暂停和恢复用的是同一个mediaPlayer实例,这意味着播放进度天然被保留。很多初学者会犯的错误是暂停时直接stop()stop()之后播放器进入Stopped状态,必须重新prepare()才能再次start(),这比pause()的成本高得多。

整段逻辑我们可以用状态表来归纳,排查问题时会非常直观:

状态触发方法后续可调用常见异常场景
Idlenew MediaPlayer()setDataSource()直接调用start()会抛IllegalStateException
InitializedsetDataSource()prepare()prepareAsync()直接调用pause()会崩溃
Preparedprepare()完成start()seekTo()
Startedstart()pause()seekTo()重复调用start()不会崩溃但无用
Pausedpause()start()seekTo()
PlaybackCompleted播放结束start()seekTo()直接调用pause()不报错但无意义

源码还通常会实现setOnCompletionListenersetOnErrorListener。前者用于歌曲播放完毕后自动切下一首,后者负责处理解码失败或文件损坏。错误监听返回true表示事件已被消费,不需要系统再做什么兜底操作。

2.3 释放资源:onDestroy 里别忘记 release()

@Override protected void onDestroy() { super.onDestroy(); if (mediaPlayer != null) { // 释放底层音频硬件资源,防止内存泄漏 mediaPlayer.release(); mediaPlayer = null; } }

release()是这份老源码里少有的“保命操作”。MediaPlayer底层持有 Native 层的音频解码器资源,单个播放器实例可能占用几十兆内存,如果不能及时释放,快速切换页面会直接OOM。源码里把它放在onDestroy里是最保守也最稳妥的做法,实际项目里你可以结合LifecycleObserver把这行代码移到onStop,但核心原则一致:播放器不用了就立刻毁掉。

3. UI 层与控制面板:进度条刷新与歌曲列表 Adapter 绑定

拿到源码后你会看到res/layout里躺着几个 XML 布局文件,打开主界面的那个,大概率是LinearLayout里嵌套一个ListView,底部固定一条SeekBar和三个按钮(播放、上一首、下一首)。这种纵向堆叠的布局方式在 Android 2.3 时代是绝对主流。

3.1 从 res/layout 看控制面板的组合方式

剥开界面截图对应的布局,核心骨架如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <ListView android:id="@+id/song_list" android:layout_width="match_parent" android:layout_height="0dp" android:layout_weight="1" /> <SeekBar android:id="@+id/seek_bar" android:layout_width="match_parent" android:layout_height="wrap_content" android:max="100" /> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal"> <Button android:id="@+id/btn_prev" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="上一首" /> <Button android:id="@+id/btn_play" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="播放" /> <Button android:id="@+id/btn_next" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:text="下一首" /> </LinearLayout> </LinearLayout>

上面的SeekBar主要用来手动跳转播放位置,它的max属性被写死成100,但你需要在代码里把max动态修改为mediaPlayer.getDuration()的返回值,才能让进度条进度与歌曲时长比例正确对应。

很多人在旧源码里会忽略一个细节,布局中按钮只是文字按钮,并没有体现界面的视觉质感。你可以通过android:background属性替换成自定义 selector 资源,实现按下状态和普通状态的切换;android:icon属性则用于在列表项中显示歌曲类型的图标。这两项改动不需要动任何 Java 代码,是成本极低的 UI 优化切入点。如果使用RelativeLayoutConstraintLayout,可以进一步压缩布局层级,但老源码用LinearLayoutweight已经足够完成任务,没必要为了追新而重构布局逻辑。

3.2 Handler + Runnable 刷新进度条的常见误用

源码里用来让SeekBar跟随播放进度走的,通常是一个HandlerRunnable的组合,核心代码长这样:

private Handler handler = new Handler(Looper.getMainLooper()); private Runnable progressRunnable = new Runnable() { @Override public void run() { if (mediaPlayer != null && mediaPlayer.isPlaying()) { int currentPosition = mediaPlayer.getCurrentPosition(); int duration = mediaPlayer.getDuration(); if (duration > 0 && currentPosition <= duration) { seekBar.setMax(duration); seekBar.setProgress(currentPosition); } } // 每 500ms 刷新一次进度条 handler.postDelayed(this, 500); } };

注意handler.postDelayed(this, 500)被放在Runnable的最后一行,这意味着只要这个任务被首次启动,它就会以每 500 毫秒一次的频率反复自我投递。这是一个典型的“自循环”模式,好处是代码很紧凑,坏处是你在Activity.onDestroy()时必须调用handler.removeCallbacks(progressRunnable),否则Runnable会持有 Activity 的外部引用,造成内存泄漏。这段逻辑里,seekBar.setMax(...)其实不需要每次刷新都调用,把它挪到歌曲切换时设置会更合理。

SeekBar的监听器也要做一层判断:

seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { // 只在用户手动拖动时才触发 seekTo,避免播放过程互相干扰 if (fromUser) { mediaPlayer.seekTo(progress); } } @Override public void onStartTrackingTouch(SeekBar seekBar) { // 拖动开始时可以暂停进度条的自动刷新,提升手感 } @Override public void onStopTrackingTouch(SeekBar seekBar) { // 拖动结束时恢复刷新 } });

fromUser参数是区分“用户手动操作”和“代码内部调用”的唯一标志。如果漏掉这个判断,播放器每次自动刷新进度时都会触发一次seekTo(),导致音乐一顿一顿地抢拍,这是新手最容易犯、最不容易被发现的逻辑错误。

3.3 歌曲列表:ListView 与 RecyclerView 的取舍

老源码中歌曲列表几乎都是ListView。从assets目录读取默认音乐清单,再通过ArrayAdapterBaseAdapter把文件名和路径绑定到列表上。如果你在 Android Studio 里打开这份源码,会发现ListView仍然能跑,但 lint 会建议你迁移到RecyclerView。两者的对比非常直接:

对比维度ListViewRecyclerView
更新数据需要手动刷新全列表拥有 DiffUtil 增量更新
复用机制ViewHolder 需自己写强制 ViewHolder 模式
动画能力没有内置项动画内置 ItemAnimator
点击事件自带setOnItemClickListener需自己用addOnItemTouchListenerOnItemClickListener封装
查找/更换源码中大量使用社区主流,资料多

在迁移到RecyclerView时,Adapter 的写法是这样的:

public class SongAdapter extends BaseAdapter { private List<String> songList; private Context context; public SongAdapter(Context context, List<String> songList) { this.context = context; this.songList = songList; } @Override public int getCount() { return songList.size(); } @Override public Object getItem(int position) { return songList.get(position); } @Override public long getItemId(int position) { return position; } @Override public View getView(int position, View convertView, android.view.ViewGroup parent) { if (convertView == null) { convertView = android.view.LayoutInflater.from(context) .inflate(android.R.layout.simple_list_item_1, parent, false); } android.widget.TextView textView = convertView.findViewById(android.R.id.text1); textView.setText(songList.get(position)); return convertView; } }

这里保留了BaseAdapter的接口,因为 Eclipse 时代没有ViewHolder的强制约束,源码中如果直接new TextView而不复用convertView,列表滚动时会频繁创建对象导致掉帧卡顿。拿到源码后,第一件事就是检查getView方法里有没有判断convertView是否为null,这往往是老音乐播放器卡顿的主要源头。

4. 工程迁移实录:把 Eclipse src 与 res 搬进 Android Studio

这份源码无法直接用现在的 Android Studio 打开运行,你需要手工迁移。整个迁移过程不复杂,但里面的细节决定成败。

4.1 识别老工程特征:default.properties、gen 与 .project

MusicPlayer源码.zip根目录下,你会看到三个特殊文件:.project.classpathdefault.properties.project是 Eclipse 的工程描述文件,Android Studio 完全忽略它;default.properties里写了一行target=android-17,指定了编译 API Level,这个值早就过期了;gen目录下是R.java自动生成文件,在 Android Studio 里由build过程自动完成。

用命令行直接确认关键信息:

unzip MusicPlayer源码.zip -d MusicPlayer cd MusicPlayer ls -la # 输出:src/ res/ assets/ gen/ bin/ AndroidManifest.xml default.properties cat default.properties # 输出:target=android-17 (说明这是 Android 4.2 时代的工程)

旧版本的bin/目录下有MusicPlayer.apkclasses.dex,这是 Eclipse 构建出的历史产物,迁移时可以直接删掉,避免被 Android Studio 错误识别为模块。

4.2 创建 Gradle 骨架并迁移资源

我一般不建议直接在旧目录上强行跑 AS,而是在 AS 里新建一个 Empty Activity 工程,再手动把旧工程的srcres覆盖过去。新建工程的核心 Gradle 脚本如下:

// 项目级 build.gradle buildscript { repositories { google() mavenCentral() } dependencies { classpath 'com.android.tools.build:gradle:7.4.2' } } // app 模块的 build.gradle android { compileSdk 33 defaultConfig { applicationId "com.example.musicplayer" minSdk 19 targetSdk 33 versionCode 1 versionName "1.0" } sourceSets { main { // 如果是旧目录,需要显式指定 manifest.srcFile 'src/main/AndroidManifest.xml' java.srcDirs = ['src/main/java'] res.srcDirs = ['src/main/res'] assets.srcDirs = ['src/main/assets'] } } }

compileSdk 33是当前主流配置,minSdk 19意味着兼容 Android 4.4 及以上设备。如果旧源码里依赖了android-support-v4.jar这类老库,你需要把它替换成 AndroidX 版本,也就是在依赖里加上:

dependencies { implementation 'androidx.appcompat:appcompat:1.6.1' }

同时把代码里所有import android.support.v4.app.Fragment改成import androidx.fragment.app.Fragment,同步替换 Activity 基类为AppCompatActivity。这一步不做,编译会直接报符号找不到。

资源迁移时,res/values里的styles.xmlstrings.xml直接复制没有问题,但res/drawable目录下如果有.9.png结尾的点九图,需要注意文件名不能有大写字母,否则aapt资源编译会直接中断。

4.3 排坑手册:依赖与 API 级别不匹配

迁移后最常见的编译与运行错误,几乎都逃不出下面几类:

错误信息根因解法
Error: Attribute application@icon value=(@drawable/icon) from AndroidManifest.xml新工程没有对应 drawableres/drawable下补一张图标或删除该引用
Unable to resolve superclass ... android.app.Fragment用了老 Fragment统一替换为androidx.fragment.app.Fragment
java.lang.SecurityException: Permission Denial申请了高版本权限但没运行时请求在用到WRITE_EXTERNAL_STORAGE的地方动态申请权限
ClassNotFoundException ... classes.dex依赖了旧的 jar 包移除libs下的旧 jar,改用 Gradle 依赖

权限申请是这份老源码迁移后最棘手的问题。老工程在AndroidManifest.xml里直接写<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>就够了,但在 Android 6.0 及以上,这行声明只是“提名”,真正授权需要运行时弹窗确认。源码里打开存储目录读取歌曲的部分,如果没有做权限判断,在 Android 10 上会直接崩掉。

排错阶段可以用这组命令验证 APK 是否正常安装和启动:

# 编译并打 debug 包 ./gradlew assembleDebug # 安装到已连接的设备或模拟器 adb install -r app/build/outputs/apk/debug/app-debug.apk # 启动应用的主 Activity adb shell am start -n com.example.musicplayer/.MainActivity # 查看实时运行日志,出现 FATAL EXCEPTION 就按图索骥 adb logcat | grep -E "AndroidRuntime|MusicPlayer"

adb logcat里如果报java.io.FileNotFoundException,说明路径拼接问题,检查是不是用Environment.getExternalStorageDirectory()拼出了已经失效的存储根路径。现代 Android 要求使用getExternalFilesDir()或分区存储的 MediaStore 接口,这是迁移老播放器源码时绕不开的适配项。

5. 让音乐后台播放:改造为前台服务并验证通知栏与内存开销

原版源码的播放逻辑写在 Activity 里,屏幕一关或用户切到其他应用,音乐就停了。这是因为 Activity 在onStop后进程可能被系统杀死,或者没有得到前台优先级。要解决这个问题,标准做法是把MediaPlayer挪进Service

5.1 为什么必须用服务而不是 Activity

Service本身跑在应用主线程,它不会自己开子线程,但它的生命周期独立于界面,不会因为 Activity 被销毁而强制回收。配合START_NOT_STICKY之外的启动模式,可以让音乐在锁屏场景下持续播放。更关键的是,Android 8.0 之后要求后台播放必须配合前台服务,否则几秒钟内就会收到系统强杀或 ANR。

5.2 基于 MediaPlayer 实现前台 Service

改造后的核心代码是把MediaPlayer的实例移交到 Service 内部持有:

public class PlaybackService extends Service { private MediaPlayer mediaPlayer; private static final int NOTIFICATION_ID = 1; @Override public void onCreate() { super.onCreate(); mediaPlayer = new MediaPlayer(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { String action = intent.getAction(); if ("PLAY".equals(action)) { mediaPlayer.reset(); try { mediaPlayer.setDataSource(intent.getStringExtra("path")); mediaPlayer.prepare(); mediaPlayer.start(); } catch (Exception e) { e.printStackTrace(); } } // 注册通知栏并启动前台服务 startForeground(NOTIFICATION_ID, buildNotification("正在播放")); return START_NOT_STICKY; } private Notification buildNotification(String title) { // Android 8.0+ 必须给通知创建渠道 NotificationChannel channel = new NotificationChannel( "playback", "播放控制", NotificationManager.IMPORTANCE_LOW); NotificationManager manager = getSystemService(NotificationManager.class); manager.createNotificationChannel(channel); return new Notification.Builder(this, "playback") .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title) .setContentText("音乐播放器正在后台运行") .setOngoing(true) .build(); } @Override public void onDestroy() { super.onDestroy(); if (mediaPlayer != null) { mediaPlayer.release(); mediaPlayer = null; } } @Nullable @Override public IBinder onBind(Intent intent) { return null; } }

startForeground必须在onCreateonStartCommand中调用,如果在创建后几秒内没调,系统会直接抛出ForegroundServiceDidNotStartInTimeException。通知渠道的IMPORTANCE_LOW设置很关键,它保证通知存在但不响铃不抢注意力,符合音乐软件在后台的常驻习惯。

验证改造是否生效,不要只看界面,用命令直接检查:

# 查看服务是否存活 adb shell dumpsys activity services | grep -i music # 查看应用内存占用,重点看 TOTAL 行列 adb shell dumpsys meminfo com.example.musicplayer # 模拟锁屏,观察 MediaPlayer 是否还在输出 adb shell input keyevent KEYCODE_SLEEP

dumpsys meminfo中,正常的MediaPlayer实例会体现在CursorWindowMediaCodec分类下,如果这几项没有明显增长,说明release()时机正确。最后补一个细节:Service 里的MediaPlayer是全局单例,所以 UI 层的暂停、拖动进度条都必须通过startService(Intent)把动作传进去,不能在 Activity 里直接拿着播放器对象操作,否则播放器所有权一分为二,IllegalStateException会重新找上门来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询