简介:这是一份面向C#开发者与网络编程学习者的UDP屏幕实时传输实践项目源码,围绕客户端与服务器端的屏幕截图共享展开,适合希望深入理解Socket通信、图像处理与多线程协作的中级学习者。资源包共66个文件,以cs源码、csproj工程文件、config配置、resx资源及exe可执行文件为主,另含sln解决方案与pdb调试文件,压缩包约346KB,目录分为clientDemo与ServerDemo两端,结构清晰便于对照阅读。项目覆盖UDP无连接传输、Socket收发、屏幕截屏、数据压缩、多线程处理、数据包重组与异常处理等关键知识点,读者可从中获取完整的双端通信实现思路与代码组织方式,用于课程设计、技术验证或二次开发参考。目前已有695人学习下载。
1. 从一块 1080p 屏幕说起:为什么我最终选了 UDP 而不是 TCP
去年帮一个做远程协作工具的朋友排查延迟问题,他们的屏幕共享模块用 TCP 实现,局域网内看着还行,一旦跨机房或者无线网络抖动,画面就开始"追帧"——延迟从 80ms 一路涨到 2 秒以上,操作端和显示端完全对不上。这个场景让我重新审视了一个老问题:屏幕实时传输到底该用什么协议。
答案其实不复杂:C# 基于 UDP 的屏幕实时传输,核心思路是把屏幕按帧抓取、编码压缩、分片打包,通过 UDP 直接发出去,接收端重组解码渲染。它解决的是"实时性优先、允许少量丢帧"的场景——远程桌面、教学演示、监控预览、游戏串流都吃这一套。适合谁?有 C# 基础、懂 Socket 编程、想自己搭一套低延迟投屏的开发者。如果你追求的是文件级无损,那 TCP 更合适;但只要你的需求里出现"实时"两个字,UDP 基本跑不掉。
这一篇我会把整套链路拆开讲:抓屏怎么选 API、编码怎么权衡、UDP 分片怎么设计、丢包怎么补救、参数怎么调。不是理论科普,是我自己踩过坑之后能跑通的方案。
2. 抓屏与编码:从 GDI 到 Desktop Duplication 的选型账
2.1 三种抓屏方式的实测差异
C# 里抓屏常见三条路:GDI 的Graphics.CopyFromScreen、Windows 图形接口的PrintWindow、以及 Desktop Duplication API(DXGI)。我早期图省事用的 GDI,代码三行就能跑:
// GDI 抓屏:简单但慢,适合静态画面 using var bmp = new Bitmap(width, height); using var g = Graphics.FromImage(bmp); g.CopyFromScreen(left, top, 0, 0, new Size(width, height));这段代码逻辑很直白:创建一个和目标区域等大的位图,把屏幕对应区域拷贝进去。参数left/top是抓取起点,width/height是区域尺寸。问题在于,GDI 抓一帧 1080p 大约 15~30ms,而且它走的是 CPU 拷贝,帧率一高 CPU 直接飙满。我实测 1080p 下 GDI 稳定在 20fps 左右就上不去了。
Desktop Duplication 是另一回事。它直接拿 GPU 的桌面纹理,抓一帧通常 2~5ms,而且能拿到"脏矩形"——只有变化的区域才返回。代价是代码复杂度上来了,要初始化 D3D11 设备、获取输出、处理AcquireNextFrame的超时。我一般会封装成一个ScreenCapturer类,内部维护 D3D 设备和 staging texture,对外只暴露TryGetFrame(out FrameData)。
选型结论:如果目标帧率低于 15fps、分辨率 720p 以内,GDI 够用;只要上 1080p 或要求 30fps 以上,直接上 Desktop Duplication,别在 GDI 上浪费时间优化。
2.2 编码:为什么我放弃了 JPEG 全帧
抓到的帧是 BGRA 原始数据,1080p 一帧就是 8MB 左右,直接发 UDP 不现实。必须编码压缩。常见选择是 JPEG、PNG、H.264。PNG 无损但压缩慢、体积大,实时场景基本不用。JPEG 是很多人的第一选择,System.Drawing里bmp.Save(stream, ImageFormat.Jpeg)一行搞定,质量设 50 左右,1080p 一帧能压到 100~200KB。
但 JPEG 有个硬伤:它是全帧编码,每帧都要重新压一遍,CPU 占用高,而且帧间冗余完全没利用。我后来换成了"脏矩形 + JPEG"的组合——只对变化区域编码,静止画面下带宽能降 90% 以上。再进一步,如果追求更低码率,可以引入 H.264 硬编码(通过 Media Foundation 或第三方封装),但复杂度陡增,新手不建议一上来就搞。
我一般会这样权衡:局域网内、帧率 30fps 以内,脏矩形 + JPEG 质量 60 是性价比最高的组合;跨公网、带宽受限,才考虑 H.264。下面是一个脏矩形编码的简化实现:
// 只编码变化区域,rect 来自 Desktop Duplication 的脏矩形 public byte[] EncodeRegion(Bitmap full, Rectangle rect, long quality) { using var crop = full.Clone(rect, full.PixelFormat); using var ms = new MemoryStream(); var encoder = ImageCodecInfo.GetImageEncoders() .First(c => c.FormatID == ImageFormat.Jpeg.Guid); var pars = new EncoderParameters(1); pars.Param[0] = new EncoderParameter(Encoder.Quality, quality); crop.Save(ms, encoder, pars); // 质量参数直接决定体积和画质 return ms.ToArray(); }逻辑说明:full.Clone(rect, ...)把变化区域裁出来,避免全帧编码。Encoder.Quality是关键参数,取值 0~100,我实测 50~65 是实时场景的甜点区,低于 40 画面块效应明显,高于 75 体积涨得比画质快。注意EncoderParameters用完要释放,否则高频调用下会内存泄漏——这个坑我踩过,跑两小时内存涨到 2GB。
2.3 帧率控制与采集节奏
采集不能无脑循环,否则 CPU 空转。我一般用"目标帧率 + 变化检测"双控:设定目标 30fps,每帧间隔 33ms,但如果这一轮没检测到脏矩形,就跳过编码直接 sleep。这样静止桌面下 CPU 占用能从 30% 降到 3% 以下。
具体做法是用一个Stopwatch计时,每轮开始记录时间戳,处理完计算耗时,不足 33ms 就Thread.Sleep补足。注意别用Thread.Sleep(1)循环凑时间,Windows 的睡眠精度只有 15ms 左右,会导致帧率抖动。要么用timeBeginPeriod提高精度,要么直接接受 30fps 附近的波动。
3. UDP 分片与重组:一帧 200KB 怎么塞进 1500 字节的包
3.1 分片协议设计:头部字段一个都不能少
UDP 单包理论上能到 64KB,但实际网络 MTU 通常 1500 字节,超过就会被 IP 层分片,一旦丢一个 IP 分片整个 UDP 包就废了。所以正确做法是在应用层自己分片,每片控制在 1400 字节以内(留出 IP 和 UDP 头空间)。
我设计的分片头部是 16 字节,字段如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| FrameId | 4 字节 | 帧序号,用于区分不同帧 |
| TotalChunks | 2 字节 | 本帧总分片数 |
| ChunkIndex | 2 字节 | 当前分片序号,从 0 开始 |
| PayloadLen | 2 字节 | 本片有效数据长度 |
| Flags | 1 字节 | 标志位,如是否关键帧 |
| Reserved | 5 字节 | 预留对齐 |
为什么 FrameId 要 4 字节?因为 2 字节只有 65536 个序号,30fps 下不到 40 分钟就回绕了,回绕时如果还有旧帧的分片在路上,就会串帧。4 字节够跑好几天。这个细节不注意,长时间运行必翻车。
打包代码大概长这样:
// 把一帧编码后的数据切成 UDP 分片 public List<byte[]> Fragment(byte[] frame, uint frameId, bool isKeyFrame) { const int maxPayload = 1400; int total = (frame.Length + maxPayload - 1) / maxPayload; var list = new List<byte[]>(total); for (int i = 0; i < total; i++) { int offset = i * maxPayload; int len = Math.Min(maxPayload, frame.Length - offset); var buf = new byte[16 + len]; BitConverter.GetBytes(frameId).CopyTo(buf, 0); BitConverter.GetBytes((ushort)total).CopyTo(buf, 4); BitConverter.GetBytes((ushort)i).CopyTo(buf, 6); BitConverter.GetBytes((ushort)len).CopyTo(buf, 8); buf[10] = (byte)(isKeyFrame ? 1 : 0); Buffer.BlockCopy(frame, offset, buf, 16, len); list.Add(buf); } return list; }逻辑说明:maxPayload设 1400 是经验值,留足头部空间。total用向上取整算总分片数。每个分片先写 16 字节头,再拷数据。参数frameId由发送端自增维护,isKeyFrame用于接收端判断能否作为重建起点。
3.2 接收端重组:超时丢弃与乱序处理
接收端要维护一个"正在重组帧"的缓冲区。每收到一个分片,先解析头部,按 FrameId 找到对应缓冲区,把数据填到 ChunkIndex 位置,同时记录已收到的分片数。当收齐 TotalChunks 个分片,就拼成完整帧交给解码器。
关键问题是:UDP 会丢包、会乱序、会重复。我的处理策略是:
- 乱序:按 ChunkIndex 直接填数组,天然支持乱序。
- 重复:用一个
bool[] received标记,重复的直接丢。 - 丢包:设一个超时,比如 200ms 内没收齐就丢弃整帧,等下一帧。因为屏幕传输丢一帧无所谓,下一帧马上就来,死等只会让延迟累积。
// 接收端重组缓冲区 class FrameBuffer { public uint FrameId; public byte[][] Chunks; public bool[] Received; public int ReceivedCount; public DateTime FirstSeen; public bool IsKeyFrame; public bool IsComplete => ReceivedCount == Chunks.Length; public bool IsExpired => (DateTime.Now - FirstSeen).TotalMilliseconds > 200; }逻辑说明:Chunks是分片数组,Received标记哪些位置已填。IsExpired用 200ms 超时判断,这个值要结合你的帧率调——30fps 下帧间隔 33ms,200ms 相当于容忍丢 6 帧,再长就没意义了。FirstSeen记录首片到达时间,用于超时计算。
3.3 发送节奏与拥塞感知
UDP 没有拥塞控制,无脑发会把网络打爆。我一般加一个简单的发送队列 + 速率限制:维护一个待发队列,每帧的分片按顺序发,但每秒发送字节数不超过设定上限(比如 8Mbps)。如果队列积压超过两帧,就主动丢最旧的帧,保证实时性。
这个逻辑说白了就是"宁可丢帧,不可延迟"。屏幕传输里,一帧迟到的画面比没有画面更糟糕,因为它会让接收端显示过时内容。我见过有人为了不丢帧把队列开到 10 帧,结果延迟 300ms 以上,完全没法用。
4. 避坑与排查:那些让我加班到凌晨的 UDP 问题
4.1 大包直接发导致整帧丢失
现象:小分辨率正常,一上 1080p 就大量花屏,接收端频繁报"帧不完整"。
原因:编码后一帧 200KB,如果偷懒直接用一个 UDP 包发(Send传 200KB 数组),IP 层会分成 140 多个 IP 分片,任何一个分片丢失,整个 UDP 包在接收端就被丢弃,等于整帧全废。
解决:老老实实在应用层分片,每片不超过 1400 字节。分片后单个分片丢失只影响那一小片,配合超时丢帧策略,体验反而更稳。
4.2 接收缓冲区太小导致丢包
现象:发送端显示已发出,接收端却收不到,ReceiveFrom返回的数据断断续续。
原因:UDP 的接收缓冲区默认只有 8KB 左右,高帧率下数据到达速度超过应用读取速度,内核缓冲区满了就直接丢包,而且不报错。
解决:在Socket初始化后设置ReceiveBufferSize,我一般设 1MB 到 4MB:
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); socket.ReceiveBufferSize = 4 * 1024 * 1024; // 4MB,高帧率场景必须调 socket.SendBufferSize = 1 * 1024 * 1024;注意ReceiveBufferSize要在Bind之前设,设完可以用socket.ReceiveBufferSize读回来确认是否生效——有些系统会限制上限,设太大反而被截断。
4.3 帧序号回绕导致画面串帧
现象:程序跑了几十分钟后,画面突然出现上一帧的碎片,或者花屏一下又恢复。
原因:FrameId 用了ushort,65536 帧后回绕,此时网络上可能还有旧帧的分片,接收端按 FrameId 匹配就串了。
解决:FrameId 用uint,并且接收端在切换 FrameId 时,把旧帧缓冲区全部清掉。另外可以加一个"帧年龄"判断,超过 500ms 的缓冲区直接丢弃。
4.4 编码器参数没释放导致内存泄漏
现象:程序运行几小时后内存持续上涨,最终 OOM。
原因:EncoderParameters和EncoderParameter实现了IDisposable,高频调用下不释放会累积。JPEG 编码每秒 30 次,一小时就是 10 万次泄漏。
解决:用using包住,或者手动Dispose。这个坑很隐蔽,因为短时间测试看不出来,只有长时间跑才暴露。
4.5 跨网段时 UDP 被中间设备限速
现象:局域网内流畅,一跨网段就卡顿,丢包率飙升。
原因:部分网络设备对 UDP 有 QoS 限制或直接限速,尤其是大流量 UDP。
解决:这种情况没有银弹,只能降低码率、减小分片、增加冗余。我一般会加一个简单的 FEC(前向纠错),每 10 个分片额外发 2 个冗余分片,接收端丢少量包时能恢复。代价是带宽增加 20%,但跨网稳定性明显提升。
5. 进阶技巧:把延迟压到 50ms 以内的几个手段
前面四章把链路跑通了,这一章讲怎么从"能用"做到"好用"。我自己的验收标准是:局域网 1080p 30fps,端到端延迟 50ms 以内,CPU 占用低于 15%。
第一个手段是零拷贝发送。Socket.Send每次都要把数据从托管堆拷到内核,高频调用下开销不小。可以用SocketAsyncEventArgs配合ArrayPool<byte>复用缓冲区,减少 GC 压力。我实测这一项能把 CPU 占用降 3~5 个百分点。
第二个手段是解码与渲染分离。接收端收到完整帧后,别在主线程解码,扔给一个独立线程或Task,解码完再通过双缓冲切换渲染。这样网络接收不会被解码阻塞,帧率更稳。
第三个手段是动态质量调节。维护一个最近 1 秒的丢包率统计,丢包率超过 5% 就把 JPEG 质量从 60 降到 45,低于 2% 再升回去。这个反馈环能让画质和流畅度自动平衡,比固定参数省心得多。
// 动态质量调节:根据丢包率调整 JPEG 质量 int quality = 60; double lossRate = GetRecentLossRate(); // 最近1秒丢包率 if (lossRate > 0.05) quality = Math.Max(40, quality - 5); else if (lossRate < 0.02) quality = Math.Min(75, quality + 2);逻辑说明:GetRecentLossRate统计接收端上报的丢包比例,发送端据此调整。参数 40 和 75 是质量上下限,避免调过头。调整步长 5 和 2 是经验值,降要快、升要慢,防止震荡。
最后一个技巧是关键帧兜底。虽然屏幕传输丢帧无所谓,但如果连续丢帧超过 500ms,接收端画面会完全停滞。这时候发送端应该强制发一个全帧关键帧,让接收端重新同步。我一般设一个定时器,超过 500ms 没成功发帧就触发全帧编码。
这些手段叠加下来,我在局域网实测端到端延迟稳定在 35~45ms,跨网段 80~120ms,日常远程操作基本感觉不到延迟。血泪经验是:别指望一次调到位,先跑通基础链路,再逐项加优化,每加一项测一次数据,不然出了问题都不知道是哪一层导致的。希望帮到你。
本文还有配套的精品资源,点击获取