20、RTMP播放器实现:RTMP播放流程、FLV解析、音视频解码、音视频渲染
各位同学,今天我们来啃一块硬骨头——RTMP播放器的完整实现。
说实话,RTMP播放器比推流端要复杂不少。推流是你主动把数据塞出去,而播放器要处理的事情就多了:连接、接收、解析、解码、渲染,每一步都可能出幺蛾子。我在早期做直播SDK的时候,光是把首帧画面渲染出来,就折腾了整整一周。
嗯,咱们今天就把这条链路彻底打通。
RTMP播放流程:从连接到播放
先看整体流程。RTMP播放器的工作流,说白了就是一条流水线:
- 建立连接:握手 + 建立NetConnection
- 创建流:调用createStream,获取stream ID
- 播放命令:发送play命令,带上流名称
- 接收数据:不断读取RTMP Chunk,解析成音视频数据
- FLV解封装:从接收到的数据中提取FLV Tag
- 音视频解码:将编码数据送入MediaCodec
- 音视频渲染:视频送Surface,音频送AudioTrack
我习惯把第1-3步称为「控制面」,第4-7步称为「数据面」。控制面搞定后,数据面就开始疯狂运转了。
核心要点:RTMP播放器本质上是一个「状态机」。从Handshake到Playing,每个状态转换都必须严格遵循协议。我曾经见过一个播放器在收到play响应之前就开始解码,结果画面花屏了半小时——后来发现是状态机没处理好。
下面这张图展示了播放器的核心数据流:
FLV解析:从字节流到音视频帧
RTMP传输的数据,本质上就是FLV格式。服务器发过来的每个音视频包,都封装在FLV Tag里。
FLV的结构其实很简单:
- FLV Header:9字节,标识文件类型和版本
- PreviousTagSize:4字节,前一个Tag的大小
- Tag:分为Audio Tag、Video Tag、Script Tag三种
每个Tag内部又包含:
- Tag Header(11字节):类型、数据大小、时间戳、StreamID
- Tag Data:真正的音视频编码数据
我在项目中遇到过一个问题:时间戳溢出。RTMP的时间戳是24位,最大只能表示约4.6小时。直播流如果超过这个时间,时间戳会回绕。嗯,解决办法是检测到时间戳变小的时候,加上一个偏移量。
实战技巧:解析FLV时,不要一次性把所有Tag都读到内存里。RTMP是流式协议,你应该边读边解析边送解码。我见过有人把整个FLV文件先缓存下来再解析,结果内存直接爆了——尤其是4K流。
下面是一个简化的FLV Tag解析代码:
// FLV Tag解析示例
public class FlvTagParser {
public static FlvTag parse(ByteBuffer buffer) {
FlvTag tag = new FlvTag();
// 读取Tag Header (11字节)
tag.type = buffer.get(); // 0x08=音频, 0x09=视频, 0x12=脚本
tag.dataSize = readUInt24(buffer); // 数据体大小
tag.timestamp = readUInt24(buffer); // 时间戳(低24位)
tag.timestampExt = buffer.get(); // 时间戳扩展位(高8位)
tag.streamId = readUInt24(buffer); // 总是0
// 组合完整时间戳
tag.pts = (tag.timestampExt << 24) | tag.timestamp;
// 读取Tag Data
tag.data = new byte[tag.dataSize];
buffer.get(tag.data);
// 跳过PreviousTagSize (4字节)
buffer.getInt();
return tag;
}
private static int readUInt24(ByteBuffer buffer) {
return (buffer.get() & 0xFF) << 16 |
(buffer.get() & 0xFF) << 8 |
(buffer.get() & 0xFF);
}
}
音视频解码:MediaCodec的正确打开方式
Android上的硬解码,绕不开MediaCodec。这东西用好了是神器,用不好就是噩梦。
视频解码的流程:
- 配置MediaCodec:设置MIME类型(video/avc)、输出Surface
- 启动解码器:调用start()
- 喂数据:从FLV Tag中提取H.264 NALU,送入dequeueInputBuffer
- 取数据:dequeueOutputBuffer,拿到解码后的帧
- 渲染:releaseOutputBuffer(render=true),画面就显示到Surface上了
音频解码类似,只是MIME类型是audio/mp4a-latm,输出是PCM数据,需要自己送到AudioTrack播放。
注意:H.264的SPS/PPS必须优先发送。很多播放器黑屏,就是因为SPS/PPS丢失或者顺序不对。我建议你在解析到Video Tag时,先检查是不是AVC sequence header,如果是,立刻保存SPS/PPS,并在配置MediaCodec时设置进去。
配置MediaCodec时,SPS/PPS是这样设置的:
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
// 设置SPS和PPS
format.setByteBuffer("csd-0", ByteBuffer.wrap(sps));
format.setByteBuffer("csd-1", ByteBuffer.wrap(pps));
// 设置帧率(可选)
format.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
// 设置I帧间隔(可选)
format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
MediaCodec codec = MediaCodec.createDecoderByType("video/avc");
codec.configure(format, surface, null, 0);
codec.start();
音频的配置类似,但需要额外注意ADTS头。AAC在FLV中存储的是RAW格式,没有ADTS头。你需要手动给每个AAC帧加上7字节的ADTS头,MediaCodec才能正确解码。
我的习惯:写一个工具方法,专门给AAC数据加ADTS头。参数包括采样率、声道数、帧长度。这样解码器就能正确识别了。我曾经因为忘记加ADTS头,调试了整整一个下午——最后发现是音频一直没声音。
音视频渲染:让画面和声音出来
解码完成后,渲染就相对简单了。
视频渲染:
- 使用SurfaceView或TextureView
- MediaCodec直接输出到Surface
- 注意:SurfaceView有自己的渲染线程,不要在主线程操作
音频渲染:
- 使用AudioTrack
- 配置:采样率(44100/48000)、声道数(1/2)、编码格式(ENCODING_PCM_16BIT)
- 模式:MODE_STREAM(流式写入)
音频渲染的代码示例:
// 初始化AudioTrack
int sampleRate = 44100; // 从FLV Audio Tag中解析
int channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
int audioFormat = AudioFormat.ENCODING_PCM_16BIT;
int bufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat);
AudioTrack audioTrack = new AudioTrack(
AudioManager.STREAM_MUSIC,
sampleRate,
channelConfig,
audioFormat,
bufferSize,
AudioTrack.MODE_STREAM
);
audioTrack.play();
// 解码后的PCM数据直接写入
byte[] pcmData = ...; // 从MediaCodec输出获取
audioTrack.write(pcmData, 0, pcmData.length);
这里有个坑:AudioTrack的write是阻塞的。如果写入速度跟不上解码速度,或者解码速度跟不上写入速度,都会出问题。我建议用一个独立的音频渲染线程,配合一个环形缓冲区来解耦。
音视频同步:让嘴型和声音对上
这是播放器里最头疼的问题。没有之一。
最简单的方案是以音频时间为基准:
- 音频播放时,记录当前播放的位置(AudioTrack.getPlaybackHeadPosition)
- 视频渲染时,计算当前帧的PTS与音频时间的差值
- 如果视频快了,就延迟渲染;如果慢了,就丢帧
丢帧策略我一般这样处理:
// 视频帧同步逻辑
long videoPts = ...; // 当前视频帧的PTS
long audioTimeUs = ...; // 当前音频播放位置(微秒)
long diff = videoPts - audioTimeUs;
if (diff > 100_000) { // 视频快了超过100ms
// 延迟渲染
Thread.sleep(diff / 1000);
} else if (diff < -50_000) { // 视频慢了超过50ms
// 丢帧,不渲染
codec.releaseOutputBuffer(outputIndex, false);
continue;
} else {
// 正常渲染
codec.releaseOutputBuffer(outputIndex, true);
}
这个阈值不是固定的。我一般在项目中做成可配置的,根据网络状况动态调整。网络好的时候,阈值可以设小一点,追求低延迟;网络差的时候,阈值设大一点,保证流畅。
总结一下:RTMP播放器的实现,核心就是处理好「接收-解析-解码-渲染」这条流水线。每个环节都可能成为瓶颈。我个人建议,先跑通最简单的场景(本地文件播放),再逐步加入网络、同步等复杂逻辑。一口吃不成胖子,播放器这东西,得一点点调。
好了,这一章的内容就到这里。代码都在手边,建议你跟着敲一遍。遇到问题别慌,大概率是SPS/PPS没设对,或者时间戳处理出了问题——这两个坑我当年都踩过。
公众号:蓝海资料掘金营,微信deep3321