简介这是一份面向C#开发者、特别是需要嵌入远程桌面功能到跨平台.NET应用中的技术实践者所用的高性能VNC客户端库。资源提供完全托管的RFB协议实现支持Tight、ZRLE等高效图像编码在低带宽下仍可保障流畅画面传输适用于远程运维工具、IoT设备管理界面、教育类远程控制软件等场景。压缩包为ZIP格式大小931KB含源代码及示例应用主要文件类型为C#项目文件.csproj、核心协议实现类.cs与跨平台抽象层接口结构清晰便于二次开发与协议定制。目前已有667人学习下载读者可直接集成该Alpha版稳定库已通过TigerVNC、LibVNCServer、RealVNC等主流服务端验证快速获得开箱即用的VNC连接能力、完整的事件驱动交互模型及平台无关的渲染适配方案。1. MarcusW.VncClient 是什么一个被低估的 C# 跨平台 VNC 客户端底层库不是 GUI 应用而是你写远程控制能力的「肌肉」MarcusW.VncClient 不是某个带界面的“远程桌面软件”也不是封装完就点开能用的.exe安装包——它是一个纯 C# 编写的、零原生依赖的 VNC 协议客户端实现库。它的核心价值在于在 .NET 6 环境下不调用 libvncclient、不嵌入 C/C DLL、不依赖系统级图形栈仅靠托管代码完成 RFB 协议解析、像素解码、输入事件注入与会话状态管理。这意味着你能把它嵌进 Windows Forms、WPF、Avalonia、Blazor Server通过 SignalR 中继、甚至 Unity 的 Editor 模式里做定制化远程监控面板、工业 HMI 远程调试桥、教育平台的实验设备共享模块或者给 IoT 设备加一个轻量级 Web 控制台。它不解决“怎么画 UI”但彻底解决了“怎么把远端屏幕帧高效喂进来、把鼠标键盘指令精准送出去”这个黑匣子问题。如果你正在用 C# 做跨平台远程交互类项目又卡在 libvncclient 编译失败、SharpVNC 兼容性差、或自己手撕 RFB 协议三天没跑通 SetEncodings 阶段——那 MarcusW.VncClient 就是那个你翻了 27 个 GitHub 仓库后终于找到的“能编译、能连、能动、还能看懂源码”的务实选择。2. 从 NuGet 到首帧渲染三步跑通最小可运行 VNC 客户端2.1 创建项目并引用库确认目标框架与兼容性边界MarcusW.VncClient 当前稳定版截至 2024 年中为3.2.0支持 .NET Standard 2.1 及以上实测在 .NET 6 / .NET 8含 Windows、Linux x64/arm64、macOS Intel/Apple Silicon全平台可用。关键约束不支持 .NET Framework 4.x不支持 UWP 或 Xamarin.iOS/macOS。新手常在此处翻车——误建 .NET Framework 项目后死磕 NuGet 安装失败。正确做法如下# 新建一个干净的 .NET 6 控制台项目后续可迁移到任何 UI 框架 dotnet new console -f net6.0 -n VncDemo cd VncDemo # 添加库注意不是 MarcusW.VncClient.Core也不是 VncClientLib —— 名称必须精确 dotnet add package MarcusW.VncClient --version 3.2.0提示若使用 Visual Studio务必在“管理 NuGet 包”中搜索MarcusW.VncClient作者名 Marcus W 是唯一标识避免选错同名但非同一作者的废弃包如VncClient或RfbClient。该包无运行时 native 依赖安装后bin/Debug/net6.0/下不会出现.so/.dylib/.dll文件——这是它“纯托管”特性的第一眼验证。2.2 构建连接器用 12 行代码建立握手并接收首帧VNC 连接本质是 TCP 握手 RFB 协议协商 认证 帧缓冲初始化。MarcusW.VncClient 将此过程封装为VncClient类但不自动启动 UI 渲染线程——你需要手动订阅事件并处理像素数据。以下是最小可运行连接示例假设目标 VNC 服务地址为192.168.1.100:5900密码为admin123using MarcusW.VncClient; using System.Drawing; // 1. 创建客户端实例不传参数即使用默认配置 var client new VncClient(); // 2. 订阅连接成功事件此时已通过认证可发请求 client.Connected (s, e) { Console.WriteLine($✅ 已连接到 {e.Host}:{e.Port}分辨率 {e.FramebufferWidth}x{e.FramebufferHeight}); // 3. 发起首次帧请求关键否则不会触发 FramebufferUpdate client.RequestUpdate(); }; // 4. 订阅帧更新事件核心拿到原始像素字节 client.FramebufferUpdate (s, e) { // e.Data 是按 BGRXWindows或 RGBXLinux/macOS排列的原始字节数组 // e.Rectangle 给出更新区域x,y,width,height非全屏时只更新脏区 Console.WriteLine($️ 收到帧更新{e.Rectangle.Width}x{e.Rectangle.Height} ({e.Rectangle.X},{e.Rectangle.Y})); // 此处应将 e.Data 转为 Bitmap/SkiaSharp/ImageSharp 对象供 UI 渲染 // 示例转为 System.Drawing.Bitmap仅 Windows跨平台请用ImageSharp // var bitmap ImageFromBytes(e.Data, e.Rectangle.Width, e.Rectangle.Height); }; // 5. 启动连接异步内部处理 TCP RFB 握手 await client.ConnectAsync(192.168.1.100, 5900, admin123);逻辑说明Connected事件触发表示 RFB 协议握手完成包括协议版本协商、安全类型选择、VNC 认证通过此时客户端已获知服务端能力如支持的编码类型、桌面名称、初始分辨率。FramebufferUpdate是核心数据通道每次服务端推送新画面或局部更新时触发e.Data是未压缩的原始像素流格式由服务端在SetEncodings阶段声明MarcusW.VncClient 默认请求Raw编码以保兼容性e.Rectangle明确告知本次更新的矩形区域——这是实现高效局部重绘的基础。RequestUpdate()必须显式调用库默认不自动轮询由使用者控制更新节奏防带宽浪费。首次连接后立即调用才能触发第一帧下发。2.3 渲染像素到 UI跨平台图像解码与显示链路FramebufferUpdate返回的是原始字节需转换为 UI 框架可消费的图像对象。不同平台方案不同此处给出三类主流场景的落地代码▶ Windows FormsSystem.Drawingprivate static Bitmap ImageFromBytes(byte[] data, int width, int height) { var bitmap new Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format32bppArgb); var bitmapData bitmap.LockBits( new Rectangle(0, 0, width, height), System.Drawing.Imaging.ImageLockMode.WriteOnly, System.Drawing.Imaging.PixelFormat.Format32bppArgb); // 注意VNC Raw 编码默认为 BGRX蓝-绿-红-空而 Bitmap 是 BGRA可直接 memcpy Marshal.Copy(data, 0, bitmapData.Scan0, data.Length); bitmap.UnlockBits(bitmapData); return bitmap; }参数说明data.Length必须等于width * height * 4若服务端返回 RGBX如某些 Linux VNC 服务需手动字节交换B↔R。▶ Avalonia跨平台 UI用 SkiaSharpusing SkiaSharp; private static SKBitmap ImageFromBytes(byte[] data, int width, int height) { // 创建 SKBitmap指定 RGBA_8888 格式Skia 默认 var bitmap new SKBitmap(width, height, SKImageInfo.PlatformColorType, SKAlphaType.Premul); // 将 data 复制到 bitmap 的像素内存Skia 内部为 RGBAVNC Raw 为 BGRX → 需转换 unsafe { var dst bitmap.GetPixels(); for (int i 0; i data.Length; i 4) { dst[i 0] data[i 2]; // R ← B dst[i 1] data[i 1]; // G ← G dst[i 2] data[i 0]; // B ← R dst[i 3] data[i 3]; // A ← X (保持不透明) } } return bitmap; }▶ Blazor ServerWeb 渲染转 Base64// 在 FramebufferUpdate 事件中 string base64Image Convert.ToBase64String(e.Data); // 通过 JS Interop 将 base64 传给前端 img srcdata:image/bmp;base64,xxx await JSRuntime.InvokeVoidAsync(updateVncImage, base64Image);注意Web 场景需额外处理帧率避免高频 Base64 传输压垮 SignalR建议添加简单帧率限制如Throttle事件。3. 连接稳定性与性能调优三个必调参数与两层缓冲策略3.1 影响首连成功率的三个关键参数MarcusW.VncClient 的VncClientOptions允许深度定制连接行为。以下三个参数在真实网络尤其高延迟、丢包环境中决定“连得上”还是“连不上”参数默认值推荐值作用说明ConnectionTimeoutMs500010000TCP 连接超时。内网可设 3000跨公网建议 10000避免因 SYN 重传延迟导致误判失败AuthenticationTimeoutMs1500030000VNC 认证阶段超时。某些老旧服务如 TightVNC 2.8在 DES 加密协商时耗时波动大过短易中断InitialEncodingEncodingType.RawEncodingType.Tight首次请求的编码类型。Raw兼容性最好但带宽高Tight压缩率高实测降低 60% 流量但需服务端支持现代 TigerVNC/x11vnc 均支持设置方式var options new VncClientOptions { ConnectionTimeoutMs 10000, AuthenticationTimeoutMs 30000, InitialEncoding EncodingType.Tight // 启用 Tight 编码 }; var client new VncClient(options);3.2 解决卡顿与撕裂双缓冲队列 主动丢帧策略FramebufferUpdate事件在 UI 线程同步触发若渲染耗时 服务端帧间隔如 33ms30fps会导致事件堆积、UI 卡顿、画面撕裂。官方未内置渲染调度需自行实现缓冲层。我采用的生产级方案是“双缓冲队列 时间戳丢帧”// 1. 定义线程安全缓冲队列 private readonly ConcurrentQueue(byte[] Data, Rectangle Rect, long Timestamp) _frameQueue new(); // 2. 在 FramebufferUpdate 中只入队不渲染 client.FramebufferUpdate (s, e) { var frame (e.Data.ToArray(), e.Rectangle, DateTimeOffset.Now.ToUnixTimeMilliseconds()); _frameQueue.Enqueue(frame); }; // 3. 启动独立渲染循环每 16ms 执行一次 Task.Run(async () { while (true) { if (_frameQueue.TryDequeue(out var frame)) { // 检查是否过期只渲染距当前时间 100ms 的帧丢弃旧帧 long now DateTimeOffset.Now.ToUnixTimeMilliseconds(); if (now - frame.Timestamp 100) { RenderFrameToUi(frame.Data, frame.Rect); // 实际渲染方法 } } await Task.Delay(16); // 锁定 ~60fps 渲染节奏 } });血泪经验曾在线上环境遇到服务端突发推送 50 帧/秒而 UI 渲染仅 20fps导致内存暴涨、GC 频繁。加入时间戳丢帧后内存占用下降 70%UI 响应恢复流畅。这不是玄学优化是应对网络抖动的刚需设计。3.3 输入事件注入鼠标与键盘的精准坐标映射VNC 协议中鼠标坐标基于服务端桌面分辨率而客户端 UI 窗口可能缩放、居中或存在边框。若直接转发屏幕坐标会出现“鼠标指哪点哪不中”。必须做坐标归一化// 假设 UI 控件尺寸为 clientAreaWidth × clientAreaHeight // 服务端桌面分辨率为 vncWidth × vncHeight private Point NormalizeMousePosition(int clientX, int clientY, int clientAreaWidth, int clientAreaHeight, int vncWidth, int vncHeight) { // 1. 计算 UI 控件内相对坐标扣去边框/标题栏 double scaleX (double)vncWidth / clientAreaWidth; double scaleY (double)vncHeight / clientAreaHeight; // 2. 归一化到 [0,1] 区间再映射到服务端坐标 double normX Math.Clamp((double)clientX / clientAreaWidth, 0, 1); double normY Math.Clamp((double)clientY / clientAreaHeight, 0, 1); return new Point( (int)Math.Round(normX * vncWidth), (int)Math.Round(normY * vncHeight) ); } // 使用示例UI 控件 MouseDown 事件中 private void OnMouseDown(object sender, MouseEventArgs e) { var vncPos NormalizeMousePosition(e.X, e.Y, uiWidth, uiHeight, serverWidth, serverHeight); client.SendPointerEvent(vncPos.X, vncPos.Y, MouseButton.Left, true); // 按下 }关键细节SendPointerEvent的buttonMask参数是位掩码1左键2中键4右键8滚轮上16滚轮下isDown为true表示按下false表示释放。必须成对发送否则服务端状态错乱。4. 避坑指南五个真实踩过的坑与当场解决方案4.1 现象连接成功但FramebufferUpdate从不触发原因服务端未启用Raw或Tight编码或客户端未正确发送SetEncodings请求。MarcusW.VncClient 默认只请求Raw但某些精简版 VNC 服务如部分嵌入式设备仅支持Hextile。解决在Connected事件中手动追加编码请求client.Connected (s, e) { // 强制请求 Hextile 编码兼容老旧设备 client.SetEncodings(new[] { EncodingType.Hextile }); client.RequestUpdate(); };4.2 现象画面颜色异常全绿、偏紫、闪烁原因像素格式不匹配。FramebufferUpdate.Data的字节序取决于服务端声明的PixelFormat通过ServerInit消息获取而开发者常忽略此字段硬编码 BGRX 解析。解决监听PixelFormatChanged事件动态适配client.PixelFormatChanged (s, e) { Console.WriteLine($ 像素格式BPP{e.BitsPerPixel}, Depth{e.Depth}, BigEndian{e.BigEndian}, TrueColor{e.TrueColor}); // 根据 e.RedMax/e.GreenMax/e.BlueMax 判断 RGB 排列或直接用 e.BitsPerPixel 32 且 e.TrueColor 为 true 时按 RGBA 处理 };4.3 现象键盘输入无效或按键被重复触发原因未处理KeyRepeat事件或SendKeyEvent的downFlag参数逻辑错误。VNC 协议要求按键按下时downFlagtrue松开时downFlagfalse若只发按下不发松开服务端会持续触发该键。解决绑定 UI 的KeyDown/KeyUp事件严格配对uiControl.KeyDown (s, e) client.SendKeyEvent(e.KeyCode, true); uiControl.KeyUp (s, e) client.SendKeyEvent(e.KeyCode, false);4.4 现象Linux 服务端连接后黑屏但日志显示已连原因服务端启用了DesktopSize调整但客户端未响应FramebufferResize事件导致后续帧请求区域超出实际缓冲区。解决订阅FramebufferResize并重新请求client.FramebufferResize (s, e) { Console.WriteLine($️ 分辨率变更{e.Width}x{e.Height}); client.RequestUpdate(); // 重置更新区域 };4.5 现象.NET 8 macOS 上ConnectAsync抛OperationCanceledException原因.NET 8 的SocketsHttpHandler默认启用 HTTP/3而 VNC 是纯 TCP 协议此异常实为底层 DNS 解析超时伪装。解决禁用 HTTP/3 并强制 IPv4规避 IPv6 DNS 问题AppContext.SetSwitch(System.Net.Http.SocketsHttpHandler.Http3Support, false); // 并在 ConnectAsync 前确保 host 为 IP 地址避免 DNS 查询 await client.ConnectAsync(192.168.1.100, 5900, pwd); // 不要用域名5. 进阶实战构建一个带会话管理的 Avalonia 远程控制面板5.1 架构设计分离连接、渲染、输入三层职责真实项目不能把所有逻辑塞进MainWindow.xaml.cs。我采用分层架构SessionManager管理多个VncClient实例处理连接池、重连策略、凭据加密存储FrameRenderer独立线程消费_frameQueue用 SkiaSharp 渲染到SKElement支持缩放、平移、右键菜单InputBridge将 Avalonia 的PointerPressed/KeyDown事件经坐标归一化后转发至对应VncClient。核心文件结构VncControlPanel/ ├── Session/ │ ├── SessionManager.cs # 连接生命周期管理 │ └── VncSession.cs # 封装 client 配置 状态 ├── Rendering/ │ ├── FrameRenderer.cs # 像素解码与 Skia 渲染 │ └── VncImageView.axaml.cs # 自定义控件继承 SKElement └── Input/ └── InputBridge.cs # 事件拦截与坐标转换5.2 SessionManager实现断线自动重连与凭据安全public class SessionManager : IDisposable { private readonly Dictionarystring, VncSession _sessions new(); private readonly Timer _reconnectTimer; public SessionManager() { _reconnectTimer new Timer(OnReconnectTick, null, Timeout.Infinite, Timeout.Infinite); } public async Task ConnectAsync(string id, string host, int port, string password) { var session new VncSession(host, port, password); _sessions[id] session; // 启动连接失败则 5 秒后重试指数退避可自行扩展 try { await session.Client.ConnectAsync(host, port, password); } catch { _reconnectTimer.Change(5000, Timeout.Infinite); } } private void OnReconnectTick(object? state) { foreach (var session in _sessions.Values.Where(s !s.IsConnected)) { _ Task.Run(async () await session.Client.ConnectAsync(session.Host, session.Port, session.Password)); } } public void Dispose() { _reconnectTimer?.Dispose(); foreach (var session in _sessions.Values) session.Client.Dispose(); } }关键技巧VncSession内部将password用ProtectedData.ProtectWindows或SecretStoremacOS/Linux加密避免明文存配置文件。这是企业级部署的合规底线不是可选项。5.3 FrameRendererGPU 加速渲染与局部更新优化Avalonia 的SKElement支持硬件加速但需避免每帧都创建新SKBitmapGC 压力大。我采用对象池复用public class FrameRenderer { private readonly ObjectPoolSKBitmap _bitmapPool; private SKBitmap _currentBitmap; public FrameRenderer(int width, int height) { _bitmapPool new DefaultObjectPoolProvider().Create(new BitmapPooledPolicy(width, height)); _currentBitmap _bitmapPool.Get(); } public void RenderFrame(byte[] data, Rectangle rect) { // 复用 bitmap只更新 dirty 区域 using var canvas new SKCanvas(_currentBitmap); var paint new SKPaint { FilterQuality SKFilterQuality.Low }; // 将 data 解码为 SKImage仅 blit 到 rect 区域 using var image SKImage.FromPixels(SKPixmap.Decode(data, rect.Width, rect.Height, SKImageInfo.PlatformColorType)); canvas.DrawImage(image, rect.X, rect.Y, paint); } public SKBitmap GetCurrentBitmap() _currentBitmap; } // BitmapPooledPolicy 确保复用相同尺寸 bitmap internal class BitmapPooledPolicy : IPooledObjectPolicySKBitmap { private readonly int _width, _height; public BitmapPooledPolicy(int width, int height) (_width, _height) (width, height); public SKBitmap Create() new SKBitmap(_width, _height, SKImageInfo.PlatformColorType, SKAlphaType.Premul); public bool Return(SKBitmap obj) true; // 总是复用 }5.4 最后一句经验永远用VncClient.Disconnect()而非Dispose()Dispose()会立即释放 socket但若服务端正发送最后一帧可能导致连接异常终止、服务端资源泄漏。正确流程是先Disconnect()优雅关闭等待 FIN-ACK再Dispose()清理托管资源。我在某工业客户现场因跳过Disconnect()导致 VNC 服务端进程僵死重启需人工介入——这成了我写任何网络库集成代码时的第一行条件检查if (client.IsConnected) await client.DisconnectAsync(); client.Dispose();希望帮到你。本文还有配套的精品资源点击获取