20、RTMP播放器实现:RTMP播放流程、FLV解析、音视频解码、音视频渲染

各位同学,今天我们来啃一块硬骨头——RTMP播放器的完整实现

说实话,RTMP播放器比推流端要复杂不少。推流是你主动把数据塞出去,而播放器要处理的事情就多了:连接、接收、解析、解码、渲染,每一步都可能出幺蛾子。我在早期做直播SDK的时候,光是把首帧画面渲染出来,就折腾了整整一周。

嗯,咱们今天就把这条链路彻底打通。

RTMP播放流程:从连接到播放

先看整体流程。RTMP播放器的工作流,说白了就是一条流水线:

  1. 建立连接:握手 + 建立NetConnection
  2. 创建流:调用createStream,获取stream ID
  3. 播放命令:发送play命令,带上流名称
  4. 接收数据:不断读取RTMP Chunk,解析成音视频数据
  5. FLV解封装:从接收到的数据中提取FLV Tag
  6. 音视频解码:将编码数据送入MediaCodec
  7. 音视频渲染:视频送Surface,音频送AudioTrack

我习惯把第1-3步称为「控制面」,第4-7步称为「数据面」。控制面搞定后,数据面就开始疯狂运转了。

核心要点:RTMP播放器本质上是一个「状态机」。从Handshake到Playing,每个状态转换都必须严格遵循协议。我曾经见过一个播放器在收到play响应之前就开始解码,结果画面花屏了半小时——后来发现是状态机没处理好。

下面这张图展示了播放器的核心数据流:

RTMP播放器核心数据流 RTMP网络层 握手 / Chunk读取 FLV解封装 Tag解析 / 音视频分离 MediaCodec解码 H.264 / AAC 视频渲染 SurfaceView / TextureView 音频渲染 AudioTrack播放 音视频同步 时间戳对齐 / 丢帧策略

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。这东西用好了是神器,用不好就是噩梦。

视频解码的流程:

  1. 配置MediaCodec:设置MIME类型(video/avc)、输出Surface
  2. 启动解码器:调用start()
  3. 喂数据:从FLV Tag中提取H.264 NALU,送入dequeueInputBuffer
  4. 取数据:dequeueOutputBuffer,拿到解码后的帧
  5. 渲染: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