
简介安卓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后你会看到src、gen、res、assets、AndroidManifest.xml和default.properties这是一个非常典型的 Eclipse ADT 老工程结构而不是现在 Android Studio 期望的 Gradle 工程。目录里还有一份源码说明.txt和一张1_120828192818_1.png界面截图前者是这个项目的“入门钥匙”后者能直接看到播放器界面的最终长相。这个工程里没有引入任何第三方框架或播放器内核核心全靠 Android 自带的MediaPlayer类把音频资源从装载到播放串起来。正因为它足够老、足够直白反而适合拿来拆解 Android 多媒体框架的底层状态机、UI 线程刷新和后台 Service 生命周期。对刚接触安卓开发的人这是一份可以对着adb install跑通的完整可运行源码对工作五年以上的人也可以借这个干净工程快速回顾MediaPlayer的IllegalStateException边界或者直接把它迁移成高版本的 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处于Prepared、Started、Paused或PlaybackCompleted状态它都能安全工作。你唯一要注意的是如果在Idle状态直接调它IllegalStateException照样会砸到脸上。源码中暂停和恢复用的是同一个mediaPlayer实例这意味着播放进度天然被保留。很多初学者会犯的错误是暂停时直接stop()stop()之后播放器进入Stopped状态必须重新prepare()才能再次start()这比pause()的成本高得多。整段逻辑我们可以用状态表来归纳排查问题时会非常直观状态触发方法后续可调用常见异常场景Idlenew MediaPlayer()setDataSource()直接调用start()会抛IllegalStateExceptionInitializedsetDataSource()prepare()、prepareAsync()直接调用pause()会崩溃Preparedprepare()完成start()、seekTo()无Startedstart()pause()、seekTo()重复调用start()不会崩溃但无用Pausedpause()start()、seekTo()无PlaybackCompleted播放结束start()、seekTo()直接调用pause()不报错但无意义源码还通常会实现setOnCompletionListener和setOnErrorListener。前者用于歌曲播放完毕后自动切下一首后者负责处理解码失败或文件损坏。错误监听返回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:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:orientationvertical ListView android:idid/song_list android:layout_widthmatch_parent android:layout_height0dp android:layout_weight1 / SeekBar android:idid/seek_bar android:layout_widthmatch_parent android:layout_heightwrap_content android:max100 / LinearLayout android:layout_widthmatch_parent android:layout_heightwrap_content android:orientationhorizontal Button android:idid/btn_prev android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text上一首 / Button android:idid/btn_play android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text播放 / Button android:idid/btn_next android:layout_width0dp android:layout_heightwrap_content android:layout_weight1 android:text下一首 / /LinearLayout /LinearLayout上面的SeekBar主要用来手动跳转播放位置它的max属性被写死成100但你需要在代码里把max动态修改为mediaPlayer.getDuration()的返回值才能让进度条进度与歌曲时长比例正确对应。很多人在旧源码里会忽略一个细节布局中按钮只是文字按钮并没有体现界面的视觉质感。你可以通过android:background属性替换成自定义 selector 资源实现按下状态和普通状态的切换android:icon属性则用于在列表项中显示歌曲类型的图标。这两项改动不需要动任何 Java 代码是成本极低的 UI 优化切入点。如果使用RelativeLayout或ConstraintLayout可以进一步压缩布局层级但老源码用LinearLayout的weight已经足够完成任务没必要为了追新而重构布局逻辑。3.2 Handler Runnable 刷新进度条的常见误用源码里用来让SeekBar跟随播放进度走的通常是一个Handler加Runnable的组合核心代码长这样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目录读取默认音乐清单再通过ArrayAdapter或BaseAdapter把文件名和路径绑定到列表上。如果你在 Android Studio 里打开这份源码会发现ListView仍然能跑但 lint 会建议你迁移到RecyclerView。两者的对比非常直接对比维度ListViewRecyclerView更新数据需要手动刷新全列表拥有 DiffUtil 增量更新复用机制ViewHolder 需自己写强制 ViewHolder 模式动画能力没有内置项动画内置 ItemAnimator点击事件自带setOnItemClickListener需自己用addOnItemTouchListener或OnItemClickListener封装查找/更换源码中大量使用社区主流资料多在迁移到RecyclerView时Adapter 的写法是这样的public class SongAdapter extends BaseAdapter { private ListString songList; private Context context; public SongAdapter(Context context, ListString 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、.classpath、default.properties。.project是 Eclipse 的工程描述文件Android Studio 完全忽略它default.properties里写了一行targetandroid-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 # 输出targetandroid-17 说明这是 Android 4.2 时代的工程旧版本的bin/目录下有MusicPlayer.apk和classes.dex这是 Eclipse 构建出的历史产物迁移时可以直接删掉避免被 Android Studio 错误识别为模块。4.2 创建 Gradle 骨架并迁移资源我一般不建议直接在旧目录上强行跑 AS而是在 AS 里新建一个 Empty Activity 工程再手动把旧工程的src和res覆盖过去。新建工程的核心 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.xml和strings.xml直接复制没有问题但res/drawable目录下如果有.9.png结尾的点九图需要注意文件名不能有大写字母否则aapt资源编译会直接中断。4.3 排坑手册依赖与 API 级别不匹配迁移后最常见的编译与运行错误几乎都逃不出下面几类错误信息根因解法Error: Attribute applicationicon value(drawable/icon) from AndroidManifest.xml新工程没有对应 drawable在res/drawable下补一张图标或删除该引用Unable to resolve superclass ... android.app.Fragment用了老 Fragment统一替换为androidx.fragment.app.Fragmentjava.lang.SecurityException: Permission Denial申请了高版本权限但没运行时请求在用到WRITE_EXTERNAL_STORAGE的地方动态申请权限ClassNotFoundException ... classes.dex依赖了旧的 jar 包移除libs下的旧 jar改用 Gradle 依赖权限申请是这份老源码迁移后最棘手的问题。老工程在AndroidManifest.xml里直接写uses-permission android:nameandroid.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|MusicPlayeradb logcat里如果报java.io.FileNotFoundException说明路径拼接问题检查是不是用Environment.getExternalStorageDirectory()拼出了已经失效的存储根路径。现代 Android 要求使用getExternalFilesDir()或分区存储的 MediaStore 接口这是迁移老播放器源码时绕不开的适配项。5. 让音乐后台播放改造为前台服务并验证通知栏与内存开销原版源码的播放逻辑写在 Activity 里屏幕一关或用户切到其他应用音乐就停了。这是因为 Activity 在onStop后进程可能被系统杀死或者没有得到前台优先级。要解决这个问题标准做法是把MediaPlayer挪进Service。5.1 为什么必须用服务而不是 ActivityService本身跑在应用主线程它不会自己开子线程但它的生命周期独立于界面不会因为 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必须在onCreate或onStartCommand中调用如果在创建后几秒内没调系统会直接抛出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_SLEEPdumpsys meminfo中正常的MediaPlayer实例会体现在CursorWindow或MediaCodec分类下如果这几项没有明显增长说明release()时机正确。最后补一个细节Service 里的MediaPlayer是全局单例所以 UI 层的暂停、拖动进度条都必须通过startService(Intent)把动作传进去不能在 Activity 里直接拿着播放器对象操作否则播放器所有权一分为二IllegalStateException会重新找上门来。本文还有配套的精品资源点击获取