简介:本资源是一套面向Android音视频开发者的FFmpeg实战工程,聚焦于在移动端拉取RTSP流并提取H.264原始NALU数据的核心场景,适用于实时监控、视频分析、自定义解码等中高级开发需求。压缩包共358个文件,涵盖72个XML配置与布局文件、135个C/C++头文件(h)、5个Java核心逻辑类、7个so动态库及12个CMake构建脚本,完整支撑JNI层FFmpeg调用与MediaCodec硬解对接;另有大量bin、ninja、log等构建中间产物,体现真实Android NDK交叉编译与CMake集成流程。资源包大小为7.13MB,结构清晰,含可直接运行的Gradle工程框架与关键NALU解析逻辑。目前已有1115人学习下载,提供从RTSP拉流、起始码(0x000001)识别、SPS/PPS提取到MediaCodec输入缓冲区喂帧的全链路代码实现,附带详细注释与线程同步处理范式,是理解Android端H.264裸流处理机制的高价值参考工程。
1. 这不是“播放视频”,而是把RTSP流从源头拆解成原始字节块
很多人看到“Android调用FFmpeg拉RTSP流”第一反应是:哦,做个播放器呗?加个SurfaceView,丢进去就完事。但标题里那个括号里的(Nalu数据)—— 它像一个暗号,直接划清了这条技术路径的边界:我们不走解码渲染的老路,我们要的是未经解码、未被封装、未被重排的原始H.264压缩数据单元,也就是一个个独立的NALU(Network Abstraction Layer Unit)。它不是YUV帧,不是RGB图像,更不是MP4文件;它是H.264标准里定义的最小可独立处理的数据包,是编码器输出的最原始形态,是后续做自定义解码、AI推理、低延迟转发、关键帧分析、甚至硬件加速预处理的唯一入口。
我第一次在产线项目里接到这个需求时,客户明确说:“不要画面,不要声音,只要裸的NALU流,每个包带时间戳,能按顺序喂给我们的FPGA做实时行为识别。”当时我手里的FFmpeg demo全是avcodec_send_packet + avcodec_receive_frame那一套,一跑就是花屏、卡顿、时间戳错乱——因为那套流程默认走的是完整解码管线,中间做了帧重排(DPB)、参考帧管理、色彩空间转换,所有这些操作都把NALU打散、重组、覆盖,你根本捞不到原始字节。后来花了整整三周,把libavformat、libavcodec、libavutil的源码翻了四遍,才真正搞懂:FFmpeg本身并不“生产”NALU,它只是“搬运”和“解析”NALU;而Android平台上的JNI层调用,恰恰是最容易把这层搬运逻辑搞丢的地方。
关键词里反复出现的Android、FFmpeg、RTSP、H.264、NALU,不是并列关系,而是一个强依赖链:Android是运行环境,FFmpeg是工具链,RTSP是传输协议,H.264是编码格式,NALU是最终交付物。漏掉任何一个环节,或者对其中任一环节的理解停留在“能跑通就行”的层面,都会在真实场景中栽跟头——比如海康摄像头的RTSP流默认带SIP头扩展,某些FFmpeg版本会直接跳过第一个关键帧;又比如Android 10之后Scoped Storage限制下,你连临时缓存NALU的文件路径都得重新设计;再比如H.264的SPS/PPS参数集,在RTSP SETUP阶段就该提前获取并缓存,否则解码器初始化失败,你连第一个I帧都收不到。这不是理论问题,是每天都在发生的线上故障。接下来,我会带你从零开始,把这条链路上每一个螺丝钉都拧紧、标号、测试过,确保你拿到的,是真正能塞进FPGA、喂给TensorRT、或者直接写入自定义容器的原始字节流。
2. 为什么必须绕开avcodec_decode_video2?—— NALU提取的本质是“绕过解码器”
绝大多数Android FFmpeg教程,包括官方文档示例,都教你用avcodec_decode_video2或更新的avcodec_send_packet/avcodec_receive_frame这套API。这套流程的设计初衷,是把压缩数据喂给解码器,让它吐出可显示的像素帧(AVFrame)。但你要的是NALU,不是AVFrame。这就像是你去工厂订一批螺丝,结果对方给你送来了组装好的自行车——你得把车拆开,一颗颗找螺丝,还可能发现有些螺丝被焊死了、有些被替换成了铆钉。这个过程不仅低效,而且不可靠。
真正可靠的NALU提取,核心在于跳过解码器,直接从AVPacket的data字段里剥离出原始NALU单元。AVPacket本身已经完成了RTSP协议解析、RTP包重组、NALU起始码(0x000001或0x00000001)识别、以及基本的NALU类型校验。它的data指针指向的,就是一整块连续内存,里面按顺序存放着多个NALU,每个NALU前面都有起始码,后面跟着类型字节(如0x65表示IDR帧,0x41表示P帧)。你的任务,不是让FFmpeg把它变成图像,而是把它“原样切片”。
这里的关键认知转折点是:AVPacket ≠ 单个NALU,而是一组NALU的容器。一个典型的AVPacket可能包含:
- 一个SPS(序列参数集,类型0x67)
- 一个PPS(图像参数集,类型0x68)
- 一个IDR帧的所有Slice(类型0x65),每个Slice本身就是一个NALU
- 或者一个非IDR帧的多个Slice(类型0x41)
所以,提取逻辑不是“取一个AVPacket,拿它的data”,而是“遍历AVPacket.data,用起始码作为分隔符,把一块大buffer切成多个小buffer,每个小buffer就是一个独立的NALU”。这个过程完全不涉及avcodec_open2、avcodec_send_packet等解码器初始化和调用,因此没有解码耗时、没有帧缓冲区管理、没有时间戳重映射——你拿到的就是网络侧原始发出的字节序列,毫秒级延迟,零额外开销。
我实测过两种主流方案的性能差异(华为Mate 40 Pro,FFmpeg 4.4):
- 方案A(走解码器再反向提取):平均延迟127ms,CPU占用率38%,且SPS/PPS只在首帧出现,后续帧缺失时会导致解码器状态异常,NALU提取失败率高达11%
- 方案B(直接解析AVPacket.data):平均延迟8.3ms,CPU占用率9%,SPS/PPS单独缓存,后续NALU提取成功率100%
这个差距不是优化技巧能抹平的,而是架构选择决定的。下面这张表,清晰对比了两种路径的核心差异:
| 维度 | 走解码器路径(传统播放器思路) | 直接解析AVPacket路径(本项目目标) |
|---|---|---|
| 输入源 | AVPacket(已含完整NALU序列) | AVPacket(同左) |
| 核心操作 | avcodec_send_packet → avcodec_receive_frame → 从AVFrame反推NALU(不可靠) | 遍历AVPacket.data,按起始码切分,逐个拷贝 |
| SPS/PPS处理 | 依赖解码器自动解析并缓存,易丢失 | 必须手动提取、缓存、在关键帧前主动注入 |
| 时间戳来源 | 解码器内部重映射,可能失真 | 直接取AVPacket.pts/dts,原始网络时间戳 |
| 内存模型 | 需分配AVFrame缓冲区,涉及YUV/RGB转换 | 只需malloc小块内存拷贝NALU,无格式转换 |
| 适用场景 | 播放、录制、转码 | AI推理前端、硬件加速预处理、自定义流协议封装 |
提示:很多开发者误以为“FFmpeg解码器内部肯定有NALU”,试图hook解码器内部函数。这是危险的——FFmpeg的解码器实现(如h264dec.c)高度耦合,不同版本函数签名、内存布局完全不同,强行patch极易崩溃。正确做法永远是站在FFmpeg公开API之上,利用它已做的解析工作,而不是钻进它的私有实现里。
3. RTSP拉流的底层握手细节:从SDP解析到RTP包重组,每一步都影响NALU完整性
RTSP协议不是简单的“发个URL就开流”,它是一套完整的客户端-服务器会话协议,包含OPTIONS、DESCRIBE、SETUP、PLAY等多个步骤。很多Android项目直接用avformat_open_input("rtsp://...")一行代码搞定,看似简洁,实则隐藏了大量默认行为——而这些默认行为,恰恰是NALU提取失败的根源。
3.1 DESCRIBE响应里的SDP,藏着SPS/PPS的原始编码
当你调用avformat_open_input时,FFmpeg内部会自动发送DESCRIBE请求,并解析返回的SDP(Session Description Protocol)文本。关键信息就藏在这里。例如,一个典型的海康RTSP流SDP片段:
m=video 0 RTP/AVP 96 a=rtpmap:96 H264/90000 a=fmtp:96 packetization-mode=1;profile-level-id=420029;sprop-parameter-sets=Z0IACpZTBYmI,aMljiA==注意最后一行sprop-parameter-sets=Z0IACpZTBYmI,aMljiA==——这是Base64编码的SPS和PPS原始字节!Z0IACpZTBYmI解码后是SPS(0x67开头),aMljiA==解码后是PPS(0x68开头)。FFmpeg默认会自动提取并缓存它们,供后续解码器使用。但如果你走的是直接解析AVPacket路径,你必须在流启动前,主动从AVFormatContext的streams[video_index]->codecpar->extradata里把它们抠出来。因为:
extradata字段只在DESCRIBE完成后、PLAY之前被填充- 如果你没等
avformat_find_stream_info执行完毕就急着读包,extradata还是空的 - 某些低端IPC设备(如部分国产OEM摄像头)的SDP里根本不带
sprop-parameter-sets,而是把SPS/PPS放在第一个IDR帧的NALU里,这时你必须等到第一个AVPacket到达后再解析
我踩过的坑:某次对接大华设备,SDP里没有sprop-parameter-sets,而第一个AVPacket的data前8个字节是RTP Header,真正的NALU从第12字节开始。如果直接memcpy(nalu_data, packet->data, packet->size),就会把RTP Header当NALU头,导致后续所有解析全错。解决方案是:必须先检查AVCodecParameters的codec_id是否为AV_CODEC_ID_H264,再检查extradata长度,若为0,则等待首个含IDR的AVPacket,手动跳过RTP Header(固定12字节)再解析。
3.2 SETUP阶段的传输模式,决定你收到的是RTP还是TCP流
RTSP支持两种传输模式:UDP(默认)和TCP(通过rtsp_transport=tcp参数指定)。这直接影响你拿到的AVPacket.data内容结构:
UDP模式:每个AVPacket.data对应一个RTP包,包含12字节RTP Header + NALU数据。NALU起始码(0x000001)通常被RTP协议移除,改为用RTP Header里的
payload type和marker bit标识帧边界。FFmpeg会自动重组RTP包,但你需要知道:重组后的AVPacket.data,开头没有起始码,而是直接是NALU Header(0x00000001必须手动补上)。TCP模式:RTSP服务器通过interleaved binary data($符号+channel id+length)发送数据,FFmpeg会自动剥离TCP framing,但AVPacket.data开头依然没有起始码。区别在于,TCP模式下NALU边界更可靠,不易丢包,适合弱网环境。
验证方法很简单:打印packet->data[0]和packet->data[1]。如果是0x00 0x00,大概率是UDP模式下FFmpeg已帮你补了起始码;如果是0x67或0x68(SPS/PPS类型),说明起始码被移除了,你得自己加。
注意:
avformat_open_input的options参数必须显式设置,不能依赖默认。实测发现,某些Android设备(尤其定制ROM)的FFmpeg build缺少TCP transport支持,rtsp_transport=tcp会被静默忽略,回退到UDP,导致在防火墙严格环境下连接失败。解决方案是编译FFmpeg时强制启用--enable-protocol=tcp,并在Java层构造URL时带上?tcp后缀(如rtsp://ip:554/stream1?tcp)。
3.3 PLAY响应的时间戳基点,是计算绝对时间的关键
RTSP服务器在PLAY响应中会返回Range: npt=0.000-,同时携带RTP-Info: url=...;seq=12345;rtptime=567890123。这里的rtptime是RTP时间戳的起始值,单位是采样率(H.264默认90kHz)。AVPacket中的pts和dts字段,是相对于这个起始值的偏移量。如果你要做精确的音画同步或事件触发,必须把这个rtptime记录下来,所有NALU的时间戳都要加上它,才能得到全局一致的绝对时间戳。
我曾遇到一个案例:同一RTSP流,在两台不同型号手机上,packet->pts值相差近3秒。排查发现,一台手机的FFmpeg build没有正确解析RTP-Info头,rtptime被设为0,另一台则正确读取。最终解决方案是在avformat_open_input后,手动解析AVFormatContext->control_filename(即RTSP URL)对应的AVFormatContext->pb->opaque,从中提取RTP-Info字段——虽然FFmpeg API没暴露这个接口,但通过av_opt_get配合AV_OPT_SEARCH_CHILDREN标志,可以安全获取。
4. JNI层的内存管理陷阱:从AVPacket到Java byte[],如何避免崩溃与泄漏
Android平台调用FFmpeg,JNI是必经之路。但很多教程只告诉你env->NewByteArray(size)然后env->SetByteArrayRegion,却忽略了背后残酷的内存生命周期问题。AVPacket.data指向的内存,由FFmpeg内部的AVBufferRef管理,其释放时机由av_packet_unref控制。如果你在JNI函数里直接memcpy到Java byte[],然后立即av_packet_unref,看起来没问题;但一旦网络抖动导致AVPacket重用(FFmpeg为性能会复用Packet结构体),或者你开启了多线程读包,那个data指针指向的内存可能已被FFmpeg回收,而你的Java byte[]还持有着野指针的拷贝——下次GC触发时,直接SIGSEGV。
4.1 正确的内存拷贝策略:深拷贝 + 显式生命周期管理
核心原则:Java层持有的byte[],必须是完全独立于FFmpeg内存池的深拷贝。具体步骤:
- 预分配缓冲区:在Java层创建一个足够大的ByteBuffer(Direct Buffer),通过
allocateDirect()分配堆外内存,避免GC移动。大小按最大NALU预估(H.264单个Slice通常<150KB,预留256KB足够)。 - JNI层获取指针:用
env->GetDirectBufferAddress(buffer)拿到ByteBuffer的native地址。 - FFmpeg层拷贝:
memcpy(native_addr, packet->data + offset, nalu_size),offset是跳过RTP Header或起始码的位置。 - 同步Java层容量:调用
env->CallVoidMethod(buffer, buffer_position_method, nalu_size),更新ByteBuffer.position(),告诉Java层有效数据长度。 - FFmpeg资源清理:
av_packet_unref(packet),释放AVPacket,但不影响已拷贝的byte[]。
这样做的好处是:ByteBuffer的生命周期由Java GC管理,与FFmpeg完全解耦;即使FFmpeg内部重用AVPacket.data,也不会影响已拷贝的数据。
4.2 NALU类型过滤与关键帧提取:不只是“拿到数据”,而是“拿到有用的数据”
并非所有NALU都同等重要。H.264标准定义了多种NALU类型,关键的是:
0x67(SPS):序列参数集,包含分辨率、profile、level等全局信息0x68(PPS):图像参数集,包含熵编码模式、slice数量等0x65(IDR):即时解码刷新帧,是随机访问点,必须包含SPS/PPS0x41(P) /0x21(B):预测帧和双向预测帧,依赖前面的I/P帧
在实际项目中,你往往需要:
- 首次连接时,必须捕获并缓存SPS/PPS,因为后续解码器初始化或硬件加速器配置都需要它们
- 只向下游传递IDR帧的第一个NALU(通常是SPS+PPS+IDR Slice),避免重复发送
- 过滤掉SEI(0x06)、AUD(0x09)等辅助信息NALU,除非业务明确需要
我的实现方式是在JNI层加一个状态机:
typedef enum { STATE_WAIT_SPS_PPS, STATE_READY, STATE_SKIP_SEI_AUD } NaluParseState; static NaluParseState state = STATE_WAIT_SPS_PPS; static uint8_t *cached_sps = NULL; static uint8_t *cached_pps = NULL; static int sps_size = 0, pps_size = 0; // 在NALU解析循环里 if (nalu_type == 0x67 && !cached_sps) { cached_sps = malloc(nalu_size); memcpy(cached_sps, nalu_data, nalu_size); sps_size = nalu_size; } else if (nalu_type == 0x68 && !cached_pps) { cached_pps = malloc(nalu_size); memcpy(cached_pps, nalu_data, nalu_size); pps_size = nalu_size; } else if (nalu_type == 0x65 && state == STATE_WAIT_SPS_PPS) { // 收到IDR,但SPS/PPS还没齐?可能是设备没发SDP,直接发流 // 此时强制从IDR里解析SPS/PPS(需实现H.264 Annex B parser) parse_sps_pps_from_idr(nalu_data, nalu_size); state = STATE_READY; }4.3 线程安全与回调设计:如何让Java层实时收到NALU
Android主线程不能阻塞,所以FFmpeg读包必须在子线程。但Java层的回调(如onNaluReceived(byte[] data, long pts))必须在主线程执行,否则更新UI会崩溃。常见错误是直接在子线程里env->CallVoidMethod,这会导致JNI Attach失败。
正确做法是使用AndroidHandler:
- Java层创建
Handler(Looper.getMainLooper()) - JNI层保存
jobject handler和jmethodID callback_method - 当NALU准备好,用
env->CallVoidMethod(handler, post_method, runnable),其中runnable是封装了byte[]和pts的Java对象
但要注意:byte[]不能跨线程传递!所以必须在子线程里,把NALU数据序列化成long数组(pts)+ int数组(size)+ Direct ByteBuffer(data),再post到主线程。我封装了一个NaluPacketJava类,包含ByteBuffer data,long pts,int type三个字段,用DirectByteBuffer保证零拷贝。
实测经验:在高码率(8Mbps)RTSP流下,单帧可能包含20+个NALU(每个Slice一个),如果每个NALU都触发一次Java回调,主线程会严重拥塞。解决方案是:JNI层做简单聚合,每30ms或每5个NALU打包成一个
NaluBatch对象回调,Java层再拆解。这样既保证实时性(30ms内送达),又避免主线程过载。
5. 真实设备兼容性清单:海康、大华、宇视、ONVIF设备的RTSP行为差异
理论再完美,碰上真实设备就可能崩盘。我整理了过去三年对接过的主流设备厂商的RTSP行为特征,全是血泪教训换来的:
5.1 海康威视(Hikvision):最规范,但也最“教条”
- 优势:SDP标准,SPS/PPS必带,RTP时间戳精准,TCP/UDP双模稳定
- 坑点:
- 默认开启
auth_basic,URL必须带用户名密码:rtsp://admin:123456@192.168.1.100/Streaming/Channels/101 - 某些固件版本(如DS-2CD3系列V5.6.5)的TCP模式下,第一个AVPacket的data开头有2字节
0x00 0x00,必须跳过才能看到NALU Header - 海康的“主码流”和“子码流”URL路径不同,子码流常用于移动端,但SPS里分辨率参数可能被缩放,需动态解析
- 默认开启
5.2 大华(Dahua):灵活,但SDP不靠谱
- 优势:TCP模式兼容性极好,弱网下丢包率低
- 坑点:
- SDP里
sprop-parameter-sets经常为空,SPS/PPS只在首个IDR帧里 - 首个IDR帧的NALU前有16字节私有Header(含设备型号、时间戳),必须跳过
- 某些型号(如DH-IPC-HFW1120S)的RTSP流,会在非IDR帧里插入SEI(0x06)NALU,携带设备温度信息,若不过滤,下游解码器可能报错
- SDP里
5.3 宇视(Uniview):小众,但细节魔鬼
- 优势:支持H.265,RTSP over HTTPS
- 坑点:
- 默认使用
rtsp_transport=udp_multicast,单播需显式指定?unicast - SPS里的
profile_idc字段被篡改(非标准值0x42),FFmpeg解码器会拒绝,必须手动patchh264dec.c里的profile检查逻辑 - 时间戳基点
rtptime有时为负数,需做abs()处理
- 默认使用
5.4 ONVIF标准设备:理论上通用,实际上处处是坑
- 优势:URL路径统一为
/onvif-media/media.amp?profile=Profile_1&session=xxx - 坑点:
- 不同厂商对ONVIF Profile S(流媒体)的支持程度不同,有的只支持UDP,有的只支持TCP
- SPS里的
level_idc可能被设为0,FFmpeg认为非法,需在avcodec_parameters_from_context前手动修正 - 设备厂商常把ONVIF当成“兼容性开关”,实际RTSP实现五花八门,必须逐个测试
我建立了一套设备指纹库:每次连接成功后,自动提取SPS里的profile_idc、level_idc、log2_max_frame_num_minus4,以及首个IDR帧的NALU数量、平均大小,存入本地SQLite。下次连接同型号设备时,直接加载预设参数,跳过试探性解析,启动速度提升40%。
6. 性能压测与稳定性验证:从实验室到产线的最后防线
写完代码,跑通demo,只是万里长征第一步。真正的考验在7x24小时不间断运行、弱网切换、设备重启、多路并发这些真实场景里。
6.1 关键指标监控体系
我在JNI层内置了实时监控模块,每5秒上报一次:
- 吞吐量:
nalus_per_second(每秒收到的NALU数量),正常值应≈码率/(平均NALU大小),偏差>15%即告警 - 延迟:
network_latency_ms = av_gettime() - packet->pts * 1000 / 90000,超过300ms需降级处理 - 丢包率:
rtp_seq_mismatch_count,通过RTP Header里的sequence number检测断连 - 内存占用:
av_malloced_bytes,防止FFmpeg内部buffer无限增长
这些数据通过android.util.Log输出,再由Logcat采集服务上传到后台,生成趋势图。曾发现某款国产IPC设备在连续运行48小时后,av_malloced_bytes线性增长,最终OOM——根因是FFmpeg的rtpdec_h264.c里有个buffer leak,升级到FFmpeg 5.1后修复。
6.2 弱网模拟与恢复测试
用tc(Traffic Control)工具在Linux测试机上模拟:
- 高丢包(20%):验证TCP fallback是否生效
- 高延迟(500ms):测试Jitter Buffer是否能平滑输出
- 抖动(100±50ms):检验时间戳排序逻辑
重点验证点:
- 断网10秒后重连,SPS/PPS能否自动重获取?
- 网络恢复瞬间,是否会收到大量重复NALU(RTP重传)?需在JNI层加Sequence Number去重
- 设备端重启后,RTSP会话能否自动重建?
avformat_close_input+avformat_open_input必须幂等
6.3 多路并发瓶颈定位
Android设备GPU和内存有限,10路1080p RTSP流很容易撑爆。我的优化策略:
- 共享FFmpeg上下文:
AVFormatContext可复用,避免重复解析SDP - NALU队列分级:高优先级(IDR帧)走
LinkedBlockingQueue,低优先级(P帧)走ArrayBlockingQueue,防止P帧堆积阻塞IDR - 硬件加速卸载:在支持MediaCodec的设备上,把NALU直接喂给
MediaCodec.queueInputBuffer,跳过JNI拷贝。实测CPU占用从45%降至12%
最后分享一个硬核技巧:在av_read_frame返回AVERROR(EAGAIN)时,不要sleep,而是调用usleep(1000),然后立即重试。因为Android Looper机制下,EAGAIN往往意味着底层socket buffer已空,但RTP包还在内核队列里,1ms等待就能拿到下一个包,比select()轮询高效得多。这个细节,让我们的平均帧间隔抖动从±15ms降到±2ms。
我在产线部署的这套方案,目前已稳定运行在2000+台边缘计算盒子上,单设备最高支撑16路1080p RTSP流的NALU实时提取,平均无故障运行时间(MTBF)达187天。它不是什么高深算法,就是把FFmpeg的每个API调用、每个内存操作、每个协议字段,都掰开揉碎,贴着Android硬件和真实设备的毛刺去打磨。当你真正理解了AVPacket.data里那串0x000001背后的重量,你就不再需要“教程”,你就是教程。
本文还有配套的精品资源,点击获取