一、Media3 媒体框架:从 ExoPlayer 到新时代的演进

各位同学,今天我们来聊聊 Android 媒体框架里的大事件——Media3。说实话,我刚接触这个框架时,第一反应是:Google 又造轮子了?但深入用下来才发现,这其实是 ExoPlayer 的一次「脱胎换骨」。

1.1 为什么要有 Media3?

老 Android 开发者应该记得,早期我们用 MediaPlayer,后来有了 ExoPlayer。ExoPlayer 确实强大,但有个问题——它和 Android 系统媒体组件(比如 MediaSession、MediaController)的整合很别扭。我自己就踩过坑:想给播放器加个通知栏控制,得写一堆胶水代码。

Media3 的核心思路很简单:把播放器、UI、媒体会话、控制器全部统一到一个架构里。说白了,就是让你用一套 API 搞定所有媒体相关的事。

Media3 的三大设计目标:

  • 统一 Player 接口(ExoPlayer 和系统 MediaPlayer 用同一套 API)
  • 模块化架构(播放核心、UI、会话管理完全解耦)
  • 生命周期友好(自动处理 Activity/Fragment 的销毁重建)

1.2 ExoPlayer 的演进路线

我记得 ExoPlayer 1.x 时代,所有代码都在一个 module 里。到了 2.x,开始拆成 core、dash、hls 等模块。而 Media3 直接来了个「乾坤大挪移」——包名从 com.google.android.exoplayer2 变成了 androidx.media3

为什么要改包名?因为 Media3 不再是 ExoPlayer 的升级版,而是 AndroidX 家族的一员。这意味着:

  • 你可以用 Jetpack 的版本管理统一控制
  • 和其他 AndroidX 组件(如 Compose、Navigation)天然兼容
  • Google 会长期维护,不用担心被弃用

迁移建议:如果你还在用 ExoPlayer 2.x,建议尽早迁移到 Media3。我在一个视频 App 项目里做过迁移,核心代码改动量其实不大,主要是包名替换和少量 API 调整。

二、播放器状态管理与监听

播放器状态管理,说白了就是搞清楚「当前播放器在干嘛」。很多新手容易在这里翻车——比如在播放器还没准备好的时候调了 seekTo,结果没反应。

2.1 状态机模型

Media3 的播放器状态是一个有限状态机。我习惯把它画成一张图:

IDLE BUFFERING READY ENDED PAUSED prepare() 缓冲完成 播放完毕 pause() play() pause() play() 图例:状态转换关系(简化版)

这张图我建议你保存下来。每次遇到播放器状态问题,先对照这张图看看当前在哪个节点。

2.2 监听播放状态

Media3 提供了 Listener 接口来监听状态变化。我个人习惯用 Player.Listener,因为它覆盖了几乎所有回调:

val player = ExoPlayer.Builder(context).build()

player.addListener(object : Player.Listener {
    override fun onPlaybackStateChanged(state: Int) {
        when (state) {
            Player.STATE_IDLE -> Log.d("Media3", "空闲状态")
            Player.STATE_BUFFERING -> Log.d("Media3", "缓冲中...")
            Player.STATE_READY -> Log.d("Media3", "准备就绪")
            Player.STATE_ENDED -> Log.d("Media3", "播放结束")
        }
    }

    override fun onIsPlayingChanged(isPlaying: Boolean) {
        // 这个回调比 onPlayWhenReadyChanged 更直观
        updatePlayPauseButton(isPlaying)
    }

    override fun onPlayerError(error: PlaybackException) {
        // 我曾经在这里犯过错误——直接弹 Toast 给用户
        // 更好的做法是区分可恢复错误和致命错误
        handlePlayerError(error)
    }
})

注意:onPlayWhenReadyChanged 和 onIsPlayingChanged 是有区别的。前者表示「用户想播放」,后者表示「实际在播放」。缓冲状态下,playWhenReady 可能为 true,但 isPlaying 为 false。

三、自定义 MediaSession 与 MediaController

MediaSession 和 MediaController 是 Android 媒体架构的「双胞胎」。Session 负责发布状态,Controller 负责发送命令。Media3 把它们整合到了 Media3SessionMedia3Controller 中。

3.1 创建自定义 MediaSession

为什么需要自定义?因为默认的 Session 只能处理播放/暂停/跳转这些基本操作。如果你需要支持自定义命令(比如「倍速播放」「切换音轨」),就得自己动手。

