本地音乐播放器的核心体验往往藏在两个不起眼的环节里:首次打开时能不能快速看到曲库,以及切换到后台后声音能不能稳定继续。比较常见的做法是把媒体扫描直接放在主线程里执行,或者用一次查询返回全部音频数据,曲库数量少的时候看不出问题,一旦设备里存了几千首歌曲,主线程就会因为游标遍历和 IO 等待而长时间阻塞,界面卡顿甚至无响应。后台播放同样容易踩坑,只启动一个普通 Service 而不做前台提升,锁屏几分钟后很可能被系统回收。本文从 MediaStore 查询、前台服务以及媒体变化监听三个方面,拆解一套可以实际运行的本地扫描与后台播放链路。

异步分页扫描:别让曲库查询拖垮主线程
Android 系统通过 MediaStore 统一管理本地媒体文件,应用可以借助 ContentResolver 查询 MediaStore.Audio.Media.EXTERNAL_CONTENT_URI 来获取音频列表。这个操作看起来只是一个数据库查询,但底层会涉及文件系统扫描和媒体元数据解析,耗时并不固定。如果把查询和游标遍历写在主线程里,ANR 的风险会随着歌曲数量直线上升。更合适的做法是把查询放到协程或线程池中执行,并且只投影必要的列,比如 _ID、TITLE、ARTIST、ALBUM、DURATION 和 DATA,避免把整行数据全部读取出来。
分页是另一个容易被忽略的优化点。首次进入播放器时,用户只关心前几十条歌曲能否快速展示,后面的数据完全可以在列表滚动时按需加载。MediaStore 的 sortOrder 参数可以追加 LIMIT 和 OFFSET,配合 RecyclerView 的滚动监听就能实现简单的分页加载。下面是一个 Kotlin 版本的扫描函数,它接收偏移量和每页条数,只返回当前页的音频信息。通过控制每页大小,即使曲库超过一万首,首屏扫描也能保持较快响应。
private fun scanAudioFiles(context: Context, offset: Int, limit: Int): List<AudioItem> {
val result = mutableListOf<AudioItem>()
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.DATA
)
val selection = "${MediaStore.Audio.Media.IS_MUSIC} != 0"
val sortOrder = "${MediaStore.Audio.Media.TITLE} ASC LIMIT $limit OFFSET $offset"
context.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
null,
sortOrder
)?.use { cursor ->
while (cursor.moveToNext()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID))
val title = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE))
val artist = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST))
val duration = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION))
result.add(AudioItem(id, title, artist, duration))
}
}
return result
}
权限配置同样要做好版本适配。Android 13 及以上运行时需要申请 READ_MEDIA_AUDIO,Android 12 及以下则需要 READ_EXTERNAL_STORAGE。不要只写死一个权限,最好根据 Build.VERSION.SDK_INT 动态判断。还需要处理用户仅允许部分媒体权限的情况,当音频权限被拒绝时,单纯返回空列表会让用户误以为没有本地音乐,此时可以提示用户前往设置页开启完整媒体权限。
前台服务与 MediaSession:后台播放不被系统回收
后台播放不能只依赖普通 Service,尤其是在 Android 8.0 以上的后台限制策略下,普通后台 Service 很可能在应用进入后台后被系统限制或销毁。前台服务需要配套一条持续存在的通知,系统才会认为应用仍在前台运行,从而降低被回收的概率。启动流程要先把 startForegroundService() 调用起来,然后在 Service 的 onStartCommand() 里立即调用 startForeground()。如果超过 5 秒还没有进入前台状态,系统会抛出 ForegroundServiceDidNotStartInTimeException。从 Android 10 开始,音频播放场景还应该声明 FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK,并在清单文件中为 <service> 组件补充对应的 foregroundServiceType 属性。
MediaSession 是后台播放与系统媒体控制器之间的桥梁。通过设置 PlaybackStateCompat 和 MediaMetadataCompat,锁屏界面、蓝牙耳机以及系统媒体通知都能获取当前歌曲信息,并且可以向应用发送播放、暂停、切歌等指令。播放器核心可以使用 ExoPlayer,它在处理本地文件、缓存和音频焦点方面都比较成熟。Service 里要把 MediaSessionCompat.Callback 和 ExoPlayer 的播放状态同步起来,在 onPlay()、onPause()、onSkipToNext() 等回调中触发对应操作,这样用户从通知栏或耳机按键控制播放时,状态才能保持一致。
通知渠道和前台通知的创建代码比较固定,下面是核心实现。需要留意通知渠道 ID 要和后续构建通知时传入的 ID 保持一致,否则部分设备上通知可能无法展示。代码中涉及 API 版本判断,低版本设备走普通前台通知即可,高版本设备则传入媒体播放类型参数。
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"music_playback",
"音乐播放",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
private fun startForegroundWithNotification() {
val notification = NotificationCompat.Builder(this, "music_playback")
.setContentTitle("正在播放")
.setContentText("歌曲名称")
.setSmallIcon(R.drawable.ic_music)
.setOngoing(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(1, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
startForeground(1, notification)
}
}
ContentObserver 监听媒体变化与防抖更新
用户在文件管理器里新增或删除音乐文件后,MediaStore 数据库会发生变化。应用需要及时感知这种变化,而不是等用户手动刷新。ContentObserver 可以监听媒体库 URI,并在数据变化时收到 onChange() 回调。注册时可以传入 notifyForDescendants 为 true,这样子目录里的变化也能被通知到。这个机制非常适合做曲库的增量刷新,避免每次回到前台都做一次全量扫描。
实际场景里,批量拷贝歌曲会连续触发多次 onChange(),如果每次回调都立即全量扫描,反而会加重 CPU 和 IO 压力。常见做法是加一个防抖窗口,比如收到变化后延迟 500 毫秒再执行扫描,期间如果有新回调就重置计时器。这样可以把一批文件变化合并成一次扫描。下面的观察者将变化事件交给外部处理,注册时需要绑定主线程 Handler,否则回调线程不一定安全,更新 UI 时需要额外切换线程。
class AudioContentObserver(
private val handler: Handler,
private val onChanged: () -> Unit
) : ContentObserver(handler) {
override fun onChange(selfChange: Boolean) {
super.onChange(selfChange)
onChanged()
}
}
注册与注销观察者的逻辑可以放在扫描管理类中,示例如下。当媒体库内容发生变化时,通过防抖函数延迟触发重新扫描,而不是在回调线程里直接执行查询。
observer = AudioContentObserver(Handler(Looper.getMainLooper())) {
debounceScan()
}
contentResolver.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
true,
observer
)
如果曲库很大,还可以在扫描结果里保存文件的 MediaStore ID 和修改时间。下次 onChange() 后先查询最近修改的条目,只更新变化部分。这里可以结合 Room 数据库缓存原始数据,用 ContentResolver 查询带 DATE_MODIFIED 条件的结果,只获取修改时间晚于上次扫描时间的记录。对于删除的文件,MediaStore 已经查不到,可以通过对比数据库 ID 集合找出需要移除的项。这样增量更新比全量重建列表更平滑,也能减少列表闪烁。
从扫描到播放的完整链路与不稳定的来源
把扫描结果交给播放队列时,建议只传递歌曲 ID 和必要元数据,不要在 Intent 或 Bundle 里塞入整个数据对象。Service 侧根据 ID 再查询 MediaStore 获取完整路径或 content URI。这样做的好处是数据源始终一致,即使扫描结果在传输过程中过期,也能通过 ID 补偿查询。如果直接从文件路径播放,需要处理分区存储带来的 file:// 访问限制,更稳妥的是使用 content:// 形式的媒体 URI 交给 ExoPlayer。
很多播放中断问题其实不是服务被系统回收,而是音频焦点没有处理干净。当其他应用播放导航语音或来电时,系统会发送音频焦点变化回调。播放器应该在失去焦点时降低音量或暂停,重新获得焦点时再恢复。忽略音频焦点不仅体验差,部分 Android 版本还可能直接打断播放。通过 AudioFocusRequest 配合 ExoPlayer 的 setAudioAttributes 和 setHandleAudioBecomingNoisy,可以覆盖大部分系统音频打断场景。
最后是服务与界面之间的同步。Activity 每次可见时重新绑定 Service,读取当前播放状态并刷新 UI,比依赖全局广播更加可靠。可以维护一个状态对象,包含当前索引、播放状态和歌曲元数据,使用 LiveData 或 StateFlow 在 Service 和 Activity 之间共享。即便 Activity 被销毁重建,只要 Service 还持有状态,界面就能很快恢复。这一套组合下来,本地扫描不会卡住首屏,后台播放也不容易因为系统回收或音频焦点问题而中断。

