深入 Android Media3 媒体播放架构:从 ExoPlayer 演进到 MediaSession 统一播放管线的全链路解析

去年接手一个视频播放项目时,画中画、耳机线控、通知栏控制各写了一套逻辑——每次改播放策略要在三个地方同步修改。团队当时还在用 ExoPlayer 2.x,切到 Media3 之后,这些分散的控制逻辑终于收敛到了一处。这篇文章把升级过程中对架构的理解梳理出来。

为什么 ExoPlayer 需要演进为 Media3

ExoPlayer 从 2014 年发布到 2.x 版本,核心定位始终是「媒体播放器」。它解决了 Android 原生 MediaPlayer 协议支持少、扩展性差的问题,但架构上存在三个瓶颈。

第一个瓶颈是播放器与媒体控制分离。ExoPlayer 负责解码渲染,MediaSession 负责外部控制(线控、车载、通知栏),两者通过 PlayerConnector 手动桥接。桥接代码散落在 Activity 和 Service 里,每个接入方都要重复实现一遍状态同步逻辑。

第二个瓶颈是多 Surface 场景支持不足。画中画切换、副屏投送、音频焦点抢占时,调用方需要自行管理 Surface 生命周期和播放器实例切换,没有统一的抽象层兜底。

第三个瓶颈是模块命名混乱。com.google.android.exoplayer2 下同时存在 coreuiextension 等模块,依赖关系不透明——比如 hls 模块会间接依赖 dash,平白引入不需要的体积。

Media3 的核心思路是把媒体播放与媒体控制视为同一个系统。它不再区分「播放器 API」和「会话 API」,而是通过统一的 Player 接口和 MediaSession 集成,把播放状态和控制指令收敛到一条管线里。

// Media3 统一入口:Player + MediaSession 一体化
val player = ExoPlayer.Builder(context).build()
val session = MediaSession.Builder(context, player).build()

// 设置播放源与控制都通过同一个 player 实例
player.setMediaItem(MediaItem.fromUri(videoUrl))
player.playWhenReady = true

对比旧方案需要分别管理 SimpleExoPlayerMediaSessionCompat 并手动同步状态,Media3 的胶水代码直接消失了。

播放器内核的核心变化

Media3 的播放器内核在 ExoPlayer 基础上重构了三个关键路径。

TrackSelector 按场景拆分。 旧版 DefaultTrackSelector 集成了自适应码率、屏幕尺寸匹配、语言偏好等全部策略,参数膨胀到 20 多个。Media3 拆出 TrackSelector 抽象接口,默认实现保留基础策略,复杂场景可以注入自定义实现:

class AdaptiveTrackSelector : TrackSelector {
    override fun selectTracks(
        rendererCapabilities: Array<out RendererCapabilities>,
        trackGroupArray: TrackGroupArray,
        constraints: Constraints
    ): TrackSelection {
        // 根据网络速度和设备能力动态选择码率
        val sortedGroups = trackGroupArray.sortedByDescending { 
            it.getFormat(0).bitrate 
        }
        return FixedTrackSelection(sortedGroups.first(), 0)
    }
}

Renderers 解耦为独立组件。 旧架构中 MediaCodecVideoRendererMediaCodecAudioRenderer 直接持有解码器引用,运行时无法替换。Media3 把 Renderer 改为无状态工厂模式,每次 prepare() 重新创建 Renderer 实例。对画中画场景来说这个改动很关键——切到小窗时可以无缝切换到低分辨率 Renderer,不用重建整个播放器。

MediaSource 工厂化。 ProgressiveMediaSourceHlsMediaSource 不再直接依赖播放器,改为通过 MediaSource.Factory 创建。多源切换时,Media3 内部做了 Source 预热:下一个 Source 可以在后台 prepare() 而不中断当前播放。

缓冲策略:从被动加载到主动调度

ExoPlayer 的缓冲策略由 LoadControl 接口控制。默认实现 DefaultLoadControl 的逻辑很简单:当前缓冲区低于阈值(默认 15 秒)时持续加载,高于上限(默认 30 秒)时暂停。但这套逻辑对网络特征不敏感——卡顿之后它不知道是暂时抖动还是持续恶化。

