一、Android 播放器架构全景
1.1 为什么 Android 播放器开发值得深入
Android 平台的视频播放器开发,几乎涵盖了音视频技术的所有核心问题:
- 编解码:硬解码(MediaCodec + OMX)、软解码(FFmpeg)
- 渲染:SurfaceView、TextureView、GLSurfaceView、SurfaceTexture
- 同步:音画同步、缓冲管理、卡顿处理
- 协议:HTTP(ExoPlayer)、RTSP(Live555)、RTMP(FFmpeg)
- 系统:OMX 服务、MediaCodec 状态机、BufferQueue 机制
掌握这些,就掌握了整个 Android 多媒体子系统的半壁江山。
1.2 Android 播放器的技术演进
Android 1.0 ~ 2.3(2008-2010)
└── VideoView(底层封装 MediaPlayer,仅 SurfaceView)
└── MediaPlayer(C 层实现,不透明,扩展性极差)
Android 3.0(2011)------ 里程碑
└── MediaCodec API 引入(OpenMax 标准化)
└── SurfaceTexture(TextureView 基础)
└── OMX IL 1.1.2 成为标准接口
Android 4.1+(2012)------ 开放能力
└── MediaCodec 完全开放(非仅 HAL)
└── SurfaceView 双缓冲机制完善
└── MediaCodec + Surface 组合成为主流
Android 4.4+(2013)------ ExoPlayer 开源
└── Google 内部播放器框架开源
└── DASH/HLS 全面支持
└── MediaCodec 无锁缓冲队列
Android 7.0+(2016)------ 现代化
└── MediaCodec 2.0 API(Codename:Nougat)
└── Surface枕持化
└──MediaMuxer 支持 HEVC
Android 10+(2019)------ 隐私与 MediaSession
└── Scoped Storage
└── MediaSession 标准化
Android 11+(2020)------ 现代框架
└── ExoPlayer 2.x 成为 Jetpack 组件
└── Media3 统一 API(MediaSession + ExoPlayer)
1.3 三种播放方案横向对比
| 方案 | 架构层次 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| MediaPlayer + SurfaceView | 系统级 | 快速集成、简单播放 | 一行代码、硬件加速 | 无法自定义解码、无法缩略图 |
| MediaCodec + Surface | 应用层(裸 API) | 需要自定义解码/滤镜 | 完全可控、灵活 | 代码量大、状态机复杂 |
| ExoPlayer(Media3) | 应用层(框架层) | 直播/点播/DRM | 功能完整、社区活跃 | 框架包袱、定制需深挖源码 |
入门建议:先掌握 MediaCodec 裸 API(理解原理),再过渡到 ExoPlayer(工程实用)。
1.4 Android 播放器核心组件
┌─────────────────────────────────────────────────────────────┐
│ Application 层 │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 播放器外壳(PlayerActivity / PlayerFragment) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ 控制层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ │
│ │ │ ExoPlayer / │ │ 音画同步器 │ │ 缓冲管理 │ │ │
│ │ │ MediaCodec │ │ SyncEngine │ │ BufferMgr│ │ │
│ │ └──────────────┘ └──────────────┘ └──────────┘ │ │
│ └───────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ 解码层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ │ │
│ │ │ VideoDecoder│ │ AudioDecoder│ │ │
│ │ │ (MediaCodec)│ │ (MediaCodec)│ │ │
│ │ └──────────────┘ └──────────────┘ │ │
│ └───────────────────────────┬───────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ 渲染层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │ │
│ │ │ SurfaceView │ │ TextureView │ │ GLSurface│ │ │
│ │ │ (双缓冲) │ │ (UI线程合成) │ │ View │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────┘ │ │
│ └───────────────────────────┬───────────────────────────┘ │
└──────────────────────────────┼───────────────────────────────┘
│
┌──────────────────────────────┼───────────────────────────────┐
│ 系统服务层(Binder IPC) │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ OMX Master │ │ MediaCodec │ │ AudioFlinger │ │
│ │ (OMX IL 核心) │ │ (Codec 服务) │ │ (音频输出) │ │
│ └─────────────────┘ └─────────────────┘ └───────────────┘ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ SurfaceFlinger│ │ AudioPolicy │ │ MediaPlayer │ │
│ │ (帧合成/显示) │ │ Service │ │ Service │ │
│ └─────────────────┘ └─────────────────┘ └───────────────┘ │
└──────────────────────────────────────────────────────────────┘
二、核心技术概念详解
2.1 MediaCodec:编解码器的核心 API
MediaCodec 是 Android 提供的一套访问硬件/软件编解码器的底层 API,本质上是 生产者-消费者 模式的队列处理器。
2.1.1 架构模型
┌─────────────────────────────────────┐
│ MediaCodec │
│ │
编码后数据 ──────→│ InputBuffer 队列(生产者写入) │
(AVPacket) │ │
│ ┌───────────────────────────────┐ │
│ │ Codec Core(硬件/软件) │ │
│ │ ┌─────────┐ ┌─────────┐ │ │
│ │ │ 解码器 │ │ 编码器 │ │ │
│ │ └─────────┘ └─────────┘ │ │
│ └───────────────────────────────┘ │
│ │
解码后数据 ←──────│ OutputBuffer 队列(消费者读取) │
(Surface/YUV) │ │
└─────────────────────────────────────┘
2.1.2 编解码器状态机
configure()
┌───────────────────────────────────────────────┐
│ ↓
┌───┴───┐ ┌─────┴─────┐
│ │ │ │
│ Unconfigured │ Configured │
│ │ │ │
└───────┘ └──────┬───┘
↑ │
│ ┌────────────────────────────────┘
│ │ start()
│ ↓
┌───┴───────────────────────────────────────────┐
│ │
│ ┌─────────┐ dequeueInputBuffer() ┌───────┴───────┐
│ │ │────────────────────────→│ │
│ │ │ │ InputPending │
queueInput │ └───────┬───────┘
│ │ Buffer │
│ │ │ ┌──┴──────────┐
│ │ │ ←─── dequeueOutputBuffer │ │
│ │ Flushed │ │OutputPending │
│ │ │ └───────┬──────┘
│ └─────────┘ │
│ ↑ │
└─────────┼────────────────────────────────────┘
│ flush() / endOfStream / error
┌─────┴─────┐
│ │
│ Executing │ ←─── start() 后进入此状态
│ │
└───────────┘
状态说明:
| 状态 | 说明 | 可调用方法 |
|---|---|---|
| Uninitialized | 初始状态 | configure() |
| Configured | 已配置 | start() / release() |
| Flushed | 刷新状态(start 后立即进入) | queueInputBuffer() |
| Running | 运行中 | dequeueInputBuffer() / dequeueOutputBuffer() |
| End of Stream | 流结束 | queueInputBuffer (EOS) |
| Error | 错误状态 | reset() / release() |
关键 API:
kotlin
// ===== 创建解码器 =====
val mediaFormat = MediaFormat.createVideoFormat(
MediaFormat.KEY_MIME, // "video/avc" / "video/hevc" / "audio/mp4a-latm"
width, // 视频宽
height // 视频高
)
// 可选参数
mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface)
mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, 5_000_000)
mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30)
mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // 1s 一个 I 帧
val decoder = MediaCodec.createDecoderByType("video/avc")
// ===== 配置解码器 =====
decoder.configure(mediaFormat, surface, crypto, 0)
// 参数说明:
// mediaFormat:格式(宽/高/码率/帧率/I帧间隔)
// surface:输出 Surface(视频直接渲染到 Surface,跳过 APP 层 YUV 拷贝)
// crypto:DRM 加密(null = 无加密)
// flags:0 = 解码器,CONFIGURE_FLAG_ENCODE = 编码器
// ===== 启动解码器 =====
decoder.start()
// ===== 解码循环 =====
while (isPlaying) {
// 1. 取输入缓冲区,写入压缩数据
val inputBufIndex = decoder.dequeueInputBuffer(TIMEOUT_US)
if (inputBufIndex >= 0) {
val inputBuf = decoder.getInputBuffer(inputBufIndex)
val sampleSize = extractor.readSampleData(inputBuf, 0)
if (sampleSize < 0) {
decoder.queueInputBuffer(inputBufIndex, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM)
} else {
decoder.queueInputBuffer(
inputBufIndex, 0, sampleSize,
extractor.sampleTime, // PTS(微秒)
0 // flags
)
extractor.advance()
}
}
// 2. 取输出缓冲区,获取解码后数据
val bufferInfo = MediaCodec.BufferInfo()
val outputBufIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
when {
outputBufIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val newFormat = decoder.outputFormat
Log.d("Decoder", "Output format changed: $newFormat")
}
outputBufIndex >= 0 -> {
// 可选:检查是否为关键帧
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0) {
Log.d("Decoder", "Key frame output")
}
// 渲染到 Surface(第三个参数 = 延迟渲染的时间戳,微秒)
decoder.releaseOutputBuffer(outputBufIndex, bufferInfo.presentationTimeUs)
}
}
}
2.1.3 MediaFormat:格式描述核心类
kotlin
// ===== 常见视频格式参数 =====
val videoFormat = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1920, 1080).apply {
setInteger(MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface) // 直接渲染模式
setInteger(MediaFormat.KEY_BIT_RATE, 5_000_000) // 5 Mbps
setInteger(MediaFormat.KEY_FRAME_RATE, 30) // 30 fps
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1) // I 帧间隔 1 秒
setLong(MediaFormat.KEY_MAX_INPUT_SIZE, 1920 * 1080 * 4) // 最大输入缓冲
}
// ===== 常见音频格式参数 =====
val audioFormat = MediaFormat.createAudioFormat(MediaFormat.MIMETYPE_AUDIO_AAC, 48000, 2).apply {
setInteger(MediaFormat.KEY_BIT_RATE, 128_000) // 128 kbps
setInteger(MediaFormat.KEY_AAC_PROFILE,
MediaCodecInfo.CodecProfileLevel.AACObjectLC)
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 16384)
}
// ===== 查询可用编码器/解码器 =====
fun findDecoder(mime: String): MediaCodecInfo? {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
for (info in codecList.codecInfos) {
if (info.isEncoder) continue
for (type in info.supportedTypes) {
if (type.equals(mime, ignoreCase = true)) {
return info
}
}
}
return null
}
2.2 Surface:解码输出的渲染目标
Surface 是 Android 图形系统的核心接口,代表生产者-消费者共享的原生队列。
2.2.1 Surface 的三重身份
┌─────────────────────────────────────────────────────────┐
│ Surface │
│ │
│ ① 视频解码器输出目标(生产者) │
│ MediaCodec.configure(format, surface, ...) │
│ → 解码器直接写入 Surface,跳过 APP 层 YUV 拷贝 │
│ │
│ ② 图形消费者(SurfaceView / TextureView) │
│ SurfaceView:Surface 嵌入窗口,双缓冲,独立的 GPU 合成的 │
│ TextureView:Surface 作为纹理,UI 线程合成 │
│ │
│ ③ ANativeWindow(Native 层) │
│ 可直接对接 Camera NDK / FFmpeg libavcodec │
└─────────────────────────────────────────────────────────┘
2.2.2 SurfaceView vs TextureView
| 特性 | SurfaceView | TextureView |
|---|---|---|
| 渲染位置 | SurfaceFlinger(独立合成) | UI 线程(View 树合成) |
| 双缓冲 | ✅ 硬件双缓冲(翻页式) | ❌ 单缓冲(可能导致撕裂) |
| 性能 | ✅ 高(独立层,无 UI 线程开销) | ⚠️ 中(UI 线程参与合成) |
| 截屏 | ⚠️ 需特殊处理 | ✅ 直接截图 |
| 动画/变换 | ❌ 不支持 View 变换 | ✅ 支持旋转/缩放/alpha |
| Z-order | 可置于任意位置 | 服从 View 层级 |
| 延迟 | 最低(直接写入帧缓冲区) | 略高(UI 合成延迟) |
| 适用场景 | 直播播放器、Camera 预览 | 视频编辑、特效叠加 |
SurfaceView 使用示例:
kotlin
// XML 布局
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
// 代码中获取 Surface
class MainActivity : AppCompatActivity(), SurfaceHolder.Callback2 {
private lateinit var surfaceView: SurfaceView
private var surface: Surface? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
surfaceView = findViewById(R.id.surfaceView)
surfaceView.holder.addCallback(this)
// 可选:设置 Surface 的像素格式和缓冲数量
surfaceView.holder.setFormat(PixelFormat.RGBA_8888)
}
override fun surfaceCreated(holder: SurfaceHolder) {
// Surface 创建后,立即配置解码器
surface = holder.surface
setupDecoder(surface!!)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int,
width: Int, height: Int) {
Log.d("Player", "Surface changed: ${width}x$height")
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
// 释放解码器
releaseDecoder()
surface = null
}
}
TextureView 使用示例:
kotlin
// XML
<TextureView
android:id="@+id/textureView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
// 代码
class TexturePlayerActivity : AppCompatActivity(), TextureView.SurfaceTextureListener {
private lateinit var textureView: TextureView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
textureView = findViewById(R.id.textureView)
textureView.surfaceTextureListener = this
}
override fun onSurfaceTextureAvailable(surface: SurfaceTexture,
width: Int, height: Int) {
// SurfaceTexture 可用后创建 Surface
val surface = Surface(surface)
setupDecoder(surface)
}
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
// 每帧更新时调用(UI 线程)
}
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
releaseDecoder()
surface.release()
return true
}
}
2.3 MediaExtractor:流解复用
MediaExtractor 负责从容器文件(MP4/MKV/TS 等)中提取轨道(Track),包括视频轨、音频轨、字幕轨。
kotlin
// ===== 创建 Extractor 并设置数据源 =====
val extractor = MediaExtractor()
extractor.setDataSource(filePath) // 或 URL、FileDescriptor
// ===== 枚举所有轨道 =====
for (i in 0 until extractor.trackCount) {
val format = extractor.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("video/")) {
Log.d("Extractor", "Video track: $i, $format")
extractor.selectTrack(i) // 选择视频轨
} else if (mime.startsWith("audio/")) {
Log.d("Extractor", "Audio track: $i, $format")
}
}
// ===== 获取当前选中轨道的格式 =====
val videoFormat = extractor.getTrackFormat(selectedTrack)
val width = videoFormat.getInteger(MediaFormat.KEY_WIDTH)
val height = videoFormat.getInteger(MediaFormat.KEY_HEIGHT)
val duration = videoFormat.getLong(MediaFormat.KEY_DURATION) // 微秒
val frameRate = videoFormat.getIntegerOrDefault(MediaFormat.KEY_FRAME_RATE, 30)
val colorFormat = videoFormat.getIntegerOrDefault(MediaFormat.KEY_COLOR_FORMAT, 0)
// ===== 读取压缩数据 =====
val buffer = ByteBuffer.allocate(1024 * 1024) // 1MB 缓冲
val sampleSize = extractor.readSampleData(buffer, 0)
if (sampleSize < 0) {
// 流结束
}
val sampleTime = extractor.sampleTime // 当前帧 PTS(微秒)
val sampleFlags = extractor.sampleFlags // BUFFER_FLAG_*
// ===== 跳到指定时间 =====
extractor.seekTo(positionUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
// SEEK_TO_CLOSEST_SYNC:跳到最近的同步点(I 帧)
// SEEK_TO_PREVIOUS_SYNC:跳到上一个 I 帧
// SEEK_TO_NEXT_SYNC:跳到下一个 I 帧
// ===== 释放 =====
extractor.release()
2.4 音画同步:PTS 驱动的显示控制
Android 播放器的音画同步,是在解码器输出帧后,通过比较 帧的 PTS 与 当前音视频时钟 的差值来决定立即显示、延迟等待还是丢弃。
2.4.1 同步策略分类
| 策略 | 主时钟 | 原理 | 优点 | 缺点 |
|---|---|---|---|---|
| 视频同步到音频 | 音频 | 音频精确,音频播放,视频等音频 | 音频不卡顿 | 视频可能掉帧 |
| 音频同步到视频 | 视频 | 视频固定帧率,音频调整采样率 | 视频流畅 | 音频可能有 Pop |
| 同步到系统时钟 | NTP | 以系统时间为基准 | 外部一致性好 | 漂移难控制 |
行业最佳实践:视频同步到音频(音频为主时钟),因为音频采样率由硬件晶振控制,精度远高于视频帧率。
2.4.2 同步算法实现
kotlin
class SyncController(private val masterClock: MasterClock) {
// 同步阈值
companion object {
private const val MAX_FRAME_DROP_MS = 100L // 超过 100ms 即丢帧
private const val SYNC_THRESHOLD_MS = 50L // 50ms 内无需同步
private const val SLEEP_GRANULARITY_MS = 10L // 睡眠粒度
}
/**
* @param framePtsUs 帧的 PTS(微秒)
* @param currentTimeUs 当前主时钟时间(微秒)
* @return SyncResult:立即显示 / 等待后显示 / 丢弃
*/
fun getSyncResult(framePtsUs: Long, currentTimeUs: Long): SyncResult {
val diffMs = (framePtsUs - currentTimeUs) / 1000
return when {
// 视频超前:需要等待
diffMs > SYNC_THRESHOLD_MS -> {
SyncResult.WaitAndDisplay(diffMs)
}
// 视频落后超过阈值:丢弃
diffMs < -MAX_FRAME_DROP_MS -> {
Log.w("Sync", "Frame late by ${-diffMs}ms, dropping")
SyncResult.Drop
}
// 在可接受范围内:立即显示
else -> SyncResult.DisplayNow
}
}
sealed class SyncResult {
object DisplayNow : SyncResult()
data class WaitAndDisplay(val waitMs: Long) : SyncResult()
object Drop : SyncResult()
}
}
/**
* 主时钟(音频作为主时钟)
*/
class AudioMasterClock(private val audioTrack: AudioTrack) : MasterClock {
override fun getCurrentTimeUs(): Long {
// AudioTrack.getPlaybackHeadPosition() 返回已播放采样数
val headPos = audioTrack.playbackHeadPosition
// 将采样位置转换为微秒
val sampleRate = audioTrack.sampleRate
return headPos * 1_000_000L / sampleRate
}
}
2.5 缓冲管理:避免卡顿的核心
2.5.1 三级缓冲架构
┌─────────────────────────────────────────────────────────────┐
│ 网络/HTTP 缓冲 │
│ OkHttp / URLConnection │
│ 典型缓冲:2--10 MB │
└─────────────────────────────┬───────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 解码器输入缓冲 │
│ MediaCodec Input Buffer │
│ 典型缓冲:3--5 帧 │
│ dequeueInputBuffer() → queueInputBuffer() │
└─────────────────────────────┬───────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 解码器输出缓冲 │
│ MediaCodec Output Buffer │
│ 典型缓冲:5--10 帧 │
│ dequeueOutputBuffer() → releaseOutputBuffer() │
└─────────────────────────────┬───────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 渲染缓冲 │
│ Surface/TextureView 帧缓冲 │
│ 典型缓冲:2--3 帧 │
│ SurfaceView 双缓冲(自动) │
└─────────────────────────────────────────────────────────────┘
2.5.2 缓冲监控与自适应
kotlin
class BufferMonitor(private val decoder: MediaCodec) {
private val bufferHistory = ArrayDeque<Long>(100)
// 监控解码器缓冲队列深度
fun checkBufferHealth(): BufferHealth {
val inputCount = bufferHistory.filter { it > 0 }.size
val outputCount = bufferHistory.filter { it < 0 }.size
return when {
inputCount > 3 && outputCount < 1 -> BufferHealth.UNDERFLOW
inputCount < 1 && outputCount > 5 -> BufferHealth.OVERFLOW
else -> BufferHealth.NORMAL
}
}
enum class BufferHealth {
NORMAL,
UNDERFLOW, // 解码器"饿"了(输入不足)
OVERFLOW // 解码器"撑"了(输出积压)
}
// 动态调整:缓冲不足时降低解码帧率,积压时加速
fun adaptiveDecodeSpeed(health: BufferHealth): Float {
return when (health) {
BufferHealth.UNDERFLOW -> 1.2f // 加速解码
BufferHealth.OVERFLOW -> 0.8f // 减速解码
BufferHealth.NORMAL -> 1.0f
}
}
}
三、ExoPlayer(Media3):工程级播放器框架
3.1 ExoPlayer vs MediaCodec:何时选择
小场景 → MediaPlayer + SurfaceView(一行代码)
需要自定义解码流程 → MediaCodec 裸 API
需要:直播(HLS/DASH/RTMP)、多音轨、DRM、缩略图、
预加载、播放器状态机 → ExoPlayer (Media3)
3.2 ExoPlayer(Media3)架构
┌─────────────────────────────────────────────────────────────┐
│ Media3 ExoPlayer │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Player UI(PlayerView) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ ExoPlayer 核心 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ MediaSource │ │ TrackSelector │ │LoadControl │ │ │
│ │ │ (数据源管理) │ │ (轨道选择) │ │(缓冲控制) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────┘ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ │
│ │ │ Renderers │ │ MediaCodec │ │ Clock/Sync │ │ │
│ │ │ (渲染器) │ │ (解码器) │ │(时钟同步) │ │ │
│ │ └──────────────┘ └──────────────┘ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼───────────────────────────┐ │
│ │ 数据源层(MediaSource) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Progressive│ │ HlsMediaSource│ │DashMediaSource│ │RtspMediaSource│ │
│ │ │(普通文件)│ │(HTTP直播流) │ │(自适应流) │ │(实时流) │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
3.3 ExoPlayer 快速上手
3.3.1 依赖引入(Media3 / ExoPlayer 2.19+)
kotlin
// build.gradle.kts (Module)
dependencies {
// Media3 ExoPlayer(推荐)
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1") // 播放器控件
implementation("androidx.media3:media3-exoplayer-hls:1.2.1") // HLS 支持
implementation("androidx.media3:media3-exoplayer-dash:1.2.1") // DASH 支持
implementation("androidx.media3:media3-exoplayer-rtsp:1.2.1") // RTSP 支持
implementation("androidx.media3:media3-session:1.2.1") // MediaSession
}
3.3.2 基础播放(10 行代码)
kotlin
class MainActivity : AppCompatActivity() {
private var player: ExoPlayer? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// ===== 1. 创建 Player =====
player = ExoPlayer.Builder(this).build().also { exoPlayer ->
// ===== 2. 绑定视图 =====
findViewById<PlayerView>(R.id.playerView).also { pv ->
pv.player = exoPlayer
pv.useController = true // 显示播放控制条
}
// ===== 3. 创建 MediaItem(数据源)=====
val mediaItem = MediaItem.Builder()
.setUri("https://www.w3schools.com/html/mov_bbb.mp4")
// 或本地文件:Uri.parse("file:///sdcard/video.mp4")
.setMimeType("video/mp4")
.build()
// ===== 4. 设置媒体并播放 =====
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.playWhenReady = true // 准备好后自动播放
}
}
override fun onStart() {
super.onStart()
player?.playWhenReady = player?.playWhenReady ?: true
}
override fun onStop() {
super.onStop()
player?.playWhenReady = false // 切后台暂停
}
override fun onDestroy() {
super.onDestroy()
player?.release() // 务必释放!
player = null
}
}
3.3.3 XML 布局(PlayerView)
xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:show_buffering="when_playing" <!-- 播放时显示缓冲 -->
app:use_controller="true" <!-- 显示控制条 -->
app:controller_layout_id="@layout/exo_player_control_view" <!-- 自定义控制条 -->
app:resize_mode="fit" <!-- 自适应 -->
app:surface_type="surface_view" <!-- SurfaceView(性能优先)-->
/>
</androidx.constraintlayout.widget.ConstraintLayout>
3.4 ExoPlayer 进阶配置
3.4.1 自定义 LoadControl(缓冲控制)
kotlin
val loadControl = DefaultLoadControl.Builder()
.setAllocator(DefaultAllocator(true, 32))
.setBufferDurationsMs(
/* minBufferMs = */ 15_000, // 最小缓冲 15 秒
/* maxBufferMs = */ 50_000, // 最大缓冲 50 秒
/* bufferForPlaybackMs = */ 2_500, // 开始播放需缓冲 2.5 秒
/* bufferForPlaybackAfterRebufferMs = */ 5_000 // 重缓冲需缓冲 5 秒
)
.setTargetBufferBytes(25 * 1024 * 1024) // 目标缓冲 25 MB
.build()
val player = ExoPlayer.Builder(context)
.setLoadControl(loadControl)
.build()
3.4.2 轨道选择(多音轨/多字幕)
kotlin
// 监听轨道变化
player.addListener(object : Player.Listener {
override fun onTracksChanged(tracks: Tracks) {
for (trackGroup in tracks.trackGroups) {
for (i in 0 until trackGroup.length) {
val format = trackGroup.getFormat(i)
Log.d("Track", "轨道 ${trackGroup.type}: ${format.sampleMimeType} "
+ "${format.width}x${format.height} @ ${format.bitrate}")
}
}
}
})
// 手动选择轨道
val trackSelector = DefaultTrackSelector(context)
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setPreferredTextLanguage("zh") // 首选中文字幕
.setPreferredAudioLanguage("zh-CN") // 首选中文音频
.setMaxVideoSizeSd() // 最大 DVD 画质(省流量)
)
3.4.3 HLS 直播流播放
kotlin
// HLS 直播流(自动处理 #EXTINF 和分片下载)
val hlsMediaItem = MediaItem.Builder()
.setUri("https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build()
player.setMediaItem(hlsMediaItem)
player.prepare()
// DASH 直播流
val dashMediaItem = MediaItem.Builder()
.setUri("https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd")
.setMimeType(MimeTypes.APPLICATION_DASH)
.build()
3.4.4 播放事件监听
kotlin
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
when (state) {
Player.STATE_IDLE -> Log.d("Player", "空闲")
Player.STATE_BUFFERING -> Log.d("Player", "缓冲中...")
Player.STATE_READY -> Log.d("Player", "就绪")
Player.STATE_ENDED -> Log.d("Player", "播放结束")
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d("Player", "播放状态: ${if (isPlaying) "播放中" else "暂停"}")
}
override fun onPlayerError(error: PlaybackException) {
Log.e("Player", "播放错误: ${error.errorCode}")
when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
showRetryDialog("网络错误")
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ->
showRetryDialog("解码器初始化失败")
}
}
override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
Log.d("Player", "位置不连续: ${oldPosition.positionMs} → ${newPosition.positionMs}")
}
})
3.5 ExoPlayer 完整 Demo 项目结构
app/
├── build.gradle.kts
└── src/main/
├── AndroidManifest.xml
├── java/com/example/androidplayer/
│ ├── MainActivity.kt # 主播放器界面
│ ├── player/
│ │ ├── PlayerManager.kt # 播放器管理器(单例)
│ │ ├── CustomPlayerActivity.kt # 自定义播放器(不用 PlayerView)
│ │ ├── HlsPlayerActivity.kt # HLS 直播播放器
│ │ └── PlayerService.kt # 后台播放服务(MediaSession)
│ ├── decoder/
│ │ ├── MediaCodecDecoder.kt # 裸 MediaCodec 解码示例
│ │ └── SurfaceRenderer.kt # Surface 渲染辅助
│ └── utils/
│ └── BufferMonitor.kt # 缓冲监控工具
└── res/
├── layout/
│ ├── activity_main.xml # 基础 ExoPlayer 布局
│ ├── activity_custom_player.xml # 自定义布局
│ └── activity_hls_player.xml # HLS 直播布局
└── values/strings.xml
四、裸 MediaCodec 解码器:手把手实现
4.1 完整的 MediaCodec 解码流程
以下代码展示如何使用 MediaCodec 裸 API 解码 MP4 文件并渲染到 Surface,不依赖 ExoPlayer:
kotlin
/**
* MediaCodecVideoDecoder:裸 MediaCodec 解码器
* 支持:H.264 / H.265 / VP8 / VP9
*/
class MediaCodecVideoDecoder(
private val context: Context,
private val surface: Surface
) {
private var decoder: MediaCodec? = null
private var extractor: MediaExtractor? = null
private var videoTrackIndex = -1
private var isRunning = false
private var thread: Thread? = null
// 帧率控制
private var frameIntervalUs = 33_333L // 30fps ≈ 33333us 每帧
/**
* 解码主循环
*/
fun start(filePath: String) {
if (isRunning) return
isRunning = true
thread = Thread {
try {
// ===== 步骤 1:解复用 =====
val ext = MediaExtractor()
ext.setDataSource(filePath)
extractor = ext
// ===== 步骤 2:找到视频轨道 =====
for (i in 0 until ext.trackCount) {
val format = ext.getTrackFormat(i)
val mime = format.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("video/")) {
ext.selectTrack(i)
videoTrackIndex = i
// ===== 步骤 3:创建解码器 =====
val mimeType = mime
decoder = MediaCodec.createDecoderByType(mimeType)
// ===== 步骤 4:配置解码器(关键!)=====
// configure 的第三个参数 surface:将解码输出直接送入 Surface
decoder!!.configure(format, surface, null, 0)
decoder!!.start()
break
}
}
if (videoTrackIndex < 0 || decoder == null) {
throw IllegalStateException("未找到视频轨道或无法创建解码器")
}
// ===== 步骤 5:解码循环 =====
decodeLoop(ext, decoder!!)
} catch (e: Exception) {
Log.e("Decoder", "解码错误", e)
} finally {
release()
}
}
thread?.start()
}
private fun decodeLoop(ext: MediaExtractor, dec: MediaCodec) {
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
var outputDone = false
while (!outputDone && isRunning) {
// ===== A. 喂入压缩数据(生产者端)=====
if (!inputDone) {
val inputBufIndex = dec.dequeueInputBuffer(10_000) // 10ms 超时
when {
inputBufIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
// 无可用输入缓冲,稍后再试
}
inputBufIndex >= 0 -> {
val inputBuf = dec.getInputBuffer(inputBufIndex)!!
val sampleSize = ext.readSampleData(inputBuf, 0)
if (sampleSize < 0) {
// 流结束,发送 EOS
dec.queueInputBuffer(
inputBufIndex, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM
)
inputDone = true
Log.d("Decoder", "Input EOS sent")
} else {
// 写入压缩数据及 PTS
dec.queueInputBuffer(
inputBufIndex, 0, sampleSize,
ext.sampleTime, // PTS(微秒)
0
)
ext.advance()
}
}
}
}
// ===== B. 取出解码数据(消费者端)=====
val outputBufIndex = dec.dequeueOutputBuffer(bufferInfo, 10_000)
when {
outputBufIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
// 无可用输出
}
outputBufIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
val newFormat = dec.outputFormat
Log.d("Decoder", "输出格式: $newFormat")
// 可在此获取输出宽高
val outWidth = newFormat.getInteger(MediaFormat.KEY_WIDTH)
val outHeight = newFormat.getInteger(MediaFormat.KEY_HEIGHT)
Log.d("Decoder", "输出分辨率: ${outWidth}x${outHeight}")
}
outputBufIndex >= 0 -> {
val render = (bufferInfo.size != 0)
if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) {
outputDone = true
Log.d("Decoder", "Output EOS received")
}
// ===== 步骤 6:渲染到 Surface =====
// 第二个参数 = true → 按 PTS 定时渲染(音画同步核心)
// false → 立即渲染
dec.releaseOutputBuffer(outputBufIndex, render)
if (render) {
// 记录帧信息
val frameMs = bufferInfo.presentationTimeUs / 1000
// Log.v("Decoder", "Frame @ ${frameMs}ms")
}
}
}
}
}
fun stop() {
isRunning = false
thread?.join(2000)
}
fun release() {
isRunning = false
try {
decoder?.stop()
decoder?.release()
decoder = null
extractor?.release()
extractor = null
} catch (e: Exception) {
Log.e("Decoder", "释放错误", e)
}
}
}
4.2 带音画同步的完整播放器
kotlin
/**
* SimpleMediaPlayer:带音画同步的完整播放器
* 使用 MediaCodec 裸 API 实现
*/
class SimpleMediaPlayer(
private val context: Context,
private val surfaceView: SurfaceView
) {
private var videoDecoder: MediaCodec? = null
private var audioTrack: AudioTrack? = null
private var extractor: MediaExtractor? = null
private var videoThread: Thread? = null
private var audioThread: Thread? = null
private var isRunning = AtomicBoolean(false)
private val videoStartTimeUs = AtomicLong(0)
// 视频缓冲队列
private val videoFrameQueue = LinkedBlockingQueue<Frame>(5)
// 音频采样队列
private val audioSampleQueue = LinkedBlockingQueue<AudioSample>(50)
data class Frame(val data: ByteBuffer, val ptsUs: Long, val size: Int)
data class AudioSample(val data: ByteBuffer, val ptsUs: Long, val size: Int)
fun play(filePath: String) {
if (isRunning.get()) return
isRunning.set(true)
// 初始化 Extractor
val ext = MediaExtractor()
ext.setDataSource(filePath)
extractor = ext
// 分别启动音视频解码线程
videoThread = Thread { decodeVideo(ext, surfaceView.holder.surface) }
audioThread = Thread { playAudio(ext) }
videoThread?.start()
audioThread?.start()
}
private fun decodeVideo(ext: MediaExtractor, surface: Surface) {
// 找到视频轨道
var trackIndex = -1
var format: MediaFormat? = null
for (i in 0 until ext.trackCount) {
val f = ext.getTrackFormat(i)
val mime = f.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("video/")) {
ext.selectTrack(i)
trackIndex = i
format = f
break
}
}
if (trackIndex < 0 || format == null) return
// 创建解码器
val mime = format.getString(MediaFormat.KEY_MIME)!!
videoDecoder = MediaCodec.createDecoderByType(mime)
videoDecoder!!.configure(format, surface, null, 0)
videoDecoder!!.start()
val bufferInfo = MediaCodec.BufferInfo()
var inputDone = false
while (isRunning.get()) {
// 喂入数据
if (!inputDone) {
val bufIdx = videoDecoder!!.dequeueInputBuffer(10_000)
if (bufIdx >= 0) {
val buf = videoDecoder!!.getInputBuffer(bufIdx)!!
val size = ext.readSampleData(buf, 0)
if (size < 0) {
videoDecoder!!.queueInputBuffer(bufIdx, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
} else {
videoDecoder!!.queueInputBuffer(bufIdx, 0, size,
ext.sampleTime, 0)
ext.advance()
}
}
}
// 取出渲染
val outIdx = videoDecoder!!.dequeueOutputBuffer(bufferInfo, 10_000)
if (outIdx >= 0) {
// 设置 PTS 参考时间(第一帧时记录)
if (videoStartTimeUs.get() == 0L && bufferInfo.size > 0) {
videoStartTimeUs.set(System.nanoTime() / 1000 - bufferInfo.presentationTimeUs)
}
val renderTimeUs = videoStartTimeUs.get() + bufferInfo.presentationTimeUs
val currentTimeUs = System.nanoTime() / 1000
val sleepUs = renderTimeUs - currentTimeUs
if (sleepUs > 0) {
// 音画同步:视频等待音频
Thread.sleep(sleepUs / 1000, (sleepUs % 1000).toInt() * 1000)
}
val doRender = (bufferInfo.size != 0)
videoDecoder!!.releaseOutputBuffer(outIdx, doRender)
}
}
}
private fun playAudio(ext: MediaExtractor) {
// 找到音频轨道
var trackIndex = -1
var format: MediaFormat? = null
for (i in 0 until ext.trackCount) {
val f = ext.getTrackFormat(i)
val mime = f.getString(MediaFormat.KEY_MIME) ?: continue
if (mime.startsWith("audio/")) {
ext.selectTrack(i)
trackIndex = i
format = f
break
}
}
if (trackIndex < 0 || format == null) return
val sampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
val channelCount = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
// 创建 AudioTrack
val channelMask = if (channelCount == 1) AudioFormat.CHANNEL_OUT_MONO
else AudioFormat.CHANNEL_OUT_STEREO
val bufSize = AudioTrack.getMinBufferSize(sampleRate, channelMask,
AudioFormat.ENCODING_PCM_16BIT)
audioTrack = AudioTrack.Builder()
.setAudioFormat(AudioFormat.Builder()
.setSampleRate(sampleRate)
.setChannelMask(channelMask)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.build())
.setBufferSizeInBytes(bufSize * 2)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
audioTrack!!.play()
// 音频解码(使用 MediaCodec)
val mime = format.getString(MediaFormat.KEY_MIME)!!
val audioDecoder = MediaCodec.createDecoderByType(mime)
audioDecoder.configure(format, null, null, 0)
audioDecoder.start()
val bufferInfo = MediaCodec.BufferInfo()
val audioBuf = ByteArray(4096)
var inputDone = false
while (isRunning.get()) {
if (!inputDone) {
val bufIdx = audioDecoder.dequeueInputBuffer(10_000)
if (bufIdx >= 0) {
val buf = audioDecoder.getInputBuffer(bufIdx)!!
val size = ext.readSampleData(buf, 0)
if (size < 0) {
audioDecoder.queueInputBuffer(bufIdx, 0, 0, 0,
MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputDone = true
} else {
audioDecoder.queueInputBuffer(bufIdx, 0, size,
ext.sampleTime, 0)
ext.advance()
}
}
}
val outIdx = audioDecoder.dequeueOutputBuffer(bufferInfo, 10_000)
if (outIdx >= 0) {
val buf = audioDecoder.getOutputBuffer(outIdx)!!
buf.get(audioBuf, 0, bufferInfo.size)
audioTrack?.write(audioBuf, 0, bufferInfo.size)
audioDecoder.releaseOutputBuffer(outIdx, false)
}
}
audioDecoder.stop()
audioDecoder.release()
audioTrack?.stop()
audioTrack?.release()
}
fun stop() {
isRunning.set(false)
videoThread?.join(2000)
audioThread?.join(2000)
}
}
五、实战:完整可运行的 Demo 项目
5.1 项目配置
文件:app/build.gradle.kts(完整依赖)
kotlin
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.androidplayer"
compileSdk = 34
defaultConfig {
applicationId = "com.example.androidplayer"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// AndroidX Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
// Media3 / ExoPlayer(推荐使用 Media3)
implementation("androidx.media3:media3-exoplayer:1.2.1")
implementation("androidx.media3:media3-ui:1.2.1")
implementation("androidx.media3:media3-exoplayer-hls:1.2.1")
implementation("androidx.media3:media3-exoplayer-dash:1.2.1")
implementation("androidx.media3:media3-exoplayer-rtsp:1.2.1")
implementation("androidx.media3:media3-session:1.2.1")
// Lifecycle
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// Testing
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
}
5.2 AndroidManifest.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- 网络权限(播放 HTTP 流媒体)-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 存储权限(播放本地文件)-->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- SurfaceView 硬件加速(默认开启)-->
<uses-feature
android:name="android.hardware.surface.virtual"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="AndroidPlayer"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidPlayer"
android:hardwareAccelerated="true"
tools:targetApi="34">
<!-- 主播放器 Activity -->
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:exported="true"
android:screenOrientation="landscape"
android:theme="@style/Theme.AndroidPlayer.Fullscreen">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 自定义播放器(裸 MediaCodec) -->
<activity
android:name=".player.CustomPlayerActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="landscape" />
<!-- HLS 直播播放器 -->
<activity
android:name=".player.HlsPlayerActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="landscape" />
</application>
</manifest>
5.3 MainActivity:ExoPlayer 基础播放
kotlin
package com.example.androidplayer
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.PlayerView
import com.example.androidplayer.databinding.ActivityMainBinding
/**
* MainActivity:ExoPlayer 基础播放器
* 展示最简单的 ExoPlayer 集成方式
*/
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private var player: ExoPlayer? = null
// ===== 示例媒体 URL =====
private val sampleVideos = listOf(
// Sintel 开放电影(MP4)
MediaItem.Builder()
.setUri("https://download.sintel.org/sintel.mp4")
.setMimeType("video/mp4")
.build(),
// Big Buck Bunny
MediaItem.Builder()
.setUri("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")
.setMimeType("video/mp4")
.build(),
// Tears of Steel
MediaItem.Builder()
.setUri("https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4")
.setMimeType("video/mp4")
.build(),
)
private var currentVideoIndex = 0
// 文件选择器
private val pickFileLauncher = registerForActivityResult(
ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let { playUri(it) }
}
// 权限请求
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) {
openFilePicker()
} else {
Toast.makeText(this, "需要存储权限以打开本地文件", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupClickListeners()
}
private fun setupClickListeners() {
// 点击屏幕:选择视频
binding.playerView.setOnClickListener {
showVideoSelector()
}
// 双击:切换下一个视频
binding.playerView.setOnClickListener { /* prevent double-listener */ }
}
private fun showVideoSelector() {
val titles = sampleVideos.mapIndexed { index, item ->
when (index) {
0 -> "Sintel (开放电影)"
1 -> "Big Buck Bunny"
2 -> "Tears of Steel"
else -> "视频 ${index + 1}"
}
}.toMutableList().apply { add("打开本地文件...") }
android.app.AlertDialog.Builder(this)
.setTitle("选择视频")
.setItems(titles.toTypedArray()) { _, which ->
if (which < sampleVideos.size) {
playMediaItem(sampleVideos[which])
} else {
requestStoragePermission()
}
}
.show()
}
private fun requestStoragePermission() {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
// Android 13+ 使用 READ_MEDIA_VIDEO
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED) {
openFilePicker()
} else {
permissionLauncher.launch(Manifest.permission.READ_MEDIA_VIDEO)
}
}
else -> {
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
openFilePicker()
} else {
permissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
}
}
private fun openFilePicker() {
pickFileLauncher.launch("video/*")
}
private fun playMediaItem(mediaItem: MediaItem) {
ensurePlayerReady()
player?.setMediaItem(mediaItem)
player?.prepare()
player?.playWhenReady = true
}
private fun playUri(uri: Uri) {
ensurePlayerReady()
val item = MediaItem.Builder()
.setUri(uri)
.build()
player?.setMediaItem(item)
player?.prepare()
player?.playWhenReady = true
}
private fun ensurePlayerReady() {
if (player == null) {
initializePlayer()
}
}
private fun initializePlayer() {
// 轨道选择器(自动选择最佳质量)
val trackSelector = DefaultTrackSelector(this)
trackSelector.setParameters(
trackSelector.buildUponParameters()
.setMaxVideoSizeHd() // 最大 720p(省流量)
)
// 渲染工厂(优先使用硬件解码)
val renderersFactory = DefaultRenderersFactory(this)
.setEnableDecoderFallback(false)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER)
player = ExoPlayer.Builder(this, renderersFactory)
.setTrackSelector(trackSelector)
.setSeekForwardIncrementMs(10_000) // 快进 10 秒
.setSeekBackIncrementMs(10_000) // 快退 10 秒
.build()
.also { exo ->
binding.playerView.player = exo
binding.playerView.useController = true
// 播放事件监听
exo.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) {
val stateName = when (state) {
Player.STATE_IDLE -> "空闲"
Player.STATE_BUFFERING -> "缓冲中"
Player.STATE_READY -> "就绪"
Player.STATE_ENDED -> "播放结束"
else -> "未知"
}
Toast.makeText(this@MainActivity, "播放器: $stateName",
Toast.LENGTH_SHORT).show()
}
override fun onPlayerError(error: PlaybackException) {
val msg = when (error.errorCode) {
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
"网络连接失败"
PlaybackException.ERROR_CODE_IO_BAD_URI ->
"无效的媒体地址"
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED ->
"解码器初始化失败"
PlaybackException.ERROR_CODE_TIMEOUT ->
"网络超时"
else -> "播放错误: ${error.errorCodeName}"
}
Toast.makeText(this@MainActivity, msg, Toast.LENGTH_LONG).show()
}
})
}
// 自动播放第一个视频
playMediaItem(sampleVideos[0])
}
override fun onStart() {
super.onStart()
if (player == null) {
initializePlayer()
}
}
override fun onResume() {
super.onResume()
player?.playWhenReady = player?.playWhenReady ?: true
}
override fun onPause() {
super.onPause()
// 播放状态自动保存
}
override fun onStop() {
super.onStop()
player?.playWhenReady = false
}
override fun onDestroy() {
super.onDestroy()
releasePlayer()
}
private fun releasePlayer() {
player?.release()
player = null
}
}
5.4 activity_main.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:show_buffering="when_playing"
app:use_controller="true"
app:resize_mode="fit"
app:surface_type="surface_view"
app:controller_layout_id="@layout/exo_player_control_view" />
<!-- 加载指示器 -->
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<!-- 提示文字 -->
<TextView
android:id="@+id/hintText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="16dp"
android:text="点击选择视频"
android:textColor="@android:color/white"
android:textSize="16sp" />
</FrameLayout>
5.5 CustomPlayerActivity:裸 MediaCodec 播放
kotlin
package com.example.androidplayer.player
import android.os.Bundle
import android.util.Log
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.example.androidplayer.databinding.ActivityCustomPlayerBinding
/**
* CustomPlayerActivity:使用裸 MediaCodec API 的自定义播放器
* 展示完整的解复用 → 解码 → 渲染流程
* 对比 ExoPlayer 方案:代码量更大,但完全可控
*/
class CustomPlayerActivity : AppCompatActivity(), SurfaceHolder.Callback2 {
private lateinit var binding: ActivityCustomPlayerBinding
private var decoder: MediaCodecVideoDecoder? = null
private var videoPath: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityCustomPlayerBinding.inflate(layoutInflater)
setContentView(binding.root)
// 从 Intent 获取视频路径
videoPath = intent.getStringExtra("video_path")
setupUI()
setupSurface()
}
private fun setupUI() {
binding.surfaceView.holder.addCallback(this)
// 全屏
window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
)
}
private fun setupSurface() {
binding.surfaceView.holder.setFormat(android.graphics.PixelFormat.RGBA_8888)
}
// ===== SurfaceHolder.Callback2 实现 =====
override fun surfaceCreated(holder: SurfaceHolder) {
Log.d("CustomPlayer", "Surface created, starting decoder...")
startPlayback(holder.surface)
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int,
width: Int, height: Int) {
Log.d("CustomPlayer", "Surface changed: ${width}x${height}")
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
Log.d("CustomPlayer", "Surface destroyed, stopping decoder...")
stopPlayback()
}
override fun surfaceRedrawNeeded(holder: SurfaceHolder) {
// 不需要额外处理
}
private fun startPlayback(surface: android.view.Surface) {
val path = videoPath
if (path == null) {
Toast.makeText(this, "未指定视频路径", Toast.LENGTH_SHORT).show()
finish()
return
}
// 启动裸 MediaCodec 解码器
decoder = MediaCodecVideoDecoder(this, surface)
decoder?.start(path)
}
private fun stopPlayback() {
decoder?.stop()
decoder?.release()
decoder = null
}
override fun onPause() {
super.onPause()
stopPlayback()
}
override fun onDestroy() {
super.onDestroy()
stopPlayback()
}
}
5.6 activity_custom_player.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/black">
<!-- SurfaceView:性能最优的视频渲染方式 -->
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<!-- 进度显示 -->
<TextView
android:id="@+id/statusText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|start"
android:padding="16dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:background="#80000000"
android:text="解码中..." />
</FrameLayout>
六、常见问题与调试技巧
6.1 解码器创建失败
kotlin
// 常见原因 1:未找到对应 Mime 的解码器
// 解决:先查询可用解码器
fun listAvailableCodecs() {
val codecList = MediaCodecList(MediaCodecList.REGULAR_CODECS)
for (info in codecList.codecInfos) {
if (!info.isEncoder) {
Log.d("Codec", "Decoder: ${info.name}")
for (type in info.supportedTypes) {
Log.d("Codec", " - $type")
}
}
}
}
// 常见原因 2:Surface 已销毁后尝试配置解码器
// 解决:在 Surface 可用时再配置
// 常见原因 3:Android 10+ 作用域存储限制
// 解决:使用 SAF (Storage Access Framework)
private val pickerLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
// 持久化权限
contentResolver.takePersistableUriPermission(
it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
playUri(it)
}
}
6.2 EGL 初始化失败(TextureView)
kotlin
// TextureView 需要 EGL 支持,在部分设备上可能失败
// 解决:降级到 SurfaceView
try {
val surfaceTexture = textureView.surfaceTexture ?: return
surfaceTexture.setDefaultBufferSize(videoWidth, videoHeight)
val surface = Surface(surfaceTexture)
// 配置解码器
} catch (e: Exception) {
Log.e("EGL", "TextureView EGL 失败,降级到 SurfaceView", e)
switchToSurfaceView()
}
6.3 音画不同步
kotlin
// 诊断:打印 PTS vs 实际渲染时间
Log.d("Sync", "Frame PTS: ${ptsUs}us, "
+ "Render delay: ${renderDelayUs}us, "
+ "Total buffer: ${totalBufferedMs}ms")
// 常见原因:
// 1. 视频关键帧间隔太大 → 减少 I_FRAME_INTERVAL
// 2. 缓冲时间太长 → 减少 bufferForPlaybackMs
// 3. SurfaceView vs TextureView 延迟差异 → 使用 SurfaceView
6.4 解码器缓冲区溢出/下溢
// 缓冲区溢出(Output Buffer 积压):
// 原因:渲染速度 < 解码速度
// 解决:增加 Surface 缓冲,或加速渲染
// 缓冲区下溢(Input Buffer 不足):
// 原因:网络卡顿 / 文件读取慢
// 解决:增加网络缓冲,或使用 ExoPlayer 的 AdaptiveLoadControl
6.5 使用 adb 调试播放
bash
# 查看 MediaCodec 状态
adb shell dumpsys media.codec
# 查看 SurfaceFlinger 层
adb shell dumpsys SurfaceFlinger
# 查看 MediaPlayer 服务
adb shell dumpsys media.player
# 查看 ExoPlayer 日志(需要开启调试模式)
# 在代码中:ExoPlayer.setLogLevel(Util.SDK_INT >= 21 ? Log.VERBOSE : Log.WARN)
adb logcat | grep -i "ExoPlayer\|MediaCodec\|Decoder"
# 抓取视频帧进行验证
adb shell screencap /sdcard/frame.png
adb pull /sdcard/frame.png
七、性能优化与进阶方向
7.1 性能对比参考
| 渲染方式 | 内存占用 | CPU 占用 | 渲染延迟 | 适合场景 |
|---|---|---|---|---|
| SurfaceView | 低 | 低 | ~1 帧 | 直播、大屏播放器 |
| TextureView | 中 | 中 | ~2 帧 | 需 View 变换/截图 |
| GLSurfaceView | 高 | 高 | ~3 帧 | 视频滤镜/OpenGL 特效 |
| ExoPlayer (默认) | 中 | 低 | ~1 帧 | 工程级播放器 |
7.2 进阶功能速览
| 功能 | 实现方式 | 参考 |
|---|---|---|
| 视频截图 | textureView.bitmap / SurfaceView.lockCanvas() |
Android 官方 |
| 倍速播放 | player.setPlaybackParameters(PlaybackParameters(2.0f)) |
ExoPlayer |
| 多音轨切换 | trackSelector.setParameters(...) |
ExoPlayer |
| DRM 加密播放 | MediaDrmCallback |
ExoPlayer Widevine |
| 后台播放 | MediaSessionService + MediaSession |
Media3 |
| 投屏/CAST | MediaRouteButton + Presentation |
Android Presentation |
| 视频滤镜 | GLSurfaceView + OpenGL ES 2.0 |
GPUImage Android |
| 硬件解码器选择 | MediaCodecList 遍历 + isHardwareAccelerated() |
CodecCapabilities |
7.3 ExoPlayer 后台播放 + MediaSession
kotlin
/**
* PlayerService:后台播放服务
* 支持通知栏控制、耳机线控、蓝牙控制
*/
class PlayerService : MediaSessionService() {
private var mediaSession: MediaSession? = null
private lateinit var player: ExoPlayer
override fun onCreate() {
super.onCreate()
player = ExoPlayer.Builder(this).build()
mediaSession = MediaSession.Builder(this, player)
.setCallback(MediaSessionCallback())
.build()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.playWhenReady) {
stopSelf()
}
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
mediaSession = null
}
}
private inner class MediaSessionCallback : MediaSession.Callback {
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>
): com.google.common.util.concurrent.ListenableFuture<MutableList<MediaItem>> {
// 将 Uri 转换为真实可播放的 MediaItem
val resolved = mediaItems.map { item ->
item.buildUpon()
.setUri(item.requestMetadata.mediaUri ?: item.localConfiguration?.uri)
.build()
}.toMutableList()
return Futures.immediateFuture(resolved)
}
}
}
八、调试命令与工具速查
| 工具 | 命令/用途 | 示例 |
|---|---|---|
| adb dumpsys | 查看系统多媒体服务状态 | adb shell dumpsys media.codec |
| adb logcat | 过滤 ExoPlayer/MediaCodec 日志 | adb logcat -s ExoPlayerImpl:* MediaCodec:* |
| adb screencap | 截取当前帧 | adb shell screencap /sdcard/frame.png |
| adb screenrecord | 录制播放过程 | adb shell screenrecord --time-limit 10 /sdcard/play.mp4 |
| MediaCodecList | 查询设备支持的编解码器 | 代码中遍历 MediaCodecList |
| GPU Render | 调试 GPU 渲染 | 设置 → 开发者 → GPU 渲染分析 |
| perfetto | 系统级性能追踪 | adb shell perfetto -c config.txt -o /sdcard/trace.perfetto |
| net槐 | 网络抓包 | Wireshark / mitmproxy 分析 HLS/DASH |
九、总结
9.1 学习路径推荐
第一阶段(1--2周):
MediaPlayer + SurfaceView → 实现简单播放
↓
第二阶段(2--4周):
MediaExtractor + MediaCodec → 理解解复用和解码原理
↓
第三阶段(4--6周):
ExoPlayer 基础使用 → 掌握轨道选择、缓冲控制
↓
第四阶段(持续):
ExoPlayer 深度定制 → MediaSession、后服务、DRM
9.2 关键知识点回顾
| 层次 | 核心概念 | 关键 API |
|---|---|---|
| 容器解析 | 轨道提取、压缩数据读取 | MediaExtractor |
| 解码 | 编解码器状态机、输入/输出缓冲 | MediaCodec.configure/start/dequeue |
| 渲染 | Surface 生产者/消费者、双缓冲 | SurfaceView / TextureView |
| 同步 | PTS 驱动、音画对齐策略 | SyncController |
| 框架 | ExoPlayer 组件化、MediaSource | ExoPlayer + PlayerView |
| 后台 | MediaSession、通知栏控制 | MediaSessionService |
9.3 项目快速启动
bash
# 1. 创建 Android Studio 项目
# File → New → New Project → Empty Activity
# 2. 添加 Media3 依赖(见 5.1 节)
# 3. 将 MainActivity.kt 和 activity_main.xml 复制到项目中
# 4. 运行到设备(推荐真机,模拟器性能有限)
十、参考文献
| # | 文档 | 出处 |
|---|---|---|
| 1 | Android MediaCodec API | https://developer.android.com/reference/android/media/MediaCodec |
| 2 | ExoPlayer (Media3) Guide | https://developer.android.com/media/media3/exoplayer |
| 3 | Android Graphics Architecture | https://source.android.com/docs/graphics/architecture |
| 4 | SurfaceView vs TextureView | https://developer.android.com/develop/ui/views/look-and-feel/gl-surface-rendering |
| 5 | RFC 3550 --- RTP (音视频同步基础) | IETF |
| 6 | ExoPlayer Source Code | https://github.com/androidx/media |
| 7 | Android MediaCodecInfo | https://developer.android.com/reference/android/media/MediaCodecInfo |
| 8 | MediaSession | https://developer.android.com/media/media3/media-session |
| 9 | Big Buck Bunny / Sintel | https://peach.blender.org/(开放电影素材) |