简介:这是一套基于Qt开发的Windows远程控制软件,完整提供服务器端(被控端)与客户端(主控端)的源代码和编译程序;服务器端以进程形式运行,客户端为图形界面,连接时需指定被控端IP及显示宽度、高度,且不能超出服务器屏幕分辨率。资源面向有C++基础、想学习Qt网络编程或远程控制原理的开发者,也适用于在局域网内快速部署远程协助工具;压缩包整体约6.08MB,共40个文件,其中14个头文件与14个C++源文件构成工程主体,另有编译后的exe、运行所需的QtCore/QtGui/QtNetwork等DLL库以及pro工程文件,结构清晰,便于直接查看与二次开发。目前已有5327人学习;通过这份代码,既能直接运行客户端和服务器端体验完整的远程控制流程,也能重点研读Qt中TCP连接、屏幕图像抓取/传输/显示、窗口尺寸自适应等实现细节,对搭建轻量级Windows远程控制工具具有很强的参考意义。
1. 远程控制这个老需求,用 Qt 重写一遍值在哪
远程控制软件在 Windows 平台从来不是新鲜事,但多数现成方案要么闭源、要么捆绑了太多用不上的功能,想改个协议细节或者嵌入到自有系统里非常吃力。这套基于 Qt 的远程控制项目(服务器端+客户端)价值在于:它把屏幕捕获、输入事件注入、图像编码传输、断线重连这些核心链路用 C++ 实现了完整闭环,而且两端都是 Qt Widgets 界面,源码可控、可裁剪、可二次开发。
适合的人群很明确:需要给内部工具加远程协助能力的桌面软件开发者、做设备运维系统的工程师、以及想研究远程控制原理的学生。项目不是开箱即用的商业产品,而是一个能跑通全流程的架构参考——你拿到手能编译、能连接、能看代码理解每一步在干什么。我拆完这套源码后发现,它的难点不在 Qt 本身,而在 Windows 平台 API 与网络传输之间的耦合处理,这部分恰恰是文档里最值得抠的。
2. 先看架构:服务端与客户端到底怎么分工
2.1 两端的角色定义与连接模型
这个项目采用经典 C/S 架构,服务端是被控端,客户端是主控端。服务端监听一个 TCP 端口,等待客户端主动连接。连接建立后,服务端负责持续采集屏幕画面、监听用户输入并发送给客户端;客户端负责接收画面渲染显示,并把用户的鼠标键盘操作指令回传给服务端注入执行。
角色分工不复杂,但值得注意一个细节:数据流是双向的,而且方向不同。屏幕画面从服务端流向客户端,输入指令从客户端流向服务端。这意味着两端都需要同时维护收发两条链路,不能用简单的单工模式处理。项目里的实现方式是服务端开两个线程——一个负责采集发送画面,一个负责接收输入指令;客户端也对称地开两个线程。
2.2 通信协议字段设计与粘包处理
TCP 是流式协议,没有消息边界,这是所有网络通信项目绕不开的坑。项目里定义了一个极简的协议头来切分消息:
struct PacketHeader { quint32 magic; // 固定 0x4D434F4E,用于校验 quint32 type; // 消息类型:1 画面,2 输入,3 心跳 quint32 length; // 载荷长度 };协议头固定 12 字节,服务端和客户端都先读完整头部,再按 length 读取剩余的载荷数据。接收端维护一个接收缓冲区,每次从 socket 读取数据追加到缓冲区尾部,然后循环尝试解析——先检查缓冲区是否够 12 字节,够则解析 header,再检查载荷是否收齐。
void TcpChannel::onReadyRead() { buffer_.append(socket_->readAll()); while (buffer_.size() >= HEADER_SIZE) { PacketHeader header = decodeHeader(buffer_.data()); if (buffer_.size() < HEADER_SIZE + header.length) break; QByteArray payload = buffer_.mid(HEADER_SIZE, header.length); handlePacket(header.type, payload); buffer_.remove(0, HEADER_SIZE + header.length); } }这里的关键是while循环不能用if替代,因为readAll()可能一次读入多个完整消息,只处理一个会留下残包导致解析错位。另外magic字段的校验属于防御性设计,防止数据错乱后把垃圾数据当协议头处理——一旦解析出现连续性错误,直接断开重连比在错误状态里挣扎要省事得多。
2.3 心跳机制与断线检测
远程控制场景里,两端断网或被控端休眠是很常见的事。项目在应用层实现了心跳包:客户端每 3 秒向服务端发一个 type=3 的空载荷包,服务端收到后回复确认。任意一端如果 9 秒内没有收到任何数据——包括心跳和业务数据——就判定连接失效,主动关闭 socket。
我一般建议心跳超时时间设成心跳间隔的 3 倍,太短会导致移动网络下频繁误判,太长则用户操作后画面恢复慢。这个项目用 9 秒超时搭配 3 秒心跳,属于中规中矩的配置,适配性和稳定性之间比较平衡。
3. 服务端实现:屏幕采集、编码与输入注入
3.1 屏幕帧采集与差分判定
屏幕采集在 Windows 上最常见的做法是GetDC(NULL)配合BitBlt抓取整屏位图。这个项目也是这么做的,但它做了一个关键优化:不是每帧都传输完整图像,而是先对比相邻两帧的差异区域,只把变化部分的矩形和位图数据发送出去。
QImage ScreenCapturer::capture(int& changedCount) { HDC screenDc = GetDC(NULL); HDC memDc = CreateCompatibleDC(screenDc); HBITMAP bitmap = CreateCompatibleBitmap(screenDc, width_, height_); SelectObject(memDc, bitmap); BitBlt(memDc, 0, 0, width_, height_, screenDc, 0, 0, SRCCOPY); QImage image = convertToQImage(bitmap); QRect dirtyRect = diffWithPrevious(image); changedCount = dirtyRect.isNull() ? 0 : 1; DeleteObject(bitmap); DeleteDC(memDc); ReleaseDC(NULL, screenDc); return image; }convertToQImage的实现要特别注意:CreateCompatibleBitmap出来的位图格式是设备相关的(DDB),需要先GetDIBits转成 DIB 才能可靠地转成 QImage,否则在高 DPI、不同色深环境下会出颜色错乱或者扫描行对齐问题。常见的做法是先获取BITMAPINFOHEADER信息,按 32 位 BGRA 格式申请缓冲,让 Qt 直接复用这块内存构造 QImage。
差分判定这个思路值得展开说一下。如果调用方在 capture 时存储上一次的 QImage 副本,全屏 BitBlt 后逐像素比较计算量过大,性能开销不可接受。项目采用的办法是先对两张图做小范围抽样比较,如果抽样区域变化明显,再对整个图像进行按块(比如 64x64 像素一块)的哈希比较,找出真正的脏矩形。抽样逻辑让整帧检测费用低,块比较让传输量小,两端做了取舍。
3.2 JPEG 压缩与质量参数配置
差分矩形拿到后,服务端会把这块子图像压缩成 JPEG 再推给客户端。JPEG 压缩用 Qt 的QImageWriter或QBuffer写入QByteArray,设置质量参数在 60 到 80 之间。
QByteArray ScreenSender::compressDirtyRect(const QImage& rect) { QByteArray bytes; QBuffer buffer(&bytes); buffer.open(QIODevice::WriteOnly); QImageWriter writer(&buffer, "JPEG"); writer.setQuality(70); writer.write(rect); return bytes; }质量参数设置需要结合网络带宽和操作延迟需求判断。700 这个值在多数局域网和办公网络下,1080P 画面的一小块脏矩形大概 10-50KB,实时性足够。调到 85 以上,画面文字边缘会更锐利,但同样大小的矩形数据传输量会上升约 30%——远程操作场景里我通常先给 70,如果用户反馈文字糊了,再上调到 80,比一开始拉满更稳妥。
远程控制场景还要考虑一个细节:鼠标光标不一定要进画面抠图。常见做法是把光标隐藏或单独捕获,否则压缩和传输过程中光标位置会落后于实际设备移动轨迹,造成操作手感飘。项目默认用SetCursor把系统光标设为空,然后服务端单独传光标坐标,客户端用 Qt 的QCursor::setPos同步显示,体验上会跟手很多。
3.3 鼠标键盘事件注入:SendInput 与裁剪策略
服务端收到客户端的输入指令后,用 Windows 的SendInputAPI 完成注入。这是与 Qt 本身相关性较低、但最容易出问题的一块。SendInput能模拟鼠标移动、滚轮、按键按下和抬起,但前提是传入的INPUT结构必须初始化正确,尤其是MOUSEINPUT的dwFlags和键盘事件的wVk要匹配具体的消息类型。
void InputInjector::moveMouse(int x, int y, int screenWidth, int screenHeight) { INPUT input = {0}; input.type = INPUT_MOUSE; input.mi.dx = x * 65535 / screenWidth; input.mi.dy = y * 65535 / screenHeight; input.mi.dwFlags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE; SendInput(1, &input, sizeof(INPUT)); }注意MOUSEEVENTF_ABSOLUTE配合的坐标范围不是屏幕像素值,而是 0 到 65535 归一化坐标。直接用像素坐标传进去,鼠标会飞到屏幕角落,这是几乎每个初写远程控制的人都会踩的坑。如果不用绝对坐标,要先把鼠标当前位置和偏移量算清楚,相对移动模式在 RDP 场景下会因为两端分辨率比例不同产生累积误差,这个项目采用的绝对定位方式是更稳的方案。
按键注入则要注意KEYEVENTF_KEYUP不能漏。模拟按键点击时,如果只发按下没发抬起,被控端的应用程序会一直认为键在按住——比如 Ctrl 被卡住,后面所有快捷键操作都会错乱。我在写这类功能时习惯用 RAII 结构把 keydown 和 keyup 包在一起,即使中途出现异常也能保证键盘状态被正确释放,比手工SendInput两个调用可靠得多。
3.4 服务端整体流程串起来
服务端的主循环流程大致是:监听端口 → 接受连接 → 建立发送线程和接收线程 → 发送线程循环抓屏、差分、压缩、发送 → 接收线程阻塞等待客户端指令 → 收到指令后调用注入API → 心跳超时则清理资源退出循环。
在 Qt 的框架下,抓屏和 SendInput 都在非 GUI 线程执行。这里有个容易忽略的点:GetDC(NULL)这样的 GDI 操作在非主线程调用并没有问题,但 QImage 的 QImage 对象如果是跨线程构造的,发送线程构造 QImage、接收线程释放同一个对象就会引起崩溃。项目的正确处理方式是让 QImage 生命周期从头到尾限定在发送线程内,即使显示给用户预览,也要通过 signal-slot 把副本传递到 GUI 线程。
4. 客户端实现:画面渲染、远端操作与多线程协调
4.1 帧缓冲与实时渲染策略
客户端接收端做了一件正确的事:不逐帧立即刷新 UI,而是维护一个最近一帧的缓冲 QImage,用一个定时器以 30 FPS 的频率把缓冲内容绘制到界面上。这样做的好处是,即使网络抖动导致帧到达时间不平均,屏幕上看到的画面也是平滑的,不会出现一顿一顿的撕裂感。
void ViewerWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.drawImage(targetRect_, latestFrame_); }latestFrame_的写入发生在网络接收线程,而paintEvent在 GUI 线程执行,所以要加锁保护。项目里给 QImage 读写加了一个QMutex,接收线程在更新缓冲前先 lock,绘制时也先 lock,把整帧复制出来再画。跨线程共享图像数据的替代方案是把它封装成QSharedPointer<QImage>,所有线程只持有共享指针的副本,配合std::atomic标记新帧是否到达,这样可以规避频繁加锁的开销。
绘制边界还有个细节需要说明。客户端窗口大小和被控端屏幕分辨率未必一致,drawImage时用targetRect_做全图缩放即可。但如果直接用painter.scale()或 smooth transform,图片会显得模糊。我会在绘制前开启抗锯齿和高质量插值:
painter.setRenderHint(QPainter::SmoothPixmapTransform, true);这样在 4K 被控端缩放到 1080P 客户端窗口时,文字边缘不会出现明显的锯齿。
4.2 鼠标键盘操作的捕获与转发
客户端的输入捕获是用eventFilter重写了ViewerWidget的鼠标事件、滚轮事件、键盘事件。捕获之后拼装成协议包发给服务端,服务端再注入到被控端的系统输入流。
bool ViewerWidget::eventFilter(QObject* obj, QEvent* event) { if (event->type() == QEvent::MouseMove) { QMouseEvent* me = static_cast<QMouseEvent*>(event); sendMouseMove(me->pos().x(), me->pos().y()); return true; } if (event->type() == QEvent::KeyPress) { QKeyEvent* ke = static_cast<QKeyEvent*>(event); sendKeyEvent(ke->key(), ke->modifiers(), false); return true; } return QWidget::eventFilter(obj, event); }这里的关键是:鼠标坐标必须从 Widget 的本地坐标映射到被控端的屏幕坐标。假设客户端窗口大小是 800x600,被控端分辨率是 1920x1080,那映射公式就是x = localPos.x() * 1920 / 800。项目里这层转换放在sendMouseMove内部,每台设备首次连接时会从服务端拉取屏幕尺寸参数,客户端把参数缓存后,每次转换都用它,避免掉分辨率变化导致鼠标定位错乱的问题。
键盘处理还有一个坑:QKeyEvent拿到的key()是 Qt 定义的枚举值,不是 Windows 虚拟键码。Qt 的映射在大多数按键上是重合的,但功能键、数字键盘、组合键都有例外。项目里维护了一张映射表,从Qt::Key_*映射到VK_*。写这张表很繁琐但必须完整,漏掉 F1-F12 或方向键会让用户直接感到功能缺失。
4.3 连接会话管理:状态机与 UI 联动
客户端的连接管理代码不是一把梭的状态变量,而是定义了一个简单的状态机:Disconnected、Connecting、Connected、Reconnecting。状态变化时触发信号,UI 层的连接按钮、断开按钮、全屏控制开关会根据状态自动启用或禁用。
enum class ConnState { Disconnected, Connecting, Connected, Reconnecting };断线重连这块很多基础远程控制 Demo 不处理,但实际用起来特别影响体验。被控端重启、路由器切换、网络闪断——任何一个情况发生,客户端都要能自动重连,否则用户只能手动再点一次连接。项目的做法是:客户端在状态机进入 Disconnected 后,启动一个 2 秒定时器,如果目标地址没变且用户没主动断开,就发起重连尝试。重连次数上限到 5 次后停止,弹提示框告诉用户被控端无响应。
UI 层的具体联动还包括:连接成功时隐藏连接配置面板、显示画面窗口;断开时反向操作。把状态机放在业务层而不是界面层,后面如果要做命令行版或 Web 版客户端,这部分逻辑可以完整复用。
5. 避坑与排查:远程控制项目最常见的五个问题
5.1 屏幕画面发不出去或黑屏
现象:服务端抓屏正常,但客户端画面全黑,或者画面传输偶尔中断,重连后又能恢复。
原因:GetDC(NULL)在锁屏界面或者安全桌面上抓不到正常画面。Windows 在 UAC 弹窗、Ctrl+Alt+Del 登录界面这些场景下会切换到安全桌面,普通权限的进程抓不到这部分像素。另一个可能原因是CreateCompatibleBitmap返回的位图和 QImage 构造时的颜色深度不一致,导致像素数组尺寸不匹配、只有纯黑输出。
解决:区分两种场景处理。普通桌面黑屏检查BITMAPINFOHEADER的biBitCount,确认按 32 位转 QImage、每行 4 字节对齐。安全桌面属于 Windows 系统特权机制限制,常见做法是服务端进程注册成系统服务运行在 Session 0,同时开启交互式服务模式,或者协议层增加安全桌面检测标识、客户端明确提示当前画面受系统保护。处理完这两类情况,黑屏问题基本消除。
5.2 鼠标位置和屏幕内容偏移错位
现象:在客户端点击某个位置,被控端实际点击到了左上方或右下方,位置偏差固定在一定比例。
原因:发送端用了绝对坐标计算,但接受端换算时拿到的被控端分辨率是旧的——比如被控端从扩展屏切换为单屏显示,分辨率变化了,服务端没检测到显示模式变化,持续按旧宽度做归一化。比例偏差基本是这个原因,跟延时无关。
解决:在服务端每次抓屏前查一次当前屏幕宽度高度,如果和上次发现的不同,立即给客户端推一个新分辨率通知包。客户端收到通知后更新映射参数,再做坐标转换。注意多显示器场景要用GetSystemMetrics(SM_CXVIRTUALSCREEN)取虚拟屏幕尺寸,取单个屏的SM_CXSCREEN只能拿到主屏参数,副屏上的鼠标坐标转换必然错位。
5.3 远程操作后本机鼠标键盘失灵
现象:关闭远程控制连接后,被控端本地的键盘好像锁死了,只能通过任务管理器强行杀掉进程恢复。
原因:按键注入时漏发KEYUP,被控端的 Ctrl、Alt 或 Shift 还处于按下状态。更隐蔽的情况是远程连接过程中某个弹窗抢了焦点,按键事件注入了错误窗口,窗口销毁后把键盘钩子状态带坏了。
解决:代码里实现一个按键状态的清理函数,在连接断开时把所有当前认为是按住状态的虚拟键码统一发一遍KEYUP。另外一个保险做法是每次注入按键后,都从GetAsyncKeyState读回实际状态,校验跟预期是否一致,不一致就补发。
5.4 长时间连接后内存持续上升直至卡死
现象:远程控制画面流畅,但跑 20 到 30 分钟后,被控端或主控端的内存占用涨到几个 G,最后 CPU 拉满、卡到没法操作。
原因:发送端 QImage 的拷贝没有及时释放。把截图塞进 signal-slot 队列时,Qt 会隐式拷贝 image 的像素数据;如果改成跨线程传指针,接收线程又没 delete,内存泄漏就发生了。差分计算时的上一帧备份对象也是一块常驻内存,多显示器下备份对象占用的内存尤其可观。
解决:凡是跨线程传递的图像,要么用QSharedPointer管理,要么走 Qt 的QMetaType直接拷贝,总之不能在堆上 new 出来之后没有任何归属。同时确认差分逻辑里previousImage_是成员对象而不是每次在栈上重建。我在代码审查时会专门搜new QImage和QImage img(等号赋值)的写法,一个推崇的好习惯是:在关键入口用qDebug定期输出当前线程内存占用,跑一次长连接测试就能提前发现问题。
5.5 连接建立失败但端口测试是通的
现象:telnet 测试端口能通,但客户端连接服务端,服务端却收不到任何数据。
原因:服务端监听地址写的是回环地址(127.0.0.1),防火墙策略又挡了外网网卡的入站请求。服务端绑定的是 gateway 网卡的 IP,客户端访问的公网 IP 转发没做 NAT 映射,或者路由器上没开对应端口转发。
解决:服务端默认监听地址必须写QHostAddress::Any而不是具体网卡 IP。如果服务端部署在内网、客户端从外网连,要在路由器上做 TCP 端口映射,并确认 Windows 防火墙对对应的入站规则放行的是专有网络和公用网络全部勾选。排查时先在服务端本机连127.0.0.1验证程序没问题,再逐层检查外网可达链路,物理位置、防火墙和网络映射之间很大概率就卡在某一环。
6. 进阶:把这套远程控制改造成自己产品的三个实用技巧
项目跑通之后要落地到自己的工具里,通常会盯上三个改造点。第一是接口层从自定义协议迁移到标准 WebSocket,让网页端、移动端也能接入;第二是加密从可选的 TLS 通道变成默认强制开启;第三是把屏幕采集从纯 GDI 升级为 DXGI Desktop Duplication,减少高分辨率下的 CPU 消耗。
WebSocket 改造其实不难,因为项目逻辑层的消息结构已经拆得很干净。Qt 自带的QWebSocketServer和QWebSocket在做二进制帧传输时,用法跟QTcpSocket非常像。原来 TCP 通道的PacketHeader里的 magic、type、length 语义可以直接平移到 WebSocket 二进制帧的 payload 首部,客户端和服务端则同时需要一个 HTTP 升级的握手逻辑配合处理长度更大的报文段。兼容问题最大的点是 QWebSocket 在偶发网络抖动时会走 TCP 重传,重传会导致旧数据堆积,直接让画面显示滞后——我在改造时会在收到带更高序列号的帧时主动丢弃缓冲区的旧数据。
加密层推荐直接用QSslSocket做服务端,客户端使用QSslSocket::connectToHostEncrypted,证书可以先用自签名。这个项目中如果直接叠加标准 Qt SSL,代码侵入很少,缺点是首次建立连接时的证书信任问题——用户看到警告框会疑惑,体验上不如内网部署的私有 CA 方案省心。我会写一个初始化函数,加载 CA 证书并设置ignoreSslErrors只在白名单主机名生效。
DXGI 采集替换 GDI 要麻烦一些,主要涉及 D3D 设备创建、纹理复制、CPU 回读和 QImage 转换四个步骤。核心伪代码结构如下:
IDXGIOutputDuplication* duplication = nullptr; duplication->AcquireNextFrame(500, &frameInfo, &desktopResource); desktopResource->QueryInterface(__uuidof(ID3D11Texture2D), &texture); context->CopyResource(stagingTexture, texture); D3D11_MAPPED_SUBRECT mapped; context->Map(stagingTexture, 0, D3D11_MAP_READ, 0, &mapped); QImage image(mapped.pData, width, height, mapped.RowPitch, QImage::Format_BGRA8888);这里RowPitch和像素宽度不一定是严格的 4 倍关系,存在 GPU 按对齐字节补位的情况,所以构造 QImage 时必须直接用mapped.RowPitch而不是width * 4,否则图像会斜切。另外 DXGI 在桌面分辨率或显式切换时返回DXGI_ERROR_ACCESS_LOST,需要重新创建 duplication 对象,代码里要把这个分支处理成可恢复的错误。
改造上线前我习惯做三件事:内网环境连续 12 小时挂机测试内存曲线、模拟丢包和延迟测试重连表现、在 1366x768 和 4K 两种分辨率下对比 CPU 占用。远程控制这种工具类软件,用户的耐心阈值很低——延迟超过 200 毫秒鼠标就感觉飘了,内存泄漏超过 100MB/小时基本就会被放弃。任何对外宣称能用的版本都要以至少一星期的长时间运行不崩溃为底线。
从那次拆完这套源码之后,我自己做 Windows 桌面与服务器远程维护工具时,每写一个网络模块都会强制走一遍:先列协议字段,再写粘包解析单元测试,再手动模拟一次拔网线场景,确认断线清理逻辑没有阻塞在 socket 读写上。这套 Qt 远程控制的源码包完整跑通了这个流程的所有环节,希望帮到你。
本文还有配套的精品资源,点击获取