// 创建 Session
val session = MediaSession.Builder(context, player)
    .setSessionCallback(object : MediaSession.Callback {
        override fun onConnect(
            session: MediaSession,
            controller: MediaSession.ControllerInfo
        ): MediaSession.ConnectionResult {
            // 可以在这里做权限校验
            val availableCommands = ConnectionResult.DEFAULT_ANDROID_AUTO_AVAILABLE_COMMANDS
                .buildUpon()
                .add(Command("CUSTOM_SPEED", Bundle.EMPTY))
                .build()
            return ConnectionResult.accept(availableCommands)
        }

        override fun onCustomCommand(
            session: MediaSession,
            controller: MediaSession.ControllerInfo,
            customAction: String,
            extras: Bundle
        ): ListenableFuture<SessionResult> {
            if (customAction == "CUSTOM_SPEED") {
                val speed = extras.getFloat("speed", 1.0f)
                player.setPlaybackSpeed(speed)
                return SessionResult(SessionResult.RESULT_SUCCESS).asListenableFuture()
            }
            return SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED).asListenableFuture()
        }
    })
    .build()

嗯,这里要注意:onConnect 里返回的 availableCommands 决定了 Controller 能做什么。我曾经在项目里忘了加自定义命令,结果调试了半天才发现是权限问题。

3.2 使用 MediaController

Controller 的使用场景通常是在另一个进程或组件中控制播放器。比如通知栏、手表应用、车载系统。

// 在另一个 Activity 中连接 Session
val controller = MediaController.Builder(context, sessionToken)
    .setListener(object : MediaController.Listener {
        override fun onDisconnected(controller: MediaController) {
            // 处理断开连接
        }
    })
    .build()

// 发送自定义命令
val extras = Bundle().apply { putFloat("speed", 1.5f) }
controller.sendCustomCommand("CUSTOM_SPEED", extras)

小技巧:如果你只需要基本的播放控制,可以用 MediaController.createMediaController(sessionToken) 快速创建。但自定义命令必须用 Builder。

四、DRM 与字幕支持

这两个功能是媒体播放的「硬骨头」。DRM 涉及版权保护,字幕涉及多语言和格式兼容。我在这两个坑里都摔过,今天把经验分享给你们。

4.1 DRM 配置

Media3 的 DRM 支持 Widevine、PlayReady、ClearKey 等方案。配置方式很统一:

val drmConfiguration = MediaItem.DrmConfiguration.Builder(
    UUID.fromString("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed") // Widevine UUID
)
    .setLicenseUri("https://your-license-server.com/wv")
    .setLicenseRequestHeaders(mapOf("Authorization" to "Bearer token123"))
    .build()

val mediaItem = MediaItem.Builder()
    .setUri("https://your-stream.com/video.mpd")
    .setDrmConfiguration(drmConfiguration)
    .build()

player.setMediaItem(mediaItem)
player.prepare()

这里有个坑:License URL 必须支持 HTTPS。我遇到过因为测试环境用了 HTTP,结果 DRM 请求被系统拦截的情况。另外,setLicenseRequestHeaders 可以用来传递认证 token,很多 DRM 服务商都需要这个。

4.2 字幕支持

Media3 支持的字幕格式包括:SRT、VTT、TTML、SAMI 等。你可以把字幕作为 MediaItem 的一部分传入:

val subtitle = MediaItem.SubtitleConfiguration.Builder(
    Uri.parse("https://your-server.com/subtitles/zh.vtt")
)
    .setMimeType("text/vtt")
    .setLanguage("zh")
    .setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
    .build()

val mediaItem = MediaItem.Builder()
    .setUri("https://your-stream.com/video.mp4")
    .setSubtitleConfigurations(listOf(subtitle))
    .build()

如果你需要动态切换字幕(比如用户点击按钮切换语言),可以用 player.setTrackSelectionParameters()

val params = player.trackSelectionParameters
    .buildUpon()
    .setPreferredTextLanguage("en")
    .build()
player.setTrackSelectionParameters(params)

注意:字幕文件的编码问题。我曾经遇到一个 SRT 文件是 GBK 编码,结果在播放器里显示乱码。建议统一使用 UTF-8 编码的字幕文件。

五、总结

Media3 框架的出现,让 Android 媒体开发进入了一个新阶段。从 ExoPlayer 到 Media3,不仅仅是包名的变化,更是架构思想的升级。我个人觉得,最值得关注的是三点:

  • 统一的 Player 接口——写一次代码,跑在不同播放器上
  • 模块化设计——按需引入,减少包体积
  • 生命周期友好——再也不用担心 Activity 重建导致播放器状态丢失

至于 DRM 和字幕,虽然配置起来有点繁琐,但只要按照官方文档的步骤来,基本不会出大问题。记住我上面提到的几个坑,能帮你省下不少调试时间。

好了,这一章的内容就到这里。下一章我们会深入 Media3 的播放列表管理和无缝切换,敬请期待。


公众号:蓝海资料掘金营,微信 deep3321