C# Socket传输服务器截图至客户端时第二张图不刷新、接收后卡顿问题
TCP流式无边界导致的粘包/拆包解析失败
TCP是面向字节流的协议,本身不携带消息边界标识,当前代码没有任何长度头校验,第一次接收时刚好完整读到第一张图的字节就能正常解析,第二张图传输时很容易因为流中残留上一次的无效字节、或两次发送的数据包粘连,导致读到的字节流不是完整的单张图数据,Bitmap.FromStream()解析失败就会中断后续逻辑,不会触发控件刷新。
另外你固定申请了1MB的接收缓冲区,每次接收时不管实际拿到多少字节,直接把整个缓冲区丢给内存流解析,残留的旧数据会直接混在新的图像流头部,根本无法正常解码。UI线程被阻塞导致界面无响应
你把Socket阻塞接收逻辑直接写在按钮点击事件(UI线程)里,不管是单次接收还是注释里的无限循环,只要UI线程卡在client.Receive()等待数据的状态,就无法处理窗口重绘、前台切换、控件更新等UI消息,自然会出现窗口卡死后切不回来、PictureBox不刷新的情况。
而且当前接收逻辑是绑定在按钮点击上的,第二张图到达时你没有再次触发按钮事件,Receive()根本不会执行,控件自然不会更新。资源泄漏导致的渲染异常
每次给PictureBox赋新图片时,没有主动释放上一次的Image GDI资源,长期运行会占满系统绘图资源,触发控件渲染失败。
服务端截图后先存本地文件再读字节的逻辑属于冗余IO,不仅拖慢速度,还会因为文件读写占用导致发送时机异常。
首先需要定义极简的传输协议,彻底解决粘包问题:
每次传输分两步:先发送4字节的图片实际长度(转网络字节序),再发送完整的图片字节内容,客户端按这个规则分段读取,就不会出现拆包读不全、粘包读混的问题。
服务端代码修正点
- 去掉冗余的临时文件读写,直接把内存中的Bitmap转成JPEG格式的字节数组
- 按照协议先传长度、再传图片内容,循环调用
Send()确保所有字节全部发送完成,避免一次Send只发了部分数据 - 核心修正代码:
private void button2_Click(object sender, EventArgs e) { if (client == null || !client.Connected) return; // 截图资源用using自动释放 using var bmp = TakingScreenshotEx1(); using var ms = new MemoryStream(); // 直接内存转JPEG,绕开临时文件IO bmp.Save(ms, ImageFormat.Jpeg); byte[] imgBytes = ms.ToArray(); // 转4字节网络序的长度头,兼容后续Android端跨平台解析 byte[] lenBytes = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(imgBytes.Length)); client.Send(lenBytes); // 循环确保图片全部发送完成 int totalSent = 0; while (totalSent < imgBytes.Length) { totalSent += client.Send(imgBytes, totalSent, imgBytes.Length - totalSent, SocketFlags.None); } Console.WriteLine("Image SENT!"); }
客户端代码修正点
- 把接收逻辑挪到独立后台线程,绝对不能阻塞UI线程
- 严格按照长度协议分段读取,读够指定长度的字节再去解析图片,不要用整块大缓冲区直接解析
- 解析完图片后,通过UI线程的Invoke方法切回主线程更新PictureBox,禁止跨线程直接操作控件
- 更新图片时主动释放旧的Image资源,避免GDI泄漏
- 窗口关闭时主动停止接收循环、释放Socket连接
- 核心修正代码:
private CancellationTokenSource receiveCts; private void Form1_Load(object sender, EventArgs e) { pictureBox1.SizeMode = PictureBoxSizeMode.StretchImage; if (!client.Connected) { client.Connect(iep); } // 启动后台接收任务,不阻塞UI receiveCts = new CancellationTokenSource(); _ = Task.Run(() => ReceiveLoop(receiveCts.Token)); } private void ReceiveLoop(CancellationToken ct) { byte[] lenBuffer = new byte[4]; try { while (!ct.IsCancellationRequested) { // 第一步:读够4字节的长度头 int readCount = 0; while (readCount < 4) { int receiveLen = client.Receive(lenBuffer, readCount, 4 - readCount, SocketFlags.None); if (receiveLen == 0) return; // 连接对端断开,退出循环 readCount += receiveLen; } // 网络序转主机序,得到单张图片的实际长度 int imageLength = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(lenBuffer, 0)); // 第二步:按长度读够完整的图片字节 byte[] imageBuffer = new byte[imageLength]; readCount = 0; while (readCount < imageLength) { int receiveLen = client.Receive(imageBuffer, readCount, imageLength - readCount, SocketFlags.None); if (receiveLen == 0) return; readCount += receiveLen; } // 第三步:切回UI线程更新图片 Invoke(() => { // 释放旧图片资源 if (pictureBox1.Image != null) { var oldImage = pictureBox1.Image; pictureBox1.Image = null; oldImage.Dispose(); } // 用收到的实际图片字节构建Image using var ms = new MemoryStream(imageBuffer); pictureBox1.Image = Image.FromStream(ms); }); } } catch (Exception ex) { Console.WriteLine($"接收异常: {ex.Message}"); } } // 窗口关闭时清理资源 private void Form1_FormClosing(object sender, FormClosingEventArgs e) { receiveCts?.Cancel(); client.Close(); }
后续Android适配注意事项
- 上述后台接收、跨线程更新UI的逻辑本身就符合Android的开发规范(Android本身就禁止在主线程做网络操作,也要求必须在主线程更新控件),传输层的逻辑可以1:1复用
- Android端的屏幕截图需要单独申请悬浮窗、媒体投影权限,和Windows端的截图实现逻辑不同,不需要改动现有传输协议
- Android端更新控件时用
RunOnUiThread()替代Windows端的Invoke()即可
内容的提问来源于stack exchange,提问作者ehem

