ARTICLE DETAIL

资讯详情

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

后台播放多个音乐:ExoPlayer队列、音频焦点与媒体会话实战

后台播放多个音乐:ExoPlayer队列、音频焦点与媒体会话实战 需求单上只写了一句话做个后台播放多个音乐的模块。我当时觉得这活儿不大——系统播放器封装一下、加个播放列表、绑个按钮完事。等真正动起手才发现这句话背后牵着一整套音频会话管理、队列调度、系统策略博弈的活儿。最典型的场景就是你兴冲冲把App切到后台音乐不出三秒就断了连个报错都没有。这篇文章以Android侧为主线播放内核用ExoPlayer媒体会话走androidx.media这条路线同时会在关键位置交代iOS侧实现的差异。内容覆盖需求拆解、播放队列架构、后台播放机制、真机调试踩坑和交付前验证适合刚接触音频开发的移动端工程师参考。如果你是产品经理想评估“加个后台播放”的真实工作量这篇也能给你一个比较具体的心理预期。1. 一句话需求拆出来工作量翻了三倍1.1 显性需求背后藏着的四个隐性要求“后台播放多个音乐”如果只看字面很容易被理解成“把MP3列表循环播放就行”。但用户真正要的东西拆开来看至少有四层播放不能因为切后台被系统暂停或回收多首音乐之间要有队列、有顺序、能随机、能循环锁屏、通知栏、控制中心能看到歌曲信息并且能操作来电、闹钟、耳机插拔这类事件要优雅处理不能直接崩。这四层需求对应到Android系统里分别是前台服务、媒体会话、音频焦点管理、播放队列调度。任何一环漏掉做出来的东西都会卡在半成品状态。我一开始天真地以为在AndroidManifest里加上播放权限声明让Activity保持运行就能后台播放。结果实测下来大部分机型上切后台30秒内音乐就会停不是崩溃是“静默暂停”日志里什么都没有。后来才看懂系统对音频播放有一套独立的托管机制和Activity生命周期关系很大但并不是Activity活着就行。1.2 播放内核选型这一步直接决定后面省不省心播放内核的选择是整件事的起点我在这一步比较过四条路线整理成表格方便你直接看结论方案队列支持流媒体能力自定义程度维护成本适用场景系统MediaPlayer弱自己维护多个实例弱缓冲控制粗糙一般高极简单的单曲播放ExoPlayer强原生MediaItem队列强HLS/DASH/渐进式都行高中大多数Android音视频场景三方播放器SDK看厂商实现参差不齐受限低赶快速Demo但后续受牵制iOS的AVQueuePlayer中等可排多首强高中iOS原生侧队列播放最终选了ExoPlayer理由很简单它把“多个音乐”抽象成了MediaItem列表队列增删、循环模式、随机播放都有现成API不需要自己去维护一堆MediaPlayer实例。它的音频渲染层是AudioTrack对后台播放的支持更底。iOS侧同理基础队列用AVQueuePlayer复杂场景再往下用AVPlayer加AVPlayerItem自己接管。有个反面例子有些团队为了省事直接接三方播放器SDK接入确实快但后台播放这种和系统强耦合的能力恰恰是第三方SDK最容易出兼容问题的部分。出了问题也不好定位因为那层代码是黑盒。所以团队如果有原生能力建议自己基于ExoPlayer封装一层把可控权留在自己手里。1.3 平台差异必须提前说清楚Android和iOS在后台播放的机制上完全是两套思路。Android需要前台服务加媒体通知并且Android 13之后前台服务必须声明媒体播放类型还要动态申请通知权限。iOS需要配置UIBackgroundModes里的audio同时把AVAudioSession的Category设置成Playback。两边逻辑没有任何复用性如果你立项时抱着“一套方案打天下”的想法后期基本会返工。我见过一些项目先用Web技术做播放器后来被锁屏控制和稳定后台播放卡住又回头改原生。Web页面在移动浏览器里很难稳定做到多曲目的后台持续播放锁屏控制更是基本实现不了。所以这个功能从一开始就该用原生路线立项早定技术栈能省掉后面大量重构成本。2. 播放队列架构让“多首”不是简单的“首首”2.1 队列模型一个播放器加N个媒体条目而不是N个播放器把“播放多个音乐”理解成“创建N个MediaPlayer轮换”代码会迅速滑向深渊因为每个播放器都要单独管理生命周期、状态同步、资源释放。正确思路是反过来的一个播放器承载N个媒体条目。ExoPlayer的Playlist API就是这个模型它维护一组MediaItem内部按顺序取出加载。核心代码大概是这样val player ExoPlayer.Builder(context).build() val mediaItems songList.map { song - MediaItem.Builder() .setUri(song.url) .setMediaMetadata( MediaMetadata.Builder() .setTitle(song.title) .setArtist(song.artist) .build() ) .build() } player.setMediaItems(mediaItems, startIndex, startPositionMs) player.prepare() player.playWhenReady true这里有两个细节容易被忽略。第一setMediaItems支持传入起始索引和起始位置这意味着可以一键定位到“上一次播到的那首歌、那个进度点”而不是每次从列表头重新开始。第二媒体元数据直接挂在MediaItem上后续生成媒体通知和锁屏信息时直接取当前条目就行不需要再维护一张索引到歌曲信息的映射表。为什么不搞播放器数组因为切歌时要处理前一个暂停、后一个准备还要兜住边界情况第一首切到上一首、最后一首切到下一首、列表为空、重复模式。这些复杂度加起来比单播放器加列表的方案翻一倍都不止。单播放器的状态机天然把这些规则内聚在一起代码量反而少。2.2 循环、随机、顺序模式的正确做法ExoPlayer的循环模式有现成枚举REPEAT_MODE_OFF不循环、REPEAT_MODE_ONE单曲循环、REPEAT_MODE_ALL列表循环。随机播放这里有个常见误解随机不等于“把数组打乱”因为一旦在切歌时重新洗牌或者从随机列表里删掉一首索引追踪就乱了。ExoPlayer提供了shuffleModeEnabled开关它内部维护一份乱序映射你仍然用原始索引访问曲目由播放器负责把原始顺序映射到播放顺序。这样做的好处是暂停、恢复、切歌之后的状态一致不会出现“随机模式播着播着又跑回原位”的诡异现象。顺序播放模式其实和列表循环的代码是同一套只是播完最后一首后是否停下来的差别。REPEAT_MODE_OFF播完列表最后一首会进入STATE_ENDED状态通知栏此时应该显示“已播放完”播放按钮变成可重新播放的状态REPEAT_MODE_ALL则继续循环。这块逻辑看着简单但和通知栏状态联动时很容易做漏后面会专门说。2.3 进度持久化播到一半被杀掉重进App要能接着来很多用户对后台播放不满意的点不在于“能不能播”而在于“早上听到一半的歌晚上回来全没了”。所以进度持久化不是加分项是刚需。我的做法是监听播放位置在三个时机存盘每隔5秒做一次定时存储切换歌曲时存上一首的位置播放器进入STATE_ENDED或应用退到后台时也存一次。存储结构不复杂歌曲Id加positionMs加当前索引用Room或者SharedPreferences都能满足。但有一点要注意不要每次播放位置一变就写存储对闪存的写入压力太大而且在低端机上频繁IO会卡音频渲染。定时存储加关键节点存储丢失的进度最多也就5秒体感可以忽略。恢复逻辑要稍微多想一步启动模块时先读本地播放快照如果歌曲还在当前队列里判断标准用歌曲Id而不是索引然后执行setMediaItems(mediaItems, index, savedPosition)。为什么按Id不按索引因为用户可能在列表里删过歌索引是会变的用Id才能正确定位到那首歌。2.4 无缝衔接别让两首歌之间出现明显停顿需求提出来的时候我一度以为ExoPlayer默认就能无缝切歌实际测试下来不行。相邻两首歌之间会有一段很短的间隙因为当前曲目播放完要释放渲染资源下一首要重新准备中间音频管线的切换有延迟。对于纯音乐场景还不算明显但播客、有声书这类人声内容会让人立刻察觉。想优化这个体验重点在预加载和资源复用。我的做法是提前读取下一首的媒体数据同时关闭播放器内部针对seek的抖动处理。调整之后两首歌之间的间隙从肉眼可感知缩小到基本无感。如果你用iOS的AVQueuePlayer做纯顺序播放曲目之间也可以做到相对顺滑但要做到绝对无缝得在更底层的AVPlayerItem上做渲染接管这个成本和收益需要自己权衡。3. 后台播放真正难啃的部分系统策略、音频焦点与媒体会话3.1 “切后台就断”的根因不只是一个权限问题最开始我怀疑是播放器写错了后来用adb盯着进程状态、用logcat看播放器日志发现播放器本身没有停是进程被系统回收了或者音频焦点被别的应用抢走了。这里其实有两类完全不同的原因。第一类是Android系统的省电机制。厂商为了让设备续航更久对“不活跃的后台进程”做强力回收。如果播放器只是Activity上的一个局部变量进程一旦被回收整个播放状态全丢。解决办法是把播放器宿主到Service上并且把这个Service作为前台服务运行起来。前台服务的作用是向系统声明这个进程正在为用户提供持续可感知的功能不要轻易杀它。Android 13之后前台服务必须声明类型manifest这样写service android:name.PlaybackService android:exportedfalse android:foregroundServiceTypemediaPlayback /如果不声明类型或权限不足启动服务时可能直接抛SecurityException这个错误在开发阶段很容易被当成偶发崩溃忽略掉。第二类是厂商省电策略主要出现在定制ROM上。系统会把App归类到“后台高耗电”名单里即使你有前台服务锁定屏幕一段时间后仍可能被限制网络和CPU调度。这个问题没有百分之百的代码解法只能引导用户把App加入电池优化白名单并在功能页提供一个检测入口。说句实在话这个环节不是bug是生态策略问题花大量时间去对抗系统不值得。3.2 音频焦点来电、闹钟、另一个App开始播放你的模块该怎么反应音频焦点这个概念iOS和Android都有但Android更严格。系统同一时刻只允许一个应用持续响亮地播放其他应用要么降低音量要么暂停。如果App没有正确申请音频焦点来电时音乐会和铃声叠在一起这种体验基本属于事故级别。Android里申请焦点分两步。第一步是请求val audioManager context.getSystemService(Context.AUDIO_SERVICE) as AudioManager val result audioManager.requestAudioFocus( audioFocusRequest, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN )第二步是处理焦点变更回调override fun onAudioFocusChange(focusChange: Int) { when (focusChange) { AudioManager.AUDIOFOCUS_LOSS - { // 长期失去焦点比如其他音乐App开始播放这里应该暂停并释放焦点 player.pause() audioManager.abandonAudioFocusRequest(audioFocusRequest) } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT - { // 临时失去焦点比如来电暂停即可 player.pause() } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - { // 允许以低音量继续播放比如导航语音 player.volume 0.3f } AudioManager.AUDIOFOCUS_GAIN - { // 重新获得焦点恢复音量和播放 player.volume 1.0f player.play() } } }这里有个容易踩的细节当焦点是TRANSIENT_LOSS时来电结束后系统会重新发放GAINApp要主动恢复播放但如果是其他音乐App明确播放导致的永久LOSSApp就不应该偷偷复活。判断标准就是LOSS类型。很多模块把这两种情况混在一起处理导致的结果是用户切到另一个音乐App这边的歌又自己响起来非常闹心。iOS侧对应的是AudioSession中断通知。要监听AVAudioSession.interruptionNotification根据AVAudioSessionInterruptionTypeBegan和AVAudioSessionInterruptionTypeEnded做暂停和恢复逻辑上是Android的镜像。3.3 媒体会话与锁屏控制系统UI怎么知道你正在放什么歌后台播放如果只做到“声音还在”用户依然会觉得功能不完整因为锁屏界面应该有歌曲名、封面、上一首、下一首、播放暂停按钮。Android这一套靠媒体会话加媒体通知完成。做法是在播放Service里创建MediaSessionCompat把播放器状态同步到会话上再通过MediaStyle通知把控制动作提交给系统UI。通知的关键代码val notification NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(metadata.title) .setContentText(metadata.artist) .setLargeIcon(bitmapCover) .setStyle(androidx.media.app.NotificationCompat.MediaStyle() .setMediaSession(mediaSession.sessionToken) .setShowActionsInCompactView(0, 1, 2)) .addAction(R.drawable.ic_prev, 上一首, prevPendingIntent) .addAction(playOrPausePendingIntent) .addAction(R.drawable.ic_next, 下一首, nextPendingIntent) .setOnlyAlertOnce(true) .build() startForeground(NOTIFICATION_ID, notification)这里有四个注意点都是我实际踩过的MediaStyle().setMediaSession(sessionToken)是通知和媒体会话之间的桥梁少了它锁屏控件基本不出现在系统UI里。setShowActionsInCompactView(0,1,2)控制通知栏收起时显示哪些操作按钮一般把上一首、播放暂停、下一首三个放进去。PendingIntent要用FLAG_IMMUTABLE标志Android 12开始这是强制要求不加会崩溃。播放状态变化时必须同步更新通知内容否则用户点了暂停按钮通知栏上还是播放图标状态就对不上了。iOS侧是完全不同的路线通过MPRemoteCommandCenter注册play、pause、nextTrack、previousTrack命令并在MPNowPlayingInfoCenter.default()里设置标题、艺术家、封面等元数据锁屏界面就会自动渲染。两边的机制都绕不开一个核心你的App要主动向系统汇报“现在播的是什么、当前状态是什么”系统只是负责展示和回传按钮事件。3.4 状态同步页面、通知栏、锁屏控制必须共用一条状态链路实现媒体会话之后真正的负担来自状态一致性。播放器暂停了通知栏还在显示播放中用户点了通知栏的下一首歌切了但封面没更新——这些问题都会让用户觉得模块是坏的。我的方案是所有界面元素包括页面播放按钮、通知栏、锁屏控制只做两件事。一是把用户意图发送给播放Service二是监听Service抛出的事件刷新UI。页面不直接操作播放器。系统UI唤起的事件和App内部事件全部走同一条状态分发链路避免多路写状态导致错乱。这个设计在开发前期看起来多写了一点代码但后期调试省下来的时间远超投入。4. 真机调试现场四类坑及完整排查链路4.1 从后台回到前台界面状态和人记忆中的不一样典型场景用户切到后台在通知栏上切了几首歌回到App页面看到播放按钮还是暂停状态但声音明明在响。这类问题的根源是Activity走了一遍常见的重建流程界面重新初始化时没有从正在运行的播放Service读取最新状态。排查链路是这样的先在logcat里确认播放Service没有被销毁发现进程和Service都活着再看Activity的onResume有没有主动刷新结果原始代码里只在onCreate阶段初始化页面状态压根没有从MediaSession读取当前播放状态的逻辑。修复方式不算复杂在onResume里统一拉一次当前播放器状态并刷新按钮同时订阅MediaSession的播放状态回调。这里有一个通用经验所有“状态不对”的问题先分清楚是状态源错了还是UI没刷新不要一上来就改代码。把播放器状态和UI状态拆开UI绑定一个StateFlow或者LiveData播放器真正变更状态时才发射事件UI只管消费事件刷新这个模式下状态错乱的概率会低很多。4.2 后台播放几分钟后“静默无声”进程明明还活着这个坑最隐蔽现象是声音停了但通知栏还在App没有崩溃logcat里也没有异常。我一开始误判成播放器超时断流跑去查网络请求配置查了半天才发现完全不是一回事。后续复现时我让手机连着adb在声音消失的前几秒看到系统发出一个前台服务暂停执行的事件。原因指向厂商省电策略屏幕熄灭后系统对进程做了冻结调度处理限制了网络访问和CPU唤醒进程没死但所有后台任务都停摆了。说白了这是系统层面在跟App打架。如果遇到这类现象先排除两个因素一是前台服务是否申请成功且类型正确二是设备设置里的电池优化是否对App有特殊限制。生产环境里解决不了所有厂商的激进策略只能通过引导用户加入电池白名单兜底。我在模块里增加了一个省电优化检测在用户首次开启后台播放功能时提示一次点击“去设置”直接跳系统电池页面。上线后统计“听一半断掉”的反馈大概减少了一半。4.3 拔掉耳机声音直接从外放炸出来这个问题观感最差。耳机拔掉的那一刻音频路由从耳机切换到扬声器如果正在以耳机音量播放外放突然来一下大的用户心态可能直接崩掉。Android系统对耳机插拔是有广播的叫AudioManager.ACTION_AUDIO_BECOMING_NOISY。正确做法是动态注册监听这个广播收到之后立即暂停播放。注意这个处理不能只依赖音频焦点变化拔耳机会触发焦点回调但焦点回调并不总按预期时序到达主动监听广播更直接。还有个容易忽略的坑Receiver的注册要跟着Service生命周期走而不是Activity。如果注册在Activity里页面销毁之后Receiver就失效了后台播放状态下拔耳机照样外放声音。4.4 快速切歌时的索引混乱连点三次下一首跳了两首用户手指快的时候能在通知栏上连点好几下“下一首”。结果要么歌曲跳了两首要么封面和声音对不上严重时直接崩溃。这种现象多数情况下是回调事件重入导致的。ExoPlayer的nextMediaItemIndex连续调用时playWhenReady和状态回调会堆积。如果页面每次都根据“当前播放索引”去加载封面和元数据临界状态下读写索引就会交错状态错乱随之而来。我的处理方式是切歌操作加防抖同一时刻只允许一个切歌请求在途间隔300毫秒内的重复点击直接吞掉。同时在元数据更新回调里不查“当前播放器位置”而是用事件里携带的目标索引去刷新UI。这样从根上避免了竞态快速连点也能稳定落在正确的歌曲上。5. 交付前的自查清单与实测数据一次“开发完成”经不经得起验证5.1 我固定用来回归的十条验证场景功能开发完了不能自己点两下就提交测试。我把验证场景列成一张固定清单每轮回归必跑播放第一首后立即切后台5分钟内持续播放不能中断顺序播放模式下播完最后一首通知栏显示停止状态随机模式下连续切10首索引不重复、不越界锁屏状态下用控制中心暂停、恢复、切歌来电过程中自动暂停挂断后自动恢复拔出耳机立即暂停插入耳机不自动恢复另一个音乐App播放时本模块不偷偷并发播放杀进程重启后能从上次进度继续播放弱网条件下持续后台播放不连续卡顿到死从后台回到前台页面按钮状态与通知栏一致。这套清单覆盖了后台播放最典型的体验面。任何一条挂了都说明模块还没到可交付状态。5.2 三组实测数据这是我在几台不同厂商的测试机上量出来的数据不构成严格基准但可以给后续做性能优化的人一个参考内存占用包含播放器、媒体会话和通知栏稳定在45MB到70MB之间。比视频播放小很多但后台常驻意味着它一直占着内存不能轻视。CPU占用正常播放时约3%到5%开启音轨预加载的短时峰值会到8%左右。功耗连续后台播放1小时厂商定制ROM在未加入电池白名单时掉电约13%加入白名单后回落到约10%Android原生环境的测试机约10%。内存这块有个优化点很值得说通知栏的封面大图不要原图直接加载用BitmapFactory的inSampleSize采样到256x256像素就够了。原图加载会导致内存瞬间暴涨尤其在多首歌曲连续播放时频繁换封面图会引发频繁GC音频播放反而受影响。5.3 多机型适配里最容易翻车的三个点厂商定制系统的通知权限。部分机型默认关闭通知权限而前台服务依赖媒体通知存在通知权限被关会导致后台播放极不稳定。上线前要检测NotificationManagerCompat.areNotificationsEnabled()如果返回false引导用户去设置里打开。控制中心的播放来源。有用户反馈锁屏界面上的媒体卡片有时候指向另一个音乐App这是因为系统里同时存在多个媒体会话。正确做法是在模块启动时调用MediaSessionCompat.setActive(true)确保自己是当前活跃媒体会话。Android版本差异。targetSdkVersion升到33之后动态权限、前台服务类型、通知权限的规则都变了。如果项目还在用旧版targetSdk建议开发这个模块时一起升级避免后续被新系统限制或者上架时被应用商店拦下。5.4 播完最后一首歌别忘了收尾这个细节特别容易被忽略。播放器进入STATE_ENDED状态时AudioTrack相关资源还占着内部管线。如果不做任何处理下一次重新播放同一列表时会多一段明显的准备延迟。我在模块里专门加了这个收尾逻辑播完最后一首且没有开启循环模式时主动执行player.seekToDefaultPosition()同时把通知栏更新成“待播”状态。资源释放不急着做交到下一次播放时再分配。这个处理让重播的启动速度从大约1秒降到了接近即时体感上的提升非常明显。再回头看那句话需求“后台播放多个音乐”翻译成人话其实是让用户信任你的App能在没人看着的时候继续稳定工作。这种信任靠前台服务保命靠音频焦点守规矩靠媒体会话给反馈靠队列设计扛住各种边界操作。实现本身不算难难的是面对系统各种策略时的应对预案。如果哪天你也被扔来这样一句话需求希望你能照着这篇文章把该避的坑避开不用像我当初那样从头踩一遍。
返回列表