Qt 信号槽机制在跨进程 UI 中的应用——倒计时窗口的线程设计与信号路由
本文以工具的交易签名确认窗为例,讲清三件事:Qt 子线程如何安全地监听 FIFO、跨线程信号如何路由到 UI、QTimer 倒计时如何被外部事件重置。适合做 Qt 跨线程编程的入门案例。
一、场景
交易签名时弹出确认窗口:
- 显示交易信息(金额、账号等)
- 60 秒倒计时,归零自动关闭
- 支持三种外部输入:Key 上的确认键(继续签名)、Key 上的取消键(中止)、Key 上的翻页键(重置倒计时)
挑战在于事件源全都不在 Qt 进程内:
Qt 进程需要在一个非主线程里持续 read() FIFO(阻塞调用不能放主线程,否则界面卡死),再把结果安全地通知主线程更新 UI。这正是 Qt 信号槽 Qt::QueuedConnection 的经典用武之地。
二、线程架构
分工:
| 线程 | 职责 | 禁止事项 |
|---|---|---|
| 主线程 | 所有 UI 操作、QTimer 倒计时 | 阻塞式 read |
| monitor 线程 | 阻塞轮询 FIFO、发射信号 | 直接操作 UI 控件 |
三、monitor 线程:FIFO 监听与信号发射
3.1 头文件(monitor.h)
#ifndef MONITOR_H
#define MONITOR_H
#include <QThread>
#define FIFONAME "/tmp/FIFO_BANKA"
class monitor : public QThread
{
Q_OBJECT // ← 有信号必须加这个宏
public:
monitor();
~monitor();
void run() override; // QThread 的线程入口
signals:
void closeSignal(); // 通知 UI 关闭(对应 "close")
void notifyResetTimer(); // 通知 UI 重置倒计时(对应 "Refresh")
private:
int m_fd;
};
#endif
3.2 实现(monitor.cpp)
void monitor::run()
{
char str[20] = {0};
// FIFO 由动态库创建;不存在说明 dylib 侧没准备好,直接放弃
if (::access(FIFONAME, F_OK) == -1)
return;
m_fd = ::open(FIFONAME, O_RDONLY | O_NONBLOCK);
if (m_fd < 0)
return;
while (true)
{
memset(str, 0, sizeof(str));
int nread = ::read(m_fd, str, sizeof(str) - 1);
if (nread > 0)
{
// 消息1:关闭窗口
if (0 == strcmp(str, "close"))
{
emit closeSignal();
break; // 关闭后线程使命完成,退出
}
// 消息2:重置倒计时 {0x07,'R','e','f','r','e','s','h'}
// 注意不能 strcmp:首字节 0x07 不是合法 C 字符串结尾前的内容,
// 且消息是二进制语义,必须 memcmp 定长比较
unsigned char szRefresh[] = {0x07,0x52,0x65,0x66,0x72,0x65,0x73,0x68};
if (nread >= (int)sizeof(szRefresh) &&
0 == memcmp(str, szRefresh, sizeof(szRefresh)))
{
emit notifyResetTimer();
continue; // ← 关键:不 break,继续监听后续信号
}
}
msleep(100); // 无数据时休眠,避免 CPU 空转
}
::close(m_fd);
m_fd = -1;
}
三个设计要点:
strcmpvsmemcmp:close 是纯文本消息,strcmp足够;Refresh 消息首字节是二进制长度0x07,strcmp在遇到0x07后的行为依赖实现,必须用memcmp且先校验nread长度。- close 后 break,Refresh 后 continue:语义完全不同——关闭意味着整个对话框要销毁;重置倒计时只是周期性事件,线程必须继续活着接收下一次重置。
msleep(100)轮询:FIFO 以O_NONBLOCK打开,read 无数据立即返回 0,不 sleep 会 100% CPU 空转。(更优方案是select/poll或去掉O_NONBLOCK让 read 阻塞,此处轮询胜在实现简单且延迟 100ms 可接受。)
四、倒计时窗口:QTimer 与槽函数
4.1 头文件(transconfirm.h)
class TransConfirm : public QDialog
{
Q_OBJECT
public:
explicit TransConfirm(QWidget *parent = nullptr);
~TransConfirm();
public slots:
void slotTimeDecrease(); // QTimer timeout:每秒递减
void slotResetTimer(); // monitor 信号:重置为 60
private:
Ui::TransConfirm *ui;
QTimer *m_timer;
monitor *m_monitor;
int m_timeCount; // 剩余秒数
};
4.2 实现(transconfirm.cpp)
TransConfirm::TransConfirm(QWidget *parent)
: QDialog(parent), ui(new Ui::TransConfirm)
{
ui->setupUi(this);
// 倒计时初始化
m_timeCount = 60;
ui->label_timeCount->setText(QString::number(m_timeCount));
// 每秒滴答
m_timer = new QTimer(this);
connect(m_timer, &QTimer::timeout,
this, &TransConfirm::slotTimeDecrease);
m_timer->start(1000);
// 启动 FIFO 监听线程
m_monitor = new monitor();
connect(m_monitor, &monitor::notifyResetTimer,
this, &TransConfirm::slotResetTimer);
connect(m_monitor, &monitor::closeSignal,
this, &QDialog::reject);
m_monitor->start(); // QThread::start 触发 run() 在新线程执行
}
void TransConfirm::slotTimeDecrease()
{
m_timeCount--;
if (m_timeCount <= 0)
{
m_timer->stop();
reject(); // 超时,按取消处理
return;
}
ui->label_timeCount->setText(QString::number(m_timeCount));
}
void TransConfirm::slotResetTimer()
{
m_timeCount = 60;
ui->label_timeCount->setText(QString::number(m_timeCount));
}
重置为什么这么简单? 因为倒计时的"状态"只有 m_timeCount 一个变量,QTimer 只负责固定周期递减它。重置 = 改回 60,下一个 tick 自然从 60 重新递减。不需要 restart QTimer,不需要任何同步操作。
五、核心机制:跨线程信号槽为什么安全
5.1 自动连接类型
connect(m_monitor, &monitor::notifyResetTimer,
this, &TransConfirm::slotResetTimer);
connect 默认第五参数是 Qt::AutoConnection:
- 发射者与接收者同线程 → 直接调用(
DirectConnection) - 跨线程 → 事件投递(
QueuedConnection):emit 把调用打包成事件放入接收者线程(主线程)的事件队列,主线程事件循环取出来执行槽函数
本例中 emit notifyResetTimer() 在 monitor 线程执行,slotResetTimer() 在主线程执行——这就是为什么子线程里只 emit 不碰 UI 是安全的,Qt 在底层帮你做了线程切换。
5.2 参数传递的坑
Queued 连接的信号参数会被拷贝进事件队列。自定义类型必须注册:
qRegisterMetaType<MyStruct>("MyStruct");
否则运行时报:
QObject::connect: Cannot queue arguments of type 'MyStruct'
本例的两个信号都无参数,天然规避了这个问题。
5.3 线程亲和性
一个常见误解是"QThread 对象在哪个线程,run 就在哪个线程"。正确理解:
monitor对象本身(构造、信号发射器)活在其被创建的线程——本例是主线程run()函数体在start()后的新线程里执行- 所以
emit notifyResetTimer()语句执行位置 = monitor 线程 = 与接收者(主线程)不同 → 自动走 Queued
如果把 emit 写在构造函数里(主线程执行),同样的 connect 就会走 Direct——同一条信号槽连接的行为随发射线程而变,这是初学者最容易混淆的点。
六、窗口销毁时的线程清理
对话框关闭(reject())时必须停掉 monitor 线程,否则线程还在 read FIFO、对象却析构了 → 崩溃:
TransConfirm::~TransConfirm()
{
if (m_monitor && m_monitor->isRunning())
{
m_monitor->quit(); // 请求事件循环退出(对 run 轮询无效,见下)
// 本例 run 是裸 while 循环没有事件循环,quit 无效,
// 依赖 closeSignal 分支的 break 自然退出;
// 若 FIFO 无后续消息,可用管道写端注入终止标记,或
// m_monitor->wait(1000) 后 terminate()(最后手段)
}
delete m_monitor;
delete ui;
}
实际上本例的退出闭环是:dylib 发 "close" → monitor 线程 break → 线程函数返回 → 线程自然结束。只要保证任何退出路径最终都有人往 FIFO 写一条 close(包括超时路径由 UI 自己 reject() 时注意线程回收顺序),就不会泄漏线程。
七、总结
这个案例麻雀虽小,覆盖了 Qt 多线程编程的核心知识点:
- QThread 的正确用法:继承 + 重写
run(),耗时 I/O 放子线程 - 子线程只 emit,主线程管 UI:跨线程信号自动走 Queued 连接
- 二进制协议用 memcmp,文本协议用 strcmp,混用必出诡异 bug
- 语义决定循环控制流:close 是终态(break),refresh 是瞬态(continue)
- 状态机式倒计时:单变量 + 固定周期递减,重置只需改状态,不动定时器
- 线程退出要有闭环:靠协议消息(close)驱动线程自然结束,优于暴力 terminate
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2202_75623950/article/details/164064145




