☰
C#基于UDP实现屏幕实时传输:抓屏、分片、重组与延迟优化
2026/10/12 2:37:39 网站建设 项目流程

简介:这份资源是C#基于UDP实现屏幕实时传输的完整项目源码,面向具备一定C#基础、希望深入理解网络通信与实时图像传输的开发者,可用于学习Socket编程、屏幕截屏、多线程与数据压缩等综合技能。压缩包共66个文件,约346KB,以cs源码文件为主,配合csproj项目文件、sln解决方案、config配置、resx资源及exe可执行文件,完整呈现客户端与服务器端两套工程结构,便于直接编译运行与二次修改。项目围绕UDP无连接、低延迟特性展开,涵盖屏幕截图编码、GZip压缩、多线程收发、数据包重组与异常处理等关键环节,并配有简单界面控制传输启停。目前已有695人学习下载,适合作为网络编程与图像传输方向的实践案例,帮助读者快速搭建可运行的屏幕共享原型,理解实时传输中可靠性与效率的权衡思路。

1. 为什么我最终选了 Udp 而不是 Tcp 来做屏幕实时传输

做过远程桌面或屏幕共享的同行大概都有过这种体验:用 Tcp 把画面推出去,局域网里看着还行,一旦跨网段或者丢包率上来,画面就开始卡成幻灯片,延迟越堆越高,最后干脆卡死。问题不在编码,而在 Tcp 的重传机制——它为了保证有序可靠,会把一个丢包后面所有已到达的数据全堵在缓冲区里,等那个包重传回来才继续交付。屏幕流是典型的“实时优先”场景,迟到的帧等于废帧,重传回来的旧画面毫无意义。

C# 基于 Udp 的屏幕实时传输,核心思路就是放弃“每一帧都必须到”的执念,转而追求“最新的一帧尽快到”。Udp 不保证顺序、不保证到达,正好把控制权交回给我们:自己决定哪些帧可以丢、丢了之后怎么补、怎么在接收端把乱序和残缺的画面拼回去。这套方案适合做局域网投屏、教学演示、远程协助、多屏监控这类对延迟敏感、能容忍偶尔花屏的场景。如果你要做的是文件传输或必须像素级无损的归档,那 Udp 不是好选择,别硬上。

我下面讲的这套东西,是一个能在两台 Windows 机器之间跑通的完整链路:抓屏、压缩、分片、发送、接收、重组、渲染,每一环都有可抄的代码和参数说明。新手照着能跑起来,熟手能看到分片大小、发送节奏、丢包补偿这些边界在哪。

2. 抓屏与编码:把桌面变成一帧能塞进 Udp 的数据

2.1 抓屏方案选型:GDI 够用,Desktop Duplication 更快

C# 里抓屏常见三条路:GDI 的CopyFromScreen、Windows 图形接口的BitBlt、以及 Desktop Duplication API。GDI 最简单,Graphics.CopyFromScreen一行就能拿到整屏位图,缺点是走 CPU 拷贝,1080p 下每帧大概 5 到 15 毫秒,30 帧勉强够。Desktop Duplication 走 GPU,延迟低、CPU 占用小,但要用 DXGI 互操作,代码量大,还得处理显卡切换、全屏独占这些边界情况。

我一般先用 GDI 把链路跑通,确认传输和渲染没问题,再考虑换 Desktop Duplication 压延迟。下面是最小抓屏代码:

// 抓取主屏,返回一个 32 位 ARGB 的 Bitmap public Bitmap CaptureScreen() { // 获取主屏尺寸,多屏场景要遍历 Screen.AllScreens Rectangle bounds = Screen.PrimaryScreen.Bounds; Bitmap bmp = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format32bppArgb); using (Graphics g = Graphics.FromImage(bmp)) { // 源坐标和目标坐标一致,拷贝整屏 g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size, CopyPixelOperation.SourceCopy); } return bmp; }

逻辑说明:Screen.PrimaryScreen.Bounds拿到主屏的物理像素范围,CopyFromScreen把屏幕内容直接拷进位图。参数上,PixelFormat.Format32bppArgb保证后续编码器能直接读 BGRA 数据,不用再做格式转换。多屏场景要把bounds.X/Y作为源偏移传进去,否则抓出来的是拼接后的虚拟桌面左上角。

2.2 编码选型:Jpeg 编码器是性价比最高的起点

原始 1080p BGRA 一帧是 1920×1080×4 ≈ 8.3 MB,直接发 Udp 不现实。必须压缩。C# 内置的System.Drawing.Imaging里有 Jpeg 编码器,质量 50 到 70 之间,1080p 一帧能压到 80 到 200 KB,编码耗时 10 到 25 毫秒。H.264 压缩率更高,但要引入 FFmpeg 或 Media Foundation,复杂度和依赖都上来了。

