音视频 / Media3 / ExoPlayer
本章以 AndroidX Media3 的公开组件为基线. 预加载深度, 缓存大小, buffer 和播放器数量都是业务参数, 必须通过设备, 网络, 内容和 QoE 数据测量, 不能给固定通用数字.
一, 播放链路
数据源 → manifest/容器解析 → 音视频轨道 → decoder → AudioTrack/Surface → A/V 同步与渲染. 首帧可能受 DNS/TCP/TLS, 首包, DRM, manifest, 缓存, 解码器初始化和 Surface ready 影响.
二, Media3 核心组件
| 组件 | 职责 |
|---|---|
Player / ExoPlayer | 播放控制, timeline, 状态与错误 |
MediaItem / MediaSource | 媒体描述和实际数据源 / 轨道 |
DataSource | HTTP, 文件, 缓存等字节读取 |
Renderer | 音频/视频/字幕渲染 |
LoadControl | 缓冲策略 |
MediaSession | 向系统, 通知, 车机, 耳机等暴露播放会话 |
MediaController | 跨组件 / 进程控制 MediaSession |
MediaLibraryService | 媒体浏览目录与后台播放服务 |
后台播放应围绕 MediaSession/Service 生命周期设计, 而不是让 Activity 持有全局播放器. ExoPlayer 2.x 到 Media3 1.x (androidx.media3) 的迁移是高频题 (对应包名 com.google.android.exoplayer2 → androidx.media3), 核心是包名与依赖替换以及 API 更名, 例如 Player.EventListener → Player.Listener.
三, 最小播放示例
val player = ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(uri))
prepare()
playWhenReady = true
}
// 在明确的 owner 生命周期结束时释放
player.release()
真实项目还需要处理音频焦点, 耳机拔出, 通知, 后台限制, 字幕, DRM, 错误分类和恢复位置.
有 Surface 与释放路径的最小播放页
以下是上下文片段, 以 2026-08 核验时的 Media3 稳定版为基线 (示例用 <media3-version> 占位, 应替换为当前稳定版). 本文不存在可依赖的官方 BOM 写法, 所有模块显式使用同一版本. 还需要在 manifest 声明 android.permission.INTERNET, 并在布局中提供 androidx.media3.ui.PlayerView 的 @+id/player_view; 未在本仓库执行.
固定测试素材为 ExoPlayer 公开测试媒体 https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4(MP4, 含视频轨); URL 核验日期为 2026-08-07. 开始播放后, 预期首帧是 Big Buck Bunny 动画的非纯黑视频画面, 而非只有声音. 只有使用含视频轨的素材, 才可用首帧, Surface 绑定和黑屏排障来验证视频渲染; MP3 只能验证音频路径.
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- res/layout/activity_video.xml 的上下文 -->
<androidx.media3.ui.PlayerView
android:id="@+id/player_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
// app/build.gradle.kts
dependencies {
implementation("androidx.media3:media3-exoplayer:<media3-version>")
implementation("androidx.media3:media3-ui:<media3-version>")
implementation("androidx.media3:media3-session:<media3-version>") // MediaSession/后台播放需要
implementation("androidx.media3:media3-exoplayer-hls:<media3-version>") // HLS 才需要
implementation("androidx.media3:media3-exoplayer-dash:<media3-version>") // DASH 才需要
}
// imports: android.os.Bundle; android.widget.Button; androidx.activity.viewModels;
// androidx.appcompat.app.AppCompatActivity;
// androidx.lifecycle.ViewModel;
// androidx.media3.common.AudioAttributes; androidx.media3.common.C;
// androidx.media3.common.MediaItem; androidx.media3.exoplayer.ExoPlayer;
// androidx.media3.ui.PlayerView
class VideoActivity : AppCompatActivity() {
companion object {
private const val VIDEO_TEST_URI =
"https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4"
}
private var player: ExoPlayer? = null
private lateinit var playerView: PlayerView
private val playbackState: VideoPlaybackViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_video) // 布局内含 id 为 player_view 的 PlayerView
playerView = findViewById(R.id.player_view)
playerView.useController = false // 禁用内置控制器;页面提供 id 为 play_pause 的自定义 Button
findViewById<Button>(R.id.play_pause).setOnClickListener {
setPlaybackIntent(!playbackState.shouldPlay)
}
}
override fun onStart() {
super.onStart()
if (player != null) return // 防御重复 onStart,避免泄漏第二个 player
val attributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
val activePlayer = ExoPlayer.Builder(this)
.setAudioAttributes(attributes, true) // 管理音频焦点
.setHandleAudioBecomingNoisy(true) // 默认 false;启用后处理耳机拔出/路由断开
.build()
player = activePlayer
playerView.player = activePlayer
activePlayer.setMediaItem(MediaItem.fromUri(VIDEO_TEST_URI))
activePlayer.prepare()
activePlayer.seekTo(playbackState.positionMs)
activePlayer.playWhenReady = playbackState.shouldPlay
}
override fun onStop() {
val activePlayer = player
if (activePlayer != null) {
playbackState.positionMs = activePlayer.currentPosition
// 生命周期自动暂停不改变用户已经保存的播放意图.
activePlayer.pause()
}
playerView.player = null // PlayerView 只负责绑定/解绑;重复 onStop 也保持解绑
activePlayer?.release()
player = null
super.onStop()
}
// 播放/暂停控件调用此函数,而不是只改 player 的瞬时状态.
private fun setPlaybackIntent(shouldPlay: Boolean) {
playbackState.shouldPlay = shouldPlay
player?.playWhenReady = shouldPlay
}
override fun onDestroy() {
playerView.player = null
player?.release() // onStop 未运行时的兜底;released player 不可复用
player = null
super.onDestroy()
}
}
class VideoPlaybackViewModel : ViewModel() {
// true 表示首次进入或一次生命周期自动暂停后应恢复播放.
var shouldPlay = true
var positionMs = 0L
}
这个简单前台页面禁用 PlayerView 内置控制器, 所有用户播放 / 暂停操作都经过 setPlaybackIntent, 由该方法同时更新 VideoPlaybackViewModel.shouldPlay 和 player.playWhenReady. Activity 是短生命周期 owner: 每次正常 onStart 新建 player, 绑定 view, 设置媒体, 恢复保存的位置, 并只按保存的用户意图恢复; 重复 onStart 不创建第二个实例. 每次 onStop 先保存 currentPosition, 再暂停, 解绑, release() 并置空; 即使重复 onStop 也维持 view 已解绑. onDestroy 只覆盖 onStop 未运行的异常销毁路径, 不能用它让 Activity 长期持有 codec. 因此 “用户主动暂停 → 后台 → 返回” 仍暂停, 而 “播放中 → 后台自动暂停 → 返回” 会在保存的位置恢复播放. 不要在 onStart 无条件设为 true, 也不要用无法区分用户与系统原因的泛化监听覆盖该状态.
示例中页面播放意图由 ViewModel owner 持有, 可跨配置变化保留; 需要进程重建恢复时改用 SavedStateHandle 或持久化页面状态. 若项目保留 PlayerView 内置控制器, 必须通过 Player.Listener 结合变更 reason 同步用户意图, 并将页面自身的生命周期命令与用户请求区分; 不能直接套用本示例的状态处理. 后台连续播放时, player 的 owner 应是 MediaSessionService, Activity 只连接 controller, 不能沿用页面级 release 模型.
若使用 Compose, PlayerView 仍可经 AndroidView 承载; 这是上下文片段. onRelease 只 detach view, DisposableEffect 由创建 player 的 Composable owner 释放. 不能同时由 Activity 和 Composable 对同一 player 重复 release.
val player = remember { ExoPlayer.Builder(context).build() }
AndroidView(
factory = { PlayerView(it) },
update = { view -> view.player = player },
onRelease = { view -> view.player = null },
)
DisposableEffect(player) {
onDispose { player.release() }
}
setAudioAttributes(attributes, true) 管理音频焦点, 其中 usage 仅选择 C.USAGE_MEDIA 或 C.USAGE_GAME; 焦点丢失通过 PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS 和 PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS 等 Player 状态观察. setHandleAudioBecomingNoisy(true) 是另一项设置, 默认值为 false; 启用后才自动监听耳机拔出或路由断开并暂停, 可由 PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY 观察. Player.Listener 同时记录 STATE_BUFFERING 与 ended, 分别处理缓冲和播放结束; 不要虚构原始焦点回调.
// imports: androidx.media3.common.AudioAttributes; androidx.media3.common.C;
// androidx.media3.common.Player
val attributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
player.setAudioAttributes(attributes, true)
player.addListener(object : Player.Listener {
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
when (reason) {
Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS -> Unit
Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_BECOMING_NOISY -> Unit
}
}
override fun onPlaybackSuppressionReasonChanged(reason: Int) {
if (reason == Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS) Unit
}
override fun onPlaybackStateChanged(state: Int) = Unit // STATE_BUFFERING / STATE_ENDED
})
错误分类与黑屏排障
PlaybackException 应记录错误码, cause, URI (脱敏), 网络类型, 设备/decoder, 是否已有 Surface, timeline 和播放位置; 网络/HTTP/DRM/解析/decoder/渲染错误的恢复动作不同, 不能一律无限重试. HLS/DASH 的 MediaItem 用 URI 即可由支持模块识别; 若缺少对应扩展依赖, 表现通常是 source / 解析失败, 应先检查依赖与 manifest 响应.
| 症状 | 证据 | 定位与修复 | 验证 |
|---|---|---|---|
| 无声 | 焦点状态, 音量, AudioTrack/route 日志 | 请求焦点, 处理 duck/拔耳机, 检查蓝牙/系统音量 | 有线, 蓝牙和其他媒体抢占下分别播放 |
| 黑屏有声音 | PlayerView.player, Surface 创建, decoder 日志, 以及素材实际含视频轨 | 先确认测试素材含视频轨, 再确认 view 已绑定 player, Surface ready, 编码 / DRM 支持 | 仅以含视频轨素材检查切后台返回, 旋转和不同设备的首帧可见 |
| 卡顿 | rebuffer, 加载时长, 码率切换, 网络/CDN 数据 | 区分网络, 缓存, manifest 与 decoder; 按 QoE 调整策略 | 比较同条件下 rebuffer P50/P95 和废弃流量 |
这些步骤是排障方法, 表中不代表既有线上数据或已完成测试.
四, 缓存, 下载与预加载
- 流式缓存: 使用 Media3 Cache/DataSource 组合, 定义 cache key, 配额, 淘汰和并发写入.
- 离线下载: 使用 Media3 download 组件管理任务, 约束, 断点, 状态与 DRM 许可, 不把普通 HTTP 文件缓存冒充离线下载系统.
- 预加载: 使用 Media3 preload 相关组件或受控 prepare 策略, 根据滑动速度, 命中率, 废弃流量, 内存, 温度和首帧分布调参.
- AndroidVideoCache 等第三方本地代理不是当前主推荐路径; 如历史项目保留, 应标维护状态, 安全边界和迁移计划.
五, Feed 播放器设计
不要给每个 Item 无限制创建播放器, 也不要把 “全局 1–3 个播放器池” 写成通用答案. 可选方案包括单 active player 切换 Surface, 少量预热 player, Media3 preload manager 或按页面生命周期管理实例.
决策指标:
- 同屏可播放数量和切换频率;
- decoder/Surface/内存上限;
- 首帧 P50/P95, rebuffer ratio, 播放失败率;
- 预加载命中率与废弃字节;
- 发热, 电量和后台网络消耗.
六, 音频焦点, DRM 与字幕
- 请求 / 响应音频焦点, 处理 duck, pause, 耳机拔出和其他媒体抢占.
- DRM 设计包含 license 获取, 离线许可, 过期, 设备时间, 错误恢复和日志脱敏.
- 字幕要处理轨道选择, 语言, 样式, 同步和无障碍.
- 直播低延迟, 长视频稳定性和短视频秒开目标不同, 不能复用一组 buffer 参数.
七, QoE 与排障
分段记录: 建连, 首包, manifest, DRM, prepare, decoder, Surface, 首帧, rebuffer, 码率切换和错误原因. 线上按内容, CDN, 网络类型, 设备 decoder, 版本和实验组聚合.
| 指标 | 含义 |
|---|---|
| Time to first frame | 从用户动作到首个视频帧 |
| Rebuffer ratio/count | 播放中断占比/次数 |
| Playback failure rate | 无法开始或中途失败比例 |
| Average bitrate/resolution | 体验质量与流量权衡 |
| Preload hit/waste | 预加载收益和废弃成本 |
八, 音视频版权与风控交叉
音视频与风控常交叉在版权与风控场景: 设备绑定与并发播放检测可基于设备指纹或账号维度判断多端同时播放 / 共享账号滥用; 防盗链依赖签名 URL 与 Referer 校验, 对盗播可叠加数字水印 (视频帧水印 / 音频指纹) 用于溯源; DRM (如 Widevine L1/L3) 依赖设备信任环境, 客户端只能上报设备信任级别, 最终授权与判定在服务端.
高频面试题
Q1: Media3 与 ExoPlayer 什么关系?
ExoPlayer 已迁移到 AndroidX Media3 命名空间; Media3 还包含 Session, Controller, Library, Transformer, Download 等媒体组件.
Q2: Feed 如何避免滑动卡顿?
限制活跃解码器与 Surface, 稳定复用 / 切换策略, 按目标尺寸和网络预加载, 用首帧, rebuffer, 废弃流量和热量验证; 不背固定池大小.
Q3: 缓存与离线下载有什么区别?
缓存是播放路径的可淘汰加速层; 离线下载是用户可管理, 可恢复, 有状态和版权约束的任务系统.
易错点 / 追问
- 不混淆容器, 编码和传输协议.
- 不把固定预加载条数, 字节数或播放器池大小当最佳实践.
- 不只说 “加缓存”, 要说明 key, 配额, 版权, 并发和清理.
- 不忽略 MediaSession, 音频焦点, 后台播放和 DRM.