ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

C# UDP屏幕实时传输实战:截屏压缩、分片与拼帧全解析

C# UDP屏幕实时传输实战:截屏压缩、分片与拼帧全解析 简介一份基于C#与UDP协议实现的屏幕实时传输示例项目面向C#网络编程入门与进阶学习者演示了客户端采集屏幕图像、压缩编码、通过Socket发送以及服务器端接收、解压并显示的全流程。压缩包共66个文件约346KB以18个cs源码文件为核心另有exe可执行程序、pdb调试符号、resources资源文件及sln工程配置便于直接打开编译运行或对照研究cache、config等文件多为Visual Studio自动生成不影响阅读核心代码。目前已有695人学习该资源。项目完整覆盖UDP Socket通信、截屏API调用、数据压缩、多线程收发、简易WinForms界面等关键环节并附带clientDemo与ServerDemo两套可运行工程结构清晰客户端与服务器端均配有界面与逻辑代码可从Program.cs入口跟踪截屏、发送、接收、显示主流程。既适合作为网络编程综合实践的参考模板也可用于理解实时传输中的丢包、乱序及数据重组思路。1. 先把 UDP 屏幕传输的定位想清楚C#基于Udp的屏幕实时传输核心目标是把一台电脑的屏幕用 C# 截下来通过 UDP 报文实时送到局域网内的另一台设备上显示。这类需求最常出现在产线看板、远程协助、设备上位机画面共享等场景延迟必须低但偶尔丢一两帧画面人眼基本无感。先给结论TCP 在这个场景里反而容易把画面拖卡UDP 丢包并不可怕可怕的是分片、乱序和丢帧恢复没设计好直接变成花屏。这篇文章按一条能落地的路径讲为什么选 UDP、截屏压缩怎么做、分片协议怎么定、接收端怎么拼、有哪些坑、上线前怎么量化验证。适合熟悉 Socket 但没碰过视频流的上位机开发者照着做。2. 协议选型为什么屏幕实时传输更吃 UDP 而不是 TCP2.1 屏幕帧的“新鲜度”压过“完整度”屏幕流和文件传输在语义上是两回事。文件传输要的是“字节完整”丢一个字节整个文件就得重传屏幕传输要的是“当前最新画面”上一帧哪怕完整到达也已经被下一帧取代了。TCP 的可靠性机制恰恰建立在“重传旧数据”的基础上一旦网络里丢了一个包TCP 会把它后面的所有数据都堵在接收队列外直到重传成功后才能继续往上送。这就是常说的队头阻塞。放到屏幕场景里TCP 重传的往往是“早就不该显示的旧帧”。接收端正在显示第 30 帧网络还在等第 25 帧的重传包这种补课毫无意义。UDP 没有这个包袱丢包就丢包下一帧马上就能到。所以屏幕实时传输在协议选型上天然偏向 UDP前提是应用层自己把丢包的影响兜住。2.2 算一笔带宽账1080P 裸帧到底有多大先算物理账。1920×1080 分辨率、24 位真彩一帧裸数据是 1920×1080×3约 6.2MB。千兆局域网的理论上限约 125MB/s裸帧也就能跑二十来帧每秒百兆局域网理论只有 12.5MB/s裸帧连 3fps 都撑不住。也就是说不压缩就谈实时传输百兆网络里连基本可用的流畅度都做不到。所以实际方案必须是“截屏 编码压缩 UDP 分片”。以 JPEG 质量 75 为例一帧 1080P 画面压缩后通常落在 150KB 到 500KB 之间文本类界面偏小视频类画面偏大。按 300KB 估算百兆网络 12.5MB/s 的带宽理论上能支撑 40fps留出网络开销后跑到 30fps 是比较稳的。这就是为什么屏幕传输在局域网里可行关键在压缩不在协议。2.3 UDP 的适用边界什么场景还得回头用 TCPUDP 不是银弹。纯 UDP 方案适合“局域网内、单向画面同步、丢包可容忍”的场景。如果链路要跨公网、经过大量路由节点或者使用者对画面完整性有硬要求纯 UDP 会很难看。跨弱网环境需要加 FEC 前向纠错或者按帧做关键帧请求复杂度直接上去。另一个常见架构是“画面走 UDP、控制走 TCP”。远程协助这类场景里鼠标键盘指令必须可靠有序走 TCP画面流走 UDP丢帧不补延迟优先。多台接收端同时看一块屏幕时可以切到 UDP 组播发送端只出一份流量接收端各自组播组收包。这些都是在做这个标题时最常见的扩展方向但第一版我建议先做单播跑通再升级。3. 最小可用发送端截屏、编码与链路自测3.1 三种截屏方案的取舍GDI、BitBlt 与 DXGI上手第一件事是选截屏方案。C# 里最常见的是 GDI 的CopyFromScreen代码量最小几行就能把屏幕拿到 Bitmap 里。它的短板是速度和兼容性高分屏 DPI 缩放处理不好会截出半屏遇到 DXGI 独占画面全屏游戏、硬件加速播放器时截出来是黑屏。如果画面比较静态想要更高效率可以用 BitBlt 配合区域检测先和前帧做差分只把变化区域编码上传。这一招在设备状态看板、文本界面上非常省带宽。再往上是 Windows 8 之后提供的 DXGI Desktop Duplication能拿到 GPU 合成后的完整画面性能最好也能捕获独占画面但需要 COM 互操作代码复杂度明显上升。截屏方案代码量硬件加速画面抓帧开销适用场景GDI CopyFromScreen最少不支持中等普通上位机、控制台画面BitBlt 区域检测中不支持较低可只传变化区域静态界面、看板DXGI Desktop Duplication多支持最低高性能远程桌面、全屏画面第一版先用CopyFromScreen跑通整条链路帧率不够再换 DXGI。屏幕实时传输的瓶颈往往不在截屏而在网络和编码先别过度设计。3.2 截屏加 JPEG 编码的最小实现下面这段代码把主屏幕截下来直接编码成 JPEG 字节数组这就是发送端的数据源。需要引用 System.Drawing 或 System.Drawing.Common。using System; using System.Drawing; using System.Drawing.Imaging; using System.IO; public static class ScreenHelper { /// summary /// 截取主屏幕并编码为 JPEG。 /// quality 建议 60~85文本界面 75 足够视频画面可降到 70。 /// /summary public static byte[] CaptureToJpeg(int quality 75) { Rectangle bounds System.Windows.Forms.Screen.PrimaryScreen.Bounds; using (Bitmap bmp new Bitmap(bounds.Width, bounds.Height)) { using (Graphics g Graphics.FromImage(bmp)) { g.CopyFromScreen(bounds.Left, bounds.Top, 0, 0, bounds.Size); } using (MemoryStream ms new MemoryStream()) { ImageCodecInfo jpegCodec GetCodec(image/jpeg); EncoderParameters ep new EncoderParameters(1); ep.Param[0] new EncoderParameter(Encoder.Quality, quality); bmp.Save(ms, jpegCodec, ep); return ms.ToArray(); } } } private static ImageCodecInfo GetCodec(string mimeType) { foreach (ImageCodecInfo codec in ImageCodecInfo.GetImageEncoders()) if (codec.MimeType mimeType) return codec; return null; } }逻辑说明先拿主屏的像素矩形建一块等大 Bitmap再用 Graphics 把屏幕内容复制进去最后通过 MemoryStream 编码成 JPEG 字节流。直接返回字节数组而不是保存文件是因为每秒要产生几十帧写磁盘纯属浪费。quality是编码质量它直接决定画面大小和清晰度后续调带宽时第一个动它。注意在 .NET 6 的控制台或服务程序里调用 GDI 截屏必须确保进程跑在交互式会话并且开启 DPI 感知。常见做法是在入口调用SetProcessDPIAware()否则高分屏截出来的图会偏尺寸。3.3 先做一个 UDP 连通性自测别急着传画面编码搞定了先别写分片逻辑先确认 UDP 链路本身是通的。用下面的代码从发送端往接收端发一个探测报文接收端用一个现成的 UDP 网络调试工具监听 8601 端口能看到screen-probe文本就算链路通。using System.Net.Sockets; using System.Text; UdpClient sender new UdpClient(0); sender.Connect(192.168.1.100, 8601); byte[] probe Encoding.UTF8.GetBytes(screen-probe); sender.Send(probe, probe.Length);逻辑说明Connect在这里不是建立 TCP 连接而是给 UdpClient 设置一个默认远端地址之后的Send就不需要每次都指定远程端点。端口 8601 是接收端的监听端口0 表示让系统随机分配本地端口。这一步能快速区分“代码问题”和“网络问题”很多新手一上来就传 JPEG最后画面出不来根本分不清是截屏坏了还是网络没通。4. 自定义分片协议让 1080P 帧穿过 1500 字节的窄门4.1 UDP 的 MTU、IP 分片与 1472 这个关键数字UDP 单个报文最大可以到 65507 字节但实际网络不允许这么大的包直接上链路。以最常见的以太网为例MTU 是 1500 字节留给应用层的最大 UDP 载荷是 1500 减去 20 字节 IP 头再减去 8 字节 UDP 头正好 1472 字节。如果你发的 UDP 包超过这个值网络层会做 IP 分片。IP 层分片对应用是透明的但它有个致命问题分片报文里只要丢一片整个原始报文就会被网络层丢弃应用层连“收到不完整包”的机会都没有。局域网通常不丢包可一旦跨交换机出现瞬时拥塞丢一片就等于丢掉整个几百 KB 的 JPEG 帧画面直接裂开。更稳的做法是应用层主动分片把一个 JPEG 帧切成多个小包每个不超过 1400 字节包之间相互独立丢一片只损失这一帧下一帧还能正常来。4.2 最小包头设计帧序号、分片序号与校验口令应用层分片必须让接收端能识别“这是哪一帧的第几片”。我常用的包头是 16 字节四个字段各 4 字节字段大小作用FrameSeq4 字节帧序号每帧自增接收端据此区分不同帧FragIndex4 字节分片序号从 0 开始FragTotal4 字节本帧总分片数Token4 字节固定校验值用于过滤无关设备的乱发包帧序号不能从 0 重复因为接收端要依靠它判断新帧到来分片序号帮接收端按位置存放总分片数用来判断一帧是否收齐。Token 是个固定常量比如0x5A6B7C8D防止局域网里其他设备往这个端口塞数据把拼帧逻辑弄花。这套包头不包括实际画面的任何信息简单直接够用。4.3 发送端完整实现分片、发送与节奏控制public void SendScreenFrame(byte[] jpegData, int frameSeq) { int payloadSize 1200; // 每片载荷保守控制在 1400 以内 int fragTotal (jpegData.Length payloadSize - 1) / payloadSize; for (int fragIndex 0; fragIndex fragTotal; fragIndex) { int offset fragIndex * payloadSize; int len Math.Min(payloadSize, jpegData.Length - offset); byte[] packet new byte[16 len]; BitConverter.GetBytes(frameSeq).CopyTo(packet, 0); BitConverter.GetBytes(fragIndex).CopyTo(packet, 4); BitConverter.GetBytes(fragTotal).CopyTo(packet, 8); BitConverter.GetBytes(0x5A6B7C8D).CopyTo(packet, 12); Array.Copy(jpegData, offset, packet, 16, len); _udp.Send(packet, packet.Length); } }逻辑说明先根据 JPEG 总长度算出需要多少片然后逐片构造报文。每片报文的前 16 字节是包头后面是 JPEG 数据的切片。fragTotal的计算用到了向上取整确保最后一小片也能被算进去。Array.Copy把图片数据切到正确位置。这里有两个参数值得注意。payloadSize是分片载荷我习惯给 1200 而不是 1472因为实际链路可能还有 VLAN Tag、PPPoE 等额外开销留出余量比极限挑战更省心。另一个隐含参数是 Socket 的发送缓冲UDP 的Send通常不阻塞发太快时数据会积压在内核缓冲超过缓冲后直接丢包这种丢包在发送端完全感知不到只能在接收端看到大量缺帧。所以我一般会调小SendBufferSize或者用定时器控制每帧发送间隔而不是闷头发。5. 接收端实现与常见问题排查花屏、高延迟、丢包放大5.1 接收端缓冲与拼帧逻辑乱序也能拼回去接收端要解决的是“按帧缓存、按片拼装、乱序写入”。UDP 底层不保证包序同一帧的 40 个片到达顺序可能完全颠倒因此不能用 List 按顺序添加而是用字典按分片序号存放最后按序号拼回。using System.Collections.Concurrent; private readonly ConcurrentDictionaryint, FrameBuffer _buffers new(); public void HandleReceive(byte[] packet) { int frameSeq BitConverter.ToInt32(packet, 0); int fragIndex BitConverter.ToInt32(packet, 4); int fragTotal BitConverter.ToInt32(packet, 8); int token BitConverter.ToInt32(packet, 12); if (token ! 0x5A6B7C8D) return; if (!_buffers.TryGetValue(frameSeq, out FrameBuffer buffer)) { buffer new FrameBuffer(fragTotal); _buffers[frameSeq] buffer; } buffer.Write(fragIndex, packet, 16); if (buffer.IsComplete()) { byte[] jpegData buffer.ToArray(); _buffers.TryRemove(frameSeq, out _); _imageReady?.Invoke(jpegData); } }逻辑说明每收到一个分片先读包头判断属于哪一帧。如果是新帧就建立一个新的 FrameBuffer否则把分片写进对应缓冲。当这一帧的分片数量凑齐后拼成完整 JPEG 字节数组交给渲染线程去显示。渲染不要放在这里否则解码耗时会把 UDP 收包线程堵死。5.2 坑一画面花屏、横向撕裂现象接收端画面经常出现马赛克、半个屏幕花掉、横向色带过一会儿又恢复。原因最常见是分片载荷设置超过了网络 MTU。比如直接把载荷设成 4000 字节IP 层就会帮你分片而中间交换机一旦丢一个分片整个 4000 字节报文全部作废接收端这一帧就缺了。解决把payloadSize压回 1200并确认帧序号和分片序号写入正确。做完这一步花屏大概率消失。5.3 坑二延迟越跑越高但 CPU 占用不高现象刚启动时画面流畅几分钟后延迟从几十毫秒涨到几秒接收端画面像慢放。原因最常见的错误是把接收、拼帧、JPEG 解码、绘制全挤在一个线程里。接收线程一旦被解码卡住后续分片全部排队延迟自然累积。另一个原因是发送端在每片之间加了Thread.Sleep或者用同步方式等待网络空闲拖慢了整体吞吐。解决接收线程只负责把分片写进缓冲区完整帧丢到队列里让 UI 线程或独立解码线程消费。发送端不要 Sleep用定时器控制帧率比如每 33ms 抓一帧并发一帧让网络栈自己消化节奏。5.4 坑三跑一晚上内存从几十 MB 涨到 GB 级现象程序长时间运行后内存持续上涨最终卡死。原因UDP 丢包后某些帧永远凑不齐分片FrameBuffer 一直挂在_buffers字典里没被回收。帧序号持续增长只要网络偶尔丢包这种残帧就会越积越多。解决给每个 FrameBuffer 记录创建时间超过 500ms 还没完整就强制移除。拼帧缓存必须坚持“宁可错杀不可积压”的原则一帧超过几秒就没意义了。5.5 坑四程序重启后连不上但刚才还好好的现象发送端第一次启动能连上重启程序后就收不到画面接收端也没报错。原因发送端如果一直用new UdpClient(0)每次启动本地端口都随机接收端自然无法可靠地向它回发控制信息。还有一类是 Windows 防火墙入站规则只允许了第一次运行时弹出的选项进程重启后规则失配。解决发送端绑固定端口比如new UdpClient(8600)接收端也固定监听 8601两边都在防火墙里放行对应 UDP 端口。另外要注意 UDP 的“连接”是单向的发送端Connect了接收端地址不代表接收端能直接回发到发送端必须两边都知道对方地址和端口。6. 上线前必调的三个参数与量化验证方法6.1 先动这三个参数屏幕传输调优不需要玄学先改三个地方。第一个是分片载荷payloadSize1200 起调第二个是 JPEG 质量默认 75第三个是发帧间隔默认 33ms。参数选择看这张表参数推荐初始值调整方向分片载荷 payloadSize1200 字节花屏就往 1100 降链路全是小 MTU 就再降JPEG 质量75画面出现毛边就加带宽不够就减别低于 60发帧间隔33ms带宽充足可降到 20ms丢包明显就升到 50ms我的习惯是带宽不够先降帧率再降分辨率最后才降质量。质量 70 以下文本边缘就发虚了不值当。6.2 用 iperf3 给局域网打底屏幕传输的卡顿有一半是链路问题不是代码问题。上线前用 iperf3 先打一遍 UDP 流量建立链路基线。# 在接收端机器上启动 iperf3 服务端 iperf3 -s -p 5201 # 在发送端机器上用 UDP 打 100Mbps 流量测 10 秒 iperf3 -c 192.168.1.100 -p 5201 -u -b 100M -t 10逻辑说明服务端监听 5201客户端向它发 100Mbps 的 UDP 流。结束后重点看 Lost/Total Datagrams 这一行丢包率如果大于 0.1%说明链路承受不了这个码率你的屏幕流得降帧率或降质量。iperf3 结果一定要留档之后出现问题先对比基线再怀疑代码。6.3 给自己的程序加三条统计日志最后一个小技巧在发送端记录每秒实际发送帧数和平均字节数在接收端记录丢片率和完整帧解码耗时。有了这三个数屏幕传输就是可量化的不用靠眼睛猜。我早期翻车最多的一次是 iperf3 明明跑通了 100M 链路一接屏幕就花屏后来发现是分片载荷设成 4000 把链路打崩了。现在每次上线我都固定先跑 iperf3 建档再把载荷压到 1200最后调质量这三板斧下来基本不会再翻车。这套方案做下来希望能帮到你少走一段从 Socket 到屏幕流的弯路。本文还有配套的精品资源点击获取
返回列表