ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C# Socket屏幕共享:从TcpListener到图像压缩的入门实践

C# Socket屏幕共享:从TcpListener到图像压缩的入门实践 简介面向C#初学者的屏幕共享示例工程基于Winform与SocketTCP实现服务端与客户端间的实时屏幕传输。工程将连接监听、图像发送、接收显示等模块拆分为独立窗体与类文件配合详尽注释讲解TcpListener/TcpClient、NetworkStream、屏幕截图转字节流、图像还原及异常处理适合想入门网络编程或做远程协助类小项目的开发者。压缩包共51个文件约117KB以cs源码、resx界面资源、exe可执行程序、config配置文件及调试缓存文件为主目录结构清晰可直接打开工程对照学习。该示例发布后已有1215人浏览学习完整覆盖从局域网获取IP、连接握手到多客户端并发传输的全过程代码中的每段Socket交互和屏幕捕获逻辑都配有说明无论是想理解C/S架构还是希望掌握屏幕图像采集与传输都能获得直观参考稍作扩展即可用于远程桌面、教学演示等场景。1. 为什么说 Socket 屏幕共享是 C# 网络编程最好的入门练习做上位机或者桌面工具的开发迟早会碰到一个需求把 A 机器的屏幕实时投到 B 机器上看。买现成的商业软件要花钱用 RDP 又太重这时候用 C# Winform 手写一个屏幕共享工具反而最灵活。这个项目把 Socket 通信里最核心的几个东西全串起来了——TcpListener 监听、TcpClient 连接、NetworkStream 读写、数据分包和粘包处理还附带屏幕捕获和图像压缩一套流程走完对 C# 网络编程的理解会上一个台阶。项目本身是给入门者准备的代码里加了详细注释服务端和客户端分两个窗体工程组织用 Visual Studio 打开即可运行。适合刚学完委托和线程、想碰 Socket 的人也适合已经写了几年业务代码、想快速搭一套 LAN 内屏幕监控原型的工程师。下面按从骨架到血肉的顺序把这个项目拆开讲透。2. TcpListener 与 TcpClient先搭起能跑通的通信骨架2.1 为什么用 TcpListener/TcpClient 而不是裸 Socket 类不少 C# 教材一上来就甩Socket类又是Bind又是Listen代码长、概念多新手很容易被吓退。这个项目用的是System.Net.Sockets下的TcpListener和TcpClient它们是 .NET 对裸 Socket 的一层封装把绑定地址、监听队列、三次握手这些底层的活都给做了。你只管设置 IP 和端口调一个AcceptTcpClient()就能拿到一条跟客户端已经连好的连接背后的细节在入门阶段不需要关心。使用TcpClient和TcpListener并不表示绕开了 Socket 本身的机制。恰恰相反这两个类内部就是 Socket 的托管封装在理解 TCP 分包、粘包问题时你仍然需要知道数据是被切割成小段、在网络上按顺序到达的。用高一层 API 入门同时保留对底层机制的感知这就是我觉得这个项目选型聪明的地方。2.2 服务端监听与客户端连接的实现服务端fuwu.cs做的事情很简单选择一个端口用TcpListener开始监听然后阻塞等待客户端接入。核心代码长这样// 服务端监听与接受连接 TcpListener listener new TcpListener(IPAddress.Any, 9527); listener.Start(); // 开始监听会把端口绑定到本机所有网卡 // 接受客户端连接程序会阻塞在这里直到有客户端连入 TcpClient client listener.AcceptTcpClient();IPAddress.Any表示监听本机所有网卡的 9527 端口这样局域网内无论客户端通过哪个网卡 IP 来连服务端都能收到。端口号 9527 是项目里写死的实际使用可以放到配置文件里避免换环境改代码。客户端kehu.cs连接时需要知道服务端的 IP 地址和端口号。局域网环境下初始化TcpClient后调用Connect方法即可// 客户端连接服务端 TcpClient tcpClient new TcpClient(); tcpClient.Connect(192.168.1.100, 9527); // 这里填服务端机器的局域网IP连接成功后TcpClient对象的GetStream()方法会返回一条NetworkStream后续屏幕图像数据的收发都靠它。提示局域网内 IP 经常变化不建议把 IP 写死。项目里在界面上留了输入框服务端启动时自动填充本机当前 IP客户端手动填目标 IP这样两台机器谁换了网段都不慌。2.3 App.config 管理连接参数项目里带了App.config文件这就是放连接参数的合适位置。把端口号和延迟毫秒数放进去省得每次改代码重新编译?xml version1.0 encodingutf-8 ? configuration appSettings !-- 屏幕共享默认端口 -- add keyListenPort value9527 / !-- 屏幕捕获间隔单位毫秒 -- add keyCaptureInterval value100 / /appSettings /configuration读取时用ConfigurationManager.AppSettings[ListenPort]配一个默认值兜底配置文件缺失时程序不会直接崩。这个习惯同样适用于其他上位机项目。2.4 项目结构里的工程组织方式这套源码的组织方式值得照着模仿。服务端逻辑集中在fuwu.cs客户端逻辑在kehu.cs发送屏幕数据的动作单独拆到fasong.cs界面上的模式选择放在xuanze.cs入口在Program.cs。即使你是把几个窗体堆在一个项目里也尽量保持这种按职责划分的原则否则一旦加功能就全乱套。3. Graphics.CopyFromScreen 与 JPEG 压缩把屏幕变成字节流3.1 捕获屏幕图像的两种方式屏幕共享的第一步是把屏幕内容截下来。C# 里捕获屏幕最直接的方法是System.Drawing.Graphics.CopyFromScreen它把指定区域的画面直接拷贝到一张Bitmap上。另一种方式是调用 Windows 图形设备接口 (GDI) 的函数比如BitBlt性能更高但 P/Invoke 声明麻烦。入门阶段用CopyFromScreen足够原理清楚了再优化不迟。截屏代码在项目里以CaptureScreen方法的形式出现大致逻辑是// 获取主屏幕的像素尺寸 Rectangle bounds Screen.PrimaryScreen.Bounds; using (Bitmap bitmap new Bitmap(bounds.Width, bounds.Height)) { using (Graphics g Graphics.FromImage(bitmap)) { // copy from screen把屏幕内容绘制到 bitmap 上 g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size); } // 到这里 bitmap 就是当前屏幕的完整图像 }这段代码要注意的关键点Screen.PrimaryScreen.Bounds只覆盖主显示器如果机器接了多块屏幕其他显示器的内容不会被捕获。想支持多屏需要遍历Screen.AllScreens然后拼接这个留到后面的进阶章节说。Bitmap和Graphics都实现了IDisposable用using包裹是必要的否则长时间运行时 GDI 对象句柄会泄漏导致截屏越来越慢。3.2 为什么输入图像必须压缩如果直接把Bitmap转成原始字节往网络上发一张 1920x1080 的 32 位位图大小是 1920 × 1080 × 4 ≈ 8.3 MB。就算局域网带宽够每秒发 10 帧就是 83 MB/s已经能吞掉千兆网卡的绝大部分吞吐量延迟也高得没法看。解决思路是压缩。BMP 转 JPEG视觉损失很小体积能缩到 50~200 KB 一帧。JPEG 编码 C# 里通过ImageCodecInfo和EncoderParameters控制质量// 图像压缩为 JPEG 并输出到 MemoryStream ImageCodecInfo jpegCodec GetEncoderInfo(image/jpeg); EncoderParameters encoderParams new EncoderParameters(1); encoderParams.Param[0] new EncoderParameter(Encoder.Quality, 70L); // 质量参数 using (MemoryStream ms new MemoryStream()) { bitmap.Save(ms, jpegCodec, encoderParams); byte[] data ms.ToArray(); // 压缩后的 JPEG 数据 }质量参数70L是我个人比较常用的平衡点。屏幕共享场景下画面大多是文字、窗口、代码JPEG 质量 50 时文字边缘已经能看到马赛克70 基本可读体积又不会太大。如果带宽紧张可以压到 50但文字会明显发虚如果走千兆局域网且机器性能好可以设成 85视觉上接近原图。3.3 转成字节数组后的发送准备压缩完成后图像就变成一段不规则的byte[]。发送前必须先告诉接收端这次要收多少字节否则对端完全不知道该读多少。项目采用最简单的方案先把长度写进网络流再写图片数据接收端先读长度再读数据。这段逻辑放在fasong.cs里// 发送数据关键代码先发长度再发数据体 byte[] lengthBytes BitConverter.GetBytes(data.Length); // 固定4字节 networkStream.Write(lengthBytes, 0, 4); // 写入长度 networkStream.Write(data, 0, data.Length); // 写入图片数据 networkStream.Flush();BitConverter.GetBytes返回 4 字节的整数小端序存储。只要收发两端都是 C#不需要担心字节序问题如果将来要和别的语言互通就要统一规定大小端。提示NetworkStream的写入是同步阻塞的。如果屏幕分辨率特别大一次Write可能需要几毫秒到几十毫秒不等这期间如果界面还在用同一个线程发送就会出现窗口拖动卡顿的观感。这个问题的处理方式放在最后一章展开。4. 帧头设计与分包发送解决粘包和超大数据包的问题4.1 TCP 粘包与拆包问题的本质TCP 是流式协议没有消息边界。发送端两次Write的数据可能被接收端一次Read全部收到这就是粘包反过来一次Write的数据也可能被拆成多次Read才能读完。屏幕共享这种持续发送大块数据的场景这两种现象出现的概率极高不处理就是黑屏、花屏、图像错位。解法是给每个数据包增加一个帧头帧头记录数据的长度。接收端先读固定长度的帧头获得本帧大小再按这个大小读取数据体。这个约定在项目里体现为长度前缀法。4.2 定义清晰的帧结构这个项目的数据帧虽然只有一张图片还是可以分成三类来管理帧头、数据体、以及可能需要的控制信号。帧头结构我用一个简单类来承载// 帧头结构命令字 数据长度 class FrameHeader { public int Command { get; set; } // 0x01表示图像数据0x02表示控制指令 public int DataLength { get; set; } // 数据体的字节长度 }为什么这里要专门拆一个命令字出来原因很简单屏幕共享只是一个起点后面很可能要追加文件传输、远程控制模拟鼠标键盘等能力。有了命令字接收端一读到帧头就知道下一步该怎么解析数据体。这比裸写一个length data格式要好扩展得多。4.3 发送端大包分包策略一张 JPEG 图像压缩后通常 50 KB 到 200 KB最大可能超过 1 MB高分辨率全屏截图且画面复杂时。直接Write一个 1 MB 的数组底层 TCP 会把它切割成 MTU通常 1500 字节大小的小包来发理论上没有问题但接收端每次Read拿到的数据量是不定的重组时稍有不慎就会丢数据。更稳的做法是在发送端手动分包每块 4 KB 发送// 分包发送每包最大 4096 字节 const int CHUNK_SIZE 4096; int offset 0; while (offset data.Length) { int count Math.Min(CHUNK_SIZE, data.Length - offset); networkStream.Write(data, offset, count); offset count; }除了让数据体切碎发送以外每块之间可以夹一个序号接收端校验序号就知道有没有丢包。不过 TCP 本身保证数据按序到达、不会丢失完整的数据包如果连接不断分包后即使不夹序号也能完整重组。之所以夹序号是为了在数据异常时能快速定位是哪一段出了问题。4.4 接收端长度驱动重组接收端要处理的场景正好相反可能一次Read收到了几帧数据也可能等了半天还差几个字节。可靠做法是写一个读取指定字节数的辅助方法循环Read直到凑满// 从 NetworkStream 读取指定字节数确保读满 byte[] ReadBytes(NetworkStream stream, int length) { byte[] buffer new byte[length]; int offset 0; while (offset length) { int readCount stream.Read(buffer, offset, length - offset); if (readCount 0) { throw new IOException(连接已断开无法读取完整数据); } offset readCount; } return buffer; } // 接收一帧 // 1) 先读帧头这里简化成 4 字节长度前缀 byte[] lenBuf ReadBytes(networkStream, 4); int frameLength BitConverter.ToInt32(lenBuf, 0); // 2) 再读 frameLength 字节的数据体 byte[] frameBody ReadBytes(networkStream, frameLength);ReadBytes里的循环是整个接收逻辑最核心的地方。Read返回 0 表示对端关闭了连接此时直接抛异常避免死循环。另外一点容易踩坑的是NetworkStream.Read并不保证一次返回你请求的全部字节数所以用while循环反复读是完全必要的。注意帧头加数据体这种方案在屏幕共享实时流里也有一个风险——如果接收端读帧头读到一半网络断开程序会卡在循环里。实际项目中可以在ReadBytes里加一个超时控制用stream.ReadTimeout或者一个计时器来兜底。5. Accept 循环与异常拦截把单连接 Demo 改成可用的多客户端会话5.1 foreach 接受多个客户端连接入门项目通常只演示一对一通信一个服务端对应一个客户端AcceptTcpClient只调用一次。真实场景下一台主机上的服务端往往要同时接收多个客户端的屏幕画面。改造方式很直接用一个while (true)循环不断接受连接每来一个客户端就丢给一个单独的线程去处理// 不断接受新的客户端连接 TcpListener listener new TcpListener(IPAddress.Any, 9527); listener.Start(); while (true) { // 等待有客户端连入 TcpClient client listener.AcceptTcpClient(); // 每个客户端单独开线程处理避免相互阻塞 Thread clientThread new Thread(HandleClient); clientThread.IsBackground true; clientThread.Start(client); }把IsBackground设为后台线程是为了防止客户端线程阻止主程序退出。HandleClient方法里面再调前面说的接收逻辑读帧头读数据体转成图像显示。这里有个隐含的问题如果服务端要同时显示多个客户端的屏幕界面上的PictureBox控件会从多个线程访问。Winform 控件默认只在创建它的线程UI 线程上可以访问跨线程访问会抛异常。常见做法是在HandleClient里把收到的图像交给 UI 线程用Control.Invoke或者BeginInvoke。5.2 网络异常拦截与客户端掉线检测屏幕共享跑在局域网网络波动、对端断电、机器休眠都会导致连接异常。Socket 的读写一旦遇到连接中断会抛SocketException或IOException不捕获的话整个线程就挂了。项目里每一路客户端连接的处理函数都要做异常兜底try { // 循环读取一帧屏幕图像并显示 while (true) { byte[] lenBuf ReadBytes(stream, 4); int dataLength BitConverter.ToInt32(lenBuf, 0); byte[] imageData ReadBytes(stream, dataLength); // 转成 Bitmap 显示到界面上 } } catch (SocketException ex) { // 连接被对端重置或网络不可达 Console.WriteLine(客户端异常断开: ex.Message); } catch (IOException ex) { // 读取超时或对端关闭连接 Console.WriteLine(连接中断: ex.Message); } finally { client.Close(); }SocketException里面有几个常见的错误码值得写进文档10054对端强制关闭连接、10060连接超时、10053连接被本地中止。调试时在catch块里把ex.SocketErrorCode打出来能快速判断是网络问题还是代码问题。5.3 bind: only one usage of each socket address 报错排查开发过程中如果程序崩溃后马上重跑经常会遇到端口还没释放的情况服务端启动时报错bind: only one usage of each socket address (protocol/network address/port)。这个报错的直接原因就是端口还在TIME_WAIT状态里还没被释放。排查步骤是这样先用netstat -ano | findstr 9527看端口被哪个 PID 占用。找到占用进程后确认是不是前一次运行的服务端没有退出干净。如果确实需要立即重跑可以在TcpListener启动前设置端口复用// 设置 Socket 选项允许端口复用 listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); listener.Start();ReuseAddress选项的含义是允许处于TIME_WAIT状态的端口被新的监听重新绑定开发调试时省了很多重启的麻烦。但要说明白生产环境服务端一般不启这个因为有安全隐患可能两条监听抢占同一个端口。作为入门项目了解这个报错的成因比一上来就SetSocketOption更有价值。5.4 客户端登录与退出流程项目里客户端的界面有一个“连接”按钮和一个“断开”按钮连接逻辑上面写过了断开逻辑需要注意不要只关 UI 窗口还要主动关闭TcpClient和NetworkStream。只关窗口不关网络对象会导致服务端那边抛一堆异常因为对端已经消失了。private void btnDisconnect_Click(object sender, EventArgs e) { try { if (networkStream ! null) networkStream.Close(); if (tcpClient ! null) tcpClient.Close(); } catch (Exception) { // 关闭时异常可以忽略对端可能已经断开 } }finally风格的主动释放以此为准所有IDisposable对象都在关闭流程里释放服务端收到Read返回 0 后自然退出while循环双端都不残留无效连接。6. 用 PictureBox 双缓冲与动态 JPEG 质量把观看体验拉满6.1 使用 BeginAcceptTcpClient 避免阻塞 UI 线程前面第 5 章的AcceptTcpClient是同步阻塞的一旦调用当前线程会卡在那里等待连接。如果把这个调用放在 UI 线程主窗口就没法拖动、点击按钮也没反应了。入门阶段可以开线程解决但线程数量一高开销就变大。这个项目注释里也提出了异步方案推荐升级思路是用BeginAcceptTcpClient回调方式让监听过程完全不占线程// 异步接受客户端连接不需要额外开线程 listener.BeginAcceptTcpClient(asyncResult { TcpClient client listener.EndAcceptTcpClient(asyncResult); // 处理这个客户端然后继续等待下一个 listener.BeginAcceptTcpClient(handleConnect, null); }, null);回调里EndAcceptTcpClient拿到连接后再调用一次BeginAcceptTcpClient形成递归实现“不断等待新连接”的效果。回调本身由线程池调度不占 UI 线程窗口也一直保持流畅。这套写法对 C# 新人有一点门槛但理解之后网络编程的阻塞问题就有了解法。6.2 PictureBox 显示高帧率图片的双缓冲设置接收端不停地接收 JPEG 数据、转成Bitmap然后赋给PictureBox.Image。这个过程如果直接操作控件属性会有两个问题一是跨线程访问控件被禁止二是刷新频率过高时控件会闪烁。项目里界面上留了PictureBox配合Control.Invoke更新同时建议对PictureBox做双缓冲处理。双缓冲并不是 PictureBox 自带的属性需要在窗体初始化里手动设置// 开启 PictureBox 双缓冲减少高帧率刷新闪烁 typeof(PictureBox).GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(pictureBox1, true, null);在接收端的图像刷新方法里接收完帧后先拿一个Bitmap对象再赋给PictureBox记得把上一帧的Image用Dispose()回收否则几十分钟后内存就被占满了。另外PictureBox.SizeMode设置成Zoom它会自动按照控件比例缩放图片不需要手动算目标矩形。6.3 根据发送耗时动态调节 JPEG 质量屏幕共享的体验瓶颈经常不在带宽而在 CPU。每次把 Bitmap 编码成 JPEG 都要耗 CPU分辨率越高越明显。项目里给的思路是动态调节压缩质量记录上一帧从截图到发完的总耗时如果超过设定的CaptureInterval就降低 JPEG 质量参数压低 CPU 开销如果速度很快就提高质量让画面更清晰。伪代码逻辑如下// 动态质量调整根据发送耗时自动升降级 if (elapsed captureInterval) { // 发送超时降低质量减少数据量 quality Math.Max(30, quality - 5); } else { // 有空余时间提高质量 quality Math.Min(90, quality 2); }简单理解这套机制是把编码耗时和网络耗时当作信号量来控制输出码率。在实际的远程控制软件里类似思路会替换成更复杂的码率控制算法这里用几条if就能达到 80% 的效果。屏幕共享用到的 JPEG 编码库在 .NET Framework 里是System.Drawing往 .NET 6 以上迁移时要换成SkiaSharp或ImageSharp因为System.Drawing在非 Windows 平台上支持有限。作为一个入门项目这段代码的完整注释已经把每一步的作用写得明明白白。建议拿到源码后先去把服务端和客户端跑通再逐个断点看每一帧数据的流向最后尝试把单客户端改成多客户端或者把 JPEG 换成 PNG 对比体积差异。这样练完Socket 编程的基本功就算打牢了。本文还有配套的精品资源点击获取
返回列表