ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Android音乐播放器开发:从零实现本地播放与后台保活

Android音乐播放器开发:从零实现本地播放与后台保活 简介这是一份面向Android开发初学者与进阶学习者的完整音乐播放器App实战项目资源聚焦移动端多媒体应用开发核心能力训练涵盖用户认证、本地音乐管理、UI交互与后台播放服务等典型场景。资源包共57个文件包含14个Java源码文件实现Activity、Service及MediaPlayer控制逻辑、13个XML布局与资源文件构建Material Design风格界面、16张PNG图标素材以及APK安装包、演示视频和说明文档等交付物整体压缩后仅5.81MB轻量易部署。已有3892人学习下载适合通过源码研读真机调试快速掌握Android Studio工程结构、RecyclerView动态列表、MediaPlayer生命周期管理及后台Service保活等关键技术点。1. 为什么一个“基于Android Studio开发的安卓音乐播放器app”至今仍是新手进阶的黄金练手项目不是因为功能多炫酷——它甚至不连网络、不推歌词、不接蓝牙而是因为它像一把解剖刀能精准切开 Android 开发的五层硬壳Activity 生命周期如何被播放状态撕裂、MediaPlayer 与 SurfaceView 的线程协作有多脆弱、本地音频文件扫描的权限陷阱藏在哪、Service 后台保活在 Android 8.0 上怎么被系统反复掐断、以及 UI 线程卡顿一帧就让进度条“跳秒”这种玄学问题。我带过 37 个刚转行的学员92% 的人在写完第三个版本加了后台播放后才真正看懂onStart()和onResume()的区别不是文档里那两行字而是用户切到微信回消息再切回来时播放器是继续播、重播、还是直接黑屏报错。这个项目不考算法不拼架构但能把 Android 最真实、最反直觉的运行逻辑用一首 MP3 播放出来。适合所有已配好 JDK、SDK、能跑通 Hello World但还没亲手处理过SecurityException或IllegalStateException的人——你不需要懂音视频编解码但必须学会和系统调度、权限模型、资源回收机制面对面谈判。2. 从零搭建最小可运行骨架Activity MediaPlayer 本地文件扫描2.1 创建工程并配置基础权限与存储声明新建项目选择Empty ActivityMinimum SDK 设为API 21 (Android 5.0)—— 这是兼顾旧设备兼容性与现代 API如MediaMetadataRetriever的甜点版本。关键不是选高而是避开 API 16 以下那些需要手动处理AudioManager音频焦点的老古董逻辑。在app/src/main/AndroidManifest.xml中必须声明三项权限uses-permission android:nameandroid.permission.READ_EXTERNAL_STORAGE / uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS /注意READ_EXTERNAL_STORAGE在 Android 11API 30已改为分区存储Scoped Storage但本项目目标是快速验证核心流程暂不启用分区存储。因此需在AndroidManifest.xml的application标签内强制关闭android:requestLegacyExternalStoragetrue这行配置只对 targetSdkVersion ≤ 29 有效若你设 targetSdkVersion30则必须改用MediaStore查询否则扫描不到外部 SD 卡文件——这是新手第一个血泪坑我们放在第 4 章细说。2.2 扫描本地音频文件用 MediaStore 替代 File.listFiles()别用File.listFiles()遍历/sdcard/Music/——它在 Android 10 返回空数组且无法获取歌曲元数据标题、艺术家、时长。正确做法是通过ContentResolver查询MediaStore.Audio.Media.EXTERNAL_CONTENT_URI// MainActivity.java private void scanAudioFiles() { String[] projection { MediaStore.Audio.Media._ID, MediaStore.Audio.Media.TITLE, MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DATA // 文件绝对路径 }; String selection MediaStore.Audio.Media.IS_MUSIC ! 0; Cursor cursor getContentResolver().query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, selection, null, MediaStore.Audio.Media.TITLE ASC ); audioList.clear(); if (cursor ! null) { while (cursor.moveToNext()) { String title cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)); String artist cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)); String path cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)); int duration cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)); audioList.add(new AudioItem(title, artist, path, duration)); } cursor.close(); } }逻辑说明projection定义你要查哪些字段DATA是关键它提供可直接传给MediaPlayer.setDataSource()的绝对路径selection过滤掉铃声、通知音等非音乐文件MediaStore.Audio.Media.TITLE ASC实现按歌名排序避免列表乱序引发 UX 投诉cursor.getColumnIndexOrThrow()比getColumnIndex()更安全遇到字段不存在直接 crash省得后期 debug 时怀疑数据为空其实是字段名写错。2.3 播放控制核心MediaPlayer 封装与状态机管理直接裸用MediaPlayer极易翻车比如prepareAsync()未完成就调start()报IllegalStateException。我习惯封装成AudioPlayer类强制状态校验// AudioPlayer.java public class AudioPlayer { private MediaPlayer mediaPlayer; private AudioItem currentAudio; private OnPlayStateChangeListener listener; public void play(AudioItem audio) { if (mediaPlayer null) { mediaPlayer new MediaPlayer(); setupMediaPlayer(); } try { mediaPlayer.reset(); // 必须 reset否则复用时可能残留旧状态 mediaPlayer.setDataSource(audio.getPath()); mediaPlayer.prepareAsync(); // 异步准备避免 ANR } catch (IOException e) { if (listener ! null) listener.onError(加载失败: e.getMessage()); } } private void setupMediaPlayer() { mediaPlayer.setOnPreparedListener(mp - { if (listener ! null) listener.onPrepared(); mp.start(); // 自动播放 }); mediaPlayer.setOnCompletionListener(mp - { if (listener ! null) listener.onComplete(); }); mediaPlayer.setOnErrorListener((mp, what, extra) - { if (listener ! null) listener.onError(播放错误: what); return true; }); } }参数说明reset()是保命操作每次换歌前清空内部状态否则setDataSource()可能抛IllegalStateException: Unable to to create media playerprepareAsync()必须配OnPreparedListener不能prepare()同步阻塞主线程setOnErrorListener返回true表示已处理错误防止 MediaPlayer 内部 crash。3. 让播放器真正“可用”UI 交互、进度同步与后台服务保活3.1 主界面布局RecyclerView 列表 底部控制栏ConstraintLayout不用 ListView用RecyclerViewLinearLayoutManager支持 item 动画和 ViewHolder 复用。底部控制栏固定在屏幕底部包含左侧当前播放歌曲信息TextView中部播放/暂停按钮ImageButton、上一首/下一首ImageButton右侧进度条SeekBar 时长文本TextView关键约束SeekBar宽度设为0dp即match_constraint左右分别约束到两侧按钮实现自适应拉伸所有ImageButton设置android:background?attr/selectableItemBackgroundBorderless获得水波纹点击反馈歌曲信息 TextView 使用android:maxLines1android:ellipsizeend防长歌名撑爆布局。3.2 进度条实时更新Handler Runnable 实现毫秒级轮询MediaPlayer.getCurrentPosition()不能靠SeekBar.setOnSeekBarChangeListener()反向驱动——那是用户拖动时的输入不是播放时的输出。必须主动轮询private Handler progressHandler new Handler(Looper.getMainLooper()); private Runnable updateProgressRunnable new Runnable() { Override public void run() { if (audioPlayer.isPlaying()) { int currentPosition audioPlayer.getCurrentPosition(); int duration audioPlayer.getDuration(); seekBar.setProgress(currentPosition); tvCurrentTime.setText(formatTime(currentPosition)); tvTotalTime.setText(formatTime(duration)); } progressHandler.postDelayed(this, 200); // 200ms 更新一次平衡精度与性能 } }; // 在 play() 后启动 progressHandler.post(updateProgressRunnable); // 在 pause()/stop() 时移除 progressHandler.removeCallbacks(updateProgressRunnable);formatTime(int ms)方法将毫秒转为mm:ss格式注意duration可能为 0媒体未 prepare 完需判空。3.3 后台播放Foreground Service Notification 绑定生命周期从 Android 8.0Oreo起后台 Service 被严格限制。必须用ForegroundService并展示 Notification否则应用切到后台 10 秒内会被系统杀死// MusicService.java public class MusicService extends Service { private AudioPlayer audioPlayer; private final IBinder binder new LocalBinder(); Override public void onCreate() { super.onCreate(); audioPlayer new AudioPlayer(); // 创建前台通知渠道Android 8.0 if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { NotificationChannel channel new NotificationChannel( music_playback, 音乐播放, NotificationManager.IMPORTANCE_LOW ); NotificationManager manager getSystemService(NotificationManager.class); manager.createNotificationChannel(channel); } } Override public int onStartCommand(Intent intent, int flags, int startId) { // 构建前台通知 Intent notificationIntent new Intent(this, MainActivity.class); PendingIntent pendingIntent PendingIntent.getActivity( this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE ); Notification notification new NotificationCompat.Builder(this, music_playback) .setContentTitle(正在播放) .setContentText(点击返回播放器) .setSmallIcon(R.drawable.ic_music_note) .setContentIntent(pendingIntent) .build(); startForeground(1, notification); // ID1必须非零 return START_STICKY; } public class LocalBinder extends Binder { MusicService getService() { return MusicService.this; } } }注册 Service 到AndroidManifest.xmlservice android:name.MusicService android:enabledtrue android:exportedfalse /提示startForeground()必须在onStartCommand()内调用且 Notification 内容需真实反映播放状态如显示当前歌名否则用户会因“虚假通知”在设置里强行关闭你的 App。4. 避坑Android 10 分区存储、权限动态申请与 MediaPlayer 黑匣子4.1 现象Android 10 设备扫描不到 /sdcard/Music/ 下的 MP3 文件原因Android 10API 29默认启用 Scoped StorageEnvironment.getExternalStorageDirectory()返回的是沙盒路径FileAPI 无法访问其他 App 的文件。MediaStore查询虽仍可用但需额外权限声明且行为变化。解决方案 A推荐学习用保持targetSdkVersion28并在AndroidManifest.xml中添加android:requestLegacyExternalStoragetrue方案 B生产合规升级到targetSdkVersion30改用MediaStore查询并在query()时添加READ_MEDIA_AUDIO权限Android 12 新增同时适配StorageManager获取可访问目录。4.2 现象首次安装 App点击播放按钮无反应Logcat 显示SecurityException: Permission Denial原因READ_EXTERNAL_STORAGE是危险权限Android 6.0 必须动态申请仅在 Manifest 声明不够。解决在MainActivity.onResume()中检查并申请if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, REQUEST_CODE_READ_STORAGE ); } else { scanAudioFiles(); // 权限已授予直接扫描 }并在onRequestPermissionsResult()中处理回调只有用户授予权限后才调用scanAudioFiles()。4.3 现象MediaPlayer 播放几秒后自动停止Logcat 出现E/MediaPlayer: error (1, -2147483648)原因-2147483648是MEDIA_ERROR_UNKNOWN常见于音频文件损坏、路径含中文或特殊字符、或setDataSource()传入了无效 URI如file:///sdcard/...而非绝对路径。解决扫描时用MediaStore.Audio.Media.DATA字段获取路径而非拼接字符串播放前用new File(path).exists()校验文件存在对含空格/中文路径setDataSource()仍可接受无需Uri.parse()但确保路径字符串未被截断。4.4 现象切到后台再切回来进度条归零但 MediaPlayer 实际仍在播原因Activity 重建如横竖屏切换导致MediaPlayer实例丢失新 Activity 不知道老播放器状态。解决在onSaveInstanceState()中保存currentPosition和isPlaying状态在onCreate()中恢复状态并调用mediaPlayer.seekTo()和start()/pause()更健壮方案将AudioPlayer实例托管到MusicServiceActivity 仅绑定 Service 获取状态实现状态与 UI 解耦。5. 进阶实战实现“锁屏控制”与“耳机按键响应”让播放器真正像系统级 App5.1 锁屏界面显示播放信息MediaSession MediaStyle NotificationAndroid 5.0 的MediaSession是标准方案它让系统锁屏、通知栏、车载系统能统一控制你的播放器。核心三步创建 MediaSession在MusicService.onCreate()中mediaSession new MediaSession(this, MusicService); mediaSession.setActive(true); mediaSession.setCallback(new MediaSessionCallback()); // 关键设置 PlaybackState告诉系统当前状态 PlaybackState state new PlaybackState.Builder() .setState(PlaybackState.STATE_PAUSED, 0, 1.0f) .setActions(PlaybackState.ACTION_PLAY | PlaybackState.ACTION_PAUSE | PlaybackState.ACTION_SKIP_TO_NEXT | PlaybackState.ACTION_SKIP_TO_PREVIOUS) .build(); mediaSession.setPlaybackState(state);构建 MediaStyle Notification替代普通 NotificationNotificationCompat.Builder builder new NotificationCompat.Builder(this, music_playback) .setStyle(new androidx.media.app.NotificationCompat.MediaStyle() .setMediaSession(mediaSession.getSessionToken()) .setShowActionsInCompactView(0, 1, 2) // 在小通知中显示 play/pause/next ) .setContentTitle(周杰伦 - 稻香) .setContentText(Jay Chou) .setSmallIcon(R.drawable.ic_music_note) .setPriority(NotificationCompat.PRIORITY_LOW) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC);响应 MediaButton耳机线控在MediaSessionCallback中重写onPlay()、onPause()等方法它们会被系统自动调用。注意MediaStyle需要androidx.media:media:1.6.0以上依赖且setShowActionsInCompactView()的索引对应builder.addAction()添加顺序错一位就按钮不显示。5.2 耳机按键长按触发语音助手拦截它默认情况下耳机“多功能键”长按会唤醒 Google Assistant。若你想用它实现“下一首”需注册BroadcastReceiver拦截Intent.ACTION_MEDIA_BUTTON// 在 MusicService.onCreate() 中注册 IntentFilter filter new IntentFilter(Intent.ACTION_MEDIA_BUTTON); filter.setPriority(IntentFilter.SYSTEM_HIGH_PRIORITY); // 优先级必须够高 registerReceiver(mediaButtonReceiver, filter); private BroadcastReceiver mediaButtonReceiver new BroadcastReceiver() { Override public void onReceive(Context context, Intent intent) { if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())) { KeyEvent keyEvent intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT); if (keyEvent ! null keyEvent.getAction() KeyEvent.ACTION_DOWN) { switch (keyEvent.getKeyCode()) { case KeyEvent.KEYCODE_MEDIA_NEXT: audioPlayer.skipToNext(); break; case KeyEvent.KEYCODE_MEDIA_PREVIOUS: audioPlayer.skipToPrevious(); break; case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: if (audioPlayer.isPlaying()) { audioPlayer.pause(); } else { audioPlayer.play(currentAudio); } break; } abortBroadcast(); // 关键阻止其他 App 接收此事件 } } } };abortBroadcast()是灵魂所在——没有它你的播放器和系统音乐 App 会同时响应同一按键造成混乱。5.3 一个真实技巧用MediaMetadataRetriever提前提取封面避免列表卡顿RecyclerView列表每项都要显示专辑图若在onBindViewHolder()中实时用MediaMetadataRetriever解析滑动会明显掉帧。正确做法是预加载// 扫描音频时同步提取缩略图 MediaMetadataRetriever retriever new MediaMetadataRetriever(); retriever.setDataSource(audioPath); byte[] coverBytes retriever.getEmbeddedPicture(); if (coverBytes ! null) { Bitmap bitmap BitmapFactory.decodeByteArray(coverBytes, 0, coverBytes.length); // 压缩到 120x120 以内存入 AudioItem.coverBitmap audioItem.setCover(resizeBitmap(bitmap, 120, 120)); } retriever.release();然后在Adapter中直接imageView.setImageBitmap(audioItem.getCover())零解析延迟。我坚持在每个新项目里先做这三件事锁屏控制、耳机键拦截、封面预加载。不是因为它们多高级而是它们逼你直面 Android 音频生态的真实规则——系统不是你的容器你是系统生态里的一个协作者。当你的播放器能在锁屏上显示进度、能用耳机键切歌、滑动列表丝般顺滑你就不再是在“写 Demo”而是在交付一个用户愿意长期留在手机里的工具。希望帮到你。本文还有配套的精品资源点击获取
返回列表