我的建议是:先用 Jpeg 把端到端跑通,把分片、重组、丢包处理这些真正难的部分做扎实,再考虑换 H.264。因为传输层的坑和编码器无关,换编码器不会让分片逻辑变简单。

// 把 Bitmap 压成 Jpeg 字节数组,quality 取 50-70 public byte[] EncodeJpeg(Bitmap bmp, long quality) { // 查找 Jpeg 编码器的 Guid ImageCodecInfo jpegCodec = ImageCodecInfo.GetImageEncoders() .First(c => c.FormatID == ImageFormat.Jpeg.Guid); // EncoderParameters 里放质量参数 EncoderParameters ep = new EncoderParameters(1); ep.Param[0] = new EncoderParameter(Encoder.Quality, quality); using (MemoryStream ms = new MemoryStream()) { bmp.Save(ms, jpegCodec, ep); return ms.ToArray(); } }

逻辑说明:ImageCodecInfo.GetImageEncoders()遍历系统注册的编码器,按FormatID找到 Jpeg。Encoder.Quality是 0 到 100 的长整型,实测 50 以下画面块效应明显,70 以上体积涨得快收益小,55 到 65 是甜点区。注意bmp.Save会同步阻塞,抓屏和编码最好放同一个后台线程,别在 UI 线程里做。

2.3 分片策略:Udp 单包别超过 1400 字节

Udp 数据报超过 MTU 会被 IP 层分片,任何一片丢了整个数据报就废了,而且分片重组在接收端是内核做的,你拿不到“哪一片丢了”的信息。所以要在应用层自己分片,每片控制在 1400 字节以内,留出 IP 头和 Udp 头的余量。

一帧 Jpeg 数据要切成 N 片,每片带一个自定义头:帧号、片序号、片总数、帧长度。接收端靠这些字段判断一帧是否收齐、哪些片缺失。

// 自定义分片头:帧号(4) + 片序号(2) + 片总数(2) + 帧总长(4) = 12 字节 public const int HeaderSize = 12; public const int MaxPayload = 1400 - HeaderSize; // 单片最大负载 public List<byte[]> FragmentFrame(byte[] frame, uint frameId) { List<byte[]> packets = new List<byte[]>(); int total = (frame.Length + MaxPayload - 1) / MaxPayload; // 向上取整 for (int i = 0; i < total; i++) { int offset = i * MaxPayload; int len = Math.Min(MaxPayload, frame.Length - offset); byte[] pkt = new byte[HeaderSize + len]; // 写头:帧号、片序号、片总数、帧总长 BitConverter.GetBytes(frameId).CopyTo(pkt, 0); BitConverter.GetBytes((ushort)i).CopyTo(pkt, 4); BitConverter.GetBytes((ushort)total).CopyTo(pkt, 6); BitConverter.GetBytes(frame.Length).CopyTo(pkt, 8); Array.Copy(frame, offset, pkt, HeaderSize, len); packets.Add(pkt); } return packets; }

逻辑说明:MaxPayload是 1388 字节,加上 12 字节头正好 1400。frameId用uint递增,接收端靠它区分不同帧。片序号和片总数用ushort,意味着单帧最多 65535 片,按 1388 字节算能撑 90 MB 一帧,足够用。注意BitConverter默认小端序,收发两端要一致,跨平台时尤其要确认。

3. 发送与接收:Udp 的节奏控制和重组逻辑

3.1 发送端:别一口气把一帧全喷出去

新手最容易犯的错是把一帧切完片之后for循环里连续Send,结果网卡缓冲区瞬间打满,Udp 直接丢包,接收端一帧都拼不齐。Udp 没有拥塞控制,发送速率完全靠应用层自己拿捏。

我的做法是每片之间加一个微小间隔,或者用Socket.Send的返回值做简单流控。更稳的方式是限制在途未确认帧的数量,但屏幕流场景下,简单限速就够用。

// 发送一帧的所有分片,片间加 1ms 间隔 public void SendFrame(Socket sock, EndPoint target, byte[] frame, uint frameId) { List<byte[]> packets = FragmentFrame(frame, frameId); foreach (byte[] pkt in packets) { sock.SendTo(pkt, target); // 片间微延时,避免瞬间打满发送缓冲区 Thread.Sleep(1); } }