Media3 保留了 LoadControl 接口,但默认实现加入了网络带宽预测。通过分析过去 10 秒内的下载速率和卡顿事件,DefaultLoadControl 可以动态调整缓冲区目标:

  • 网络稳定时,目标缓冲区降到 10 秒,减少内存占用
  • 检测到速率下降时,提前把目标缓冲区拉高到 40 秒,用空间换时间
  • 连续卡顿 3 次以上,主动切到更低码率的 Track

这个预测逻辑在 AdaptiveTrackSelection 里同样有体现。老版本 ABR 算法只看过去几秒的平均带宽,Media3 加了一个「安全边际」参数——带宽估算值乘以 0.75 作为实际选择依据,避免突然波动导致的频繁切流。

// 自定义 LoadControl:限制预缓冲不超过目标值
val loadControl = DefaultLoadControl.Builder()
    .setBufferDurationsMs(
        30000,  // 最小缓冲 30 秒
        60000,  // 最大缓冲 60 秒
        2000,   // 播放开始前的缓冲 2 秒
        5000    // 切流后的重新缓冲 5 秒
    )
    .setPrioritizeTimeOverSizeThresholds(true) // 时长优先
    .build()

实际项目中我发现 setPrioritizeTimeOverSizeThresholds(true) 对短视频场景尤其有用。默认的「字节优先」策略下,一个 10 秒 1080p 视频可能预加载 5 秒 4K 画质等效的数据量,白白浪费流量。

MediaSession 统一管线:控制指令不再迷路

Media3 的 MediaSession 承担的不再是「桥接」角色,而是播放器与系统之间的唯一控制通道。所有控制指令——无论是代码调用、系统按键、蓝牙设备还是 Wear OS——都走同一条路径:

外部控制源(线控/通知栏/车载/蓝牙)

MediaSession.Callback(统一入口)

Player 实例(状态变更与回调)

MediaSession 广播状态(通知系统 UI 更新)

这条统一管线解决了两个老架构下的棘手问题。

一是命令冲突的自动消解。旧方案中,画中画关闭可能触发 onStop()onStop() 又触发暂停播放,暂停播放再触发通知栏更新——环环相扣的副作用让状态机异常频发。Media3 的 MediaSession 内部维护了命令队列,短时间内多个控制指令会合并执行,不会出现状态反复横跳。

二是后台播放授权的简化。不用再手动管理 AudioFocusMediaBrowserService。MediaSession 向系统注册时自动申请音频焦点,被抢占时自动暂停播放并释放资源。

// MediaSession 回调:所有控制入口归一
session.callback = object : MediaSession.Callback {
    override fun onPlay() {
        player.play()
    }

    override fun onPause() {
        player.pause()
    }

    override fun onSeekTo(positionMs: Long) {
        player.seekTo(positionMs)
    }
}

这段回调代码看起来平白无奇,但藏了一个关键行为:MediaSession.Callback 的方法在主线程执行,而 player.play()player.pause() 可以安全地从任意线程调用。MediaSession 内部已经做了线程调度,外部无需关心。

实践建议

迁移旧项目时,先改依赖再改代码。Media3 保留了大部分 ExoPlayer 2.x 的 API 命名,大部分场景只需替换包名。我的顺序是先迁移 ExoPlayer(切到 androidx.media3 包),跑通后接入 MediaSession,最后替换自定义组件。拿到一个能跑的基线再逐步替换,比一次性全量重构稳妥得多。

自定义 LoadControl 之前,先在弱网环境压测。默认的带宽预测策略在 WiFi 下表现很好,但在地铁、电梯这类弱网场景下过于激进——缓冲目标拉高到 40 秒可能触发 ANR 风险。建议针对弱网场景把 maxBufferMs 调到 30 秒以内。

还有一个容易踩的坑:不要绕过 MediaSession 直接调 Player。看到不少项目在 Activity 里同时持有 player 引用和 mediaSession 引用,部分代码绕过 MediaSession 直接调 player.play()。这会导致通知栏状态与实际播放状态不一致。统一走 mediaSession.player 或通过 MediaController 发送指令,保证状态只有一份可信源。