项目做成那天晚上,我盯着手机屏幕上跳动的画面愣了好一会儿:Android手机摄像头采集到的实时画面,经过H.264编码,通过Socket一路打进Ubuntu主机,解码后在电脑上流畅播放。整个过程没有USB线、没有第三方SDK,纯粹就是自己写的网络传输逻辑。这个场景放在以前,我总觉得要搞懂一大堆底层协议才能做出来,但实际走完一遍才发现,核心链路比想象中清晰得多:采集、编码、传输、解码、显示,五个环节每个都有成熟方案,难点反而在工程细节上。这篇就把我做这个“Android手机与Ubuntu系统Socket实时传输视频数据”的开发过程掰开揉碎讲清楚,从架构设计到踩过的坑,全部摊开。
如果你正准备在Android和Linux之间做视频流传输,或者想搞懂Socket传数据的正确姿势,这篇文章应该能帮你省掉不少弯路。
1. 链路拆解:把“实时视频传输”拆成五个可落地模块
1.1 整条链路长什么样
先别急着写代码,把整体的数据流画清楚。我的方案是这样一条单向链路:
Android端:Camera2采集视频帧 → MediaCodec硬编码为H.264 → 封装成自定义帧数据 → Socket发送。
Ubuntu端:ServerSocket接收字节流 → 解析帧数据 → 丢给FFmpeg解码 → 在桌面窗口显示。
这个链路里最关键的认知是:Socket本身根本不关心你传的是视频还是文本,它只负责把字节流从A搬到B。所以真正要设计的不是“怎么传视频”,而是“怎么定义一个双方都能理解的格式,把视频帧塞进字节流里,再在另一端原样拆出来”。只要想通了这一层,整件事就变成了一道字节流协议设计题。
1.2 为什么选Socket而不是其他方案
我自己评估过Websocket、RTMP、甚至直接用USB调试通道(ADB forward),最终选了原生Socket,理由有三个:
第一,原生Socket是系统级API,不依赖任何中间件。Android和Ubuntu都内置支持,不需要部署服务端(诸如Nginx配合RTMP模块)也不需要引入额外依赖,跨平台门槛几乎为零。
第二,延迟可控。RTMP的常规做法是推流到服务器再分发,中间至少多一跳网络转发;我自己的场景是局域网内手机直连电脑,Socket点对点传输,端到端延迟能压到两三百毫秒以内,这对实时预览类应用已经够用。
第三,便于自定义协议。视频流不同于普通消息,它要求低延迟、容忍丢包但不希望数据错序,Socket流式传输配合自己设计的帧头,可以精准控制粘包、分包这些行为。
当然Socket方案也有代价:没有内置的断线重连、没有流量整形,这些都得自己写。但考虑到这个项目的定位就是“自己掌控全链路”,这点代价是值得的。
1.3 模块划分与职责边界
我把整个工程拆成了三个模块,各管一摊:
采集模块(Android端):负责打开摄像头、设置预览参数、输出YUV或直接输出编码器可用的Surface数据。
编码模块(Android端):MediaCodec把图像帧压缩成H.264码流,这一步能把每帧数据量压缩到原来的几十分之一,不然几MB一帧的原始图像在Wi-Fi下根本传不动。
传输模块(两端共性):定义帧格式、组包、拆包、发送、接收、缓存。
解码显示模块(Ubuntu端):拿到H.264的完整帧后,交给解码器还原成图像,再用OpenCV或SDL窗口展示出来。
模块化最大的好处是出了问题能快速定位。我前期遇到过采集端一直黑屏的情况,排查了半天发现是预览Surface没绑定对,跟传输链路一毛钱关系都没有。要是没有模块边界,这种问题排查起来会非常折磨人。
2. 传输协议设定:帧头、字节序与粘包处理的细活
2.1 自定义帧格式的诞生
Socket传视频流的第一个坎就是“怎么让接收端知道当前读到的字节是一帧的开始”。H.264的NALU单元虽然有起始码(00 00 00 01),但起始码会在字节流里随机出现(理论上任何位置都可能出现这个模式),直接靠它做帧边界判断不够可靠。
所以我自己定义了一个简单的帧头结构,固定8字节:
- 前4字节:魔数(Magic Number),固定为0xAA55AA55,用于快速校验帧起始位置。
- 第5字节:帧类型,比如1代表I帧,2代表P帧,3代表配置信息(SPS/PPS)。
- 第6-7字节:H.264数据的长度(小端序)。
- 第8字节:预留字段,暂填0。
帧头之后紧跟原始H.264字节流。接收端先读8字节,解析出长度,再继续读对应长度的数据,读完整就是一帧。
用魔数做帧头的思路是从串口通信那边借来的思路,在字节流混乱时能快速找回同步位置。早期版本没加魔数,只靠长度字段,结果一旦出现一次丢包,后续所有帧的边界全部错位,整个画面直接花掉。后来加了魔数校验,遇到找不到魔数的情况就逐字节滑动搜索,直到重新对齐。
2.2 粘包问题与拆包实现
TCP是流式协议,发送端调一次write,接收端不一定只触发一次read。发送端连续发了N帧数据,接收端可能一次性收到一整坨,也可能收到半帧,这就是粘包/拆包问题。
解决办法就是上面提到的“先读帧头、再按长度读数据”。伪代码如下:
// 接收端循环 byte[] header = new byte[8]; readFully(inputStream, header); // 必须读满8字节 int frameType = header[4]; int length = bytesToInt(header[5], header[6]); // 小端序拼接 byte[] frameData = new byte[length](); readFully(inputStream, frameData); // 按长度读满这里最关键的是readFully,不能用普通的read一次完事。read方法不保证一次就把缓冲区填满,尤其是Wi-Fi环境下网络抖动,可能一次只到了几十字节。我在这个坑里趴了一晚上:拿到的帧长度明明是对的,但数据总是不完整,最后发现是数据被拆成了两段,第二次read只拿到后半截。
2.3 字节序统一与大小端问题
Android和Ubuntu都是小端优先的典型平台,但我在协议设计时还是强制把所有多字节字段统一为小端序,并在代码里显式拼字节,绝不依赖平台的默认字节序。
比如把int长度写成字节数组:
byte[] lengthBytes = new byte[4]; lengthBytes[0] = (byte) (length & 0xFF); lengthBytes[1] = (byte) ((length >> 8) & 0xFF); lengthBytes[2] = (byte) ((length >> 16) & 0xFF); lengthBytes[3] = (byte) ((length >> 24) & 0xFF);反过来接收端拼回int时也用同样的位移规则。现在看这些代码会觉得都是常识,但第一次写的时候我直接用了DataOutputStream的writeInt,以为两端一致就行,没考虑以后如果接入ARM板或者其他平台可能会出现平台差异。提前统一字节序,能把未来很多的兼容性隐患直接掐灭。
2.4 控制帧与关键帧的处理策略
H.264编码出来的数据,光有图像帧还不够,解码器还得先拿到SPS/PPS(序列参数集和图像参数集)才能开始干活。MediaCodec在编码器刚启动时会通过OutputFormat接口输出这些信息,但它们不是每一帧都带,只在编码器刚启动或者参数变化时出现一次。
这就带来一个实际问题:接收端如果启动得晚,等它连上Socket时,SPS/PPS早已经被发送端丢出去了,那它拿到任何P帧都无法解码。
我的处理是把SPS/PPS单独封装成一个帧类型,在Socket连接建立后、正式视频流开始前,先主动发送一次配置帧给接收端。接收端收到后,把SPS/PPS喂给解码器创建解码上下文,之后再等视频帧就顺畅了。
每次新客户端连上来都主动推一次配置,这个习惯帮我在后面做多客户端支持时省了不少事。
3. Android端实现:Camera2、MediaCodec与Socket发送线程
3.1 摄像头采集:直接走编码器Surface而不是拿YUV
Android端的采集方案我对比过两种:一种是Camera2的ImageReader输出YUV数据,再把YUV转成编码器的输入;另一种是Camera2的预览Surface直接绑定编码器的输入Surface,让数据在GPU/硬件层面直接流转。
我选了第二种,原因很简单:省一次格式转换。走ImageReader拿YUV纯属浪费CPU,尤其在1080p分辨率下,每一帧几MB的YUV数据转来转去,帧率容易被拖垮。而直接绑定Surface,Camera2的输出格式匹配编码器的输入格式,内部会自行处理格式协商,效率高很多。
核心配置大概是这样的:
MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height); format.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); format.setInteger(MediaFormat.KEY_BIT_RATE, 2_000_000); format.setInteger(MediaFormat.KEY_FRAME_RATE, 30); format.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2); MediaCodec encoder = MediaCodec.createEncoderByType("video/avc"); encoder.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE_AS_IS); Surface inputSurface = encoder.createInputSurface();这里面的KEY_I_FRAME_INTERVAL值得单独说。I帧是全帧关键帧,数据量比P帧大好几倍,但解码器必须靠I帧才能恢复画面。间隔设得太短,码率飙升;设得太长,网络丢包之后要等很久才能恢复。H.264的常规建议值是1到2秒,我实测30fps下设2,也就是每60帧出一次I帧,画质和抗丢包能力比较平衡。如果网络信号差,可以把这个值调小一点,代价就是码率涨一些。
3.2 编码数据读取:关注BufferInfo的flags
编码器的输出要循环去取,MediaCodec把编码后的H.264数据放进输出缓冲区,我们通过dequeueOutputBuffer拿数据。这里有一个地方新手特别容易忽视:必须检查BufferInfo的flags判断当前这一包是不是配置帧(BUFFER_FLAG_CODEC_CONFIG),还要判断是不是关键帧(BUFFER_FLAG_KEY_FRAME)。
我的代码里是这样处理的:
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); int outIndex = encoder.dequeueOutputBuffer(info, 10000); if (outIndex >= 0) { ByteBuffer buffer = encoder.getOutputBuffer(outIndex); if ((info.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { saveSpsPps(buffer, info); } else if ((info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) { sendFrame(buffer, info, FRAME_TYPE_KEY); } else { sendFrame(buffer, info, FRAME_TYPE_NORMAL); } encoder.releaseOutputBuffer(outIndex, false); }实际开发中,有的机型在编码器刚启动时会把SPS/PPS和第一帧I帧打包在一起输出,flags可能同时包含CODEC_CONFIG和KEY_FRAME。我一开始只判断了CODEC_CONFIG,结果漏掉了第一帧I帧,接收端第一次画面要等下一个I帧才出现。后来改成先判断CODEC_CONFIG,再判断KEY_FRAME,两个标记都单独处理,问题就解决了。
还有一个坑是时间戳,MediaCodec输出的BufferInfo.presentationTimeUs是每帧的播放时间戳,这个值必须取出来给接收端排序用。我早期偷懒没传时间戳,解码端虽然也能出画面,但一旦网络延迟波动,帧序就会乱跳,卡顿感明显。后来老老实实把这个值进帧头发过去,接收端按时间戳顺序扔进解码队列,画面的流畅度立刻正常了。
3.3 发送线程:用独立线程和阻塞队列解耦
Android端的编码和发送不能放在同一个线程里,否则编码器一旦短暂阻塞,发送流程就会跟着卡住,采集端反过来被拖慢,整套链路延迟会像滚雪球一样越来越大。
我的做法是两条线程:编码线程负责从MediaCodec取数据;发送线程负责从阻塞队列里取帧并写入Socket。两者之间用ArrayBlockingQueue连接,队列容量设成30,也就是1秒左右的缓冲量。
BlockingQueue<FrameData> queue = new ArrayBlockingQueue<>(30);这个缓冲量卡得很微妙。设太大,延迟升高,画面不跟手;设太小,Wi-Fi带宽抖动时编码线程容易被发送线程拖累(put阻塞),整体帧率反而下降。实测下来30比较稳,Wi-Fi环境下基本不会出现队列满的情况。
Socket输出流的写入也要注意:不要每一帧都调write,系统调用次数越多,性能越差。我是一帧的数据(帧头+数据体)先写入一个ByteArrayOutputStream,再用一次write推出去。当然一次write也不代表底层真的只发一次,但至少从应用层减少了系统调用开销。
3.4 摄像头生命周期与Surface方向
Android摄像头本身是横向摆放的,竖屏使用时传感器输出的图像默认旋转90度。如果不处理,传到Ubuntu端的画面是歪的。Camera2里可以用SensorOrientation加上设备方向计算出正确的旋转角度,然后通过createCustomCaptureSession配合预处理Surface旋转。不过我初始的实现偷了个懒:直接把摄像头预览分辨率设成横屏参数(1280x720),然后用setDisplayOrientation配合Surface的旋转矩阵搞定方向。这样画面方向是对的,代价是预览画面是横屏。
如果项目对竖屏显示有硬性要求,建议老老实实把旋转逻辑做完整,用OpenGL的SurfaceTexture或者ImageReader配合Transform做旋转,否则你会在各种机型的“自适应”上疲于奔命。
4. Ubuntu端实现:ServerSocket、解析队列与FFmpeg解码
4.1 接收端线程模型
Ubuntu端我用Java写了接收服务,和Android端的语言保持一致,省去了跨语言协议对齐的麻烦。接收端结构比发送端稍微复杂一点:
一个主线程负责ServerSocket.accept()等待连接,Android端连上来之后,单独起一个接收线程处理这个Socket。接收线程死循环读帧头、解析长度、读帧体,每解析出一帧就往解码队列里塞。
这里要注意的是,Java的ServerSocket.accept()是阻塞的,不能放在UI线程里。我这边是纯命令行工具,没有UI,直接开线程就好。如果以后要接桌面窗口显示,记得把解码和渲染放在另一个线程,避免阻塞接收主循环。
4.2 解码队列的“有界”设计
解码队列我也用了ArrayBlockingQueue,容量同样是30。解码端从队列取一帧、解码一帧、显示一帧,处理速度取决于机器性能和视频分辨率。这里有个经验:如果机器解码速度跟不上网络接收速度,队列就会越积越满,延迟随之升高。这时候不应无限增加队列,而应该主动丢帧。
我的策略是:当队列满到80%时,接收线程直接把新来的非关键帧丢掉,只有关键帧才允许入队。这招很有效,画面会短暂地跳一下,但延迟能维持在200ms以内,比画面连续但延迟1秒的体验好得多。毕竟实时传输的场景里,用户对“流畅但不实时”的容忍度远低于“偶发卡顿但实时”。
丢帧策略根据场景可以调:如果是做录像存储,绝不能丢帧;如果是做实时预览,丢帧完全OK。我的项目定位是实时预览,所以丢帧是合理的主动行为。
4.3 FFmpeg解码的调用姿势
FFmpeg解码H.264的流程比较固定:
- avformat_open_input打开字节流
- avformat_find_stream_info找到视频流
- 找到解码器并打开
- av_read_frame读帧
- avcodec_send_packet送压缩数据
- avcodec_receive_frame取解码后的原始帧
但直接从Socket拿到的裸H.264流没有封装格式,直接丢给FFmpeg会被认为是裸流,虽然能解,但查找流信息可能失败。我采用的做法是用avformat_alloc_context手动创建一个自定义的AVIOContext,把网络输入封装成AVIO回调,让FFmpeg认为自己在读一个文件,这样解封装环节会更稳定,也更方便后面的扩展(比如接RTSP或者文件回放)。
一个小细节:FFmpeg新版对PTS很敏感,如果传入的AVPacket没有正确的PTS,解码出来的帧时间线会乱掉。我用的是Android端传过来的呈现时间戳,把它换算成微秒填进AVPacket的pts字段。实测如果不做这一步,视频播放的帧率不稳定,跳帧现象很频繁。
4.4 显示窗口与帧率控制
解码出来的是AVFrame,里面是YUV420P数据。直接显示需要转换,我用了SDL2配合OpenGL渲染,先在FFmpeg里转成RGBA,再上传为OpenGL纹理,这样显示窗口的刷新率可以由自己控制。
显示循环的常见错误是不等时间戳,解码一帧就画一帧,结果画面速率完全由解码速度决定,不同机器上表现不一样。正确做法是记录每一帧的PTS,和系统时间做对比,不到时间就sleep等待。这就是视频播放里的时钟同步概念。虽然我现在是“收到就解、解完就显示”的方式,只要源端帧率稳定在30fps,这样简单处理也不会出大问题。
真要做精密同步,推荐引入一个“基准时间”的概念:按第一帧到达的时间作为起点,之后每帧按PTS差值计算应该显示的时间点,让系统时间逐步对齐到这条时间线上。我第一次做的时候嫌麻烦跳过了这个步骤,结果帧率确实不稳,后来又补上了。
4.5 Java与C的交互边界
因为FFmpeg和SDL都是C库,我这边Java负责网络接收和队列调度,C侧负责解码和渲染,中间用JNI衔接。JNI这块容易出问题的点是缓存释放:AVPacket和AVFrame一定要在解码循环里及时释放,否则运行几分钟内存就飙升。
我踩过最大的坑是:AVPacket从队列取出来之后,调用avcodec_send_packet把数据交给解码器,但忘了av_packet_unref清理引用,导致内存占用持续上涨,跑五分钟就吃掉几个GB内存。FFmpeg的AVPacket每次使用完必须unref,除非你确认avcodec_send_packet返回错误并且你自行管理了引用计数。这个习惯必须养成。
5. 棘手问题复盘:延迟、卡顿、花屏与断连
5.1 花屏和绿屏
症状:接收端画面大面积花屏、绿屏,偶尔闪现正常画面。
排查过程:一开始怀疑是Socket传输丢包,后来在接收端打印接收到的字节数,发现数据量其实是对的,一帧的字节数完全等于发送端的字节数。接着怀疑是解码端SPS/PPS没配对,打印日志确认SPS/PPS已经成功发送给解码器。最后才发现是组包时字节顺序搞错了:帧头里的长度字段用的小端顺序,接收端解析用的大端逻辑,两者不一致导致长度解析出错,读到错误长度的数据喂给解码器,自然花屏。
解决:统一字节序,发送端和接收端都按小端处理。
这个经历提醒我:处理二进制流的时候,一定要做好日志打印,把帧头关键字段和解码器错误码全部打出来,问题瞬间定位。
5.2 延迟越来越高
症状:刚启动时延迟正常,跑几十秒后画面开始慢半拍,最终延迟数秒。
排查:先看发送端队列是否积压。我在发送端打印队列剩余容量,发现队列长期处于满状态,说明编码速度大于发送速度。进一步抓包发现是因为TCP拥塞:Wi-Fi环境信号一般,发送端快速推数据把带宽打满,TCP拥塞控制开始限速,数据积压在Socket缓冲区。
解决思路有三个方向:降码率(把编码码率从4M降到2M)、降帧率(从30降到15)、加丢帧策略。我最后选了前两者组合,动态码率也可以做,根据Socket的发送耗时估算带宽并动态调整编码码率。这个方向留给以后优化,先用固定码率也能跑。
5.3 连接一断就崩溃
症状:Android端锁屏或者Wi-Fi切换后,Ubuntu端接收线程直接抛异常退出,整个服务挂掉。
原因很简单:Socket在远端断开后,本地read会抛IOException,如果没有捕获处理,线程就崩了。
解决:接收线程的死循环里,所有异常都捕获并判断是否为连接断开。如果是断开,清理资源,回到accept状态等待新连接,而不是直接退出整个进程。顺手把SO_TIMEOUT也配上了,这样长期没有数据的连接也能被及时回收。
socket.setSoTimeout(10000);10秒超时足够覆盖正常视频流的帧间隔,又能及时检测死链。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 接收端一直黑屏 | 没收到SPS/PPS或I帧 | 连接建立后主动发送配置帧 |
| 花屏 | 字节序不一致 | 统一小端序并在代码中显式处理 |
| 画面延迟递增 | 队列积压 | 降码率、主动丢非关键帧 |
| 偶发跳帧 | 解码速度跟不上 | 调低分辨率或启用硬件解码 |
| 连接断开后服务崩溃 | 异常未捕获 | read循环加入异常处理并重启接收 |
| 帧率偏低 | 编码线程和发送线程互相阻塞 | 使用独立线程+队列解耦 |
6. 优化方向:多个层面可以继续玩
整套链路跑通只是第一步,往深了做还有不少空间:
一是硬件解码的方向。Ubuntu端的FFmpeg默认CPU软解,1080p 30fps能应付,但4K视频就吃力了。换用VAAPI、NVDEC等硬件解码接口可以把CPU占用降下来一大截,解码速度也能大幅提升。
二是动态码率控制。根据发送端的Socket写入耗时估算当前网络带宽,实时调整MediaCodec的码率参数,能在Wi-Fi抖动时保住流畅度。MediaCodec支持码率动态调整的API,Android 19以后的机型一般都能支持。
三是服务端改为C/C++实现。丢掉Java和JNI这一层,直接用C的Socket+FFmpeg+SDL,能省掉线程队列拷贝的开销,延迟还能再降一个台阶。
四是多客户端支持。现在的服务端只能同时服务一台手机。改成NIO模式后可以同时服务多路视频流,做成监控矩阵。
我目前的项目已经稳定跑在1080p 25fps,延迟控制在200毫秒左右,不加帧率控制时可以跑到30fps。作为局域网实时预览方案,这个表现已经能胜任日常调试和演示场景了。
最后再分享一个价值极高的调试经验:全程开启日志分级。把“帧ID、大小、时间戳、队列深度”这些关键信息每隔几十帧打印一次,一旦出问题,回看日志就能快速定位是采集、编码、网络还是解码环节出了岔子。这也是我这次项目耗时最值的一笔投资。