1. QT是什么
QT是一个跨平台的C++开发库,主要用于开发图形界面程序。简而言之,就是用来做UI界面的。下边笔者将介绍QT的基础用法,包括信号与槽机制、常用控件以及多线程。建议大家在学习QT时,先掌握QT的用法即可,不用深究原理。
2. QT的基础用法
2.1 信号与槽
**信号(signal)**指的是在特定情况下被发射的事件。比如说你在QT窗口里创建了一个按钮,点击这个按钮就会发送一个信号,然后界面窗口会对你所发送的信号进行响应。
**槽(slot)**就是对信号响应的函数,成为槽函数。它可以具有参数,也可以直接被调用。当信号发射时,所关联的槽函数会自动执行。
2.1.1 信号的创建
信号仅需要声明,不需要定义,一般在mainwindow.h里声明信号即可,如下所示:
cpp
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
/*主窗口基类*/
#include<QMainWindow>
/**
* MainWindow - 主窗口
*/
class MainWindow : public QMainWindow
{
Q_OBJECT /*在使用信号与槽的类中,必须在类的定义中加入此宏!*/
public:
/* 构造函数声明*/
MainWindow(QWidget *parent = nullptr);
~MainWindow(); /* 析构函数声明 */
signals:
/* 声明一个信号,带参数,仅需声明,无需定义*/
void start_inspection(const recipe_config &recipe, const cv::Mat &gray);
};
#endif
note:创建信号时最好贴合信号本身的含义,此处笔者的项目中代表的意思是点击按钮后,发送开始检测信号。
2.1.2 槽的创建
创建槽需要现在mainwindow.h里边进行声明,然后在main.cpp里实现槽的定义。与信号不同,声明槽必须写槽的定义,否则会编译报错。它有以下特点**:1、槽可以时任何成员函数;2、槽函数和信号的参数、返回值要保持一致。槽的创建如下所示:**
cpp
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
/*主窗口基类*/
#include<QMainWindow>
/**
* MainWindow - 主窗口
*/
class MainWindow : public QMainWindow
{
Q_OBJECT /*在使用信号与槽的类中,必须在类的定义中加入此宏!*/
public:
/* 构造函数声明*/
MainWindow(QWidget *parent = nullptr);
~MainWindow(); /* 析构函数声明 */
signals:
/* 声明一个信号,带参数,仅需声明,无需定义*/
void start_inspection(const recipe_config &recipe, const cv::Mat &gray);
public slots:
/* 声明一个槽函数,带参数,需要声明+定义*/
void request_inspection(const recipe_config &recipe, const cv::Mat &gray);
};
#endif
/*声明完成后需要在对应cpp文件里进行调用*/
void MainWindow::request_inspection(const recipe_config &recipe, const cv::Mat &gray)
{
/* 准备结果缓冲并调用核心检测引擎 */
inspection_result result{};
int code = run_inspection(recipe, gray, result);
/* 无论成功失败都发射信号,让 UI 侧统一处理 */
emit inspection_finished(code, result);
}
2.1.3 信号与槽的关联
信号与槽的关联通过connect函数来实现。其基本格式为:
cpp
connect(sender, SIGNAL(signal()), receiver, SLOT(slot()));
sender是发射对象的名称,signal() 是信号名称。信号可以看做是特殊的函 数,需要带括号,有参数时还需要指明参数。receiver 是接收信号的对象名称,slot() 是槽函数 的名称,需要带括号,有参数时还需要指明参数。
而SIGNAL 和 SLOT 是 Qt 的宏,用于指明信号和槽,并将它们的参数转换为相应的字符 串。note:当信号和槽函数带有参数时,在 connect()函数里,要写明参数的类型,但可以不写参数名称。用法如下所示:
cpp
/*当信号和槽函数带有参数时,在连接函数里边要写明参数的类型,但可以不写参数名称*/
connect(worker_, SIGNAL(inspection_finished(int, inspection_result)),
this, SLOT(on_inspection_finished(int, inspection_result)));
如上图的意思是:假设worker_是按钮,代表的意思就是按钮点击后,开始发送inspection_finished信号,然后QT界面窗口会执行槽函数on_inspection_finished。其中,this代表的是主窗口MainWindow(C++中的this指针指向实例化的对象本身)。
2.2 QT常用控件
2.2.1 按钮QPushButton
这是一个按钮控件,通常用于创建一个按钮,点击后,主界面做出相应的响应,用法如下。
1、先在MainWindow.h里引入按钮库并声明按钮对象,代码如下:
cpp
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
/*主窗口基类*/
#include<QMainWindow>
/*引入按钮控件库*/
#include <QPushButton>
/**
* MainWindow - 主窗口
*/
class MainWindow : public QMainWindow
{
Q_OBJECT /*在使用信号与槽的类中,必须在类的定义中加入此宏!*/
public:
/* 构造函数声明*/
MainWindow(QWidget *parent = nullptr);
~MainWindow(); /* 析构函数声明 */
private:
/*声明一个QPushButton对象*/
QPushButton *load_image_button_; /* 加载图片按钮 */
private slots:
/* 加载图片按钮点击槽 */
void on_load_image_clicked();
};
#endif
2、在MainWindow.cpp的MainWindow构造函数中实例化按钮对象并连接信号与槽,如下所示:
cpp
/*实例化按钮对象*/
load_image_button_ = new QPushButton(QString::fromUtf8("加载图片"), central);
/*连接信号与槽*/
connect(load_image_button_, SIGNAL(clicked()),
this, SLOT(on_load_image_clicked())); // 连接加载图片按钮点击信号和槽函数
2.2.2 文本编辑框QLineEdit
文本编辑框的意思就是提供一个可以输入信息的文本框,用法如下:
cpp
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
/*主窗口基类*/
#include<QMainWindow>
/*引入文本编辑框*/
#include <QLineEdit>
/**
* MainWindow - 主窗口
*/
class MainWindow : public QMainWindow
{
Q_OBJECT /*在使用信号与槽的类中,必须在类的定义中加入此宏!*/
public:
/* 构造函数声明*/
MainWindow(QWidget *parent = nullptr);
~MainWindow(); /* 析构函数声明 */
private:
/* 操作员输入框 */
QLineEdit *operator_edit_;
/* 初始化菜单栏(操作员输入) */
void setup_menu_bar();
};
#endif
如上图,也是需要在头文件里进行声明;接下来在对应的cpp文件里进行实例化对象,以及构建文本框,如下所示:
cpp
/**
* setup_menu_bar - 构建菜单栏(操作员输入)
*/
void MainWindow::setup_menu_bar()
{
/*QMenu是QT的菜单类,此处先通过menuBar()获取菜单栏,再添加一个"操作员"菜单 */
QMenu *operator_menu = menuBar()->addMenu(QString::fromUtf8("操作员"));
/* 把 QLineEdit 嵌入菜单栏里作为操作员录入入口 */
/*QWidgetAction是QT的控件动作类,只有通过它才可以将控件嵌入菜单栏
*一般模板为先调用setDefaultWidget()方法,将控件设置为默认小部件
*然后将QWidgetAction对象添加到菜单栏中
*/
QWidgetAction *action = new QWidgetAction(this);
operator_edit_ = new QLineEdit(this);
operator_edit_->setPlaceholderText(QString::fromUtf8("输入操作员名称"));//设置占位文本为"输入操作员名称"
operator_edit_->setText(QString::fromUtf8("unknown"));//设置默认文本为"unknown"
operator_edit_->setFixedWidth(200);//设置固定宽度为200
action->setDefaultWidget(operator_edit_);//将操作员编辑框设置为默认小部件
operator_menu->addAction(action); /* 将操作员编辑框添加到操作员菜单 */
}
2.2.3 QLable
QLable提供了一种用于文本或图像显示的小部件,其用法如下,也是需要先在头文件里进行声明:
cpp
#ifndef MAINWINDOW_H
#define MAINWINDOW_H
/*主窗口基类*/
#include<QMainWindow>
/*引入QLable*/
#include <QLabel>
/**
* MainWindow - 主窗口
*/
class MainWindow : public QMainWindow
{
Q_OBJECT /*在使用信号与槽的类中,必须在类的定义中加入此宏!*/
public:
/* 构造函数声明*/
MainWindow(QWidget *parent = nullptr);
~MainWindow(); /* 析构函数声明 */
private:
/* 左侧图像显示区 */
QLabel *image_label_;
/* 初始化中央控件与布局 */
void setup_ui();
};
#endif
下边需要在对应CPP文件里进行实例化对象以及设置控件,如下所示:
cpp
void MainWindow::setup_ui()
{
/* 创建中央 QWidget 和主水平布局 */
QWidget *central = new QWidget(this);
QHBoxLayout *main_layout = new QHBoxLayout(central);
/* 左侧图像区:至少 640x480,带边框 */
image_label_ = new QLabel(central);
image_label_->setMinimumSize(640, 480); // 设置图像标签的最小尺寸为 640x480
image_label_->setAlignment(Qt::AlignCenter); // 设置图像标签的对齐方式为居中
image_label_->setStyleSheet(QString::fromUtf8("border: 1px solid gray;")); // 设置图像标签的样式表为带边框
image_label_->setText(QString::fromUtf8("未加载图片")); // 设置图像标签的文本为"未加载图片"
main_layout->addWidget(image_label_, 3); // 将图像标签加入主布局,并指定伸缩因子为3,表示该区域占总宽度的3/4
/* 右侧按钮 + 结果区 */
QVBoxLayout *right_layout = new QVBoxLayout(); // 创建右侧垂直布局,用于容纳功能按钮和结果显示区
/*其他代码省略....*/
}
2.2.4 布局管理
如上图所示,QHBoxLayout(水平布局)、QVBoxLayout(垂直布局),简单来说,水平布局就是将控件平着放;垂直布局就是将控件垂着着放。
cpp
/* 右侧按钮 + 结果区 */
QVBoxLayout *right_layout = new QVBoxLayout(); // 创建右侧垂直布局,用于容纳功能按钮和结果显示区
/* 四个功能按钮 */
load_image_button_ = new QPushButton(QString::fromUtf8("加载图片"), central);
run_inspection_button_ = new QPushButton(QString::fromUtf8("执行检测"), central);
view_history_button_ = new QPushButton(QString::fromUtf8("查看历史"), central);
switch_recipe_button_ = new QPushButton(QString::fromUtf8("切换配方"), central);
/* 结果显示区:支持换行、左上对齐、浅灰边框 */
result_label_ = new QLabel(central);
result_label_->setWordWrap(true); // 设置结果标签支持换行
result_label_->setAlignment(Qt::AlignTop | Qt::AlignLeft); // 设置结果标签的对齐方式为左上对齐
result_label_->setText(QString::fromUtf8("等待检测...")); // 设置结果标签的文本为"等待检测..."
result_label_->setStyleSheet(QString::fromUtf8("border: 1px solid lightgray; padding: 6px;")); // 设置结果标签的样式表为浅灰边框
result_label_->setMinimumHeight(200); // 设置结果标签的最小高度为 200
result_label_->setMinimumHeight(200); // 设置结果标签的最小高度为 200
/* 按顺序加入右侧垂直布局 */
right_layout->addWidget(load_image_button_);
right_layout->addWidget(run_inspection_button_);
right_layout->addWidget(view_history_button_);
right_layout->addWidget(switch_recipe_button_);
right_layout->addWidget(result_label_, 1);
2.2.5 QTableWidget
代表表格控件,用法如下:
cpp
#ifndef HISTORY_DIALOG_HPP
#define HISTORY_DIALOG_HPP
#include <QDialog> /* QDialog 基类 */
#include <QTableWidget> /* 表格控件 */
#include <string> /* std::string */
/**
* HistoryDialog - 历史记录对话框
*/
class HistoryDialog : public QDialog
{
Q_OBJECT
public:
/* 构造函数:db_path 是 SQLite 数据库路径 */
HistoryDialog(const std::string &db_path, QWidget *parent = nullptr);
~HistoryDialog();
private:
/* 从数据库加载最多 limit 条记录填入表格 */
void load_records(int limit);
std::string db_path_; /* 数据库路径 */
QTableWidget *table_; /* 记录表格 */
};
#endif /* HISTORY_DIALOG_HPP */
可以看到也是需要先引入相应的表格控件头文件,并且在头文件里声明表格控件对象。然后在对应CPP文件里构造表格控件,用法示例如下:
cpp
HistoryDialog::HistoryDialog(const std::string &db_path, QWidget *parent)
: QDialog(parent), // 调用父类构造函数
db_path_(db_path), // 初始化数据库路径成员变量
table_(nullptr) // 初始化表格成员变量为 nullptr
{
/* 设置窗口标题与几何信息 */
this->setWindowTitle(QString::fromUtf8("检测历史记录"));
this->setGeometry(200, 200, 900, 500);
/* 构造表格控件并设置 7 列表头 */
table_ = new QTableWidget(this);
table_->setColumnCount(7);
/* 准备表头文本 */
/* QStringList是QT中专门用于存储多个字符串的容器,使用
* <<操作符向容器中追加字符串元素,如第0列为时间
*/
QStringList headers;
headers << QString::fromUtf8("时间")
<< QString::fromUtf8("产品")
<< QString::fromUtf8("结果")
<< QString::fromUtf8("失败原因")
<< QString::fromUtf8("操作员")
<< QString::fromUtf8("配方版本")
<< QString::fromUtf8("图片路径");
table_->setHorizontalHeaderLabels(headers); // 调用表格控件方法来设置表头文本
/* 最后一列自适应填充剩余宽度,避免图片路径被截断 */
table_->horizontalHeader()->setStretchLastSection(true);
/* 只读表格,禁止任何编辑 */
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
/* 构造对话框布局并装入表格 */
QVBoxLayout *layout = new QVBoxLayout(this); // 构造对话框布局
layout->addWidget(table_); // 装入表格
}
2.2.6 QPixmap和QImage
这是QT提供的图像处理类,QPixmap更侧重于显示(类似于pdf),而QImage更侧重于图像处理(类似于word,方便编辑)。一般将QImage用于转换图像到QT显示,而QPixmap用于显示图像。用法如下:
cpp
/* 转 QImage 并按 Label 尺寸等比缩放 */
QImage image = mat_to_qimage(display);
QPixmap pixmap = QPixmap::fromImage(image).scaled(
image_label_->size(), //参数1:目标尺寸
Qt::KeepAspectRatio, //参数2:缩放模式,保持宽高比
Qt::SmoothTransformation); //参数3:缩放质量,平滑转换
image_label_->setPixmap(pixmap); // 设置图像标签的像素图,显示图像
2.2.7 QFileDialog和QMessageBox
QFileDialog和QMessageBox分别代表文件对话框和消息框,QFileDialog用于打开文件或保存文件,打开文件时用getOpenFileName(参数为:父窗口、对话框标题、默认路径、过滤器),保存文件时用getSaveFileName(参数为:父窗口、对话框标题、默认路径、过滤器);
QMessageBox用于显示消息框,常用函数有QMessageBox::information(表示信息提示蓝色图标)、QMessageBox::warning(表示警告提示黄色图标)、QMessageBox::critical(表示错误提示红色图标)。用法如下:
cpp
void MainWindow::on_load_image_clicked()
{
/* 弹出选文件对话框,限制常见图片格式 */
QString file = QFileDialog::getOpenFileName(this,
QString::fromUtf8("选择待检测图片"),
QString(),
QString::fromUtf8("图片文件 (*.png *.jpg *.bmp)"));
/* 用户取消选择则直接返回 */
if (file.isEmpty())
{
return;
}
/* 读图,失败则弹警告 */
cv::Mat bgr = cv::imread(file.toStdString(), cv::IMREAD_COLOR);/*将Qstring类型转换为std string*/
if (bgr.empty())
{
QMessageBox::warning(this,
QString::fromUtf8("读取图片失败"),
QString::fromUtf8("无法读取该文件,请确认格式正确。"));
return;
}
}
2.2.8 QstatusBar
代表状态栏,一般存在于QT主窗口的底部。用法如下:
cpp
/* 初始状态栏提示 */
statusBar()->showMessage(QString::fromUtf8("就绪"));
2.3 QT多线程
为什么要使用多线程呢?因为如在单线程中,操作都是按顺序执行的,如果UI界面内某个点击按钮的操作是比较耗时的,就会发现点击后没有响应,界面卡住了,必须等到耗时结束后才能恢复。
为了解决这种问题,就要使用多线程方法,而QT中实现多线程的核心是存在QTHread线程类,其有两种多线程方法,一种是继承QThread的run函数,另一种是将继承QObject的类转移到一个线程里,官方主要推荐第二种用法,因此笔者将以第二种用法为准进行介绍。
先来看用法示例,在做讲解:
cpp
void MainWindow::setup_worker_thread()
{
/* 工作线程归 MainWindow 所有,但 Worker 对象不能挂父级,否则 moveToThread 会失败 */
worker_thread_ = new QThread(this);
worker_ = new InspectionWorker();
worker_->moveToThread(worker_thread_);//将 worker_ 移动到 worker_thread_ 线程,worker_的槽函数将在该线程中执行
/* 线程结束后自动删除 Worker 对象 */
connect(worker_thread_, SIGNAL(finished()),
worker_, SLOT(deleteLater()));
/* 主窗口信号 -> Worker 槽:按 recipe 和图像发起检测请求 */
/*当主窗口点击执行检测时会发送检测信号,worker_(在工作线程中)接收到信号后会调用request_inspection槽函数进行检测*/
connect(this, SIGNAL(start_inspection(recipe_config, cv::Mat)),
worker_, SLOT(request_inspection(recipe_config, cv::Mat)));
/* Worker 信号 -> 主窗口槽:检测完成回传结果 */
/*当信号和槽函数带有参数时,在连接函数里边要写明参数的类型,但可以不写参数名称*/
connect(worker_, SIGNAL(inspection_finished(int, inspection_result)),
this, SLOT(on_inspection_finished(int, inspection_result)));
/* 启动工作线程 */
/*调用start方法创建线程worker_thread_,并执行该线程,该线程会调用run方法,
*run方法中会调用exec()函数,该函数会启动事件循环,即检测到信号时才会唤醒,否则处于阻塞态。
*/
worker_thread_->start();
}
如上图的代码是运行在UI线程中的, worker_thread_是实例化的线程对象,worker_是实例化的InspectionWorker对象,此处的InspectionWorker就是继承自QObject的派生类。然后接下来调用moveToThread方法将worker_对象移动到所创建的工作线程(worker_thread_)中,代表之后worker_对象的槽函数将在新的工作线程中执行。
比如上图中的第二个connect函数:当UI线程对象发送开始检测信号后,处在工作线程的对象worker_会在工作线程里执行槽函数on_inspection_finished。最后调用工作线程的start方法来创建工作线程并执行。具体来说,start方法内部会调用pthread_create()创建线程,然后执行所创建的线程,最后在该线程中启动事件循环。
2.3.1 跨越信号和槽
像上述所说的,发送信号的对象this和接收对象worker_不在同一线程中时,这种情况就称为跨越信号和槽 。其工作机制是:主线程发送信号后,QT会将信号的参数拷贝,并封装成事件,然后将事件发送到工作线程的事件队列里边;等待工作线程执行事件循环取出该队列,才会去调用接收对象的槽函数进行执行。
其中,事件循环指的是底层维护一个事件队列,无事件时线程调用 epoll_wait 进入阻塞,内核将其移出就绪表、加入等待表,此时放弃CPU控制权;当其他线程投递事件时会触发内部唤醒管道,内核立即把线程移回就绪表,恢复 TASK_RUNNING 状态,随后事件循环取出队列中的事件并执行。
了解工作原理之后,下边说一个多线程调度的示例帮助理解,如:
bash
1、假设主线程有一个按钮,点击后发射信号让工作线程开始计算:
2、程序启动,主线程进入 app.exec()(代表事件循环),等待事件。
3、工作线程启动后进入 exec(),等待事件。
4、两者都阻塞,CPU 空闲。
5、用户点击按钮 → 主线程被唤醒,执行按钮对应的槽函数,该槽函数发射信号给工作线程。
6、工作线程的信号被包装为事件,投递到工作线程的事件队列,工作线程的 exec() 被唤醒,处理该事件,执行 Worker 的槽(如 doWork())。
7、工作线程耗时计算期间,完全不需要事件循环,直接占用 CPU 执行 doWork()。
8、计算完成后,Worker 发射 resultReady 信号,该信号被投递到主线程的事件队列,工作线程继续回到事件循环 exec() 等待新事件(如果没有新事件,再次阻塞)。
9、主线程的事件循环被 resultReady 事件唤醒,调用主线程槽函数更新界面。
10、更新完成后,主线程继续回到事件循环,若再无事件,则再次阻塞。
2.3.2 声明和注册元类型
上小节中跨越信号和槽的工作机制中讲到,主线程发送信号后,QT会将信号的参数拷贝,并封装成事件。但是,当QT在拷贝参数时如果碰到你自定义的数据结构类型,它是没办法认识的,因此需要采用Q_DECLARE_METATYPE 和 qRegisterMetaType 进行注册,否则QT会报错。用法如下:
cpp
// 定义一个结构体
struct SensorData
{
int id;
double temperature;
QString name;
};
// 声明元类型
Q_DECLARE_METATYPE(SensorData)
// 注册元类型
qRegisterMetaType<SensorData>("SensorData");