
简介面向Android移动开发初学者的简易音乐播放器工程覆盖SDK配置、Activity界面、XML布局、MediaPlayer播放控制、运行时权限申请、本地音乐扫描等关键知识点适合课程设计或系统自学参考。压缩包共68个文件大小3.22MB包含8个Java源文件、14个XML布局与配置文件、20个class字节码文件、13张界面用PNG图片以及可直接安装的apk和依赖jar包。工程保留Android Studio标准目录结构便于对照源码与布局逐步理解播放器实现逻辑。项目实现了登录界面、音乐列表、播放暂停与切歌、SeekBar进度同步、通知栏后台播放等环节可从中掌握MediaPlayer与Service协同、RecyclerView复用、异步加载与状态保存等技巧。目前已有263人浏览学习。通过运行示例并动手修改能够快速建立简易播放器的整体开发思路为后续接入MVP或MVVM架构打下基础。 很多年前我刚开始折腾 Android 开发写出来的第一个像样的项目就是一个简易播放器。音频播放、视频播放、进度条、暂停恢复看着屏幕上那个光秃秃的界面真有说不出的成就感。后来回头看这个“简易”的播放器其实踩遍了一个新手能踩的坑比如文件读不出来、退后台声音断了、播放完再打开报错、进度条不刷新等等。所以这篇文章我打算从零开始捋一遍“Android 简易播放器”的完整实现思路把选型、代码、生命周期、音频焦点、常见坑都摊开讲清楚适合刚学会 Activity 和布局、想动手做点能玩的东西的初学者也适合想要快速搭一个播放器内核的开发者。1. 播放器方案选型从 MediaPlayer 到 ExoPlayer1.1 为什么优先推荐 MediaPlayer 做“简易版”很多人一开始会在 MediaPlayer 和 ExoPlayer 之间纠结。我的建议很直接如果你想做的是“简易播放器”第一版就老老实实用 MediaPlayer。原因很简单MediaPlayer 是 Android 系统自带的媒体播放器封装它把解码、输出、时间同步这些底层逻辑都封装好了对外暴露的接口就那么几个非常契合“播放一个音频/视频文件”的核心场景。对一个初学者来说用 MediaPlayer 能把注意力放在播放器本身的状态机、生命周期和 UI 交互上而不是一上来就陷入 ExoPlayer 那套 Player、MediaItem、TrackSelector、LoadControl 的重型架构里。还有一个现实原因MediaPlayer 不需要引入任何第三方依赖在模拟器和真机上的兼容性都足够好。而 ExoPlayer 虽然功能强大但那是解决流媒体、HLS、DASH、自适应码率等问题时才会有的优势。如果你只是在本地放个 mp3 或者 mp4用 MediaPlayer 反而是最“简易”、最不容易出错的选择。另外MediaPlayer 的状态机设计得特别经典。从 Idle 到 Initialized、Prepared、Started、Paused、PlaybackCompleted每个方法调用都有对应的状态要求。学一遍 MediaPlayer基本也就理解了多媒体播放器的通用状态流转后面再上手 ExoPlayer 会轻松很多。简易播放器的本质不是“代码最少”而是“结构最清晰”MediaPlayer 就是最好的入门切入点。1.2 ExoPlayer 什么时候值得引入当然MediaPlayer 也不是万能的。等你哪天发现需要支持无缝切换音轨、需要动态调整播放速度、需要处理复杂的流媒体协议或者需要在视频播放时叠加字幕、做缩略图预览MediaPlayer 就会变得力不从心。这时候换 ExoPlayer 是正确的选择。ExoPlayer 是 Google 基于低层媒体 API 实现的底层可控性更强而且它是在应用进程内运行升级不依赖系统。我的建议是第一版用 MediaPlayer把整个播放器骨架跑通UI、进度、播放列表的逻辑都验证过以后再考虑把内核替换成 ExoPlayer。这样做的好处是你在换内核时会发现两者在“播放”这个抽象层面其实非常相似无非是准备好数据源、加载、播放、暂停、跳转、释放。换内核的难度会被降到最低。简易播放器项目的价值不在于你用了一个多牛的库而在于你把这个领域的基本功打扎实了。2. 初始化与核心代码搭建2.1 权限与资源准备开始写代码之前先确认 Manifest 里的权限。播放器至少要处理两类资源本地文件和网络链接。本地文件Android 6.0 以上需要动态申请READ_EXTERNAL_STORAGEAndroid 13 开始存储权限又拆分成了更细的粒度如果只读音视频可以考虑用READ_MEDIA_AUDIO和READ_MEDIA_VIDEO。网络资源需要在 Manifest 中声明INTERNET权限。因为目标是“简易”我建议在测试阶段直接把一个 mp3 文件放到应用的raw目录比如res/raw/song.mp3。这样调用setDataSource(context, Uri.parse(android.resource:// getPackageName() / R.raw.song))就能播放不涉及任何运行时权限先把逻辑跑通再切到外部存储文件。提示从res/raw读取资源时不能用file://路径而要用android.resource://这种 URI 形式。这是初学者最常踩的坑。动态权限申请的代码模板不需要多复杂用一个简单的ActivityCompat.requestPermissions就能解决。需要注意在用户拒绝后给出提示否则第二次点击播放时没有权限会直接崩。2.2 MediaPlayer 最小可用代码我会用一个MediaPlayer成员变量配合几个按钮实现播放、暂停、停止。最核心的初始化代码如下MediaPlayer mediaPlayer new MediaPlayer(); try { // 方式一播放应用内资源 Uri uri Uri.parse(android.resource:// getPackageName() / R.raw.song); mediaPlayer.setDataSource(this, uri); // 方式二播放本地文件 // mediaPlayer.setDataSource(/storage/emulated/0/Music/song.mp3); // 方式三播放网络资源 // mediaPlayer.setDataSource(https://example.com/song.mp3); // 同步准备只适合小文件回阻塞 UI 线程 mediaPlayer.prepare(); // 异步准备推荐通过监听器感知准备完成 // mediaPlayer.prepareAsync(); } catch (IOException e) { e.printStackTrace(); } mediaPlayer.start();这里有一个非常重要的事prepare()是阻塞操作。如果播放一个本地几百 KB 的小音频它很快返回问题不大但如果播放一个几十 MB 的网络文件用prepare()会把 UI 线程卡死然后触发ANR。所以正式写法是用prepareAsync()然后设置OnPreparedListenermediaPlayer.setOnPreparedListener(mp - { // 到这里才能调用 start mp.start(); seekBar.setMax(mp.getDuration()); }); mediaPlayer.prepareAsync();这段代码就是整个简易播放器的“心脏”。接下来只需要围绕它写互斥逻辑和进度刷新即可。2.3 绑定播放进度与状态监听播放器最直观的反馈就是进度条。用SeekBar加一个Handler或Runnable循环更新即可。我习惯在onStart时启动一个计时器每 500 毫秒刷新一次进度Runnable progressRunnable new Runnable() { Override public void run() { if (mediaPlayer ! null mediaPlayer.isPlaying()) { int current mediaPlayer.getCurrentPosition(); seekBar.setProgress(current); } progressHandler.postDelayed(this, 500); } };SeekBar 的拖拽监听也要细心处理不然会出现“手一碰进度条就疯狂跳”的问题。正确处理思路是在onStartTrackingTouch时暂停自动刷新在onStopTrackingTouch时才真正跳转播放位置并恢复刷新。否则播放线程一直在改 progress和用户手指拖拽打架体验非常差。seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { if (fromUser) { // 可在这里实时更新一个 TextView 时间 } } Override public void onStartTrackingTouch(SeekBar seekBar) { progressHandler.removeCallbacks(progressRunnable); } Override public void onStopTrackingTouch(SeekBar seekBar) { if (mediaPlayer ! null) { mediaPlayer.seekTo(seekBar.getProgress()); progressHandler.post(progressRunnable); } } });播放完成的监听同样重要。setOnCompletionListener里一般要重置按钮状态、让进度条归零而不是让它一直停在 100% 等用户手动点暂停。3. 生命周期、音频焦点与电量优化3.1 Activity 生命周期下播放器的自救写简易播放器最容易翻车的场景就是旋转屏幕。默认情况下旋转屏幕会销毁并重建 Activity如果你在onCreate里new MediaPlayer()那就意味着旧播放器实例没有被释放新实例又会创建音频会断甚至可能报“Should have subtitle controller already set”等乱七八糟的问题。更严重的是MediaPlayer持有的底层资源如果没有正确释放长时间运行会占用大量内存。我建议在onPause中暂停播放在onDestroy中释放播放器。但这里有个细节如果你在onPause里直接pause()那用户按下 Home 键退出桌面再回来播放进度可能会乱跳。简易播放器的逻辑可以这样设计当 Activity 失去焦点时暂停进入后台时同样暂停。如果以后做音乐播放器需要考虑后台播放那就是另一个复杂话题了。简易版把“退出可见即暂停”作为默认行为最省心Override protected void onPause() { super.onPause(); if (mediaPlayer ! null mediaPlayer.isPlaying()) { mediaPlayer.pause(); } progressHandler.removeCallbacks(progressRunnable); } Override protected void onDestroy() { super.onDestroy(); if (mediaPlayer ! null) { mediaPlayer.release(); mediaPlayer null; } }release()之后一定要把变量置空。不然回调里一旦访问已经释放的MediaPlayer就会抛IllegalStateException。这是我见过初学者频率最高的崩溃原因之一。3.2 音频焦点处理别让后台应用打架什么叫“音频焦点”想象一下你正在放歌微信来了个视频通话两个应用同时出声那场面就乱了。Android 使用音频焦点机制来协调这些冲突。简易播放器要想做得正规至少要处理两件事播放前请求音频焦点失去焦点时暂停。AudioManager audioManager (AudioManager) getSystemService(AUDIO_SERVICE); AudioAttributes attributes new AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build(); int result audioManager.requestAudioFocus(new AudioFocusRequestHelper(), AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);实际开发中更常用的是AudioFocusRequestAndroid 8.0 以上推荐写法。不过为了 “简易”你也可以只在onAudioFocusChange里处理两种状态AUDIOFOCUS_LOSS时暂停播放AUDIOFOCUS_GAIN时恢复播放。别小看这几行代码它直接决定了你的应用和其他应用能不能和睦相处。不做音频焦点处理的话用户会很容易遇到“两个应用同时出声”的尴尬状况。3.3 省电与内存注意点播放器是耗电大户之一尤其是视频播放。最直接影响电量的因素有两个屏幕常亮和系统休眠。如果你做一个视频播放器通常需要保持屏幕常亮可以用FLAG_KEEP_SCREEN_ONgetWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);对于音频播放器则应该反过来允许屏幕熄灭并且记得在暂停时释放不必要的资源。此外MediaPlayer是一个持有底层解码资源的对象它和SurfaceView/TextureView配合做视频播放时要考虑 Surface 销毁重建的问题。简易视频播放器的常见做法是让MediaPlayer把视频输出设置给SurfaceHolderSurfaceHolder holder videoView.getHolder(); mediaPlayer.setDisplay(holder);VideoView本身封装了这些逻辑所以如果你只想快速看效果直接使用VideoView也行。但自己用SurfaceView MediaPlayer写一遍对底层关系会有更实在的理解。4. 常见问题与排查技巧4.1 播放不了本地文件的权限坑这个问题我在开头提过但实在太典型值得专门梳理。很多同学把 mp3 放进Download目录然后在代码里写/storage/emulated/0/Download/song.mp3运行直接报Permission denied。第一层排查AndroidManifest.xml里有没有加权限Android 6.0 以上有没有在运行时动态申请第二层排查Android 11 开始File方式访问外部存储被强化的分区存储机制限制直接用绝对路径访问公共目录经常失败。推荐用MediaStore或ActivityResultContracts.OpenDocument让用户自己选文件。简易播放器如果想省事可以用系统文件选择器ActivityResultContracts.OpenDocument()选完拿到content://URI 后再交给setDataSource(context, uri)。这种方式避开了路径访问权限也是最贴近用户习惯的做法。4.2 音频和视频切换时的 SetDataSource 异常如果你一个 Activity 里既能放音频又能放视频复用同一个MediaPlayer时会发现播放完一个视频再给同一个MediaPlayer调setDataSource就崩了。原因是MediaPlayer没有回到初始状态。正确的流程是先调reset()再重新setDataSource。mediaPlayer.reset(); mediaPlayer.setDataSource(context, newUri); mediaPlayer.prepareAsync();reset()的作用是把播放器恢复到 Idle 状态仿佛刚 new 出来一样。忘记reset()是复用播放器最常见的错误没有之一。还有一点要注意prepareAsync()之后不能立刻调用pause()或seekTo()这些方法都有状态限制只有到了 Prepared 状态才能调用。4.3 进度条卡顿、声音断断续续进度条卡顿多数不是播放器本身的问题而是 UI 主线程被阻塞了。比如你在主线程做了网络请求、解码缩略图或者读写大文件主线程忙不过来Handler里更新进度条的任务就会被延后。解决办法是播放器相关的轻量操作getCurrentPosition、seekTo留在主线程没问题但耗时操作一定要丢到子线程或使用协程。如果声音本身断断续续那通常和网络流播放时的缓冲有关简易播放器尽量不要直接播公网大文件真有需求请提前缓存。还有一个经验不要在主线程里频繁getDuration()。每次调用底层都会跨进程查询虽然耗时不大但 500 毫秒一次累积起来也够呛。一般在onPrepared时取一次duration存到一个变量里复用即可。4.4 简易播放器还能加什么功能当你能稳定播放一个本地音频进度条可拖拽生命周期不崩溃整个项目的骨架就算立住了。后续可以按下面顺序逐步加需求难度曲线很平滑功能实现思路难度播放列表RecyclerView 点击事件切换数据源★★后台播放Service持有MediaPlayer注意通知栏★★★变速播放升级到 ExoPlayer一行代码搞定★★★音频可视化用Visualizer获取波形数据自定义 View 绘制★★★★★视频播放结合SurfaceView处理 Surface 生命周期★★★我个人觉得不要急着在第一版里堆功能。把播放器状态机、生命周期、音频焦点这些基础概念吃透比多用几个第三方库有价值得多。等你做完一个简易播放器再去看 ExoPlayer 的文档会发现很多概念都是相通的。5. 最后再分享一个实战小技巧说了这么多最后给你一个能立刻用上的经验调试播放器时一定要打开命令行日志里的MediaPlayer相关输出用adb logcat -s MediaPlayer查看系统播放器的运行状态。很多莫名其妙的播放失败日志里其实已经写了答案比如 “setDataSource failed: status0x80000000” 这种错误一眼就能看出是文件路径或权限的问题。比断点一个个设要高效得多。做简易播放器的过程本质上是理解 Android 多媒体框架的窗口期。我从这个项目里学到的状态机思维、生命周期管理、资源释放习惯后来写任何复杂应用都用得上。你接下来要做的就是打开 Android Studio新建一个工程然后把MediaPlayer跑起来。先解决“能播放”再思考“播放得好”这条路走完你就不再是只会写界面和列表的小白了。本文还有配套的精品资源点击获取