01-Qt与asyncio共存:Viewer的双线程结构与信号桥
远程桌面的控制端(也就是你家里那台电脑上跑的程序)有个绕不开的矛盾:它既要画界面,又要不停地收网络报文、解密码、拼画面。这两件事谁都别想打断谁。这篇文章就聊聊项目源码里控制端是怎么把这两摊事安排到两个线程上,又怎么让它们安全地互相传递画面数据的。
一、为什么 Qt 主线程和 asyncio 必须分居两个线程
很多新手写网络程序时,习惯把「界面」和「网络」塞在同一个循环里。比如 Qt 自己有个事件循环app.exec(),负责处理鼠标点击、重绘窗口;而我们的加密连接用的是asyncio的事件循环,负责收发wss报文。
问题来了:这两个循环都想「占着线程一直转」。
- 如果把 asyncio 的
run_forever()直接放在 Qt 主线程里,主线程就被网络循环霸占了,界面重绘、鼠标响应全部卡死,窗口会变成「无响应(未响应)」的经典白板。 - 反过来,如果把 Qt 的事件循环放到后台,前台的 asyncio 又没法更新界面像素——画面永远停在「等待画面……」。
所以结论很直接:Qt 的界面循环只能在主线程跑,asyncio 的网络循环必须挪到另一条线程去。两条循环互不抢占,各干各的。项目源码里控制端的入口就是这么干的:主线程起QApplication,然后单独开一个threading.Thread(名字叫viewer-net),在新的线程里asyncio.new_event_loop()再run_until_complete,把整个收帧、解密、还原的会话都装进这条后台线程。
二、双线程结构的全貌
画出来就是这么个样子:
Qt 主线程 后台线程 ──────────────── ──────────────── ViewerWindow(绘制 / 捕获键鼠) ←信号─ asyncio 事件循环 ViewerSession(收帧/解密/还原)左边主线程只干两件事:把画面画出来、把你本机的键鼠动作换算成归一化坐标发出去。右边后台线程跑 asyncio,专门做「从 WebSocket 收加密帧 → 解密 → 把变化块贴回画布 → 产出一帧 numpy 数组」这一整套重活。
两边不能直接互相调函数。背景线程如果把一个新数组直接塞给界面的绘制函数,就等于在错误的时间点改了界面状态,轻则画面撕裂,重则 Qt 直接崩。于是中间得架一座「桥」。
三、Bridge(QObject):五路信号的信号桥
项目源码里有一个很小但极关键的类Bridge,它继承自QObject,里面只定义了五个信号:
classBridge(QObject):frame_ready=Signal(object)# 一帧新画面到了status_ready=Signal(object)# 状态指标更新(fps、码率等)state_changed=Signal(str)# 连接状态变化(连接中/已连接/重连中…)clipboard_ready=Signal(str)# 远端剪贴板内容到了log_line=Signal(str)# 一条日志要显示为什么是五个?因为后台线程想告诉界面的信息就这几类:新画面、新指标、连接状态、剪贴板、日志。把它们全部收敛成 Qt 信号,后台线程就永远不需要直接碰界面对象,只要emit信号即可。
Signal(object)、Signal(str)里的类型参数,是 Qt 对信号载荷的类型提示,纯 Python 端拿到的还是原对象。
四、信号跨线程时 Qt 自动排队——绘制天然安全
这是 Qt 一个被很多人忽略、但极其省心的机制:当信号从一个线程发往另一个线程的槽时,Qt 默认用「队列连接」(QueuedConnection),也就是把这次 emit 打包成一个事件,丢进接收线程的事件循环里排队,等接收线程空闲时再执行槽函数。
具体到我们的场景:后台线程emit(frame_ready),这个 emit 并不会立刻跳到主线程执行on_frame,而是被 Qt 塞进主线程的事件队列。主线程的 Qt 循环在下一轮处理事件时,才在「自己」的上下文里调用on_frame去构造QImage并update()。
这意味着:绘制代码永远运行在主线程,永远不和网络线程抢时间,所以画面绘制是线程安全的,不需要你手动加锁。这一点是整个双线程结构能稳稳跑起来的基石。
五、那个真实的坑:on_frame 两参数,信号只接受一个
下面这个坑是项目源码里用「画面一帧都显示不出来」换来的教训,值得每一个写 Qt+asyncio 的人记牢。
ViewerSession的回调on_frame原始签名是两个参数:
defon_frame(self,canvas,meta):# canvas: 画布 numpy 数组# meta: 元信息(比如 tile_size)...而 Qt 信号frame_ready我们只定义了一个参数(Signal(object))。构造 Viewer 时,如果直接这么连:
# 错误写法(会导致画面全黑)self.bridge.frame_ready.connect(self.screen.on_frame)后台线程一调用self.on_frame(arr, meta)去emit(frame_ready, arr, meta),Qt 发现信号只接受一个参数却塞了两个,立刻抛TypeError。更坑的是——这个异常发生在后台线程的回调里,界面那边只是「没收到画面」,于是你看到的就是窗口一直停在「等待画面……」,一帧都显示不出来,而控制台如果不仔细看根本注意不到那句TypeError。
正确写法是在构造会话时用 lambda 把多余参数吃掉:
self.session=ViewerSession(cfg,# 注意:on_frame 会传 (画布, 元信息) 两个参数,而 Qt 信号只接受一个。# 必须在这里适配,否则 emit 会抛 TypeError 且画面一帧都显示不出来。on_frame=lambdaarr,meta:self.bridge.frame_ready.emit(arr),on_status=self.bridge.status_ready.emit,on_state=self.bridge.state_changed.emit,on_clipboard=self.bridge.clipboard_ready.emit,log=log,enable_input=enable_input,)lambda 把meta丢掉了(界面端其实不需要它),只把arr转交给信号。一个参数对一个参数,emit干干净净,画面才正常冒出来。这个坑的启示是:回调签名和信号签名不一致时,错误不会在编译期暴露,只会在运行时让你「看起来没画面」,一定要在连接处显式适配。
六、QImage 直接引用后台复制过的内存(零拷贝)
画面到了主线程,怎么变成屏幕上能看到的像素?项目源码里用的是「零拷贝」手法:
@Slot(object)defon_frame(self,arr:np.ndarray)->None:self._frame=arr# 持有引用,保证内存不被回收h,w=arr.shape[:2]self._qimage=QImage(arr.data,w,h,3*w,QImage.Format.Format_RGB888)self.update()这里有个精妙的点:「画布所有权」是在后台线程就处理好的。后台线程在把画布交出来之前,已经canvas.copy()复制了一份属于它自己、不会被下一帧原地覆盖的内存。所以主线程拿到的arr是一块独立、稳定的内存。
于是主线程直接用QImage(arr.data, w, h, 3*w, Format_RGB888)在这块内存上「套个壳」——arr.data是底层字节指针,3*w是每行字节跨度(RGB 三通道),Format_RGB888告诉 Qt 像素排列格式。Qt 不会再把数据拷一份,画布内存被 Qt 和 numpy 共享引用,绘制时直接读这块内存。只要self._frame还持有引用,内存就不会被 Python 回收,绘制就安全。
一句话总结:复制发生在后台(一次),主线程渲染零拷贝(零次)。既避免了撕裂,又不浪费 CPU 做无谓的内存搬运。
七、打包成 GUI 程序时 sys.stdout 是 None 的问题与对策
程序在开发期是用命令行跑的,日志往控制台打印,出错了你也能看到 traceback。但项目源码把控制端打包成单文件 GUI(--windowed)之后,情况变了:GUI 程序没有控制台,sys.stdout和sys.stderr都是None。一旦运行期出错,所有日志、所有异常全部「消失在空气中」,你只会看到一个打不开/一闪而过的窗口,完全无从排查。
项目源码用的对策有三板斧:
- 默认写日志文件。冻结运行(
sys.frozen为真)时,如果没显式配置日志文件路径,就把日志写到 exe 同目录下的viewer.log。这样即使没有控制台,所有LOG.info/warning都落盘可查。
ifnotlog_pathandgetattr(sys,"frozen",False):log_path=str(pathlib.Path(sys.executable).parent/"viewer.log")只在 stdout 真的存在时才挂控制台 handler。用
if sys.stdout is not None:判断,避免往None写日志直接炸。安装
sys.excepthook。任何没被捕获的异常,都通过 hook 写进日志文件,而不是默默被吞掉:
def_excepthook(exc_type,exc,tb):LOG.critical("未捕获的异常",exc_info=(exc_type,exc,tb))这三步合起来,保证打包后的 GUI 程序「出问题一定有迹可循」,这也是工程化交付里非常关键的一环。
八、状态栏显示哪些指标
控制端窗口底部有个状态栏,项目源码通过status_ready信号实时刷新,展示的是一组能让你一眼判断链路健康的指标:
| 指标 | 含义 |
|---|---|
| 画面尺寸 | 当前画布分辨率,如 2560×1408 |
| fps | 实际帧率,目标上限 20 |
| Mbps | 接收码率 |
| 解码 ms | 单帧还原耗时,实测约 5 ms |
| RTT ms | 往返延迟,反映网络质量 |
| 总帧数 | 累计收到的帧 |
| 关键帧数 | 累计整屏关键帧,用于判断自愈频率 |
这些数字合在一起,能让你快速区分「是网络卡了」「是对端静止没发包」还是「解码慢了」。比如画面不动,先看 fps——如果是 0 且对端确实是静止的(办公场景大部分时间如此),那是设计如此,不是 bug。
九、用红/绿明确提示键鼠注入是否启用
远程桌面最危险的一件事,就是「你以为自己在看,其实键鼠已经被接管了」。所以项目源码在界面上用最显眼的方式区分状态:
- 键鼠注入已启用:文字是红色加粗「⚠️ 键鼠注入:已启用」,提醒你「现在你在本机敲的键、动的鼠标会真实地操作公司那台电脑」。
- 键鼠注入已禁用(只读观看):文字是绿色「✅ 键鼠注入:已禁用(只读观看)」,明确告诉你「你只能看,动不了对端」。
ifself.enable_input:self.input_label.setText("⚠️ 键鼠注入:已启用")self.input_label.setStyleSheet("color:#c00; font-weight:bold;")else:self.input_label.setText("✅ 键鼠注入:已禁用(只读观看)")self.input_label.setStyleSheet("color:#080;")这个设计背后是一种安全默认:控制端默认就是只读,必须显式加--enable-input才开启注入;而一旦开启,界面用刺眼的红色把「你正在操作远端」这件事拍在你脸上,不给你「不知情」的余地。
到这里,控制端的双线程骨架就讲清楚了:主线程管画、后台线程管网,中间用Bridge的五个信号桥接,靠 Qt 的队列连接保证绘制安全,再用零拷贝和日志兜底把细节磨平。