一、整体流程
scss
[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 → 小程序端
二、硬编码器优选:从芯片碎片化中找确定性
kotlin
---
## 二、硬编码器优选:从芯片碎片化中找确定性
教育硬件最大的坑是**芯片不统一**。不同厂商的 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。
ini
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 的保守主义
scss
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_AVC |
H.264 | 兼容性最好的视频编码格式,小程序端 100% 支持 |
KEY_BIT_RATE |
动态 | 480p@15fps 约 1.5Mbps,720p@30fps 约 2.5Mbps |
KEY_FRAME_RATE |
15/30 | 通话场景 15fps 足够流畅,高端设备可 30fps |
KEY_I_FRAME_INTERVAL |
2 | 每 2 秒一个 IDR 关键帧,网络丢包后 2 秒内可恢复 |
KEY_COLOR_FORMAT |
NV12 | 与 Camera 预览输出格式一致,减少一次格式转换 |
KEY_BITRATE_MODE |
VBR | 可变码率,画面静止时自动降码率省带宽 |
KEY_PROFILE |
Baseline | 禁用 B 帧,降低编解码延迟,适合实时通话 |
KEY_LEVEL |
Level 3.1 | 支持 720p@30fps,教育硬件场景足够 |
为什么用 Baseline 而不是 Main/High?
Baseline 不支持 B 帧,只有 I/P 帧,解码延迟低。Main/High 的 B 帧需要参考未来帧,不适合实时通话(会增加 1-2 帧延迟)。
四、编码循环:InputBuffer / OutputBuffer 与时间戳
kotlin
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 硬编码器启动后,通常按这个顺序输出:
- SPS(Sequence Parameter Set) :序列参数集,包含分辨率、Profile、Level 等
- PPS(Picture Parameter Set) :图像参数集,包含熵编码模式、切片分组等
- IDR(Instantaneous Decoder Refresh) :关键帧,接收端解码的起点
问题 :如果把 SPS/PPS 和 IDR 分开发送,接收端(小程序)收到 IDR 时手里没有 SPS/PPS,根本不知道怎么解码,结果就是首帧花屏或黑屏,直到下一个 IDR 到来。
5.2 解决方案:缓存 SPS/PPS,与第一个 IDR 合并
ini
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
scss
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 动态码率
kotlin
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 动态帧率
kotlin
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 停止标志 + 锁
kotlin
@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 重建线程池
ini
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 保障并发安全