You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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()
    }
}

二、调试注意事项

  1. 先确认服务能正常启动,MediaBrowser连接无异常
  2. 测试后台播放:按Home键退后台,看音乐是否继续、通知是否显示
  3. 测试通知按钮:点击播放/暂停、上一曲、下一曲,看ExoPlayer是否响应
  4. 通知不显示的话,检查通知渠道是否创建成功,以及服务是否调用了startForeground

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.08 14:58:06