简介:面向C#初学者的屏幕共享示例工程,基于Winform与Socket(TCP)实现服务端与客户端间的实时屏幕传输。工程将连接监听、图像发送、接收显示等模块拆分为独立窗体与类文件,配合详尽注释讲解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 version="1.0" encoding="utf-8" ?> <configuration> <appSettings> <!-- 屏幕共享默认端口 --> <add key="ListenPort" value="9527" /> <!-- 屏幕捕获间隔,单位毫秒 --> <add key="CaptureInterval" value="100" /> </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 编程的基本功就算打牢了。
本文还有配套的精品资源,点击获取