C# UDP屏幕实时传输实战:分片重组与低延迟优化

发布时间:2026/10/12 4:04:17
C# UDP屏幕实时传输实战:分片重组与低延迟优化
简介这是一份面向C#开发者与网络编程学习者的UDP屏幕实时传输实践项目围绕客户端与服务器端的屏幕截图共享展开适合希望深入理解Socket通信、图像处理与多线程协作的中级开发者参考。资源包共66个文件以cs源码、csproj工程文件、resx资源、config配置及exe可执行文件为主另含sln解决方案与pdb调试文件压缩包约346KB目录分为clientDemo与ServerDemo两端结构清晰便于对照阅读。项目涵盖UDP无连接传输、屏幕截屏API调用、数据压缩、多线程收发、数据包重组与异常处理等核心知识点并配有简单界面用于启停传输与状态显示。目前已有695人学习下载读者可借此掌握实时屏幕共享的完整实现思路理解在牺牲可靠性换取低延迟场景下的工程取舍并积累网络通信与图像流式处理的排错经验。1. 从一块 1080p 屏幕说起为什么我最后选了 UDP 而不是 TCP去年帮一个做远程协作工具的朋友看代码他们的屏幕共享模块用 TCP 传画面局域网里跑得挺欢一到跨机房就露馅——画面延迟从 80ms 飙到 2 秒鼠标拖窗口像在拉橡皮筋。问题不在带宽而在 TCP 的重传机制丢一个包后面所有包都得排队等它队头阻塞直接把实时性拖死。屏幕传输这个场景丢几帧没人看得出来但延迟一高整个交互就废了。这就是 UDP 的用武之地——它不保证可靠但保证「现在」。这份资源是一套 C# 实现的 UDP 屏幕实时传输方案核心思路是把屏幕按帧抓取、分块压缩、UDP 分片发送接收端重组后渲染。它解决的是「局域网或可控网络环境下低延迟屏幕共享」的问题适合做远程演示、教学投屏、内网监控这类场景。如果你正在找 C# udp socket 的实战代码或者想搞明白屏幕传输里分片、丢包、乱序到底怎么处理这套东西能让你少走不少弯路。它不是生产级产品但骨架清晰改起来不费劲。2. 拆开看骨架抓屏、压缩、分片、重组四件事2.1 抓屏与压缩为什么用 GDI 而不是 DXGI方案里抓屏用的是System.Drawing.Graphics.CopyFromScreen这是最省事的做法几行代码就能拿到整屏位图。有人会问为什么不用 DXGI 或 Windows.Graphics.Capture那些确实更快但依赖多、代码量大对一份教学性质的资源来说不划算。GDI 在 1080p 下单次抓取大概 15~30ms配合 10~15fps 的发送频率够用了。抓完不能直接发原始位图1920×1080×4 字节约 8MB一帧就能把百兆网卡打满。所以要先压缩。方案里用的是 JPEG 编码通过EncoderParameters设置质量参数// 抓取屏幕区域并编码为 JPEG 字节流 Bitmap bmp new Bitmap(width, height); using (Graphics g Graphics.FromImage(bmp)) { g.CopyFromScreen(0, 0, 0, 0, new Size(width, height)); } // 质量参数 50压缩率与画质的折中点 EncoderParameters eps new EncoderParameters(1); eps.Param[0] new EncoderParameter(Encoder.Quality, 50L); ImageCodecInfo jpegCodec GetEncoderInfo(image/jpeg); using (MemoryStream ms new MemoryStream()) { bmp.Save(ms, jpegCodec, eps); byte[] frameData ms.ToArray(); // 压缩后通常 80~200KB }质量参数是这里最值得调的旋钮。设 80 以上画质好但数据量大局域网里可能没问题跨网段就容易堵设 30 以下画面糊得看不清文字。我一般从 50 起步根据实际带宽和清晰度要求上下浮动。另外注意CopyFromScreen抓的是整个屏幕如果只想传某个窗口得先拿到窗口句柄再算坐标这块方案里没做需要自己补。2.2 UDP 分片一个包塞不下就得自己切UDP 单包理论最大 65507 字节但实际网络里 MTU 一般是 1500超过这个数 IP 层会分片一旦分片丢失整个包就废了。所以常见做法是在应用层自己切每片控制在 1400 字节以内留出 UDP 头和 IP 头的空间。方案里的分片逻辑大致是这样每帧数据切成 N 片每片加一个自定义头包含帧号、片序号、总片数。接收端靠这些信息判断一帧是否收齐。// 自定义分片头帧号(4B) 片序号(2B) 总片数(2B) 数据 const int MaxPayload 1400; int totalChunks (int)Math.Ceiling(frameData.Length / (double)MaxPayload); for (int i 0; i totalChunks; i) { int offset i * MaxPayload; int size Math.Min(MaxPayload, frameData.Length - offset); byte[] packet new byte[8 size]; BitConverter.GetBytes(frameIndex).CopyTo(packet, 0); // 帧号 BitConverter.GetBytes((short)i).CopyTo(packet, 4); // 片序号 BitConverter.GetBytes((short)totalChunks).CopyTo(packet, 6); // 总片数 Buffer.BlockCopy(frameData, offset, packet, 8, size); udpSocket.SendTo(packet, remoteEndPoint); }帧号用 4 字节 int循环递增接收端靠它区分新旧帧。片序号和总片数用 short 够用一帧 200KB 除以 1400 也就 150 片左右。这里有个细节SendTo是同步阻塞的一帧 150 个包挨个发在慢网络上会卡住抓屏线程。常见做法是丢到独立发送队列里异步发或者干脆用SendToAsync。方案里是同步发局域网没问题跨网段建议改。2.3 接收端重组丢一片整帧就扔接收端维护一个字典key 是帧号value 是收到的片数组。每收到一片就填进去当某帧的片收齐了拼起来解码显示。// 接收端重组逻辑 Dictionaryint, byte[][] frameBuffer new Dictionaryint, byte[][](); void OnDataReceived(byte[] packet, int length) { int frameIndex BitConverter.ToInt32(packet, 0); short chunkIndex BitConverter.ToInt16(packet, 4); short totalChunks BitConverter.ToInt16(packet, 6); if (!frameBuffer.ContainsKey(frameIndex)) frameBuffer[frameIndex] new byte[totalChunks][]; frameBuffer[frameIndex][chunkIndex] packet.Skip(8).Take(length - 8).ToArray(); // 检查是否收齐 if (frameBuffer[frameIndex].All(c c ! null)) { byte[] fullFrame CombineChunks(frameBuffer[frameIndex]); DisplayFrame(fullFrame); // 解码并渲染 frameBuffer.Remove(frameIndex); } }这里的关键决策是丢一片就扔整帧不等重传。因为屏幕画面是连续更新的等重传的帧到了新帧早该显示了等来的也是过时画面。所以接收端要有个清理机制把超过一定时间没凑齐的帧从字典里删掉否则内存会涨。方案里用了个简单的时间戳判断超过 500ms 没齐就丢弃。这个阈值可以根据帧率调15fps 的话一帧间隔 66ms500ms 相当于等 7 帧够宽容了。3. 动手跑起来从零搭建发送端和接收端3.1 发送端抓屏线程与发送线程的配合发送端要干三件事定时抓屏、压缩、分片发送。最直接的做法是一个Timer或者独立线程循环执行。但抓屏和发送如果串在一个线程里发送阻塞会拖慢抓屏节奏。我一般会拆成两个线程加一个帧队列抓屏线程只管往队列里塞压缩后的帧数据发送线程从队列取帧分片发。队列长度设个上限比如 3满了就丢最旧的保证发出去的永远是最新画面。// 发送端主循环抓屏线程 BlockingCollectionbyte[] frameQueue new BlockingCollectionbyte[](3); void CaptureLoop() { while (running) { byte[] frame CaptureAndCompress(); if (frameQueue.Count 3) { frameQueue.TryTake(out _); // 丢最旧的一帧 } frameQueue.Add(frame); Thread.Sleep(66); // 约 15fps } } // 发送线程 void SendLoop() { while (running) { byte[] frame frameQueue.Take(); SendFrame(frame); } }BlockingCollection的容量 3 是个经验值太小容易丢帧导致画面卡顿太大延迟会累积。Thread.Sleep(66)控制帧率实际间隔还要算上抓屏和压缩耗时所以真实帧率会略低于 15。如果要精确控制可以用Stopwatch算时间差来补偿。3.2 接收端UDP 收包与画面渲染接收端绑一个本地端口开一个接收线程循环ReceiveFrom。收到包后按帧号重组收齐的帧解码成Bitmap显示在PictureBox或自绘控件上。// 接收线程 UdpClient udpClient new UdpClient(listenPort); IPEndPoint remoteEP new IPEndPoint(IPAddress.Any, 0); void ReceiveLoop() { while (running) { byte[] packet udpClient.Receive(ref remoteEP); OnDataReceived(packet, packet.Length); } } // 解码显示 void DisplayFrame(byte[] jpegData) { using (MemoryStream ms new MemoryStream(jpegData)) { Bitmap bmp new Bitmap(ms); // 跨线程更新 UI 需要 Invoke pictureBox.Invoke(new Action(() { pictureBox.Image?.Dispose(); pictureBox.Image bmp; })); } }注意PictureBox的SizeMode要设成Zoom或StretchImage否则画面显示不全。另外每次更新要Dispose旧图不然内存泄漏很快。跨线程更新 UI 用Invoke是 WinForms 的老规矩如果用的是 WPF 或 Avalonia换成对应的调度器就行。3.3 参数怎么调帧率、质量、分片大小的三角平衡这三个参数互相牵制调之前先想清楚场景优先级。局域网教学投屏清晰度优先质量可以上 70帧率 10fps 就够分片 1400 不用动。跨网段远程协助延迟优先质量降到 40帧率提到 20fps分片可以缩到 1200 减少单包丢失概率。参数局域网推荐跨网段推荐影响JPEG 质量60~8030~50画质与带宽帧率10~15fps15~25fps流畅度与带宽分片大小1400B1200B丢包率与重组开销帧队列长度32延迟与丢帧率调参没有银弹我一般先用默认值跑一遍看接收端画面卡不卡、糊不糊再针对性改。带宽估算很简单一帧 150KB15fps 就是 2.25MB/s约 18Mbps百兆局域网完全扛得住。4. 避坑指南那些让我加班到凌晨的坑4.1 现象接收端画面花屏、下半截绿原因JPEG 数据不完整就送去解码了。分片重组时如果某片是空的但没检查拼出来的字节流缺了一段Bitmap解码器会按自己的理解补数据结果就是花屏。解决重组前严格检查每片非空收齐再解码。另外可以在帧头加一个简单的校验和收齐后算一遍对不上就丢弃。4.2 现象跑几分钟后内存暴涨然后崩溃原因frameBuffer字典里堆积了大量永远收不齐的帧。UDP 丢包是常态有些帧缺一片就永远缺了但字典没清理。解决每帧记录一个时间戳后台线程定期扫描超过 500ms 未收齐的帧直接删掉。同时Bitmap和MemoryStream用完必须DisposeWinForms 里PictureBox.Image替换时旧图不会自动释放。4.3 现象局域网正常一跨网段就卡成幻灯片原因MTU 问题。跨网段时路径 MTU 可能小于 15001400 字节的分片加上 UDP 头和 IP 头就超了IP 层二次分片后丢包率飙升。解决把分片大小降到 1200 甚至 1000留足余量。或者用Socket.DontFragment配合SocketOptionName.PathMtuDiscovery探测路径 MTU但这个在 C# 里支持有限简单粗暴降分片更省事。4.4 现象接收端画面延迟越跑越大原因发送端帧队列积压。抓屏比发送快队列一直满发出去的永远是几秒前的画面。解决队列满时丢最旧的帧而不是阻塞等待。BlockingCollection的TryTake配合容量限制就能实现。另外发送端可以用Stopwatch监控每帧从抓取到发出的耗时超过阈值就主动降帧率。4.5 现象多显示器环境下只抓到主屏原因CopyFromScreen默认抓的是虚拟屏幕的左上角区域多屏时坐标要重新算。解决用System.Windows.Forms.Screen.AllScreens遍历显示器按需抓取指定屏幕或者用Graphics.CopyFromScreen的重载传入负坐标覆盖整个虚拟桌面。这块方案里没展开需要自己补。5. 进阶玩法加一层简单重传和帧间差分基础版跑通之后如果想再压一压带宽或者提一提弱网表现有两个方向可以试。第一个是选择性重传。接收端发现某帧缺片时不傻等而是给发送端回一个 NACK 包里面带上缺的帧号和片号。发送端收到 NACK 后只重发缺失的那几片而不是整帧。这样在丢包率 5% 左右的网络里画面完整度能明显提升。实现上接收端要维护一个「已显示帧号」的滑动窗口发送端要缓存最近几帧的分片数据。代价是复杂度上去了发送端内存占用也会增加缓存帧数一般设 5~10 帧就够了。// 接收端发送 NACK void SendNack(int frameIndex, Listshort missingChunks) { byte[] nack new byte[6 missingChunks.Count * 2]; nack[0] 0x01; // 包类型NACK BitConverter.GetBytes(frameIndex).CopyTo(nack, 1); nack[5] (byte)missingChunks.Count; for (int i 0; i missingChunks.Count; i) BitConverter.GetBytes(missingChunks[i]).CopyTo(nack, 6 i * 2); udpSocket.SendTo(nack, remoteEP); }第二个是帧间差分。屏幕画面大部分区域是不变的只传变化区域能大幅降低数据量。简单做法是把当前帧和上一帧做逐块比较只把有差异的块压缩发送接收端按块更新。块大小可以设 64×64 或 128×128太小了块数量多头部开销大太大了差分效果差。这个方案在文档类场景比如写代码、看网页能省 70% 以上带宽但视频场景就没啥效果了。我自己的习惯是先把基础版跑稳确认抓屏、分片、重组这条链路没问题再往上加东西。每次只加一个变量加完用Stopwatch和带宽监控对比数据确认有收益再保留。从那以后我每次改传输参数都强制走一遍「局域网→跨网段→弱网模拟」三轮测试再也不敢只看本地效果就上线了。希望帮到你。本文还有配套的精品资源点击获取