Android音乐播放器开发:用ExoPlayer轻松实现状态栏控制(附完整代码)

📅 发布时间:2026/7/16 20:00:10 👁️ 浏览次数:
Android音乐播放器开发:用ExoPlayer轻松实现状态栏控制(附完整代码)
Android音乐播放器开发用ExoPlayer轻松实现状态栏控制附完整代码最近在做一个音乐类的Android应用发现很多开发者对状态栏和通知栏的媒体控制实现感到头疼。传统的MediaPlayer虽然基础但要完整对接系统MediaSession需要写大量样板代码处理各种回调还得自己管理通知栏的显示和更新。实际上Google早就为我们准备了更现代的解决方案——ExoPlayer配合androidx.media3库能够以极简的代码实现专业级的播放控制体验。如果你正在开发音乐播放、播客或有声书应用希望用户能在下拉状态栏、锁屏界面甚至智能手表上控制播放那么这篇文章正是为你准备的。我会从实际项目经验出发分享一套即插即用的完整代码方案帮你绕过那些常见的坑快速实现稳定可靠的状态栏控制功能。1. 为什么选择ExoPlayer而不是MediaPlayer在Android媒体播放领域MediaPlayer算是“元老级”的API了。它简单直接几行代码就能播放一个网络音频。但当你需要更复杂的功能时比如无缝播放列表切换自定义音频格式解码高效的缓存机制与系统媒体控制中心MediaSession深度集成MediaPlayer就显得力不从心了。这时候ExoPlayer的优势就凸显出来了。它不是一个简单的播放器而是一个可扩展的媒体播放框架。这意味着你可以替换其中的组件比如数据源、渲染器、解码器等来满足特定的需求。更重要的是在androidx.media3这个统一的媒体库中ExoPlayer与MediaSession、PlayerNotificationManager等组件是“一家人”。它们之间的集成是天衣无缝的很多繁琐的交互逻辑已经被封装好了。举个例子用MediaPlayer实现一个带进度条和专辑封面的通知栏你可能需要手动创建Notification。为播放、暂停、上一首、下一首按钮分别设置PendingIntent。在Service中接收这些Intent并手动调用MediaPlayer的方法。播放状态改变时手动更新Notification。而用ExoPlayer的方案你只需要配置好PlayerNotificationManager并把播放器实例Player传给它剩下的它全包了——自动创建通知、自动更新状态、自动处理按钮点击事件。注意从Android 13API 33开始系统对后台播放和通知的控制更加严格。使用ExoPlayer和标准的PlayerNotificationManager能更好地遵循这些新规范避免应用在后台被系统限制或杀死。下面是一个简单的对比表格可以更直观地看出两者的区别特性维度MediaPlayerExoPlayer (with androidx.media3)系统集成度低需大量手动代码对接MediaSession高提供PlayerNotificationManager等专用组件功能扩展性有限API较为固定极强模块化设计可替换各环节组件缓存支持需自行实现内置强大的CacheDataSource支持边播边存代码复杂度实现完整控制流程代码量庞大核心控制流程代码简洁官方封装完善维护成本高需自己处理兼容性和状态同步低由Google官方持续维护更新所以除非你的应用极其简单只做一次性音频播放否则我强烈建议直接从ExoPlayer开始。它代表了Android媒体播放的未来方向。2. 项目基础搭建与依赖配置让我们从零开始搭建一个具备状态栏控制功能的音乐播放器Demo。首先确保你的build.gradle.kts或build.gradle文件中已经添加了必要的依赖。// 在模块级的 build.gradle.kts 文件中 dependencies { // 核心ExoPlayer库 implementation(androidx.media3:media3-exoplayer:1.3.1) // 用于与系统MediaSession集成 implementation(androidx.media3:media3-session:1.3.1) // 用于生成和控制媒体通知界面 implementation(androidx.media3:media3-ui:1.3.1) // 其他可能用到的组件如数据库支持用于缓存 implementation(androidx.media3:media3-database:1.3.1) implementation(androidx.media3:media3-datasource-okhttp:1.3.1) // 使用OkHttp作为网络源 // ... 你的其他依赖 }这里我使用了1.3.1版本你可以查看官方发布页面来获取最新稳定版。media3-datasource-okhttp不是必须的但它能让你用功能更强大的OkHttp来替代默认的HttpDataSource对于处理复杂的网络环境如重试、拦截器很有帮助。接下来我们需要一个Service来在后台管理播放。这是实现状态栏控制的关键因为通知栏需要绑定到一个前台服务Foreground Service才能持续显示。创建一个MusicPlaybackService类并继承MediaSessionService。import androidx.media3.session.MediaSessionService class MusicPlaybackService : MediaSessionService() { private var mediaSession: MediaSession? null override fun onCreate() { super.onCreate() // 初始化播放器和MediaSession initializeSession() } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { // 返回我们创建的MediaSession实例 return mediaSession } private fun initializeSession() { // 这里后续会填充播放器和MediaSession的初始化代码 } override fun onDestroy() { super.onDestroy() // 释放资源 mediaSession?.run { player.release() release() } mediaSession null } }别忘了在AndroidManifest.xml中声明这个Service并申请必要的权限。service android:name.music.player.MusicPlaybackService android:exportedfalse android:foregroundServiceTypemediaPlayback intent-filter action android:nameandroidx.media3.session.MediaSessionService / /intent-filter /serviceandroid:foregroundServiceTypemediaPlayback是Android 10API 29引入的用于指明这是一个媒体播放类型的前台服务有助于系统进行资源管理。intent-filter是必须的它让系统知道这个Service可以处理MediaSession的请求。3. 核心实现构建播放器与MediaSession现在进入核心部分。我们将在initializeSession()方法中创建ExoPlayer实例和MediaSession。3.1 创建并配置ExoPlayerExoPlayer的构建非常灵活我们可以通过ExoPlayer.Builder来定制各种参数。private fun initializeSession(context: Context) { // 1. 创建播放器实例 val player ExoPlayer.Builder(context) .setSeekBackIncrementMs(15000) // 快退15秒 .setSeekForwardIncrementMs(30000) // 快进30秒 .setHandleAudioBecomingNoisy(true) // 当耳机拔出时自动暂停 .build() // 2. 设置监听器用于响应播放状态变化可选用于更新UI或日志 player.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_IDLE - Log.d(MusicService, 播放器状态: 空闲) Player.STATE_BUFFERING - Log.d(MusicService, 播放器状态: 缓冲中) Player.STATE_READY - Log.d(MusicService, 播放器状态: 准备就绪) Player.STATE_ENDED - Log.d(MusicService, 播放器状态: 播放结束) } } override fun onPlayerError(error: PlaybackException) { Log.e(MusicService, 播放出错: ${error.errorCodeName}, ${error.message}) // 这里可以处理错误比如尝试重试或通知用户 } }) }setHandleAudioBecomingNoisy(true)是一个很贴心的设置。当用户拔出有线耳机或断开蓝牙耳机时播放会自动暂停避免声音从扬声器突然外放造成尴尬。3.2 创建并激活MediaSessionMediaSession是播放器与系统以及其他应用通信的桥梁。系统媒体控制中心、蓝牙设备、智能手表等都是通过与MediaSession交互来控制播放的。// 接上面的代码 // 3. 创建MediaSession mediaSession MediaSession.Builder(context, player) .setSessionActivity(createSessionActivityPendingIntent()) .setCallback(MySessionCallback()) // 设置自定义回调处理自定义命令 .build() // 4. 将播放器与MediaSession关联通知管理器会用到 this.player playersetSessionActivity用于指定当用户点击通知栏时要启动哪个Activity。这通常是你应用的主界面或播放界面。private fun createSessionActivityPendingIntent(): PendingIntent { val intent Intent(this, MainActivity::class.java).apply { flags Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP } return PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) }MySessionCallback允许你处理一些自定义的播放命令比如“喜欢这首歌曲”、“添加到播放列表”等。如果只是基础播放控制播放/暂停/切歌可以不设置ExoPlayer会提供默认实现。private inner class MySessionCallback : MediaSession.Callback { override fun onCustomCommand( session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle ): ListenableFutureSessionResult { // 处理自定义命令 if (customCommand.customAction ACTION_LIKE) { val trackId args.getString(track_id) // 执行“喜欢”操作... return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } return super.onCustomCommand(session, controller, customCommand, args) } }4. 集成PlayerNotificationManager实现通知控制这是让状态栏控制“活”起来的关键一步。PlayerNotificationManager会监听Player的状态自动创建和更新通知栏并将用户的操作点击按钮转换为对Player的控制命令。4.1 创建通知渠道Notification Channel对于Android 8.0API 26及以上版本必须先创建通知渠道。private fun createNotificationChannel() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { val channel NotificationChannel( CHANNEL_ID, // 渠道ID需要与NotificationManager中使用的一致 音乐播放, // 用户看到的渠道名称 NotificationManager.IMPORTANCE_LOW // 重要性级别LOW表示不发出声音仅显示在状态栏 ).apply { description 显示和控制当前播放的音乐 lockscreenVisibility Notification.VISIBILITY_PUBLIC // 在锁屏上公开显示 } val notificationManager getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } }在Service的onCreate()方法中调用createNotificationChannel()。4.2 配置并使用PlayerNotificationManager现在我们来创建和配置PlayerNotificationManager。private var notificationManager: PlayerNotificationManager? null private fun setupNotificationManager() { // 创建NotificationManager notificationManager PlayerNotificationManager.Builder( this, // Context NOTIFICATION_ID, // 通知ID需唯一 CHANNEL_ID // 上面创建的通知渠道ID ) .setMediaDescriptionAdapter(createMediaDescriptionAdapter()) .setSmallIconResourceId(R.drawable.ic_music_note) // 小图标 .setNotificationListener(createNotificationListener()) .build() // 关键关联MediaSession和Player notificationManager?.setMediaSessionToken(mediaSession?.sessionToken) notificationManager?.setPlayer(player) }setMediaDescriptionAdapter这是最重要的部分之一。它负责将你正在播放的媒体MediaItem的信息如标题、艺术家、专辑封面填充到通知栏的对应位置。setNotificationListener允许你监听通知的发布和取消事件在这里我们启动前台服务。让我们实现这两个组件private fun createMediaDescriptionAdapter(): PlayerNotificationManager.MediaDescriptionAdapter { return object : PlayerNotificationManager.MediaDescriptionAdapter { override fun getCurrentContentTitle(player: Player): CharSequence { // 从当前播放的MediaItem中获取标题 return player.currentMediaItem?.mediaMetadata?.title?.toString() ?: 未知标题 } override fun getCurrentContentText(player: Player): CharSequence { // 获取艺术家信息 return player.currentMediaItem?.mediaMetadata?.artist?.toString() ?: 未知艺术家 } override fun getCurrentLargeIcon( player: Player, callback: PlayerNotificationManager.BitmapCallback ): Bitmap? { // 这里可以加载专辑封面。为了简单演示返回null。 // 实际项目中你应该使用Glide、Coil等图片库异步加载网络图片并在加载完成后调用callback.onBitmap。 return null } // 这个PendingIntent决定了用户点击通知主体时跳转到哪里 override fun createCurrentContentIntent(player: Player): PendingIntent? { return createSessionActivityPendingIntent() // 复用之前的方法 } } }private fun createNotificationListener(): PlayerNotificationManager.NotificationListener { return object : PlayerNotificationManager.NotificationListener { override fun onNotificationPosted( notificationId: Int, notification: Notification, ongoing: Boolean ) { // 当通知被创建或更新时调用。如果ongoing为true意味着播放正在进行。 if (ongoing) { // 启动前台服务并显示这个通知 startForeground(notificationId, notification) } } override fun onNotificationCancelled( notificationId: Int, dismissedByUser: Boolean ) { // 当通知被取消时调用可能是用户滑动清除或播放停止。 // 如果用户手动清除我们可能希望停止播放。 if (dismissedByUser) { player.pause() stopForeground(STOP_FOREGROUND_REMOVE) // 停止前台服务并移除通知 } } } }至此一个具备完整状态栏控制功能的音乐播放服务就搭建好了。当你调用player.play()开始播放时通知栏会自动出现暂停时通知栏按钮状态会更新点击通知栏的按钮播放器会做出相应反应。5. 高级功能与实战技巧基础功能实现后我们可以进一步优化体验增加一些高级特性。5.1 实现播放列表与播放队列管理一个音乐播放器很少只播一首歌。我们需要管理一个播放列表。ExoPlayer的Player接口提供了便捷的队列操作方法。// 假设有一个歌曲列表ListSong fun preparePlaylist(songs: ListSong) { val mediaItems songs.map { song - MediaItem.Builder() .setUri(song.audioUrl) .setMediaMetadata( MediaMetadata.Builder() .setTitle(song.title) .setArtist(song.artist) .setAlbumTitle(song.album) .setArtworkUri(Uri.parse(song.coverUrl)) .build() ) .build() } // 设置播放列表并替换当前队列 player.setMediaItems(mediaItems) player.prepare() // 如果想直接开始播放第一首 // player.play() } // 播放指定索引的歌曲 fun playSongAtIndex(index: Int) { if (index 0 index player.mediaItemCount) { player.seekToDefaultPosition(index) // 跳转到该索引歌曲的默认位置开头 player.playWhenReady true } } // 添加一首歌到队列末尾 fun addToQueue(song: Song) { val mediaItem MediaItem.fromUri(song.audioUrl) player.addMediaItem(mediaItem) }5.2 配置网络缓存优化播放体验对于网络音频流缓冲等待是影响体验的主要因素。ExoPlayer内置了强大的缓存功能可以显著减少卡顿。private fun buildCacheDataSourceFactory(context: Context): DataSource.Factory { // 1. 定义缓存目录和大小 val cacheDir File(context.cacheDir, exoplayer_cache) val cacheSize: Long 200 * 1024 * 1024 // 200MB val leastRecentlyUsedCacheEvictor LeastRecentlyUsedCacheEvictor(cacheSize) val databaseProvider StandaloneDatabaseProvider(context) // 2. 创建缓存实例 val simpleCache SimpleCache(cacheDir, leastRecentlyUsedCacheEvictor, databaseProvider) // 3. 创建上行数据源工厂用于从网络读取 val upstreamFactory OkHttpDataSource.Factory(OkHttpClient.Builder().build()) .setUserAgent(YourAppMusicPlayer/1.0) // 4. 创建缓存数据源工厂 return CacheDataSource.Factory() .setCache(simpleCache) .setUpstreamDataSourceFactory(upstreamFactory) .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) // 出错时忽略缓存 } // 在使用时将这个Factory传递给MediaSource的工厂 private fun createMediaSource(mediaItem: MediaItem): MediaSource { val cacheDataSourceFactory buildCacheDataSourceFactory(context) return ProgressiveMediaSource.Factory(cacheDataSourceFactory) .createMediaSource(mediaItem) } // 为播放器设置带缓存的MediaSource val mediaSource createMediaSource(mediaItem) player.setMediaSource(mediaSource) player.prepare()这样配置后播放过的音频数据会被缓存到本地。下次播放同一音频时会优先从缓存读取速度极快同时也节省了用户流量。5.3 处理音频焦点Audio Focus当其他应用如电话、导航需要播放声音时我们的播放器应该礼貌地暂停。这就是音频焦点的作用。private val audioFocusRequest: AudioFocusRequest private val audioFocusListener AudioManager.OnAudioFocusChangeListener { focusChange - when (focusChange) { AudioManager.AUDIOFOCUS_GAIN - { // 重新获得焦点恢复播放如果之前是因为丢失焦点而暂停的 if (wasPlayingWhenFocusLost) { player.play() wasPlayingWhenFocusLost false } } AudioManager.AUDIOFOCUS_LOSS - { // 永久丢失焦点停止播放并释放资源 wasPlayingWhenFocusLost player.isPlaying player.pause() } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT - { // 暂时丢失焦点如来电暂停播放但保留播放状态 wasPlayingWhenFocusLost player.isPlaying player.pause() } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK - { // 短暂降低音量如通知音将播放器音量调低 player.volume 0.2f } } } init { val audioManager getSystemService(Context.AUDIO_SERVICE) as AudioManager audioFocusRequest if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) .setAudioAttributes( AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_MEDIA) .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) .build() ) .setAcceptsDelayedFocusGain(true) .setOnAudioFocusChangeListener(audioFocusListener) .build() } else { // 兼容旧版本API // 实际代码中需要更详细的兼容性处理 TODO(VERSION.SDK_INT O) } } fun requestAudioFocus(): Boolean { val result audioManager.requestAudioFocus(audioFocusRequest) return result AudioManager.AUDIOFOCUS_REQUEST_GRANTED } // 在开始播放前请求音频焦点 fun playMusic() { if (requestAudioFocus()) { player.play() } else { // 无法获得焦点提示用户 Toast.makeText(context, 无法播放其他应用正在使用音频, Toast.LENGTH_SHORT).show() } }5.4 与Activity通信ViewModel与LiveData播放服务在后台运行前端的Activity或Fragment需要实时更新播放状态如进度、播放/暂停按钮。这里推荐使用ViewModel配合LiveData或Flow。首先创建一个共享的PlayerViewModelclass PlayerViewModel(application: Application) : AndroidViewModel(application) { // 使用LiveData暴露播放状态 private val _playbackState MutableLiveDataPlaybackState() val playbackState: LiveDataPlaybackState _playbackState // 当前播放的歌曲信息 private val _currentSong MutableLiveDataSong?() val currentSong: LiveDataSong? _currentSong // 播放进度百分比或毫秒 private val _playbackProgress MutableLiveDataPairLong, Long() // 当前进度总时长 val playbackProgress: LiveDataPairLong, Long _playbackProgress fun updateState(state: PlaybackState) { _playbackState.postValue(state) } // ... 其他更新方法 }在MusicPlaybackService中通过某种方式如LocalBroadcastManager、Callback或直接绑定将状态更新到ViewModel。更现代的做法是使用MediaSession的Controller在Activity中连接到Session并监听其状态变化。// 在Activity中 private lateinit var mediaController: MediaController override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ... connectToSession() } private fun connectToSession() { val sessionToken MediaSession.getSessionToken(this, ComponentName(this, MusicPlaybackService::class.java)) MediaController.Builder(this, sessionToken).buildAsync { controller - mediaController controller observePlayerState(controller) } } private fun observePlayerState(controller: MediaController) { // 监听播放状态变化 controller.addListener(object : MediaController.Listener { override fun onPlaybackStateChanged(playbackState: PlaybackState?) { // 更新UI updatePlayPauseButton(playbackState?.state PlaybackState.STATE_PLAYING) } override fun onMetadataChanged(metadata: MediaMetadata?) { // 更新歌曲信息UI val title metadata?.title val artist metadata?.artist // ... 更新TextView等 } }) } // 控制播放 fun playPause() { if (mediaController.playbackState?.state PlaybackState.STATE_PLAYING) { mediaController.transportControls.pause() } else { mediaController.transportControls.play() } }这套架构将后台播放逻辑与前端UI彻底解耦使得状态同步清晰可靠也是Google官方推荐的做法。6. 完整代码整合与避坑指南把上面的模块组合起来一个完整的MusicPlaybackService骨架如下。由于篇幅限制这里只展示最核心的结构完整的、可运行的代码建议结合官方示例项目进行构建。class MusicPlaybackService : MediaSessionService() { companion object { private const val NOTIFICATION_ID 1001 private const val CHANNEL_ID music_playback_channel } private var mediaSession: MediaSession? null private var player: ExoPlayer? null private var notificationManager: PlayerNotificationManager? null private var wasPlayingWhenFocusLost false override fun onCreate() { super.onCreate() createNotificationChannel() initializePlayerAndSession() setupNotificationManager() } override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? { return mediaSession } private fun initializePlayerAndSession() { // 1. 创建播放器 player ExoPlayer.Builder(this) .setHandleAudioBecomingNoisy(true) .build() .apply { addListener(PlayerEventListener()) } // 2. 创建MediaSession mediaSession MediaSession.Builder(this, player!!) .setSessionActivity(createSessionActivityPendingIntent()) .setCallback(MySessionCallback()) .build() } private fun setupNotificationManager() { notificationManager PlayerNotificationManager.Builder(this, NOTIFICATION_ID, CHANNEL_ID) .setMediaDescriptionAdapter(MyMediaDescriptionAdapter()) .setSmallIconResourceId(R.drawable.ic_music_notification) .setNotificationListener(MyNotificationListener()) .build() .apply { setMediaSessionToken(mediaSession!!.sessionToken) setPlayer(player) } } // ... 省略内部类PlayerEventListener, MySessionCallback, MyMediaDescriptionAdapter, MyNotificationListener // ... 省略辅助方法createNotificationChannel, createSessionActivityPendingIntent, requestAudioFocus等 override fun onDestroy() { notificationManager?.setPlayer(null) player?.release() mediaSession?.release() super.onDestroy() } // 提供给Activity调用的公共方法通过Binder或其它IPC方式 fun playMediaItem(mediaItem: MediaItem) { player?.setMediaItem(mediaItem) player?.prepare() if (requestAudioFocus()) { player?.play() } } }在实际开发中我遇到过几个常见的“坑”这里分享一下通知栏不更新或按钮无反应最常见的原因是PlayerNotificationManager没有正确关联到MediaSession的Token。确保setMediaSessionToken在setPlayer之前或之后被调用并且传入的Token来自已激活的MediaSession。播放时没有声音检查是否成功请求了音频焦点AudioFocus。在Android 10及以上版本还要确保你的应用在后台时有FOREGROUND_SERVICE权限并且服务是以startForeground启动的。锁屏界面不显示控制部件确保通知渠道Notification Channel的lockscreenVisibility设置为VISIBILITY_PUBLIC并且通知本身设置了正确的MediaSessionToken。播放状态不同步UIActivity和Service之间的状态同步务必使用可靠的机制如MediaController监听MediaSession或者通过ViewModelLiveData。避免使用BroadcastReceiver进行高频的状态同步容易丢失事件或造成性能问题。最后别忘了在onDestroy中做好资源清理释放Player和MediaSession并将PlayerNotificationManager的Player引用置空防止内存泄漏。整个流程走下来你会发现用ExoPlayer这套组合拳实现状态栏控制虽然前期需要理解的概念较多但一旦搭建好框架后续的功能扩展和维护会非常顺畅。它提供的是一套标准化、符合Android设计规范的媒体播放解决方案能帮你省去大量重复造轮子和处理系统兼容性的时间。把省下来的时间更多地去打磨你应用的业务逻辑和用户体验吧。