逻辑说明:Thread.Sleep(1)在 Windows 上实际可能睡 1 到 15 毫秒,取决于系统时钟精度。如果一帧有 100 片,这个延时会让帧发送时间拉长到 100 毫秒以上,反而增加延迟。更好的做法是用Stopwatch做忙等或者用Socket.SendBufferSize调大缓冲区。实测把SendBufferSize设到 256 KB,去掉Sleep,在千兆局域网里丢包率也能接受。参数上,SendBufferSize默认 8 KB 太小,一定要调。

3.2 接收端:用字典缓存分片,按帧号重组

接收端要维护一个“正在接收的帧”的缓存。每收到一片,按帧号找到对应的缓存槽,把片塞进去,记录已收到的片数。当已收片数等于片总数时,按片序号排序拼出完整帧,交给解码器。

// 接收端帧缓存:帧号 -> 分片数组 private Dictionary<uint, byte[][]> _frameBuffer = new Dictionary<uint, byte[][]>(); private Dictionary<uint, int> _receivedCount = new Dictionary<uint, int>(); public byte[] OnPacketReceived(byte[] pkt) { uint frameId = BitConverter.ToUInt32(pkt, 0); ushort seq = BitConverter.ToUInt16(pkt, 4); ushort total = BitConverter.ToUInt16(pkt, 6); int frameLen = BitConverter.ToInt32(pkt, 8); // 首次收到该帧,初始化缓存槽 if (!_frameBuffer.ContainsKey(frameId)) { _frameBuffer[frameId] = new byte[total][]; _receivedCount[frameId] = 0; } // 重复片直接丢弃,避免计数错乱 if (_frameBuffer[frameId][seq] == null) { byte[] payload = new byte[pkt.Length - HeaderSize]; Array.Copy(pkt, HeaderSize, payload, 0, payload.Length); _frameBuffer[frameId][seq] = payload; _receivedCount[frameId]++; } // 收齐了,拼帧并清理缓存 if (_receivedCount[frameId] == total) { byte[] frame = new byte[frameLen]; int offset = 0; for (int i = 0; i < total; i++) { Array.Copy(_frameBuffer[frameId][i], 0, frame, offset, _frameBuffer[frameId][i].Length); offset += _frameBuffer[frameId][i].Length; } _frameBuffer.Remove(frameId); _receivedCount.Remove(frameId); return frame; } return null; // 还没收齐 }

逻辑说明:_frameBuffer用帧号做键,值是按片序号索引的数组。重复片判断很关键——Udp 可能重复投递,不判重会导致计数虚高、拼帧时数组越界。frameLen用来分配最终帧的缓冲区,保证拼出来的长度精确。注意这个字典会随丢帧增长,必须加超时清理:超过 500 毫秒还没收齐的帧直接丢弃,否则内存会慢慢涨上去。

3.3 丢包处理:收不齐就跳过,别等

屏幕流的核心原则是“宁可花屏,不可卡顿”。一帧如果超过一定时间没收齐,直接放弃,把缓存清掉,等下一帧。因为下一帧很快就来,等一个残缺的旧帧毫无意义。

// 清理超过 500ms 未收齐的帧 private void CleanupStaleFrames() { uint currentId = _latestFrameId; List<uint> stale = new List<uint>(); foreach (var kv in _frameBuffer.Keys) { // 帧号落后当前帧超过 30 帧,视为过期 if (currentId - kv > 30) stale.Add(kv); } foreach (uint id in stale) { _frameBuffer.Remove(id); _receivedCount.Remove(id); } }

逻辑说明:用帧号差值判断过期比用时间戳更简单,因为帧号是单调递增的。阈值 30 帧在 30 fps 下约等于 1 秒,可以根据网络质量调。局域网可以设小一点,比如 10 帧,跨网段设大一点。这个清理要放在接收循环里定期调用,不能等字典爆了才处理。

4. 避坑与排查:Udp 屏幕传输最容易翻车的五个地方

4.1 现象:画面大面积花屏,但延迟很低

原因:Jpeg 数据在传输中丢了片,接收端却把不完整的数据当完整帧解码了。Jpeg 解码器遇到截断数据不会报错,而是解出一片灰色或彩色噪点。

解决:拼帧前严格校验_receivedCount == total,不满足就返回 null,绝不把半截数据送进解码器。另外可以在帧头加一个简单的校验和,比如对 Jpeg 字节做累加和,接收端比对,不一致直接丢。

4.2 现象:延迟越跑越高,最后卡死

原因:发送端不限速,接收端处理不过来,Udp 接收缓冲区溢出,大量丢包。更糟的是接收端还在处理旧帧,新帧又堆进来,形成恶性循环。

