13、直播推流基础:RTMP协议、FLV封装、H.264/AAC裸流打包、推流SDK集成
做直播推流,说白了就是把手机摄像头采集的画面和麦克风采集的声音,实时地送到服务器上去。这个过程听起来简单,但里面涉及的协议、封装、编码打包,每一个环节都可能让你翻车。我最早接触推流时,以为只要调个SDK就完事了,结果连RTMP握手都搞不明白,画面卡得跟幻灯片似的。
这一章,咱们就把推流这条链路拆开来看。从RTMP协议怎么建立连接,到FLV怎么封装音视频数据,再到H.264和AAC裸流怎么打包成FLV Tag,最后再聊推流SDK的集成思路。嗯,内容不少,但都是实战中绕不开的硬骨头。
13.1 RTMP协议:直播推流的“老大哥”
RTMP(Real-Time Messaging Protocol)是Adobe搞出来的协议,虽然年头不短了,但到今天依然是国内直播推流的主流选择。为什么?因为它基于TCP,能保证数据不丢包,而且延迟可以控制在1-3秒内,对于互动直播来说够用了。
RTMP的核心是“握手 + 连接 + 流”。握手阶段交换版本号和窗口大小,连接阶段建立NetConnection,流阶段才是真正的音视频数据传输。我刚开始调试时,经常卡在握手这一步,后来发现是AMF编码格式没对齐——RTMP用AMF0或AMF3来封装控制消息,服务器和客户端必须一致。
RTMP握手流程(简化版)
- C0: 客户端发送版本号(0x03)
- S0: 服务器回复版本号
- C1/S1: 交换随机数据(1536字节)
- C2/S2: 确认对方数据
- 之后进入 NetConnection 连接阶段
握手完成后,客户端发送 connect 命令,带上应用名(比如 live)和流密钥。服务器回复 _result 表示成功。然后创建 NetStream,开始推流。嗯,这里要注意:RTMP的流ID是动态分配的,别写死。
个人经验:我在项目中遇到过RTMP推流偶尔断连的问题,排查后发现是TCP keepalive没设置。Android默认的TCP超时时间很长,一旦网络波动,RTMP连接就断了。我建议在推流前设置Socket的keepalive为true,并缩短探测间隔。
13.2 FLV封装:轻量级的容器格式
FLV(Flash Video)是Adobe的另一个作品。它结构简单,解析快,非常适合直播场景。RTMP传输的数据,本质上就是FLV格式的流——只不过去掉了FLV的文件头,直接传Tag。
一个FLV文件的结构是这样的:
| 组成部分 | 说明 |
|---|---|
| FLV Header | 9字节,标识文件类型和版本 |
| PreviousTagSize0 | 4字节,固定为0 |
| Tag1 (Audio/Video/Script) | 包含Tag Header + Tag Data |
| PreviousTagSize1 | 4字节,上一个Tag的大小 |
| Tag2 ... | 循环直到文件结束 |
每个Tag由11字节的Header和可变长度的Data组成。Header里最关键的是Tag类型(8=音频,9=视频,18=脚本)和时间戳。时间戳是相对时间,单位毫秒。我踩过的一个坑是:时间戳用int32存储,如果直播时间超过4小时,时间戳会溢出回绕,导致画面错乱。解决办法是用扩展时间戳字段。
注意:FLV的音频Tag和视频Tag是交错存放的。如果你先发了一堆视频Tag,再发音频Tag,播放器会因为没有音频数据而静音。我曾经在调试时发现推流出去只有画面没有声音,查了半天才发现是Tag顺序写错了。
13.3 H.264/AAC裸流打包:把编码数据塞进FLV Tag
编码器出来的H.264数据是NAL Unit流,AAC数据是ADTS帧。要把它们塞进FLV Tag,需要做一层“包装”。
H.264视频Tag打包
H.264的FLV Tag Data分为两种:AVC Sequence Header(配置信息)和AVC NALU(实际帧数据)。
- AVC Sequence Header:包含SPS和PPS,在推流开始时发送一次。播放器需要先收到这个才能解码。
- AVC NALU:每个Tag可以包含一个或多个NAL Unit。用4字节的长度前缀来分隔。
我习惯在MediaCodec的INFO_OUTPUT_FORMAT_CHANGED回调中提取SPS和PPS,然后组装成Sequence Header。注意:SPS和PPS的NAL头(0x00 0x00 0x00 0x01)要去掉,换成4字节长度。
// H.264 AVC Sequence Header 结构
// 5字节配置版本 + 1字节SPS个数 + 2字节SPS长度 + SPS数据 + 1字节PPS个数 + 2字节PPS长度 + PPS数据
byte[] sps = ...; // 从MediaCodec获取
byte[] pps = ...;
ByteBuffer header = ByteBuffer.allocate(16 + sps.length + pps.length);
header.put((byte) 0x01); // 版本
header.put(sps[1]); // AVC profile
header.put(sps[2]); // profile compatibility
header.put(sps[3]); // AVC level
header.put((byte) 0xFF); // 6位保留 + 2位nalu长度减1
header.put((byte) 0xE1); // 3位保留 + 5位SPS个数
header.putShort((short) sps.length);
header.put(sps);
header.put((byte) 0x01); // PPS个数
header.putShort((short) pps.length);
header.put(pps);
AAC音频Tag打包
AAC的FLV Tag Data也分两种:AAC Sequence Header(AudioSpecificConfig)和AAC Raw Data。
- AAC Sequence Header:包含音频编码参数,如采样率、声道数。在推流开始时发送一次。
- AAC Raw Data:去掉ADTS头的AAC裸数据。注意:FLV要求AAC数据不带ADTS头,只保留原始帧数据。
我在项目中遇到过一个问题:某些播放器对AAC Sequence Header的解析很严格,如果AudioSpecificConfig里的采样率索引写错了,播放器会直接不出声。后来我改用MediaCodec输出的AudioSpecificConfig,不再自己拼,问题就解决了。
避坑指南:我曾经在推流时发现音频有“滋滋”的杂音,排查后发现是AAC帧的PTS没有对齐。视频帧的PTS是整数倍,音频帧的PTS是浮点数累加,如果直接取整,音频和视频的同步就会出问题。我建议音频PTS用double计算,最后再转成int。
13.4 推流SDK集成:从零到一
如果你不想自己手写RTMP协议和FLV打包,可以直接用现成的推流SDK。Android上常用的有:
- librtmp:C语言写的RTMP客户端库,轻量级,但需要自己处理FLV打包。
- FFmpeg:集成了RTMP协议和FLV muxer,功能强大,但体积大。
- 腾讯云/阿里云推流SDK:商业SDK,开箱即用,但依赖厂商服务。
我个人习惯用librtmp + 自己写FLV打包。为什么?因为灵活。你可以控制每一帧的发送时机,可以自定义丢包策略,还可以在推流前做美颜、滤镜等处理。商业SDK虽然方便,但遇到问题你只能等厂商修复。
集成librtmp的步骤大致如下:
- 编译librtmp的so库(或者用预编译版本)
- JNI层调用RTMP_Connect建立连接
- 编码器输出H.264/AAC数据后,组装成FLV Tag
- 调用RTMP_SendPacket发送数据
- 推流结束时调用RTMP_Close断开连接
关键代码片段(JNI层推流循环)
// 伪代码,实际需要处理线程同步
while (isPushing) {
Frame frame = frameQueue.take(); // 从编码队列取帧
RTMPPacket packet = flvMuxer.mux(frame); // 打包成FLV Tag
RTMP_SendPacket(rtmp, &packet, 0); // 发送
freePacket(&packet);
}
嗯,这里要注意:推流线程不能和编码线程共用同一个锁,否则容易死锁。我建议用无锁队列(比如ConcurrentLinkedQueue)来传递帧数据,编码线程只管放,推流线程只管取。
警告:不要在UI线程里做任何推流操作。RTMP的发送是阻塞的,一旦网络不好,UI线程会被卡死。我曾经见过有人直接在Activity的onCreate里调RTMP_Connect,结果应用启动就ANR了。
最后聊一下推流的优化。推流最怕什么?卡顿和延迟。卡顿是因为网络带宽不够,延迟是因为缓冲区太大。我常用的策略是:
- 动态调整视频码率:根据网络状况实时降低或升高码率
- 设置合理的GOP大小:GOP越大,延迟越高,但压缩率更好。直播一般用1-2秒的GOP
- 开启B帧:B帧能提高压缩率,但会增加解码延迟。如果对延迟敏感,建议只用P帧
说白了,推流就是一个“在有限带宽下,尽可能保证画质和流畅度”的平衡游戏。没有银弹,只有不断调参和测试。
公众号:蓝海资料掘金营,微信deep3321