Android Kotlin音乐播放器:实现后台播放与自定义媒体通知的技术求助
Android Kotlin音乐播放器:实现后台播放与自定义媒体通知的技术求助
兄弟,我太懂你这种跟着教程踩坑的感觉了!之前直接在ViewModel里注入ExoPlayer是个小误区——ViewModel是和UI生命周期绑定的,页面一退后台就可能被回收,肯定撑不住后台播放。下面我一步步给你改,完全贴合你的技术栈(Kotlin、Compose、ExoPlayer、Hilt),搞定后台播放+自定义通知:
一、核心逻辑调整:把ExoPlayer挪到媒体服务里
系统只允许MediaBrowserServiceCompat这类媒体服务在后台持有音频焦点,所以必须把ExoPlayer托管在Service里,ViewModel只负责和UI交互、给Service发指令。
1. 实现MediaBrowserServiceCompat子类
用Hilt注入ExoPlayer,同时初始化MediaSession来处理通知和媒体控制:
@AndroidEntryPoint class MusicPlayerService : MediaBrowserServiceCompat() { @Inject lateinit var exoPlayer: ExoPlayer private lateinit var mediaSession: MediaSessionCompat private lateinit var notificationManager: NotificationManager private var isForegroundService = false // 通知渠道ID(Android 8.0+必须) private val NOTIFICATION_CHANNEL_ID = "MUSIC_PLAYER_CHANNEL" private val NOTIFICATION_ID = 1001 override fun onCreate() { super.onCreate() notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager createNotificationChannel() // 初始化媒体会话,用来桥接通知、系统媒体控制和ExoPlayer mediaSession = MediaSessionCompat(this, "MusicPlayerSession").apply { // 声明支持的控制操作:播放、暂停、上一曲、下一曲 setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS) setPlaybackState( PlaybackStateCompat.Builder() .setState(PlaybackStateCompat.STATE_PAUSED, 0, 1f) .setActions( PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_NEXT ) .build() ) isActive = true } sessionToken = mediaSession.sessionToken // 监听ExoPlayer状态变化,同步到媒体会话和通知 exoPlayer.addListener(object : Player.Listener { override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) updateMediaSessionState(playbackState) updateNotification() } override fun onMediaItemChanged(mediaItem: MediaItem?) { super.onMediaItemChanged(mediaItem) updateNotification() } }) } // 创建通知渠道(Android 8.0+强制要求) private fun createNotificationChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID, "音乐播放器", NotificationManager.IMPORTANCE_LOW // 媒体通知用低优先级,避免弹窗打扰 ).apply { description = "控制音乐播放" setShowBadge(false) } notificationManager.createNotificationChannel(channel) } } // 更新媒体会话的播放状态 private fun updateMediaSessionState(exoPlayerState: Int) { val playbackStateBuilder = PlaybackStateCompat.Builder() .setActions( PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_NEXT ) when (exoPlayerState) { Player.STATE_READY -> { val isPlaying = exoPlayer.isPlaying playbackStateBuilder.setState( if (isPlaying) PlaybackStateCompat.STATE_PLAYING else PlaybackStateCompat.STATE_PAUSED, exoPlayer.currentPosition, exoPlayer.playbackParameters.speed ) // 播放时必须把服务设为前台服务,否则系统会回收 if (isPlaying && !isForegroundService) { startForeground(NOTIFICATION_ID, createNotification()) isForegroundService = true } else if (!isPlaying && isForegroundService) { // 暂停时退出前台,但保留通知 stopForeground(STOP_FOREGROUND_DETACH) isForegroundService = false notificationManager.notify(NOTIFICATION_ID, createNotification()) } } Player.STATE_IDLE, Player.STATE_ENDED -> { playbackStateBuilder.setState(PlaybackStateCompat.STATE_PAUSED, 0, 1f) stopForeground(STOP_FOREGROUND_REMOVE) isForegroundService = false stopSelf() // 播放结束可以停止服务,按需调整 } } mediaSession.setPlaybackState(playbackStateBuilder.build()) } // 构建自定义通知 private fun createNotification(): Notification { val currentMediaItem = exoPlayer.currentMediaItem ?: return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID).build() // 从媒体项里取标题、艺术家 val title = currentMediaItem.mediaMetadata.title ?: "未知标题" val artist = currentMediaItem.mediaMetadata.artist ?: "未知艺术家" // 生成播放/暂停按钮(根据当前状态切换图标) val playPauseAction = if (exoPlayer.isPlaying) { NotificationCompat.Action( android.R.drawable.ic_media_pause, "暂停", MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PAUSE) ) } else { NotificationCompat.Action( android.R.drawable.ic_media_play, "播放", MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_PLAY) ) } val prevAction = NotificationCompat.Action( android.R.drawable.ic_media_previous, "上一曲", MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS) ) val nextAction = NotificationCompat.Action( android.R.drawable.ic_media_next, "下一曲", MediaButtonReceiver.buildMediaButtonPendingIntent(this, PlaybackStateCompat.ACTION_SKIP_TO_NEXT) ) // 组装通知 return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentTitle(title) .setContentText(artist) .setSmallIcon(R.drawable.ic_music_note) // 替换成你的应用图标 .setLargeIcon( // 显示专辑封面,没有的话用默认图 currentMediaItem.mediaMetadata.artBitmap ?: BitmapFactory.decodeResource(resources, R.drawable.default_album_art) ) .setPriority(NotificationCompat.PRIORITY_LOW) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setOnlyAlertOnce(true) .setStyle( androidx.media.app.NotificationCompat.MediaStyle() .setMediaSession(mediaSession.sessionToken) .setShowActionsInCompactView(0, 1, 2) // 紧凑模式显示上一曲、播放/暂停、下一曲 ) .addAction(prevAction) .addAction(playPauseAction) .addAction(nextAction) .build() } // 更新通知 private fun updateNotification() { if (isForegroundService || exoPlayer.currentMediaItem != null) { notificationManager.notify(NOTIFICATION_ID, createNotification()) } } // 处理UI层的服务连接请求(只允许本应用连接) override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? { return BrowserRoot("/", null) } override fun onLoadChildren(parentId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) { // 不需要浏览媒体库,直接返回空列表 result.sendResult(mutableListOf()) } // 处理通知和媒体按钮的指令 override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { MediaButtonReceiver.handleIntent(mediaSession, intent) return START_STICKY // 服务被系统杀死后自动重启 } // 处理媒体会话的控制指令 private val mediaSessionCallback = object : MediaSessionCompat.Callback() { override fun onPlay() { exoPlayer.play() } override fun onPause() { exoPlayer.pause() } override fun onSkipToPrevious() { exoPlayer.seekToPreviousMediaItem() } override fun onSkipToNext() { exoPlayer.seekToNextMediaItem() } // 处理ViewModel发来的设置播放队列指令 override fun onCommand(command: String?, extras: Bundle?, cb: Result<*>?) { super.onCommand(command, extras, cb) if (command == "SET_MEDIA_ITEMS") { val mediaItems = extras?.getParcelableArrayList<MediaItem>("MEDIA_ITEMS") ?: return val startIndex = extras.getInt("START_INDEX", 0) exoPlayer.setMediaItems(mediaItems, startIndex, 0) exoPlayer.prepare() } } } override fun onCreate() { super.onCreate() // ... 其他初始化代码 ... mediaSession.setCallback(mediaSessionCallback) } override fun onDestroy() { super.onDestroy() mediaSession.release() exoPlayer.release() } }
2. 改造ViewModel:连接服务,而非直接持有ExoPlayer
现在ViewModel只负责和UI交互,通过MediaBrowserCompat连接服务、发送指令:
@HiltViewModel class MusicViewModel @Inject constructor( private val listenToSongUseCase: ListenToSongUseCase ) : ViewModel() { private val _state = MutableStateFlow(MusicState()) val state: StateFlow<MusicState> = _state.asStateFlow() private var mediaBrowser: MediaBrowserCompat? = null private val mediaControllerCallback = object : MediaControllerCompat.Callback() { // 同步播放状态到UI override fun onPlaybackStateChanged(state: PlaybackStateCompat?) { state?.let { playbackState -> _state.update { it.copy( isPlaying = playbackState.state == PlaybackStateCompat.STATE_PLAYING, currentPosition = playbackState.position ) } } } // 同步媒体元数据到UI override fun onMetadataChanged(metadata: MediaMetadataCompat?) { metadata?.let { meta -> _state.update { it.copy( currentTitle = meta.title?.toString() ?: "", currentArtist = meta.artist?.toString() ?: "" ) } } } } init { // 初始化媒体浏览器,连接服务 mediaBrowser = MediaBrowserCompat( ApplicationProvider.getApplicationContext(), ComponentName(ApplicationProvider.getApplicationContext(), MusicPlayerService::class.java), object : MediaBrowserCompat.ConnectionCallback() { override fun onConnected() { // 连接成功后获取媒体控制器,用来发指令 val mediaController = MediaControllerCompat( ApplicationProvider.getApplicationContext(), mediaBrowser!!.sessionToken ) MediaControllerCompat.setMediaController(ApplicationProvider.getApplicationContext(), mediaController) mediaController.registerCallback(mediaControllerCallback) // 同步初始状态 mediaController.playbackState?.let { mediaControllerCallback.onPlaybackStateChanged(it) } mediaController.metadata?.let { mediaControllerCallback.onMetadataChanged(it) } } override fun onConnectionSuspended() { // 可以加重连逻辑 } override fun onConnectionFailed() { // 提示用户连接失败 } } ).apply { connect() } } // UI层调用的控制方法 fun playPause() { val controller = MediaControllerCompat.getMediaController(ApplicationProvider.getApplicationContext()) if (controller.playbackState?.state == PlaybackStateCompat.STATE_PLAYING) { controller.transportControls.pause() } else { controller.transportControls.play() } } fun skipToPrevious() { MediaControllerCompat.getMediaController(ApplicationProvider.getApplicationContext()) ?.transportControls?.skipToPrevious() } fun skipToNext() { MediaControllerCompat.getMediaController(ApplicationProvider.getApplicationContext()) ?.transportControls?.skipToNext() } // 设置播放队列 fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int = 0) { val controller = MediaControllerCompat.getMediaController(ApplicationProvider.getApplicationContext()) controller.sendCommand("SET_MEDIA_ITEMS", Bundle().apply { putParcelableArrayList("MEDIA_ITEMS", ArrayList(mediaItems)) putInt("START_INDEX", startIndex) }, null) } override fun onCleared() { super.onCleared() mediaBrowser?.disconnect() MediaControllerCompat.getMediaController(ApplicationProvider.getApplicationContext()) ?.unregisterCallback(mediaControllerCallback) } }
3. 配置清单和Hilt模块
(1)AndroidManifest.xml注册服务和权限
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- 防止设备休眠停止播放 --> <application ...> <service android:name=".MusicPlayerService" android:exported="true" android:foregroundServiceType="mediaPlayback"> <intent-filter> <action android:name="android.media.browse.MediaBrowserService" /> <action android:name="android.intent.action.MEDIA_BUTTON" /> </intent-filter> </service> </application>
(2)Hilt提供ExoPlayer单例
@Module @InstallIn(SingletonComponent::class) object ExoPlayerModule { @Provides @Singleton fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer { return ExoPlayer.Builder(context) .setAudioAttributes( AudioAttributesCompat.Builder() .setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) .setUsage(AudioAttributesCompat.USAGE_MEDIA) .build(), true ) .setHandleAudioBecomingNoisy(true) // 耳机拔出自动暂停 .build() } }
二、调试注意事项
- 先确认服务能正常启动,MediaBrowser连接无异常
- 测试后台播放:按Home键退后台,看音乐是否继续、通知是否显示
- 测试通知按钮:点击播放/暂停、上一曲、下一曲,看ExoPlayer是否响应
- 通知不显示的话,检查通知渠道是否创建成功,以及服务是否调用了
startForeground
内容来源于stack exchange
相关产品推荐
相关产品推荐