解决:接收端只保留最新帧。收到完整帧后,如果解码线程还在忙,直接丢弃当前帧,不要排队。用一个volatile bool _decoding标志控制,解码中就把新帧扔掉。屏幕流永远只关心最新画面。

4.3 现象:局域网正常,跨网段就丢包严重

原因:跨网段经过路由器,MTU 可能变小,1400 字节的包被 IP 层二次分片,丢一片废一包。另外跨网段带宽和抖动都比局域网差。

解决:把MaxPayload降到 1200 甚至 1000,给 IP 层留足余量。同时降低发送帧率,从 30 fps 降到 15 fps,给网络喘息空间。实测 1200 字节在多数跨网段场景下能显著降低丢包。

4.4 现象:多屏环境下抓出来是黑屏或错位

原因:Screen.PrimaryScreen.Bounds只覆盖主屏,多屏拼接后的虚拟桌面坐标和单屏不一致。另外 DPI 缩放会导致抓屏尺寸和实际像素不匹配。

解决:遍历Screen.AllScreens,对每个屏单独抓取,或者用SystemInformation.VirtualScreen拿整个虚拟桌面。DPI 问题要在程序清单里声明 DPI 感知,或者在抓屏前调用SetProcessDpiAwareness,否则 125% 缩放下抓出来的是模糊的放大图。

4.5 现象:接收端解码报“参数无效”或内存暴涨

原因:Bitmap和MemoryStream没及时释放,GDI 对象泄漏。Jpeg 解码每次new Bitmap(ms)都会占一块非托管内存,不Dispose的话几分钟就涨到几个 GB。

解决:所有Bitmap、Graphics、MemoryStream都用using包起来。解码后的 Bitmap 在渲染完成后立刻Dispose,不要留着等 GC。GDI 对象泄漏是 C# 抓屏类程序最常见的翻车点,没有之一。

5. 进阶技巧:用双缓冲和帧号回执把延迟压到 50ms 以内

前面四章把链路跑通了,但端到端延迟大概在 100 到 200 毫秒。如果要做远程操作,这个延迟能感觉到拖影。下面两个技巧是我实际调优时最有效的。

第一个是接收端双缓冲渲染。解码和绘制分到两个线程,解码线程把Bitmap放进一个volatile引用,UI 线程用Interlocked.Exchange取走并绘制。这样解码不阻塞绘制,绘制也不等解码。注意取走的Bitmap要负责Dispose,别两边都释放。

// 双缓冲:解码线程写 _latest,UI 线程取走 private Bitmap _latest; private readonly object _swapLock = new object(); // 解码线程调用 public void PushFrame(Bitmap bmp) { Bitmap old; lock (_swapLock) { old = _latest; _latest = bmp; } old?.Dispose(); // 释放上一帧,避免泄漏 } // UI 线程调用 public Bitmap PullFrame() { lock (_swapLock) { Bitmap b = _latest; _latest = null; return b; } }

逻辑说明:lock保证交换原子性,old?.Dispose()释放被替换掉的旧帧。UI 线程拿到Bitmap后画到PictureBox或自绘控件上,画完立刻Dispose。这个模式比队列简单,且天然只保留最新帧,不会积压。

第二个是帧号回执。接收端每收齐一帧,回一个只含帧号的小包给发送端。发送端维护一个“已确认帧号”,如果发现连续多帧没被确认,说明网络拥塞,主动降帧率或降 Jpeg 质量。这个反馈环能把延迟稳定在 50 毫秒左右,代价是多了少量上行流量。

// 接收端回执:只发 4 字节帧号 public void SendAck(Socket sock, EndPoint sender, uint frameId) { byte[] ack = BitConverter.GetBytes(frameId); sock.SendTo(ack, sender); } // 发送端根据回执调整质量 private uint _lastAckId; public void OnAckReceived(uint ackId) { _lastAckId = ackId; // 落后超过 10 帧,降质量保流畅 if (_currentFrameId - _lastAckId > 10) _jpegQuality = 40; else _jpegQuality = 60; }

逻辑说明:回执包只有 4 字节,开销可忽略。_currentFrameId - _lastAckId反映在途帧数,差值大说明接收端处理慢或网络堵。质量在 40 和 60 之间切换,避免频繁抖动。这个自适应逻辑比固定参数稳得多,尤其在无线网络下。

最后说个我踩过的坑:别在发送端用Thread.Sleep做帧率控制,Sleep(33)实际可能睡 45 毫秒,帧率直接掉到 22。用Stopwatch算时间差,忙等或者用高精度定时器,才能稳住 30 fps。这套东西调下来,局域网 1080p 30 帧,端到端延迟能压到 40 到 60 毫秒,肉眼基本感觉不到拖影。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询