Android 音视频通话核心二 —— 视频编码详解记录

4 阅读8分钟

一、整体流程

[Camera.onPreviewFrame: NV12/YUV][rotateNV12_90/270][encoderH264(data: ByteArray)] 
↓
MediaCodec InputBuffer (NV12) 
↓ 
MediaCodec 硬编码 (H.264 AVC Baseline) 
↓ 
MediaCodec OutputBuffer (NAL units: SPS/PPS/IDR/P) 
↓ 
[SPS/PPS 合并到 IDR]onVideoEncoded(data) → P2P SDK → 小程序端

二、硬编码器优选:从芯片碎片化中找确定性


---

## 二、硬编码器优选:从芯片碎片化中找确定性

教育硬件最大的坑是**芯片不统一**。不同厂商的 Android 系统,MediaCodec 编码器名称、支持的颜色格式、Profile/Level 千差万别。

### 2.1 编码器选择策略

```kotlin
open fun initMediaCodecInfo(): MediaCodecInfo? {
    val hardwareEncoderList = mutableListOf(
        "OMX.MTK.VIDEO.ENCODER.AVC",      // 联发科
        "c2.android.avc.encoder",         // Android 通用硬解(部分芯片)
        "OMX.qcom.video.encoder.avc",     // 高通
        "OMX.hisi.video.encoder.avc"      // 海思
    )
    
    // 第一优先级:硬编码器
    for (codecInfo in codecList.codecInfos) {
        if (codecInfo.isEncoder && hardwareEncoderList.contains(codecInfo.name)) {
            return codecInfo
        }
    }
    
    // 第二优先级:软件编码器兜底(OMX.google.h264.encoder)
    for (codecInfo in codecList.codecInfos) {
        if (codecInfo.isEncoder && codecInfo.name.contains("avc")) {
            return codecInfo
        }
    }
    return null
}

  • 为什么优先硬编码? 软编码 CPU 占用高,教育硬件通常是 4 核 1.2G 的低端芯片,软编码 480p@15fps 就能把 CPU 吃满。
  • 为什么列这几个名字? 这是 Android 生态中常见的硬件编码器命名前缀。OMX 是 OpenMAX 标准,c2 是 Android 10+ 的 Codec2 框架。
  • 全志/瑞芯微怎么办? 代码里还有一段特殊处理 0MX.goke.video.encoder.avc(Goke 即国科/全志系),项目里遇到过这类芯片,需要显式匹配。

2.2 颜色格式 Fallback

硬编码器选好了,但它支持的颜色格式不一定是你想要的 NV12

val format = initVideoMediaFormat().apply {
    val configuredFormat = getInteger(MediaFormat.KEY_COLOR_FORMAT)
    if (!supportedColorFormats.contains(configuredFormat)) {
        val fallbackFormat = supportedColorFormats.find {
            it == COLOR_FormatYUV420SemiPlanar ||  // NV12
            it == COLOR_FormatYUV420Planar          // I420
        } ?: supportedColorFormats.firstOrNull()
        
        fallbackFormat?.let { setInteger(MediaFormat.KEY_COLOR_FORMAT, it) }
    }
}

  • COLOR_FormatYUV420SemiPlanar = NV12(UV 交错),这是 Camera1 预览回调最常见的格式。
  • COLOR_FormatYUV420Planar = I420/YUV420P(UV 分离),部分芯片只支持这个。
  • 如果两者都不支持,取 firstOrNull() 兜底,但此时需要在 Java 层做格式转换,否则编码器会报错或输出花屏。

为什么 MediaCodec 编码器不支持所有颜色格式?

硬编码器直接对接芯片 VPU/ISP,只支持硬件布局的几种格式。软件编码器(如 Google 的 OMX.google.h264.encoder)通常支持更全,但性能差。


三、MediaFormat 配置:Baseline + VBR 的保守主义

override fun initVideoMediaFormat(): MediaFormat {
    return MediaFormat.createVideoFormat(MIMETYPE_VIDEO_AVC, width, height).apply {
        setInteger(KEY_BIT_RATE, bitRate)
        setInteger(KEY_FRAME_RATE, frameRate)
        setInteger(KEY_I_FRAME_INTERVAL, iFrameInterval)
        setInteger(KEY_COLOR_FORMAT, COLOR_FormatYUV420SemiPlanar)
        setInteger(KEY_BITRATE_MODE, BITRATE_MODE_VBR)
        setInteger(KEY_PROFILE, AVCProfileBaseline)
        setInteger(KEY_LEVEL, AVCLevel31)
    }
}

参数为什么这样选
MIMETYPE_VIDEO_AVCH.264兼容性最好的视频编码格式,小程序端 100% 支持
KEY_BIT_RATE动态480p@15fps 约 1.5Mbps,720p@30fps 约 2.5Mbps
KEY_FRAME_RATE15/30通话场景 15fps 足够流畅,高端设备可 30fps
KEY_I_FRAME_INTERVAL2每 2 秒一个 IDR 关键帧,网络丢包后 2 秒内可恢复
KEY_COLOR_FORMATNV12与 Camera 预览输出格式一致,减少一次格式转换
KEY_BITRATE_MODEVBR可变码率,画面静止时自动降码率省带宽
KEY_PROFILEBaseline禁用 B 帧,降低编解码延迟,适合实时通话
KEY_LEVELLevel 3.1支持 720p@30fps,教育硬件场景足够

为什么用 Baseline 而不是 Main/High?

Baseline 不支持 B 帧,只有 I/P 帧,解码延迟低。Main/High 的 B 帧需要参考未来帧,不适合实时通话(会增加 1-2 帧延迟)。


四、编码循环:InputBuffer / OutputBuffer 与时间戳

override fun encoderH264(data: ByteArray) {
    if (data.isEmpty() || isStopping) return
    
    mExecutor.execute {
        synchronized(codecLock) {
            if (mMediaCodec == null || isStopping) return@execute
            
            // 1. 输入 YUV 数据
            val inputBufferIndex = codec.dequeueInputBuffer(-1)
            if (inputBufferIndex >= 0) {
                codec.getInputBuffer(inputBufferIndex)?.apply {
                    clear()
                    put(data)
                    val pts = if (startTimeUs == 0L) {
                        startTimeUs = System.nanoTime() / 1000
                        0L
                    } else {
                        System.nanoTime() / 1000 - startTimeUs
                    }
                    codec.queueInputBuffer(inputBufferIndex, 0, data.size, pts, 0)
                }
            }
            
            // 2. 输出 H.264 NAL 单元
            val bufferInfo = MediaCodec.BufferInfo()
            var outputBufferIndex = codec.dequeueOutputBuffer(bufferInfo, 0)
            while (outputBufferIndex >= 0) {
                // ... 输出处理见下节
                codec.releaseOutputBuffer(outputBufferIndex, false)
                outputBufferIndex = codec.dequeueOutputBuffer(bufferInfo, 0)
            }
        }
    }
}

关键点

  • dequeueInputBuffer(-1):阻塞等待,直到有可用 InputBuffer。视频帧率固定,不能丢帧,所以用阻塞模式。
  • startTimeUs:首帧 pts 为 0,后续帧用相对时间戳(微秒)。这是相对时间戳方案,避免 System.currentTimeMillis() 受系统时间调整影响。
  • synchronized(codecLock):编码器操作串行化,防止 stop() 时释放编码器与编码线程竞态。

五、SPS/PPS 与首帧 IDR:接收端不花屏的关键

5.1 现象:编码器启动后,前两个输出不是视频帧

MediaCodec 硬编码器启动后,通常按这个顺序输出:

  1. SPS(Sequence Parameter Set) :序列参数集,包含分辨率、Profile、Level 等
  2. PPS(Picture Parameter Set) :图像参数集,包含熵编码模式、切片分组等
  3. IDR(Instantaneous Decoder Refresh) :关键帧,接收端解码的起点

问题:如果把 SPS/PPS 和 IDR 分开发送,接收端(小程序)收到 IDR 时手里没有 SPS/PPS,根本不知道怎么解码,结果就是首帧花屏或黑屏,直到下一个 IDR 到来。

5.2 解决方案:缓存 SPS/PPS,与第一个 IDR 合并

var spsPpsData: ByteArray? = null

while (outputBufferIndex >= 0) {
    val outData = ByteArray(bufferInfo.size)
    buffer.get(outData)
    val isKeyFrame = (bufferInfo.flags and BUFFER_FLAG_KEY_FRAME) != 0
    
    // 识别并缓存 SPS/PPS(非关键帧、数据量小、首帧阶段)
    if (seq == 0L && !isKeyFrame && outData.size < 100) {
        spsPpsData = outData
    } else {
        // 关键帧且缓存了 SPS/PPS,合并发送
        var finalData = outData
        if (isKeyFrame && spsPpsData != null) {
            finalData = spsPpsData!! + outData
            spsPpsData = null
        }
        mEncoderListener?.onVideoEncoded(finalData, pts, seq, isKeyFrame)
        seq++
    }
}

  • 为什么 outData.size < 100 能识别 SPS/PPS? SPS/PPS 通常只有几十字节,IDR 帧通常几千到几万字节。这是一个经验阈值。
  • 为什么只缓存首帧阶段的非关键帧? 编码器运行中不会重复输出 SPS/PPS(除非手动请求 IDR),所以只在 seq == 0 阶段判断。
  • 合并后数据格式[SPS][PPS][IDR] 连续排列,接收端解码器一次性拿到所有初始化信息,首帧直接解码成功。

5.3 首帧强制 IDR

if (seq == 0L) {
    val params = Bundle()
    params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
    codec.setParameters(params)
} else if (seq % 30 == 0L) {  // 每30帧(约2秒@15fps)请求一个关键帧
    val params = Bundle()
    params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
    codec.setParameters(params)
}

解释

  • PARAMETER_KEY_REQUEST_SYNC_FRAME:请求编码器下一帧输出 IDR。
  • 首帧必须强制 IDR,因为接收端需要从 IDR 开始解码,不能从 P 帧开始。
  • 后续每 30 帧(或按 KEY_I_FRAME_INTERVAL)再请求一次,作为网络丢包后的恢复点。

六、动态码率与帧率:运行时自适应

通话过程中网络可能波动,需要实时调整编码参数。

6.1 动态码率

override fun setVideoBitRate(bitRate: Int) {
    val target = bitRate.coerceIn(bitRateRange.first, bitRateRange.last)
    if (videoEncodeParam.bitRate == target) return
    
    videoEncodeParam.bitRate = target
    val params = Bundle()
    params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, target)
    mMediaCodec?.setParameters(params)
}

关键点

  • coerceIn:限制在编码器支持的码率范围内,防止传非法值导致崩溃。
  • setParameters:运行时动态调整,不需要停止编码器。这是 MediaCodec 的 Dynamic Features API(API 19+ 支持)。

6.2 动态帧率

override fun setVideoFrameRate(frameRate: Int) {
    val target = frameRate.coerceIn(frameRateRange.first, frameRateRange.last)
    videoEncodeParam.frameRate = target
    val params = Bundle()
    params.putInt(MediaFormat.KEY_FRAME_RATE, target)
    mMediaCodec?.setParameters(params)
}

注意:部分芯片的硬编码器不支持运行时改帧率,调用后可能无效或报错。实际项目优先调码率,帧率作为辅助


七、并发安全与资源释放

7.1 停止标志 + 锁

@Volatile private var isStopping = false
private val codecLock = Object()

// 停止时
fun stop() {
    isStopping = true          // 1. 立 flag,新数据拒绝
    mExecutor.shutdown()       // 2. 等线程池结束
    mExecutor.awaitTermination(2, TimeUnit.SECONDS)
    
    synchronized(codecLock) {   // 3. 拿到锁后释放编码器
        mMediaCodec?.stop()
        mMediaCodec?.release()
        mMediaCodec = null
    }
}

设计意图

  • isStopping:轻量级,快速拒绝新数据。
  • codecLock:保护 mMediaCodec 的创建/使用/释放,防止 encoderH264 正在 dequeueInputBuffer 时,stop() 调用 release() 导致 IllegalStateException。

7.2 重建线程池

if (mExecutor.isShutdown) {
    mExecutor = Executors.newSingleThreadExecutor()
}
isStopping = false

为什么重建? 编码器生命周期与通话生命周期绑定,一次通话结束后 stop(),下次通话再 start() 时需要新的线程池。


八、踩坑记录

坑 1:编码器启动后首帧花屏/黑屏

  • 现象:小程序端收到设备端视频,前 2 秒花屏或黑屏。
  • 根因:SPS/PPS 与 IDR 分开发送,接收端先收到 IDR 但没有解码参数。
  • 解决:缓存首帧阶段的 SPS/PPS,与第一个 IDR 合并为单包发送。

坑 2:低端设备编码器初始化失败,报 UnsupportedFormat

  • 现象configure()IllegalArgumentException
  • 根因:编码器不支持配置的 NV12,或分辨率未对齐(某些芯片要求 16 字节对齐)。
  • 解决:颜色格式 Fallback(NV12 → I420 → 第一个可用);分辨率用 (width+15)/16*16 对齐。

坑 3:编码过程中切换码率,画面卡顿

  • 现象:网络波动时调用 setVideoBitRate,画面卡 1-2 秒。
  • 根因:码率变化幅度太大(如从 2Mbps 直接降到 500Kbps),编码器内部缓冲区溢出。
  • 解决:限制单次变化幅度(如每次最多 ±20%),或关闭动态码率使用固定码率。

坑 4:停止通话时崩溃,报 IllegalStateException

  • 现象:挂断时 dequeueOutputBuffer 抛异常。
  • 根因stop() 释放了 mMediaCodec,但 encoderH264 的线程还在执行。
  • 解决isStopping 标志 + codecLock 双重保护,且 stop()awaitTermination 等待线程结束。

九、总结

本文从硬编码器优选、MediaFormat 配置、编码循环、SPS/PPS 合并、动态码率五个环节,拆解了 Android 设备端 H.264 硬编码全链路。核心要点:

  • 芯片碎片化要求硬编码器优先 + 颜色格式 Fallback
  • Baseline + VBR 是实时通话的保守且最优组合
  • SPS/PPS 合并到首帧 IDR 是根治首帧花屏的关键
  • 首帧强制 IDR + 定期请求同步帧 保障网络丢包恢复
  • 运行时 setParameters 实现无感码率调整
  • isStopping + codecLock 保障并发安全