Android 播放器开发:从零构建全功能视频播放器


一、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/(开放电影素材)

相关推荐
Highcharts.js4 小时前
音频可视化图表开发|基于 Highcharts 内置音频合成器制作音乐排行榜图代码
javascript·信息可视化·音视频·highcharts
byte轻骑兵4 小时前
【LE Audio】CAP精讲[8]:CCID绑定术,打通音频流与控制的任督二脉
网络·人工智能·音视频·le audio·音视频控制
编程牛马姐4 小时前
YouTube视频一直转圈?加载卡顿原因分析与排查方法(2026)
音视频
ZC跨境爬虫4 小时前
跟着 MDN 学 HTML day_64:从 object 到 iframe 的嵌入技术全面解析
开发语言·前端·javascript·ui·html·音视频
ZFSS4 小时前
MultiNLI 多种类自然语言推理数据集介绍
人工智能·ai·ai作画·音视频·ai编程
真鬼1234 小时前
【Unity安卓】Unity 嵌入 Android Studio 完整流程
android·unity·android studio
星间都市山脉4 小时前
Windows 环境 Android 系统 APK 签名操作文档
android·windows
shuaiqinke4 小时前
【分享】OrbitV工具箱| 手表手环全能适配 |表盘应用一键装
android·智能手机