Qt 岗位实战:面试速记 + 从零学习
这是 Qt从零开始图解 的续篇。上一篇讲原理:信号槽、事件循环、对象树、多线程。这一篇讲干活:界面怎么搭、样式怎么换、串口数据怎么收、程序怎么分层、出了问题怎么查。
内容按武汉 17 个 Qt / 桌面客户端岗位的要求挑选(岗位调研见 resume/wuhan_cpp_boss_jobs_2026-09-04.md):界面开发和自定义控件(6 家)、架构和模块设计(8 家)、跨平台和移植(5 家)、性能和稳定性(3 家)、上位机和设备通信。只有一两家要求的 QML、动画、QtCreator 插件、组态软件不写。
| 场景 | 读哪里 |
|---|---|
| 第一次学 | 先读完上一篇的第四部分,再从本篇第四部分 4.0 开始按顺序读 |
| 面试前 10 分钟 | 第一部分速记页 + 第二部分易混对照 |
| 面试前一天 | 第三部分口述稿,出声念一遍 |
| 被追问时心里有底 | 第五部分追问检查表 |
| 检验是否真会 | 第六部分闭卷自测 |
| 准备项目追问 | 附录 B:把调试助手改成一个能讲的项目 |
和上一篇一样,第一次学的时候前三部分先跳过,那是学完以后复习用的。
文中标“完整例子”的代码都在 Qt 6.11 + CMake 下实测编译(-Wall -Wextra -Wpedantic 零警告),输出是真实运行结果。编译方法见附录 A。
第一部分 速记页
1.1 一页表
| 知识点 | 一句话答案 | 最容易漏掉的点 |
|---|---|---|
| QMainWindow | 主窗口骨架:菜单栏、工具栏、中央控件、停靠窗口、状态栏 | 中央控件只能有一个,要放多个控件就先放一个 QWidget 再往里加布局 |
| QAction | 一个“操作”:文字、图标、快捷键、triggered 信号 | 同一个 QAction 加进菜单和工具栏,点哪个都触发同一个槽,禁用一次两边一起灰 |
| .ui 文件 | Designer 画出来的界面,是一个 XML 文件,uic 把它转成 ui_xxx.h | setupUi(this) 之后才能用 ui.xxx 访问控件 |
| 布局 | 控件交给布局管位置和大小,窗口缩放时自动调整 | 多余空间给谁由拉伸系数(stretch)和尺寸策略(sizePolicy)决定 |
| 模态对话框 | exec() 弹出并等用户关掉才返回 | exec 里开了一个新的事件循环,定时器、网络照常工作,也可能引发重入 |
| 窗口之间传数据 | 主窗口 → 子窗口调函数;子窗口 → 主窗口发信号 | 子窗口不要直接拿主窗口指针改它的控件,会让两个类互相依赖 |
| QSS | 语法像 CSS,按类型、objectName、属性、状态选中控件 | 改了动态属性要 unpolish + polish,样式才会刷新 |
| 自定义控件进阶 | 重写 paintEvent、鼠标事件、sizeHint,对外发信号 | 用 Q_PROPERTY 暴露颜色等属性,QSS 里就能用 qproperty-xxx 设置 |
| 分层 | 通信层 → 协议层 → 业务层 → 界面层,下层用信号通知上层 | 业务层不 include 任何界面头文件,才能单独做单元测试 |
| QTest | Qt 自带的单元测试框架,QCOMPARE 断言,QSignalSpy 数信号 | 界面也能测:QTest::mouseClick 模拟点击 |
| 串口 | QSerialPort 继承 QIODevice,用法和 QTcpSocket 一样,靠 readyRead 异步读 | 串口也是字节流,一样有半包、粘包,要按协议拆帧 |
| 协议帧 | 帧头 + 长度 + 命令 + 数据 + 校验 | 校验失败只丢帧头两个字节,然后重新找帧头,不能把整段缓冲区清空 |
| 三线程模型 | 采集、界面、存储各一个线程,用 Queued 信号传数据 | 界面不要来一个数据刷一次,定时批量刷新 |
| 跨平台 | 路径用 QDir / QStandardPaths,平台差异用 Q_OS_WIN 等宏 | 给用户看的路径用 QDir::toNativeSeparators 转一下 |
| 移植到 ARM Linux | 交叉编译 Qt,板子上用 linuxfb / eglfs 平台插件 | Qt 6 交叉编译要先有一份电脑本机的 Qt 提供 moc、uic |
| 日志 | qInstallMessageHandler 把 qDebug 等输出重定向到文件 | 文件名行号默认是空的,要定义 QT_MESSAGELOGCONTEXT;多线程写日志要加锁 |
| 崩溃定位 | Linux 开 core dump 用 gdb 看调用栈;Windows 生成 minidump | 发布版也要保留符号文件,否则调用栈只有地址 |
| d 指针 | 头文件里只放一个指向私有类的指针,成员都藏在 .cpp 里 | 为了二进制兼容:加成员不改变类的大小,用这个库的程序不用重新编译 |
| QSettings | 读写配置,键用 分组/键名 | value(键, 默认值),没存过就返回默认值 |
| QJson | QJsonDocument / QJsonObject / QJsonArray | 解析一定要传 QJsonParseError 检查是否成功 |
| QSql | 数据库连接只能在创建它的线程用 | 批量插入要开事务,实测快两个数量级 |
| 多语言 | 代码里用 tr(),lupdate → Linguist → lrelease → QTranslator | tr 按类名区分翻译,类里要写 Q_OBJECT,否则翻译会对不上 |
1.2 如果只背一段
我做上位机类程序会分四层:通信层只管收发字节,协议层负责拆帧和校验,业务层做判断和状态,界面层只管显示。下层用信号通知上层,业务层不依赖任何界面类,所以能用 QTest 单独测。数据量大时采集、界面、存储各一个线程,界面用定时器批量刷新,存储攒一批写一次并开事务。出了问题靠日志文件和 core dump 定位。
这一段把分层、线程、测试、排障串起来了。面试官问“你的项目怎么设计的”,按这个顺序讲,每一层都能接住追问。
第二部分 易混对照
2.1 exec()、open()、show() 弹对话框
exec() | open() | show() | |
|---|---|---|---|
| 模态吗 | 模态,挡住整个程序的其他窗口 | 模态,只挡住父窗口 | 非模态,两个窗口都能操作 |
| 函数什么时候返回 | 用户关掉对话框才返回 | 立刻返回 | 立刻返回 |
| 结果怎么拿 | exec 的返回值 | 连接 finished(int) 信号 | 自己定义信号 |
| 风险 | 内部开了新的事件循环,可能重入 | 对话框对象要活到用户关闭,一般 new 出来 | 同上 |
| 适合 | 简单的确认框、设置框 | 官方更推荐的模态写法 | 查找窗口、工具面板 |
2.2 用 .ui 文件的三种写法
| 写法 | 代码 | 特点 |
|---|---|---|
| 成员对象 | Ui::LoginForm ui; 然后 ui.setupUi(this) | 最简单,本文用这种 |
| 成员指针 | Ui::LoginForm *ui; 构造里 new、析构里 delete | Qt Creator 默认生成的写法,头文件里只需前置声明,改 .ui 时依赖它的文件编译得少 |
| 多继承 | class LoginForm : public QWidget, private Ui::LoginForm | 直接写 okButton,不用 ui. 前缀,现在很少用 |
2.3 改控件外观的三种办法
| QSS 样式表 | QPalette 调色板 | 重写 paintEvent | |
|---|---|---|---|
| 能改什么 | 颜色、边框、圆角、间距、背景图、各种状态 | 只能改颜色 | 想画什么画什么 |
| 难度 | 低,像写 CSS | 低 | 高 |
| 缺点 | 规则多了互相覆盖难排查;自定义控件要自己支持 | 很多平台样式会忽略它 | 工作量大 |
| 适合 | 大部分换肤、美化需求 | 简单改个前景背景色 | 仪表盘、开关、波形图等标准控件没有的东西 |
2.4 串口的两种读法
readyRead 信号 + readAll() | waitForReadyRead(毫秒) | |
|---|---|---|
| 同步还是异步 | 异步,数据来了通知你 | 同步,卡在这里等 |
| 能在主线程用吗 | 能,推荐 | 不能,会卡住界面 |
| 适合 | 几乎所有情况 | 在单独线程里写的简单测试工具 |
2.5 存数据用什么
| QSettings | JSON 文件 | SQLite 数据库(QSql) | |
|---|---|---|---|
| 存什么 | 用户配置:窗口位置、上次的端口、阈值 | 结构化配置、要和别的程序交换的数据 | 大量记录:采集数据、日志、报警历史 |
| 数据量 | 几十几百项 | 中等 | 几百万行也可以 |
| 查询 | 按键取 | 整个读进来再找 | SQL 查询、排序、统计 |
2.6 界面刷新:来一条刷一次,还是定时刷
| 来一条数据刷一次 | 定时批量刷新 | |
|---|---|---|
| 1 秒来 1000 条数据时 | 界面重绘 1000 次,主线程被占满,卡 | 每 50 毫秒刷一次,1 秒 20 次 |
| 人眼能看出区别吗 | 看不出,屏幕一般 60 帧 | 看不出 |
| 做法 | 在收数据的槽里直接改界面 | 收数据的槽只存进缓冲区,QTimer 定时取出来画 |
第三部分 面试口述稿
每段都能直接说出口。详解里不重复,只标“口述见 §x.x”。
3.1 主窗口和 QAction(详解 §4.1)
主窗口我用 QMainWindow,它把菜单栏、工具栏、中央区域、停靠窗口和状态栏的位置都定好了,中央区域只能放一个控件,要放多个就先放一个 QWidget 再往里加布局。菜单和工具栏上的操作我都用 QAction 表示,同一个 QAction 可以同时加进菜单和工具栏,文字、图标、快捷键、能不能点都只维护一份,触发时走同一个槽。以前菜单项和工具栏按钮各写一套,经常出现一个禁用了另一个还能点的问题,用 QAction 就没有了。
3.2 Designer 和 .ui(详解 §4.2)
.ui 文件是 Designer 画出来的界面描述,本质是 XML。编译时 uic 把它转成 ui_xxx.h,里面是一个 Ui 类,setupUi 负责把控件 new 出来、摆进布局。我一般用成员对象或成员指针的写法,信号槽自己用 connect 连,不用 on_控件名_信号名 这种按名字自动连接,因为改了控件名它不报错,只是悄悄不连了。固定的表单用 Designer 画,动态生成的界面写代码。
3.3 布局(详解 §4.3)
界面上的控件我都交给布局管理,横排、竖排、网格、表单四种布局嵌套使用,窗口缩放时位置和大小自动调整。多余的空间给谁,看拉伸系数;控件自己愿不愿意被拉大或压小,看尺寸策略;推荐大小来自 sizeHint。遇到控件挤在一起或者拉不开,我先查这三样。
3.4 对话框和窗口通信(详解 §4.4)
简单的设置框我会用 exec,它弹出对话框、等用户关掉才返回,返回值告诉我是确定还是取消。要注意 exec 里面开了一个新的事件循环,这期间定时器、网络数据照样会触发槽函数,如果槽里又去弹对话框或者删对象,就可能出问题,所以官方更推荐 open 加 finished 信号。窗口之间传数据,我的规则是:主窗口给子窗口传就调子窗口的函数,子窗口要通知主窗口就发信号,子窗口不直接拿主窗口的指针,这样子窗口可以被别的地方复用。
3.5 QSS(详解 §4.5)
样式我用 QSS 统一管理,写在一个 .qss 文件里、放进资源文件,启动时 qApp->setStyleSheet 一次性加载。按状态变色我用动态属性,比如 QLabel[state="error"],代码里 setProperty 改状态。这里有个坑:改了属性样式不会自动刷新,要对这个控件调一次 style()->unpolish 和 polish。自定义控件想让 QSS 能改它的颜色,就用 Q_PROPERTY 把颜色暴露出来,QSS 里写 qproperty-颜色属性名。
3.6 自定义控件(详解 §4.6)
标准控件满足不了的,比如开关、仪表盘、波形图,我继承 QWidget 自己画。paintEvent 里用 QPainter 画,状态变了调 update 排队重绘,鼠标交互重写鼠标事件,对外用信号通知,比如开关状态变了发 toggled。还要重写 sizeHint,告诉布局我推荐多大。颜色这类外观属性用 Q_PROPERTY 暴露,QSS 和 Designer 都能改。做好的控件可以在 Designer 里用“提升为”放到界面上。
3.7 分层和单元测试(详解 §4.7)
上位机程序我分四层:通信层只管收发字节,串口或 TCP 都是 QIODevice,可以互换;协议层负责拆帧、校验、组帧;业务层做判断,比如超温报警、统计;界面层只负责显示和把用户操作转成对业务层的调用。下层通过信号通知上层,上层调用下层的接口。好处是改界面不影响业务,换通信方式不影响协议,而且业务层不依赖任何界面类,可以用 QTest 和 QSignalSpy 单独测试。代价是类和文件多了,很小的工具没必要分这么细。
3.8 串口和协议帧(详解 §4.8)
串口我用 QSerialPort,它和 QTcpSocket 一样继承 QIODevice,在 readyRead 里 readAll 异步读,不在主线程用 waitForReadyRead。串口数据也是字节流,一次 readyRead 可能只有半帧,也可能有好几帧,所以先追加到缓冲区,再按协议拆。我们的帧是帧头、长度、命令、数据、校验。拆的时候先找帧头,帧头前面的垃圾字节丢掉;长度不够就等下次;校验失败只丢掉这个帧头的两个字节,再往后重新找帧头,这样一个坏帧不会连带把后面的好帧丢掉。发送命令时配一个 QTimer 做超时,超时没应答就重发,超过次数就报通信故障。
3.9 采集、界面、存储三线程(详解 §4.9)
数据量大的时候我把采集、界面、存储放在三个线程。采集对象 moveToThread 到采集线程,读到数据先攒一小批再发信号;界面和存储都连这个信号,跨线程自动走 Queued 连接,参数拷贝一份,不用自己加锁。界面那边收到数据只存进缓冲区,用一个 50 毫秒的定时器批量刷新,一秒刷 20 次人眼已经看不出区别;存储那边攒够一批再写,并且开事务。这是典型的生产者消费者模型,要注意的是生产太快、消费太慢时队列会越积越多,要监控积压,必要时丢弃旧数据或者降采样。
3.10 跨平台和移植(详解 §4.10)
跨平台主要注意几件事:路径用 QDir 和 QStandardPaths 拼,不手写斜杠,给用户看时用 toNativeSeparators;文件名大小写 Linux 敏感、Windows 不敏感;中文编码统一 UTF-8,MSVC 加 /utf-8;平台相关的代码用 Q_OS_WIN、Q_OS_LINUX 包起来,集中放在一个文件里。移植到 ARM 嵌入式 Linux 时,要先交叉编译 Qt 本身,Qt 6 还需要一份电脑本机的 Qt 提供 moc、uic 这些工具;板子上没有桌面环境,就用 linuxfb 或 eglfs 平台插件直接画到屏幕上;常见问题是缺字体导致中文显示成方框、缺平台插件启动不了。我之前做过 ARM64 交叉编译和三方库适配,这一块的排查思路是一样的。
3.11 稳定性和排障(详解 §4.11)
我会用 qInstallMessageHandler 把 qDebug、qWarning 统一写进日志文件,带时间、级别、文件名和行号,按天分文件。文件名行号默认是空的,要在 CMake 里定义 QT_MESSAGELOGCONTEXT;多个线程同时写日志,处理函数里要加锁。崩溃问题在 Linux 上开 core dump,用 gdb 打开看调用栈;Windows 上生成 minidump,发布时保留 pdb 符号文件。内存泄漏先查没有 parent 的 new 对象和没有 deleteLater 的网络回复,工具用 Valgrind 或者 AddressSanitizer。界面卡顿就在主线程的槽函数里打耗时,找到耗时操作挪到后台线程。
3.12 d 指针(详解 §4.12)
d 指针就是 PIMPL 写法:公开的类在头文件里只放一个指向私有类的指针,所有成员变量都放在私有类里,私有类只在 .cpp 里定义。Qt 自己的类几乎都这么写,用 Q_D 宏取私有指针,Q_Q 宏从私有类反过来取公开类。目的是二进制兼容:以后给私有类加成员,公开类的大小不变,用这个库的程序不用重新编译。代价是多一次堆分配和一次指针跳转。
第四部分 逐个详解
4.0 Qt 岗面试问什么,这篇怎么读
flowchart LR
Q["Qt 岗面试"] --> A["原理题<br/>信号槽、事件循环、对象树、多线程<br/>上一篇"]
Q --> B["干活题<br/>界面怎么搭、样式怎么换<br/>串口怎么收、程序怎么分层<br/>本篇"]
Q --> C["项目追问<br/>你的项目怎么设计的、遇到过什么问题<br/>附录 B,只能靠自己写"]
看图: 原理题决定你能不能过第一轮,干活题决定面试官信不信你能上手干活,项目追问决定给多少钱。本篇 4.1~4.6 讲界面,4.7~4.9 讲程序结构和数据处理,4.10~4.12 讲跨平台、排障和常问的零碎知识。
每一节都假设你已经读过上一篇的第四部分,会用信号槽、知道对象树和事件循环。遇到新的 C++ 语法会单独讲。
4.1 主窗口骨架:QMainWindow 和 QAction
口述见 §3.1。
为什么需要它
上一篇的例子都用一个空白 QWidget 当窗口。真正的桌面程序几乎都有菜单栏、工具栏、状态栏,有的还有能拖动的侧边面板。这些东西自己用布局拼也能拼出来,但每个程序都拼一遍很浪费,Qt 就提供了一个现成的骨架 QMainWindow。
官方概念
- QMainWindow(主窗口):一个已经划分好区域的窗口,区域见下图。
- 中央控件(central widget):主工作区,用
setCentralWidget设置,只能有一个。 - 停靠窗口(dock widget):
QDockWidget,可以拖到上下左右、可以浮出来、可以关掉的面板。 - QAction(动作):一个“用户能执行的操作”,带文字、图标、快捷键,被触发时发出
triggered信号。菜单项和工具栏按钮本质上都是 QAction 的不同显示方式。
flowchart TB
M["菜单栏 menuBar"] --- T["工具栏 toolBar"]
T --- MID
subgraph MID["中间区域"]
direction LR
D["停靠窗口 dock<br/>可拖动、可关闭"] --- C["中央控件 centralWidget<br/>主工作区,只能有一个"]
end
MID --- S["状态栏 statusBar"]
完整例子:菜单、工具栏共用一个 QAction
#include <QAction>
#include <QApplication>
#include <QDockWidget>
#include <QListWidget>
#include <QMainWindow>
#include <QMenuBar>
#include <QStatusBar>
#include <QTextEdit>
#include <QToolBar>
#include <QDebug>
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QMainWindow window;
QTextEdit *editor = new QTextEdit;
window.setCentralWidget(editor);
QAction *clearAction = new QAction("清空", &window);
clearAction->setShortcut(QKeySequence("Ctrl+L"));
QObject::connect(clearAction, &QAction::triggered, editor, &QTextEdit::clear);
QObject::connect(clearAction, &QAction::triggered, &window, [&window] {
window.statusBar()->showMessage("已清空", 2000);
});
QMenu *editMenu = window.menuBar()->addMenu("编辑");
editMenu->addAction(clearAction);
QToolBar *toolBar = window.addToolBar("常用");
toolBar->addAction(clearAction);
QDockWidget *dock = new QDockWidget("设备列表", &window);
dock->setWidget(new QListWidget);
window.addDockWidget(Qt::LeftDockWidgetArea, dock);
editor->setPlainText("一些日志");
clearAction->trigger();
qDebug() << "编辑区内容:" << editor->toPlainText();
qDebug() << "状态栏:" << window.statusBar()->currentMessage();
qDebug() << "菜单和工具栏用的是同一个 QAction:"
<< (editMenu->actions().first() == toolBar->actions().first());
return 0;
}
输出:
编辑区内容: ""
状态栏: "已清空"
菜单和工具栏用的是同一个 QAction: true
真实使用时把最后几行换成 window.show(); return app.exec();,就能看到带菜单、工具栏、左侧面板的窗口。这里为了能打印结果,用 trigger() 代替了用户点击。
逐段拆:
QTextEdit:多行文本编辑框。setCentralWidget(editor)把它放进中央区域,同时 window 成为它的 parent。new QAction("清空", &window):新建一个动作,文字是“清空”,parent 是 window(对象树负责删除它)。setShortcut(QKeySequence("Ctrl+L")):设置快捷键。QKeySequence(按键序列) 可以直接用字符串描述组合键,在 macOS 上 Ctrl 会自动对应 Command 键。- 第一个 connect:动作被触发 → 编辑框清空。
QTextEdit::clear本身就是一个槽。 - 第二个 connect:动作被触发 → 状态栏显示“已清空”两秒。
[&window]表示 lambda 里要用外面的 window 变量(按引用,不复制)。showMessage(文字, 毫秒)到时间自动消失。 window.menuBar()->addMenu("编辑"):取出菜单栏(第一次调用时自动创建),加一个叫“编辑”的菜单,返回这个菜单的指针QMenu *。editMenu->addAction(clearAction)和toolBar->addAction(clearAction):同一个动作加到了两个地方。new QDockWidget("设备列表", &window):停靠窗口,标题是“设备列表”;setWidget放进一个列表控件;addDockWidget(Qt::LeftDockWidgetArea, dock)停在左边。clearAction->trigger():用代码触发动作,效果和用户点菜单一样。- 最后一行比较两个指针:菜单里第一个动作和工具栏里第一个动作是不是同一个对象,结果是 true。
按时间顺序:搭好窗口 → 编辑框里放一段文字 → 触发“清空” → triggered 信号按连接顺序调用两个槽:先清空编辑框,再在状态栏显示消息 → 打印结果。
QAction 共用的好处
clearAction->setEnabled(false);
这一行执行后,菜单里的“清空”和工具栏上的“清空”一起变灰,快捷键也一起失效。如果菜单项和工具栏按钮各写一套,就要记得改两处,漏一处就会出现“菜单灰了、按钮还能点”的 bug。
练习
- 再加一个“保存”动作,快捷键 Ctrl+S,同时放进“文件”菜单和工具栏,触发时在状态栏显示“已保存”。
- 编辑框为空时禁用“清空”:连接
QTextEdit::textChanged信号,在槽里根据内容是否为空调用setEnabled。
4.2 Designer 和 .ui 文件
口述见 §3.2。
为什么需要它
表单类界面(登录框、设置页、参数配置页)控件多、位置固定,一行行写 new、addWidget 很枯燥,改一个位置还要重新编译才能看到效果。Qt Designer(界面设计器) 可以用鼠标拖控件、摆布局,所见即所得。Qt Creator 里双击 .ui 文件打开的就是它。
官方概念
- .ui 文件:Designer 保存的界面描述文件,内容是 XML(一种用尖括号标签描述数据的文本格式)。
- uic(User Interface Compiler,界面编译器):编译时把
xxx.ui转成 C++ 头文件ui_xxx.h。CMake 里set(CMAKE_AUTOUIC ON)后自动运行。 - Ui 类和 setupUi:
ui_xxx.h里有一个类(例如Ui::LoginForm),它的setupUi(窗口指针)函数负责把所有控件 new 出来、设置好属性、放进布局。
flowchart LR
A["Designer 里拖控件<br/>保存成 login_form.ui"] -->|"编译时 uic 自动转换"| B["ui_login_form.h<br/>里面有 Ui::LoginForm 类"]
B -->|"#include 进来"| C["你的 LoginForm 类<br/>构造函数里 ui.setupUi(this)"]
C --> D["控件都建好了<br/>用 ui.控件名 访问"]
完整例子:一个登录表单
login_form.ui(Designer 生成的,这里手写了一个最小的版本,本机没装 Designer 也能编译):
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>LoginForm</class>
<widget class="QWidget" name="LoginForm">
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLineEdit" name="nameEdit"/>
</item>
<item>
<widget class="QPushButton" name="okButton">
<property name="text">
<string>登录</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="resultLabel"/>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>
designer_form.cpp:
#include <QApplication>
#include <QWidget>
#include <QDebug>
#include "ui_login_form.h"
class LoginForm : public QWidget {
public:
LoginForm() {
ui.setupUi(this);
connect(ui.okButton, &QPushButton::clicked, this, [this] {
ui.resultLabel->setText("你好, " + ui.nameEdit->text());
});
}
Ui::LoginForm ui;
};
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
LoginForm form;
form.ui.nameEdit->setText("李可");
form.ui.okButton->click();
qDebug() << form.ui.resultLabel->text();
qDebug() << "子对象个数:" << form.children().size();
return 0;
}
输出:
"你好, 李可"
子对象个数: 4
先看 .ui 文件,不用记语法,看得懂结构就行:
<class>LoginForm</class>:生成的类叫Ui::LoginForm。<widget class="QWidget" name="LoginForm">:最外层是一个 QWidget。<layout class="QVBoxLayout">:里面是竖排布局,三个<item>从上往下是输入框nameEdit、按钮okButton、标签resultLabel。name="..."就是生成代码里的成员变量名,代码里用ui.nameEdit访问。
再看 C++:
#include "ui_login_form.h":包含 uic 生成的头文件。文件名规则是ui_+ .ui 文件名 +.h。LoginForm():构造函数(constructor),和类同名、没有返回值,创建对象时自动调用。ui.setupUi(this):this是指向当前对象自己的指针。这一句把三个控件和布局建好,挂到当前窗口上。必须先调用它,后面才能用ui.xxx,否则那些指针还是空的。connect(ui.okButton, ..., this, [this] { ... }):在类的成员函数里写 connect 可以省掉QObject::前缀,因为 LoginForm 本身就继承了 QObject。第三个参数this是上下文对象,lambda 捕获列表[this]表示 lambda 里要用当前对象的成员。"你好, " + ui.nameEdit->text():text()返回输入框里的 QString,两个字符串用+拼起来。Ui::LoginForm ui;:把 Ui 类作为成员对象放在类里。Ui::是命名空间(namespace),uic 生成的类都放在叫 Ui 的命名空间里,避免和你自己的LoginForm重名。form.ui.okButton->click():用代码模拟点击按钮,会正常发出 clicked 信号。- 子对象个数是 4:布局加三个控件,都挂在 form 名下,和上一篇 §4.2 讲的对象树一致。
按时间顺序:创建 form → 构造函数里 setupUi 建好控件 → connect 登记“点按钮就改标签” → main 往输入框填“李可” → 模拟点击 → clicked 信号触发 lambda → 标签变成“你好, 李可”。
不要用按名字自动连接
Designer 生成的代码里,setupUi 最后会调用 QMetaObject::connectSlotsByName:如果你的类里有名字形如 on_okButton_clicked() 的槽,它会自动连到 okButton 的 clicked 信号上。看着省事,但有两个问题:
- 在 Designer 里把按钮改名成
loginButton,编译不报错,连接却悄悄没了,点按钮没反应,很难查。 - 看代码的人找不到 connect,不知道这个函数什么时候被调用。
所以更推荐像上面那样自己写 connect,改名时编译器会直接报错。
什么时候不用 Designer
控件数量要根据数据动态决定的界面(比如设备有几个通道就生成几组控件),用代码写更自然。很多项目是固定部分用 .ui、动态部分用代码,两者可以混用:先 setupUi,再往 .ui 里预留的某个空布局里用代码加控件。
4.3 布局:控件为什么挤在一起、拉不开
口述见 §3.3。
为什么需要它
不用布局,就得给每个控件写死坐标和大小(setGeometry(10, 10, 100, 30)),窗口一缩放、换一台高分辨率的屏幕、换一种语言导致文字变长,界面就乱了。布局负责在窗口大小变化时重新计算每个控件的位置和大小。
官方概念
- 四种常用布局:
QHBoxLayout横排、QVBoxLayout竖排、QGridLayout网格、QFormLayout表单(左边标签、右边输入框的两列)。 - 嵌套:布局里可以再放布局,
layout->addLayout(子布局)。 - 拉伸系数(stretch factor):多出来的空间按系数分配。
addWidget(控件, 2)的控件分到的空间是系数 1 的两倍;系数都是 0 时平均分。 - 弹簧(spacer):
addStretch()加一个看不见的弹簧,把多余空间吃掉,常用来把按钮推到右边。 - 尺寸策略(size policy):控件自己的意愿,
setSizePolicy(横向, 纵向)。常用值:Fixed固定就是推荐大小;Preferred推荐大小,能伸能缩但不主动要空间;Expanding主动要多余空间。 - 推荐大小(sizeHint):控件告诉布局“我觉得自己这么大最合适”。
一个典型的“参数输入 + 日志 + 按钮”界面的布局嵌套:
flowchart TB
W["窗口"] --> V["竖排 QVBoxLayout"]
V --> F["表单 QFormLayout<br/>端口:[输入框]<br/>波特率:[下拉框]"]
V --> T["日志表格<br/>拉伸系数 1,多余的高度都给它"]
V --> H["横排 QHBoxLayout<br/>弹簧 + 确定 + 取消"]
QVBoxLayout *root = new QVBoxLayout(this);
QFormLayout *form = new QFormLayout;
form->addRow("端口", portEdit);
form->addRow("波特率", baudCombo);
root->addLayout(form);
root->addWidget(logTable, 1);
QHBoxLayout *buttons = new QHBoxLayout;
buttons->addStretch();
buttons->addWidget(okButton);
buttons->addWidget(cancelButton);
root->addLayout(buttons);
root->addWidget(logTable, 1):第二个参数 1 是拉伸系数。其他两行(表单、按钮行)系数是 0,所以窗口变高时,多出来的高度全给日志表格。buttons->addStretch():先放一个弹簧,它把左边的空白吃掉,两个按钮就被挤到右边。
常见问题对照
| 现象 | 多半的原因 | 改法 |
|---|---|---|
| 控件叠在左上角 | 控件没加进布局,或者布局没装到窗口上 | new QVBoxLayout(this) 或 setLayout(layout) |
| 窗口变大,控件不跟着变 | 多余空间被弹簧吃了,或者控件是 Fixed | 去掉多余的弹簧;给要变大的控件设拉伸系数或 Expanding |
| 某个控件被压得很小 | 其他控件抢了空间 | 给它设 setMinimumSize 或更大的拉伸系数 |
| 按钮宽窄不一 | 文字长度不同,推荐大小不同 | 按钮设成一样的最小宽度 |
| 边缘和间距太大 | 布局默认有外边距和控件间距 | setContentsMargins(0,0,0,0)、setSpacing(4) |
这一节没有完整例子,因为布局要看效果。建议打开调试助手的 projects/qt-net-assistant/src/MainWindow.cpp,找到 buildUi() 函数,改几个拉伸系数,编译运行后拖动窗口看变化。
4.4 对话框和窗口之间传数据
口述见 §3.4。
官方概念
- QDialog(对话框):专门用来弹出的窗口,自带“确定 / 取消”的结果:
accept()表示确定,reject()表示取消,两者都会关闭对话框。 - 模态(modal):对话框打开期间,用户不能操作其他窗口。反过来叫非模态(modeless)。
- QDialogButtonBox(按钮盒):一组标准按钮,会按各平台的习惯排列(Windows 上“确定”在左,macOS 上在右)。
完整例子:端口设置对话框
#include <QApplication>
#include <QDialog>
#include <QDialogButtonBox>
#include <QSpinBox>
#include <QTimer>
#include <QVBoxLayout>
#include <QDebug>
class PortDialog : public QDialog {
public:
explicit PortDialog(QWidget *parent = nullptr) : QDialog(parent) {
m_spinBox = new QSpinBox;
m_spinBox->setRange(1, 65535);
QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
QVBoxLayout *layout = new QVBoxLayout(this);
layout->addWidget(m_spinBox);
layout->addWidget(buttons);
}
int port() const { return m_spinBox->value(); }
void setPort(int port) { m_spinBox->setValue(port); }
private:
QSpinBox *m_spinBox;
};
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
PortDialog dialog;
dialog.setPort(8080);
QTimer::singleShot(100, &dialog, [&dialog] {
qDebug() << "exec 还没返回, 定时器照样触发: 模拟用户改成 9000 并点确定";
dialog.setPort(9000);
dialog.accept();
});
qDebug() << "调用 exec, 对话框弹出";
int result = dialog.exec();
qDebug() << "exec 返回:" << (result == QDialog::Accepted ? "Accepted" : "Rejected")
<< "端口:" << dialog.port();
return 0;
}
输出:
调用 exec, 对话框弹出
exec 还没返回, 定时器照样触发: 模拟用户改成 9000 并点确定
exec 返回: Accepted 端口: 9000
逐段拆:
explicit PortDialog(QWidget *parent = nullptr) : QDialog(parent):explicit禁止编译器拿一个指针偷偷转换成 PortDialog;= nullptr是默认参数,不传就是没有父窗口;冒号后面是成员初始化列表,把 parent 交给基类 QDialog。这三样是 Qt 类构造函数的标准写法,以后会反复看到。setRange(1, 65535):端口号的合法范围。QDialogButtonBox::Ok | QDialogButtonBox::Cancel:|是按位或(bitwise OR),这里的意思是“两个按钮都要”。Qt 里很多选项都这样用|组合。- 两个 connect:点“确定”调用对话框的 accept,点“取消”调用 reject。
int port() const和void setPort(int port):对外只暴露“读端口”和“设端口”两个函数,外面的人不需要知道里面用的是 QSpinBox。这就是主窗口给子窗口传数据的方式:调子窗口的函数。private:下面的m_spinBox:成员指针,构造函数里 new 出来,布局负责挂到对话框名下。- main 里的
QTimer::singleShot(100, ...):这里用定时器模拟用户操作,100 毫秒后改端口并点确定。 dialog.exec():弹出对话框,一直等到对话框关闭才返回,返回值是QDialog::Accepted或QDialog::Rejected。条件 ? A : B:三目运算符(conditional operator),条件成立取 A,否则取 B。
按时间顺序:设端口 8080 → 登记 100 毫秒后的定时任务 → 打印“调用 exec” → exec 弹出对话框并开始等 → 100 毫秒到,定时器在 exec 内部照样触发 → 改成 9000、accept → 对话框关闭,exec 返回 Accepted → 读出端口 9000。
exec 里面发生了什么
输出第二行说明了一件事:exec 没返回的时候,定时器照样能触发。原因是 exec 里面又开了一个事件循环:
sequenceDiagram
participant M as main 函数
participant D as dialog.exec()
participant L as exec 里的新事件循环
M->>D: 调用 exec,对话框弹出
D->>L: 开一个新的事件循环
Note over L: 用户点击、输入<br/>定时器、网络数据也照常处理
L->>L: 某个槽里调用了 accept(),循环准备退出
L-->>D: 循环结束
D-->>M: 返回 Accepted,main 接着往下走
看图: 对话框打开期间,程序没有卡住,外层代码停在 exec 那一行,所有事件都由里面这个新循环处理。这带来一个隐患:如果这期间有网络数据到达,对应的槽函数会被执行,它要是又弹一个对话框,或者删掉了弹出这个对话框的对象,exec 返回以后外层代码就可能访问已经删掉的东西。这叫重入(reentrancy):一段代码还没执行完,又被卷进去执行了别的逻辑。
所以 Qt 官方更推荐另一种写法:
PortDialog *dialog = new PortDialog(this);
dialog->setAttribute(Qt::WA_DeleteOnClose);
connect(dialog, &QDialog::accepted, this, [this, dialog] {
applyPort(dialog->port());
});
dialog->open();
open():同样是模态,但立刻返回,不开新的事件循环。- 对话框必须 new 出来:open 返回后函数就结束了,栈上的对象会被销毁。
Qt::WA_DeleteOnClose:关闭时自动 delete,不用自己管。- 结果通过
accepted信号拿到,[this, dialog]同时捕获两个指针。
子窗口怎么通知主窗口
规则一句话:主窗口 → 子窗口,调函数;子窗口 → 主窗口,发信号。
flowchart LR
MW["主窗口 MainWindow"] -->|"调用 setPort(8080)"| PD["设置对话框 PortDialog"]
PD -.->|"发信号 portChanged(9000)"| MW
看图: 子窗口只管发信号,不知道也不关心是谁在听。反例是把主窗口指针传进子窗口,让子窗口直接改主窗口的控件:两个类互相依赖,子窗口换个地方就用不了,主窗口改个控件名子窗口也得跟着改。这和上一篇 §4.1 里“计数器不该认识显示器”是同一个道理。
4.5 QSS 样式表
口述见 §3.5。
为什么需要它
默认的 Qt 界面是各操作系统的原生样式,工业软件、设备软件常常要求统一的配色(深色主题、公司标准色),报警时控件要变红。一个个控件去改颜色很乱,Qt 提供了 QSS(Qt Style Sheets,Qt 样式表),语法和网页的 CSS 几乎一样,把“长什么样”集中写在一处。
官方概念
- 规则(rule):
选择器 { 属性: 值; },例如QPushButton { color: white; }。 - 选择器(selector):决定这条规则作用于哪些控件。常用的几种:
| 选择器 | 例子 | 选中谁 |
|---|---|---|
| 类型 | QPushButton | 所有 QPushButton 和它的子类 |
| objectName | QPushButton#sendButton | objectName 是 sendButton 的那个按钮(用 setObjectName 设置) |
| 属性 | QLabel[state="error"] | state 属性等于 error 的标签 |
| 伪状态 | QPushButton:hover、:pressed、:disabled | 鼠标悬停、按下、禁用时 |
| 子控件 | QComboBox::drop-down | 下拉框右边的小箭头区域 |
- 动态属性(dynamic property):
setProperty("state", "error")可以给任何 QObject 临时加一个属性,不用事先声明。配合属性选择器,就能用一行代码切换控件的外观。 - 作用范围:
app.setStyleSheet(...)对整个程序生效;widget->setStyleSheet(...)只对这个控件和它的子控件生效。
完整例子:按状态变色,以及必须刷新样式的坑
#include <QApplication>
#include <QLabel>
#include <QStyle>
#include <QDebug>
QString centerColor(QLabel &label) {
return label.grab().toImage().pixelColor(5, 5).name();
}
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
app.setStyleSheet(
"QLabel[state=\"ok\"] { background: #2e7d32; }"
"QLabel[state=\"error\"] { background: #c62828; }");
QLabel label("设备状态");
label.resize(100, 30);
label.setProperty("state", "ok");
qDebug() << "state=ok :" << centerColor(label);
label.setProperty("state", "error");
qDebug() << "改成 error, 不刷新样式:" << centerColor(label);
label.style()->unpolish(&label);
label.style()->polish(&label);
qDebug() << "unpolish + polish 之后:" << centerColor(label);
return 0;
}
输出:
state=ok : "#2e7d32"
改成 error, 不刷新样式: "#2e7d32"
unpolish + polish 之后: "#c62828"
#2e7d32 是绿色,#c62828 是红色。第二行说明:只改属性,标签还是绿的。
逐段拆:
QString centerColor(QLabel &label):一个辅助函数,参数是标签的引用(reference),&表示直接用传进来的那个对象,不复制。grab()把控件画成一张图片,toImage()转成可以读像素的 QImage,pixelColor(5, 5)取坐标 (5, 5) 的颜色,name()转成#rrggbb形式的字符串。这是为了在没有显示器的情况下验证颜色,真实程序里不需要。app.setStyleSheet(...):给整个程序设样式。两段字符串紧挨着写,C++ 会自动拼成一段。字符串里的\"是转义字符(escape character),表示一个双引号本身,不然会被当成字符串结束。QLabel[state="ok"]:属性选择器,state 属性是 ok 的标签背景设为绿色。label.setProperty("state", "ok"):给标签加一个动态属性 state,值是 ok。label.style():取出这个控件当前使用的样式对象(QStyle),QSS 最终也是通过它起作用的。unpolish(&label)再polish(&label):先把控件身上旧的样式去掉,再按当前属性重新套一遍。
为什么需要这一步:Qt 为了性能,只在控件第一次显示、或者样式表被重新设置时计算一次“哪些规则匹配这个控件”,之后缓存起来。改动态属性不会触发重新计算,所以要手动让它重新算。实际项目里一般封装成一个函数:
void setState(QWidget *w, const char *state) {
w->setProperty("state", state);
w->style()->unpolish(w);
w->style()->polish(w);
}
实际项目里怎么组织 QSS
- 所有样式写在一个
style.qss文件里,而不是散落在代码各处的setStyleSheet。 - 把 qss 文件加进资源文件(.qrc),编译进程序,代码里用
:/style.qss这样的路径读取(上一篇 §4.10 讲过 rcc)。 - 程序启动时读出来,调用一次
app.setStyleSheet(内容)。 - 换肤就是换一个 qss 文件,重新 setStyleSheet。
常见的坑:
- 给父控件写
setStyleSheet("background: red")不带选择器,所有子控件都会继承,按钮、输入框全变红。要写成QWidget#panel { background: red; }只选中它自己。 - 自己继承 QWidget 写的控件,QSS 里设 background 不生效,要在 paintEvent 里加几行让样式画背景(见 §4.6 末尾)。
- 规则冲突时,越具体的选择器优先级越高(带 objectName 的高于只写类型的),同样具体时后写的覆盖先写的。样式出不来时先检查是不是被别的规则盖掉了。
4.6 自定义控件进阶:一个开关
口述见 §3.6。
上一篇 §4.6 画了一个静态的红点。真正能用的自定义控件还要能响应鼠标、告诉布局自己多大、对外发信号、允许外部改颜色。这一节做一个手机上常见的滑动开关。
完整例子:ToggleSwitch
#include <QApplication>
#include <QPainter>
#include <QTest>
#include <QWidget>
#include <QDebug>
class ToggleSwitch : public QWidget {
Q_OBJECT
Q_PROPERTY(QColor onColor READ onColor WRITE setOnColor)
public:
explicit ToggleSwitch(QWidget *parent = nullptr) : QWidget(parent) {}
bool isOn() const { return m_on; }
QColor onColor() const { return m_onColor; }
void setOnColor(const QColor &color) { m_onColor = color; update(); }
QSize sizeHint() const override { return QSize(50, 26); }
signals:
void toggled(bool on);
protected:
void mouseReleaseEvent(QMouseEvent *) override {
m_on = !m_on;
update();
emit toggled(m_on);
}
void paintEvent(QPaintEvent *) override {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
p.setPen(Qt::NoPen);
p.setBrush(m_on ? m_onColor : QColor("#9e9e9e"));
p.drawRoundedRect(rect(), height() / 2.0, height() / 2.0);
p.setBrush(Qt::white);
int d = height() - 4;
int x = m_on ? width() - d - 2 : 2;
p.drawEllipse(x, 2, d, d);
}
private:
bool m_on = false;
QColor m_onColor = Qt::blue;
};
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
app.setStyleSheet("ToggleSwitch { qproperty-onColor: #2e7d32; }");
ToggleSwitch sw;
sw.resize(sw.sizeHint());
QObject::connect(&sw, &ToggleSwitch::toggled, [](bool on) {
qDebug() << "toggled 信号:" << on;
});
sw.ensurePolished();
qDebug() << "QSS 设置的 onColor:" << sw.onColor().name();
QTest::mouseClick(&sw, Qt::LeftButton);
QTest::mouseClick(&sw, Qt::LeftButton);
QTest::mouseClick(&sw, Qt::LeftButton);
qDebug() << "点了三下, 现在是开吗:" << sw.isOn();
return 0;
}
#include "toggle_switch.moc"
输出:
QSS 设置的 onColor: "#2e7d32"
toggled 信号: true
toggled 信号: false
toggled 信号: true
点了三下, 现在是开吗: true
逐段拆,先看类:
Q_PROPERTY(QColor onColor READ onColor WRITE setOnColor):把“打开时的颜色”登记成属性:类型 QColor,名字 onColor,用onColor()读,用setOnColor()写。登记之后,QSS 和 Designer 都能改它。setOnColor里改完颜色调update():外观相关的数据一变,就要请求重绘,否则界面不会变(上一篇 §2.4 讲过 update)。QSize sizeHint() const override:告诉布局“我推荐 50×26”。不重写的话,布局不知道该给多大,可能被压成一条线。override表示重写基类的虚函数,写错函数名时编译器会报错。signals: void toggled(bool on);:对外的通知。用开关的人只连这个信号,不需要知道开关内部怎么画。protected::受保护成员,只有自己和子类能调用。事件处理函数一般都放在 protected 里,因为它们是给 Qt 调用的,不是给外人调用的。mouseReleaseEvent:鼠标松开时调用。用松开而不是按下,是桌面程序的习惯:按下后把鼠标移出控件再松开,应当算取消。严格的写法要在这里判断松开的位置是否还在控件内(rect().contains(event->position().toPoint())),这个例子为了简短省略了。里面三件事:切换状态(!是取反)、请求重绘、发信号。paintEvent里:setRenderHint(QPainter::Antialiasing):开抗锯齿(antialiasing),圆形边缘才平滑。setPen(Qt::NoPen):不画边框线。m_on ? m_onColor : QColor("#9e9e9e"):开着用 onColor,关着用灰色。drawRoundedRect(rect(), 半径, 半径):画圆角矩形当底,半径取高度一半,两头就是半圆。2.0写成小数,是为了让除法得到小数结果,height() / 2是整数除法。- 白色圆点:直径
d比高度小 4 像素;开着靠右,关着靠左。
QColor m_onColor = Qt::blue;:默认蓝色。
再看 main:
app.setStyleSheet("ToggleSwitch { qproperty-onColor: #2e7d32; }"):qproperty-属性名是 QSS 里专门用来设置 Q_PROPERTY 的写法,这里把 onColor 设成绿色。选择器直接写自己的类名 ToggleSwitch。sw.ensurePolished():让样式立刻生效。正常显示窗口时 Qt 会自动做这一步,这里没有显示,所以手动调用。QTest::mouseClick(&sw, Qt::LeftButton):QTest 是 Qt 自带的测试模块,mouseClick模拟一次完整的鼠标左键点击(按下再松开)。§4.7 会正式讲 QTest。- 点三下:开、关、开,信号打印三次,最后是开着的。
按时间顺序:设置全局样式 → 创建开关 → 连接 toggled → ensurePolished 让 QSS 生效,onColor 从蓝变绿 → 模拟点击三次,每次 mouseReleaseEvent 翻转状态并发信号 → 最后状态是 true。
让 Designer 也能用它:提升
写好的控件想在 Designer 里摆放:先拖一个普通的 QWidget 占位,右键选 “提升为…”(Promote to),填上类名 ToggleSwitch 和头文件 toggle_switch.h。uic 生成代码时就会 new 一个 ToggleSwitch 代替 QWidget。
让 QSS 能给自定义控件画背景
直接继承 QWidget 的控件,QSS 里写 background 是不生效的,因为 QWidget 的 paintEvent 默认什么都不画。要支持的话,在 paintEvent 开头加:
QStyleOption option;
option.initFrom(this);
QPainter p(this);
style()->drawPrimitive(QStyle::PE_Widget, &option, &p, this);
意思是“请当前样式按 QSS 把我的背景画出来”,然后再画自己的内容。
4.7 程序怎么分层,业务层怎么单独测试
口述见 §3.7。
为什么需要它
参考实现 qt-net-assistant 为了好读,把界面、TCP 连接、收发逻辑全写在 MainWindow 里,392 行。练手没问题,但面试官一看就会问:
- 要支持串口,是不是要在 MainWindow 里再加一套?
- 收到的数据要做报警判断,写在哪?
- 这些逻辑怎么测试?只能手动点界面吗?
这些问题的答案就是分层(layering):按职责把程序拆成几层,每层只做一件事。
官方概念
这不是 Qt 独有的概念,是软件设计的通用做法。上位机类程序一般拆成四层:
flowchart TB
UI["界面层<br/>MainWindow、对话框、自定义控件<br/>只管显示和接收用户操作"]
BIZ["业务层<br/>TemperatureService:超温判断、统计、状态<br/>不 include 任何界面头文件"]
PROTO["协议层<br/>FrameParser:拆帧、校验、组帧<br/>§4.8"]
DEV["通信层<br/>QSerialPort 或 QTcpSocket:只收发字节"]
DEV -->|"原始字节"| PROTO
PROTO -->|"一帧帧的命令和数据"| BIZ
BIZ -->|"信号:温度变了、超温了"| UI
UI -.->|"调用接口:开始采集、改阈值"| BIZ
看图: 实线是数据往上走,靠信号;虚线是命令往下走,靠函数调用。依赖只能往下:界面层知道业务层,业务层不知道界面层。好处有三个:
- 换界面(比如加一个网页版)不用动业务层;
- 串口换成 TCP,只换通信层,因为两者都是 QIODevice,上层代码不用改;
- 业务层不依赖界面,可以脱离界面单独测试。
完整例子:业务层和它的单元测试
这是业务层的一个类:收到温度帧,算出温度,温度超过阈值时报警。下面连同它的测试一起写在一个文件里。
#include <QObject>
#include <QSignalSpy>
#include <QTest>
class TemperatureService : public QObject {
Q_OBJECT
public:
explicit TemperatureService(double limit) : m_limit(limit) {}
void handleFrame(quint8 cmd, const QByteArray &data) {
if (cmd != 0x01 || data.size() != 2) return;
int raw = (quint8(data[0]) << 8) | quint8(data[1]);
double celsius = raw / 10.0;
emit temperatureChanged(celsius);
bool over = celsius > m_limit;
if (over != m_overheat) {
m_overheat = over;
emit overheatChanged(over);
}
}
signals:
void temperatureChanged(double celsius);
void overheatChanged(bool overheat);
private:
double m_limit;
bool m_overheat = false;
};
class TemperatureServiceTest : public QObject {
Q_OBJECT
private slots:
void parsesTemperature() {
TemperatureService service(80.0);
QSignalSpy spy(&service, &TemperatureService::temperatureChanged);
service.handleFrame(0x01, QByteArray::fromHex("0107"));
QCOMPARE(spy.count(), 1);
QCOMPARE(spy.at(0).at(0).toDouble(), 26.3);
}
void alarmsOnlyWhenStateChanges() {
TemperatureService service(80.0);
QSignalSpy spy(&service, &TemperatureService::overheatChanged);
service.handleFrame(0x01, QByteArray::fromHex("0384"));
service.handleFrame(0x01, QByteArray::fromHex("0390"));
service.handleFrame(0x01, QByteArray::fromHex("0100"));
QCOMPARE(spy.count(), 2);
QCOMPARE(spy.at(0).at(0).toBool(), true);
QCOMPARE(spy.at(1).at(0).toBool(), false);
}
void ignoresOtherCommands() {
TemperatureService service(80.0);
QSignalSpy spy(&service, &TemperatureService::temperatureChanged);
service.handleFrame(0x02, QByteArray::fromHex("0107"));
QCOMPARE(spy.count(), 0);
}
};
QTEST_GUILESS_MAIN(TemperatureServiceTest)
#include "temperature_test.moc"
输出:
********* Start testing of TemperatureServiceTest *********
Config: Using QtTest library 6.11.2, Qt 6.11.2 (arm64-little_endian-lp64 shared (dynamic) release build; by Apple LLVM 21.0.0 (clang-2100.3.34.2)), macos 27.0.0
PASS : TemperatureServiceTest::initTestCase()
PASS : TemperatureServiceTest::parsesTemperature()
PASS : TemperatureServiceTest::alarmsOnlyWhenStateChanges()
PASS : TemperatureServiceTest::ignoresOtherCommands()
PASS : TemperatureServiceTest::cleanupTestCase()
Totals: 5 passed, 0 failed, 0 skipped, 0 blacklisted, 2ms
********* Finished testing of TemperatureServiceTest *********
先看业务类 TemperatureService:
- 协议约定:命令
0x01表示温度,数据 2 个字节,是温度乘以 10 的整数,高字节在前。比如01 07就是 0x0107 = 263,表示 26.3 度。0x开头是**十六进制(hexadecimal)**写法。 quint8:Qt 定义的 8 位无符号整数,取值 0~255,正好一个字节。quint8(data[0]):data[0]取出第一个字节,类型是 char,可能是负数;先转成 quint8 变成 0~255 的数。这种类型(值)的写法是类型转换(type conversion)。<< 8:左移 8 位(left shift),相当于乘以 256,把高字节挪到高位;|按位或,把低字节拼上。结果0x01 << 8 | 0x07= 263。raw / 10.0:除以 10.0(小数)得到 26.3;如果写/ 10就是整数除法,结果是 26。- 报警逻辑:
over是这次是否超温,m_overheat是上次的状态。只有状态变化时才发 overheatChanged,否则温度一直是 85 度,每秒来 10 帧就报 10 次警,界面会被弹窗淹没。 - 这个类只继承 QObject,没有 include 任何 QWidget 相关的头文件,所以能在没有界面的测试程序里运行。
再看测试类 TemperatureServiceTest:
- QTest(Qt Test):Qt 自带的**单元测试(unit test)**框架。单元测试就是写一段代码,自动检查另一段代码的结果对不对,改了代码后跑一遍就知道有没有改坏。你熟悉的 gtest 是同一类东西。
private slots:下面的每个函数是一个测试用例(test case),QTest 会按顺序自动调用它们。initTestCase和cleanupTestCase是框架自动加的开始和收尾步骤。QSignalSpy spy(&service, &TemperatureService::temperatureChanged):信号监听器,它连上这个信号,把每次发出的参数记下来。spy.count()是信号发了几次,spy.at(0).at(0)是第 1 次的第 1 个参数(参数存成 QVariant,所以要toDouble()、toBool()转回来)。QCOMPARE(实际值, 期望值):两个值不相等就判这个测试失败,并打印两个值。对应 gtest 的EXPECT_EQ。QByteArray::fromHex("0107"):把十六进制字符串转成字节,得到两个字节 0x01、0x07。- 第二个用例:
0384是 0x0384 = 900,即 90.0 度,超温 → 报警一次;再来0390= 912,即 91.2 度,还是超温,状态没变,不报;0100= 256,即 25.6 度,恢复正常 → 再报一次。所以一共 2 次,第一次 true,第二次 false。 QTEST_GUILESS_MAIN(TemperatureServiceTest):一个宏,展开成 main 函数,创建测试对象并运行所有用例。GUILESS表示不需要界面,测试界面控件时用QTEST_MAIN。
界面也能测
§4.6 里的 QTest::mouseClick 就是在测界面:模拟点击,再检查状态和信号。QTest 还有 keyClicks(模拟打字)、keyClick(按单个键)。不过界面测试写起来麻烦、容易因为布局变化而失效,大部分测试应该落在业务层和协议层,这也是要分层的原因之一。
4.8 串口和协议帧
口述见 §3.8。
为什么需要它
上位机(在电脑上运行、用来控制和监视设备的程序)最常见的工作,就是通过串口和下位机(单片机、PLC、传感器)收发数据。串口只负责把字节一个个送过去,哪几个字节算一条完整的命令,要双方自己约定,这个约定就是通信协议(protocol),一条完整的消息叫一帧(frame)。
官方概念
- QSerialPort:Qt 的串口类,在单独的 SerialPort 模块里。它和 QTcpSocket 一样继承 QIODevice(Qt 所有“能读写字节的设备”的基类,文件、TCP、串口都是它的子类),所以用法几乎一样:数据到了发
readyRead信号,readAll()读,write()写。 - 串口参数:波特率(每秒传多少位,如 9600、115200)、数据位(一般 8)、校验位(一般无)、停止位(一般 1)。两边设得不一样,收到的就是乱码。
- QSerialPortInfo:列出电脑上有哪些串口,用来做“选择串口”的下拉框。
打开串口的写法(片段:本机没有装 SerialPort 模块,这段没有编译;CMake 里要 find_package(Qt6 COMPONENTS SerialPort) 并链接 Qt6::SerialPort):
QSerialPort *port = new QSerialPort(this);
port->setPortName("/dev/ttyUSB0");
port->setBaudRate(QSerialPort::Baud115200);
port->setDataBits(QSerialPort::Data8);
port->setParity(QSerialPort::NoParity);
port->setStopBits(QSerialPort::OneStop);
if (!port->open(QIODevice::ReadWrite)) {
qWarning() << "串口打开失败:" << port->errorString();
return;
}
connect(port, &QSerialPort::readyRead, this, [this, port] {
const QList<Frame> frames = m_parser.feed(port->readAll());
for (const Frame &frame : frames) handleFrame(frame);
});
setPortName:Linux 上串口叫/dev/ttyUSB0、/dev/ttyS0,Windows 上叫COM3。open(QIODevice::ReadWrite):以读写方式打开,失败返回 false,原因用errorString()拿,常见的是“没有权限”(Linux 上用户要加入 dialout 组)或“被别的程序占用”。- readyRead 里把读到的字节交给
m_parser(下面的 FrameParser),拆出完整的帧再处理。
串口和 TCP 一样是字节流:设备连发两帧,你可能一次收到一帧半,也可能一帧分三次收到。上一篇 §4.9 讲的粘包、半包,在串口上一模一样,而且串口还多一个问题:线路干扰会产生垃圾字节、改坏某个字节。所以协议里除了长度,还要有帧头(用来找到帧从哪开始)和校验(用来发现被改坏的帧)。
协议格式
本节用的协议(真实项目里的协议大多是这个样子,只是字段多少不同):
flowchart LR
A["帧头<br/>AA 55<br/>2 字节"] --- B["长度<br/>1 字节<br/>数据有几个字节"] --- C["命令<br/>1 字节<br/>01 表示温度"] --- D["数据<br/>长度个字节"] --- E["校验<br/>1 字节<br/>命令加数据求和<br/>只留最低 8 位"]
例如温度 26.3 度的帧:AA 55 02 01 01 07 09。长度 02 表示后面有 2 个数据字节 01 07;校验 = 0x01 + 0x01 + 0x07 = 0x09。
完整例子:拆帧器
#include <QByteArray>
#include <QList>
#include <QDebug>
struct Frame {
quint8 cmd;
QByteArray data;
};
quint8 checksum(quint8 cmd, const QByteArray &data) {
quint8 sum = cmd;
for (char c : data) sum += quint8(c);
return sum;
}
QByteArray makeFrame(quint8 cmd, const QByteArray &data) {
QByteArray frame = QByteArray::fromHex("aa55");
frame.append(char(data.size()));
frame.append(char(cmd));
frame.append(data);
frame.append(char(checksum(cmd, data)));
return frame;
}
class FrameParser {
public:
QList<Frame> feed(const QByteArray &bytes) {
const QByteArray head = QByteArray::fromHex("aa55");
QList<Frame> frames;
m_buffer.append(bytes);
while (true) {
qsizetype pos = m_buffer.indexOf(head);
if (pos < 0) {
qsizetype keep = m_buffer.endsWith(char(0xAA)) ? 1 : 0;
m_dropped += m_buffer.size() - keep;
m_buffer = m_buffer.right(keep);
break;
}
m_dropped += pos;
m_buffer.remove(0, pos);
if (m_buffer.size() < 4) break;
qsizetype length = quint8(m_buffer[2]);
qsizetype total = 4 + length + 1;
if (m_buffer.size() < total) break;
quint8 cmd = quint8(m_buffer[3]);
QByteArray data = m_buffer.mid(4, length);
if (quint8(m_buffer[total - 1]) != checksum(cmd, data)) {
m_dropped += 2;
m_buffer.remove(0, 2);
continue;
}
frames.append({cmd, data});
m_buffer.remove(0, total);
}
return frames;
}
qsizetype dropped() const { return m_dropped; }
private:
QByteArray m_buffer;
qsizetype m_dropped = 0;
};
int main() {
QByteArray good1 = makeFrame(0x01, QByteArray::fromHex("0107"));
QByteArray bad = makeFrame(0x01, QByteArray::fromHex("0384"));
bad[bad.size() - 1] = char(bad[bad.size() - 1] + 1);
QByteArray good2 = makeFrame(0x02, "OK");
QByteArray stream = QByteArray::fromHex("1234") + good1 + bad + good2;
qDebug() << "整段字节:" << stream.toHex(' ');
FrameParser parser;
for (const QByteArray &chunk : {stream.left(5), stream.mid(5, 14), stream.mid(19)}) {
const QList<Frame> frames = parser.feed(chunk);
qDebug() << "收到" << chunk.size() << "字节, 拆出" << frames.size() << "帧";
for (const Frame &f : frames)
qDebug() << " 命令" << f.cmd << "数据" << f.data.toHex(' ');
}
qDebug() << "一共丢弃字节:" << parser.dropped();
return 0;
}
输出:
整段字节: "12 34 aa 55 02 01 01 07 09 aa 55 02 01 03 84 89 aa 55 02 02 4f 4b 9c"
收到 5 字节, 拆出 0 帧
收到 14 字节, 拆出 1 帧
命令 1 数据 "01 07"
收到 4 字节, 拆出 1 帧
命令 2 数据 "4f 4b"
一共丢弃字节: 9
先看测试数据是怎么造的:开头 2 个垃圾字节 12 34,然后一个好帧(温度 26.3 度),一个校验被故意改错的坏帧(最后一个字节本该是 88,改成了 89),再一个好帧(命令 2,数据是字母 OK,也就是 4f 4b)。整段 23 个字节,分三次喂给拆帧器:前 5 个、中间 14 个、最后 4 个,模拟串口数据断断续续到达。
逐段拆:
struct Frame { ... };:结构体(struct),和 class 基本一样,区别只是成员默认公开。用来装“一帧”:命令和数据。checksum:for (char c : data)是范围 for 循环(range-based for),依次取出 data 里的每个字节放进 c。sum是 quint8,加超过 255 会自动回绕(只保留最低 8 位),正好就是“求和取最低 8 位”。makeFrame:按协议格式一段段拼出一帧,char(...)把数字转成一个字节。这里用它造测试数据,真实程序里发送命令时也是这样组帧。FrameParser的成员m_buffer:缓冲区,还没拆完的字节都留在这里,下次来了新字节接着拆。feed里的while (true)循环,每一轮尝试拆一帧:indexOf(head)找帧头AA 55的位置,找不到返回 -1。- 找不到帧头:缓冲区全是垃圾,丢掉。但如果最后一个字节是
AA,它可能是下一个帧头的前半个,要留下。m_buffer.right(keep)取最右边 keep 个字节。 - 找到帧头:帧头前面的
pos个字节是垃圾,remove(0, pos)删掉。 - 不够 4 个字节(帧头 2 + 长度 1 + 命令 1)就连长度都读不到,
break等下次。 - 读出长度,算出整帧应有
total个字节;不够就break等下次,这就是处理半包。 - 算校验。校验不对,只删掉帧头这 2 个字节,
continue回到循环开头重新找帧头。不能把整段 total 个字节都删掉,因为这个“帧头”可能根本不是真帧头,只是数据里恰好出现的AA 55,它后面可能紧跟着一个真正的好帧。 - 校验通过,
frames.append({cmd, data})存起来,{cmd, data}是用花括号直接构造一个 Frame;再删掉这一整帧,继续拆下一帧(这就是处理粘包)。
qsizetype:Qt 6 里表示大小、位置的整数类型。
按时间顺序:
- 第一次收到 5 字节
12 34 aa 55 02:找到帧头,丢掉前面 2 个垃圾字节;剩 3 字节,不够 4,等。 - 第二次收到 14 字节,缓冲区凑够了第一帧,校验 09 正确,拆出温度帧;接着找到第二个帧头,长度够了,但校验不对,丢掉帧头 2 字节;往后找,下一个
AA 55前面还有 5 字节(02 01 03 84 89),丢掉;剩下aa 55 02不够,等。 - 第三次收到 4 字节,凑够第三帧,校验正确,拆出。
- 丢弃总数:开头 2 + 坏帧头 2 + 坏帧剩余 5 = 9。
超时重发
发一条命令后,设备应该在一定时间内回应答。没回,可能是线路干扰丢了,要重发;重发几次都不回,就报“通信故障”。这件事用一个单次定时器做(片段):
void Device::sendCommand(const QByteArray &frame) {
m_pending = frame;
m_retries = 0;
m_port->write(frame);
m_timeout.start(500);
}
void Device::onTimeout() {
if (++m_retries > 3) {
emit communicationFailed();
return;
}
m_port->write(m_pending);
m_timeout.start(500);
}
m_timeout是一个成员 QTimer,构造函数里setSingleShot(true)(只触发一次)并把 timeout 连到 onTimeout。- 收到应答帧时调用
m_timeout.stop(),定时器就不会触发。 ++m_retries > 3:先加 1 再比较,第 4 次超时时放弃。
整个过程都是异步的:发完命令函数就返回了,界面不会卡。千万不要写成 write 之后 waitForReadyRead(500) 在主线程里死等。
断线重连
USB 转串口线被拔掉时,QSerialPort 会发 errorOccurred 信号,错误类型是 QSerialPort::ResourceError。处理方式:关闭串口、界面显示“已断开”、用一个 QTimer 每隔几秒尝试重新 open,成功后恢复。
4.9 采集、界面、存储三个线程
口述见 §3.9。
为什么需要它
设备每毫秒送来一个数据,一秒 1000 个。如果全部在主线程处理:收一个画一次、存一次数据库,主线程被占满,界面拖不动(上一篇 §4.3 的事件循环被堵住)。解决办法是把活分给三个线程,这就是生产者消费者模型(producer-consumer):采集线程是生产者,界面和存储是消费者。
flowchart LR
subgraph acq["采集线程"]
A["Acquirer<br/>每 1 毫秒读一个数<br/>攒够 10 个发一批"]
end
subgraph ui["主线程"]
S["Screen(界面)<br/>收到先存着<br/>每 50 毫秒刷新一次"]
end
subgraph st["存储线程"]
D["Storage<br/>攒够 200 个<br/>写一次"]
end
A -->|"batchReady 信号<br/>跨线程,Queued"| S
A -->|"batchReady 信号<br/>跨线程,Queued"| D
看图: 三个对象住在三个线程里,数据只靠一个信号往外送。跨线程的信号自动走 Queued 连接,参数拷贝一份交给接收方所在的线程(上一篇 §4.5),全程不需要自己加锁。每一级都在“攒一批再处理”,这是整个设计的关键。
完整例子
#include <QCoreApplication>
#include <QList>
#include <QThread>
#include <QTimer>
#include <QDebug>
class Acquirer : public QObject {
Q_OBJECT
public slots:
void start() {
QTimer *timer = new QTimer(this);
timer->setTimerType(Qt::PreciseTimer);
connect(timer, &QTimer::timeout, this, [this] {
m_batch.append(m_next++);
if (m_batch.size() == 10) {
emit batchReady(m_batch);
m_batch.clear();
}
});
timer->start(1);
}
signals:
void batchReady(const QList<int> &samples);
private:
QList<int> m_batch;
int m_next = 0;
};
class Storage : public QObject {
Q_OBJECT
public slots:
void append(const QList<int> &samples) {
m_pending += samples;
if (m_pending.size() >= 200) {
++writes;
m_pending.clear();
}
}
public:
int writes = 0;
private:
QList<int> m_pending;
};
class Screen : public QObject {
public:
Screen() {
QTimer *timer = new QTimer(this);
connect(timer, &QTimer::timeout, this, [this] {
if (m_latest.isEmpty()) return;
++refreshes;
m_latest.clear();
});
timer->start(50);
}
void append(const QList<int> &samples) {
received += samples.size();
m_latest += samples;
}
int received = 0;
int refreshes = 0;
private:
QList<int> m_latest;
};
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QThread acquireThread;
QThread storageThread;
Acquirer *acquirer = new Acquirer;
Storage *storage = new Storage;
acquirer->moveToThread(&acquireThread);
storage->moveToThread(&storageThread);
Screen screen;
QObject::connect(&acquireThread, &QThread::started, acquirer, &Acquirer::start);
QObject::connect(acquirer, &Acquirer::batchReady, &screen, &Screen::append);
QObject::connect(acquirer, &Acquirer::batchReady, storage, &Storage::append);
QObject::connect(&acquireThread, &QThread::finished, acquirer, &QObject::deleteLater);
acquireThread.start();
storageThread.start();
QTimer::singleShot(1000, &app, &QCoreApplication::quit);
app.exec();
acquireThread.quit();
acquireThread.wait();
storageThread.quit();
storageThread.wait();
qDebug() << "1 秒内界面收到数据:" << screen.received << "条";
qDebug() << "界面刷新:" << screen.refreshes << "次";
qDebug() << "存储批量写入:" << storage->writes << "次";
delete storage;
return 0;
}
#include "pipeline.moc"
一次实际输出(数字每次略有不同):
1 秒内界面收到数据: 820 条
界面刷新: 20 次
存储批量写入: 4 次
收到 800 多条而不是 1000 条,是因为 macOS 上 1 毫秒的定时器做不到每次都准,这不影响要说明的问题:800 多条数据,界面只刷了 20 次,存储只写了 4 次。
逐段拆,三个类:
- Acquirer(采集):
start()里新建一个 1 毫秒的定时器,每次触发产生一个数(真实程序里是从串口读),攒够 10 个就发一次batchReady,然后清空。Qt::PreciseTimer:精确定时器,默认的定时器允许约 5% 的误差,短间隔时用精确型。m_next++:后置自增,先用 m_next 当前的值,再把它加 1。const QList<int> &samples:信号参数是 int 列表。跨线程时 Qt 会拷贝它,QList 是隐式共享的(上一篇 §4.8),拷贝只是加一个引用计数,很便宜。- 为什么定时器在 start() 里创建:start 是采集线程开始运行后才被调用的,这时创建的定时器一出生就属于采集线程,最稳妥。如果放在构造函数里,要分两种情况(都实测过):
new QTimer(this)带 parent 的,moveToThread 时会作为子对象跟着 acquirer 一起搬过去,能正常工作;写成成员变量QTimer m_timer;的,它没有 parent,不会跟着搬,还留在主线程,之后在采集线程里调用 start,控制台会报Timers cannot be started from another thread,定时器不工作。QTimer 只能在它所属的线程里启动(上一篇 §4.5 末尾的坑)。
- Storage(存储):收到一批就加进
m_pending(+=把两个列表接起来),攒够 200 个算“写一次”,真实程序里这里是开事务批量插入数据库(§4.12)。 - Screen(界面):收到数据只存进
m_latest,不刷新;另有一个 50 毫秒的定时器,到点了才“刷新一次”(真实程序里是调控件的 update)。没有新数据就不刷。Screen 没有写 Q_OBJECT,因为它不声明信号,只被连接;新写法的 connect 可以连到任何成员函数。
再看 main:
- 两个 QThread,采集对象和存储对象分别 moveToThread 过去;Screen 留在主线程。
connect(&acquireThread, &QThread::started, acquirer, &Acquirer::start):线程一启动,就在采集线程里调用 start。- 两个 batchReady 连接:一个信号连两个接收者,分别在主线程和存储线程执行。
QThread::finished连deleteLater:线程结束时删除 acquirer(上一篇 §4.5 的标准写法)。QTimer::singleShot(1000, ...):运行 1 秒后退出事件循环。- 退出后依次让两个线程 quit 并 wait,确保都停了,再读统计数字。storage 最后手动 delete,因为它的线程已经停了,deleteLater 没有事件循环来执行。
按时间顺序:两个线程启动 → 采集线程里 start 开始每毫秒产生数据 → 每攒 10 个发一次信号 → 这批数据被拷贝两份,一份排进主线程队列,一份排进存储线程队列 → 主线程收到后先存着,每 50 毫秒刷新一次;存储线程攒到 200 个写一次 → 1 秒后主线程退出事件循环 → 停线程 → 打印统计。
生产比消费快怎么办
如果存储线程写数据库慢,跟不上采集速度,存储线程的事件队列会越排越长,内存一直涨。常见的对策:
- 监控积压:定期打印待处理的数量,超过阈值报警。
- 降采样:界面不需要每个点都画,1000 个点里取 100 个画就够了。
- 丢旧数据:实时显示只关心最新的,队列满了丢掉最老的。
- 批量更大:数据库一次插 1000 行比插 10 次 100 行快得多。
4.10 跨平台和移植到 ARM Linux
口述见 §3.10。
同一份代码在 Windows 和 Linux 上的差异
| 方面 | Windows | Linux | 在 Qt 里怎么写 |
|---|---|---|---|
| 路径分隔符 | \ | / | 代码里一律用 /,Qt 在 Windows 上也认;给用户看时用 QDir::toNativeSeparators |
| 文件名大小写 | 不区分 | 区分 | 代码和资源里的文件名大小写保持一致 |
| 配置和数据放哪 | C:/Users/名字/AppData/... | ~/.config/...、~/.local/share/... | QStandardPaths::writableLocation 自动给出 |
| 换行符 | \r\n | \n | 文本文件用 QIODevice::Text 模式打开,Qt 自动转换 |
| 中文编码 | 系统 API 是 GBK | UTF-8 | 源码统一 UTF-8,MSVC 加 /utf-8(上一篇 §4.10) |
| 串口名 | COM3 | /dev/ttyUSB0 | 用 QSerialPortInfo 列出来让用户选 |
| 高分屏 | 缩放 150%、200% 常见 | 看桌面环境 | 图标用 SVG 或提供 @2x 图,不写死像素坐标 |
| 平台专属代码 | 用 Q_OS_WIN、Q_OS_LINUX 包起来,集中放在一个文件 |
完整例子:路径和平台判断
#include <QCoreApplication>
#include <QDir>
#include <QStandardPaths>
#include <QDebug>
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QCoreApplication::setOrganizationName("Demo");
QCoreApplication::setApplicationName("NetAssistant");
QString dataDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
QString logPath = QDir(dataDir).filePath("logs/today.log");
qDebug() << "数据目录:" << dataDir;
qDebug() << "Qt 内部写法:" << logPath;
qDebug() << "给用户看的写法:" << QDir::toNativeSeparators(logPath);
#ifdef Q_OS_WIN
qDebug() << "当前是 Windows";
#elif defined(Q_OS_MACOS)
qDebug() << "当前是 macOS";
#elif defined(Q_OS_LINUX)
qDebug() << "当前是 Linux";
#endif
return 0;
}
在 macOS 上的输出:
数据目录: "/Users/like/Library/Application Support/Demo/NetAssistant"
Qt 内部写法: "/Users/like/Library/Application Support/Demo/NetAssistant/logs/today.log"
给用户看的写法: "/Users/like/Library/Application Support/Demo/NetAssistant/logs/today.log"
当前是 macOS
同一个程序在 Windows 上,数据目录会是 C:/Users/名字/AppData/Roaming/Demo/NetAssistant,“给用户看的写法”会变成反斜杠;在 Linux 上是 ~/.local/share/Demo/NetAssistant。
逐段拆:
setOrganizationName和setApplicationName:设置公司名和程序名,QStandardPaths 和 QSettings 都会用它们来决定目录名,要在最开始设置。QStandardPaths::writableLocation(QStandardPaths::AppDataLocation):返回“这个程序存数据的目录”在当前系统上的位置。还有ConfigLocation(配置)、DocumentsLocation(我的文档)等。QDir(dataDir).filePath("logs/today.log"):在这个目录下拼出一个文件路径,不用自己处理斜杠。#ifdef Q_OS_WIN…#elif defined(...)…#endif:条件编译(conditional compilation)。这是**预处理器(preprocessor)**指令,在编译之前执行:条件不成立的那几行直接被删掉,根本不会被编译。Q_OS_WIN、Q_OS_MACOS、Q_OS_LINUX是 Qt 按当前编译平台自动定义的宏。
移植到 ARM 嵌入式 Linux
很多 Qt 岗的产品跑在 ARM 板子上(工控屏、仪表、医疗设备)。流程是:
flowchart LR
A["x86 电脑上准备<br/>交叉编译器 + 板子的 sysroot"] --> B["先编一份电脑本机的 Qt<br/>提供 moc、uic 这些工具"]
B --> C["交叉编译 Qt 本身<br/>指定目标平台和本机 Qt 路径"]
C --> D["得到 ARM 版 Qt 库"]
D --> E["用它交叉编译你的程序"]
E --> F["拷到板子上<br/>用 linuxfb 或 eglfs 启动"]
看图: 关键在 B:moc、uic 要在你的电脑上运行,所以它们必须是 x86 版本;而 Qt 库要在板子上运行,必须是 ARM 版本。Qt 6 把这两件事分开了,交叉编译 Qt 时用 -qt-host-path 指向本机那份 Qt,再用 CMake 工具链文件(toolchain file)告诉它用哪个交叉编译器、sysroot 在哪。sysroot 是板子根文件系统的一份拷贝,里面有板子上的头文件和库,交叉编译时去这里找依赖。
./configure -release -prefix /opt/qt6-arm -qt-host-path /opt/qt6-host -nomake examples -nomake tests -- -DCMAKE_TOOLCHAIN_FILE=/path/to/arm-toolchain.cmake
(示意命令,具体选项看板子和 Qt 版本。)
板子上常见的问题:
| 现象 | 原因 | 办法 |
|---|---|---|
could not find the Qt platform plugin | 板子上没有桌面环境,默认的 xcb 插件用不了;或者插件目录没拷过去 | 启动时 -platform linuxfb,或设置环境变量 QT_QPA_PLATFORM=eglfs |
| 中文显示成方框 | 板子上没有中文字体 | 拷一个中文字体(如文泉驿)到 QT_QPA_FONTDIR 指向的目录,或用 QFontDatabase::addApplicationFont 加载 |
| 触摸屏没反应或坐标反了 | 输入设备没配置 | 设置 QT_QPA_EVDEV_TOUCHSCREEN_PARAMETERS 或用 tslib 校准 |
| 程序启动报找不到 .so | 库搜索路径不对 | 设置 LD_LIBRARY_PATH,或编译时设置 RPATH |
| 界面很卡 | 没有 GPU 加速,linuxfb 纯软件绘制 | 有 GPU 就用 eglfs;减少大面积重绘和半透明效果 |
面试时怎么把你的经历讲进来: 你做过 ARM64 交叉编译、CMake Toolchain、glibc 和 musl 适配、RPATH 和动态库加载排障。这些正是移植 Qt 到 ARM 板子时最容易卡住的地方。被问到“有没有做过嵌入式 Qt”时,如实说 Qt 本身在板子上跑得不多,但交叉编译、sysroot、动态库这一整套你排查过,然后讲一个你真实解决过的动态库加载问题。这比硬说“做过嵌入式 Qt”可信得多。
4.11 稳定性:日志、崩溃、内存泄漏、卡顿
口述见 §3.11。
程序交到用户手里,出问题时你不在现场,只能靠日志和崩溃文件还原现场。面试官问“你们程序在现场出了问题怎么查”,考的就是这一节。
完整例子:把 qDebug 写进日志文件
#include <QCoreApplication>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMutex>
#include <QDebug>
#include <cstdio>
QFile logFile;
QMutex logMutex;
void writeLog(QtMsgType type, const QMessageLogContext &context, const QString &message) {
const char *level = "DEBUG";
if (type == QtInfoMsg) level = "INFO";
if (type == QtWarningMsg) level = "WARN";
if (type == QtCriticalMsg || type == QtFatalMsg) level = "ERROR";
QString file = context.file ? QFileInfo(context.file).fileName() : "?";
QString line = QString("%1 [%2] %3 (%4:%5)\n")
.arg(QDateTime::currentDateTime().toString("hh:mm:ss.zzz"), level, message, file)
.arg(context.line);
QMutexLocker locker(&logMutex);
logFile.write(line.toUtf8());
logFile.flush();
}
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
logFile.setFileName(QDir::temp().filePath("qtjob_demo.log"));
if (!logFile.open(QIODevice::WriteOnly | QIODevice::Truncate)) return 1;
qInstallMessageHandler(writeLog);
qDebug() << "串口已打开" << "/dev/ttyUSB0";
qWarning() << "校验失败, 丢弃 7 字节";
qInstallMessageHandler(nullptr);
logFile.close();
if (!logFile.open(QIODevice::ReadOnly)) return 1;
std::printf("%s", logFile.readAll().constData());
return 0;
}
直接编译的输出:
04:55:03.684 [DEBUG] 串口已打开 /dev/ttyUSB0 (?:0)
04:55:03.684 [WARN] 校验失败, 丢弃 7 字节 (?:0)
CMake 里加一行 target_compile_definitions(logger PRIVATE QT_MESSAGELOGCONTEXT) 后的输出:
04:55:04.067 [DEBUG] 串口已打开 /dev/ttyUSB0 (logger.cpp:34)
04:55:04.068 [WARN] 校验失败, 丢弃 7 字节 (logger.cpp:35)
第一组输出里文件名和行号是空的,这是实际项目里常踩的坑:Qt 默认不记录 qDebug 出自哪个文件哪一行,要定义 QT_MESSAGELOGCONTEXT 这个宏才会记录。
逐段拆:
QFile logFile;和QMutex logMutex;写在所有函数外面:全局变量(global variable),整个程序只有一份,所有函数都能用。writeLog的三个参数是 Qt 规定好的:消息级别QtMsgType、上下文(文件、行号、函数名)QMessageLogContext、消息内容。函数的参数和返回值必须完全按这个格式写,才能交给 qInstallMessageHandler。const char *level = "DEBUG":const char *是 C 风格的字符串指针,这里存级别名字。context.file ? ... : "?":file 可能是空指针(没定义 QT_MESSAGELOGCONTEXT 时),空就用问号。QFileInfo(...).fileName()从完整路径里只取文件名。QString("%1 [%2] %3 (%4:%5)\n").arg(...).arg(...):%1~%5是占位符,arg 按顺序填进去。一个 arg 可以一次填多个字符串。QMutexLocker locker(&logMutex):加锁。多个线程可能同时调用 qDebug,同时往一个文件里写会把两行日志搅在一起。QMutexLocker 创建时加锁、函数结束时自动解锁(RAII 写法,和std::lock_guard一样)。flush():立刻写到磁盘。不 flush 的话数据可能还在内存缓冲里,程序一崩溃,最后几行日志就丢了,而最后几行往往最重要。qInstallMessageHandler(writeLog):从这以后所有 qDebug、qWarning 都交给 writeLog 处理。传 nullptr 恢复默认。- 最后读出日志文件打印出来,
std::printf是 C 语言的打印函数,需要#include <cstdio>。
按时间顺序:打开日志文件 → 安装处理函数 → qDebug、qWarning 各一条,都被转进 writeLog,加锁写进文件 → 恢复默认处理 → 读出文件内容打印。
实际项目还会加:按天或按大小切分文件(否则一个日志文件几个 G),保留最近 N 天,Release 版不写 DEBUG 级别。
崩溃了怎么查
Linux:core dump(核心转储)。程序崩溃时,系统把当时的内存内容写成一个 core 文件,用 gdb 打开就能看到崩在哪一行、是怎么一路调用过来的。
先允许生成 core 文件(默认大小限制是 0,不生成):
ulimit -c unlimited
崩溃后用 gdb 打开程序和 core 文件,输入 bt(backtrace,调用栈)查看:
gdb ./NetAssistant core
用 systemd 的系统上 core 文件由 systemd-coredump 统一收集,用下面的命令直接打开最近一次崩溃:
coredumpctl gdb
Windows:minidump(小型转储)。程序里注册一个崩溃处理函数,崩溃时写出 .dmp 文件,拿回来用 Visual Studio 或 WinDbg 打开。常用 Google 的 Crashpad 或 Breakpad 库来做这件事。
两个平台都要注意:发布的程序也要保留调试符号(Linux 上把符号剥离到单独的 .debug 文件,Windows 上保留 .pdb),否则调用栈只有一串地址,看不出是哪个函数。
内存泄漏
Qt 程序里最常见的泄漏点:
| 泄漏点 | 为什么会漏 | 怎么改 |
|---|---|---|
| new 出来没给 parent 的对象 | 对象树不管它 | 给 parent,或用智能指针 |
| QNetworkReply | 每次请求都 new 一个,要自己删 | finished 里 reply->deleteLater() |
| 关掉的对话框 | 关闭只是隐藏,不会删 | setAttribute(Qt::WA_DeleteOnClose) |
| 反复 connect | 每次打开窗口都 connect 一次,连接越来越多,槽被调用多次 | 只在构造函数里 connect,或用 Qt::UniqueConnection |
| 模型里只增不删 | 日志表一直追加 | 设上限,超过就删最老的 |
工具:Linux 上用 Valgrind(慢,但不用重新编译)或 AddressSanitizer(编译时加 -fsanitize=address,快,还能查越界访问)。长时间运行的程序,还可以定期把进程内存占用写进日志,看是不是一直在涨。
界面卡顿
先确认是不是主线程被堵住(上一篇 §4.3)。办法很朴素:在怀疑的槽函数前后用 QElapsedTimer 计时,超过 16 毫秒(一帧的时间)就打一条警告日志。找到慢的地方,按上一篇 §4.3 的表格处理:耗时计算挪到线程,频繁刷新改成定时批量(§4.9),大数据表改用 Model/View(上一篇 §4.7)。
4.12 常问的零碎知识:d 指针、配置、JSON、数据库、多语言
口述见 §3.12。
d 指针
为什么需要它: 你写了一个库给别人用,头文件里的类有两个私有成员。下个版本你加了第三个私有成员,这个类占的内存变大了。所有用这个库的程序,都是按旧的大小分配内存的,不重新编译就会读写越界、直接崩溃。对 Qt 这种被成千上万个程序使用的库,这是不能接受的。
办法: 头文件里的类只放一个指针,真正的成员都放在一个只在 .cpp 里定义的私有类中。以后怎么加成员,都只改私有类,公开类的大小永远是一个指针。这叫 PIMPL(Pointer to Implementation,指向实现的指针),Qt 里叫 d 指针(d-pointer)。能做到“升级库不用重新编译程序”,叫二进制兼容(binary compatibility)。
flowchart LR
E["Engine 对象<br/>写在 engine.h 里,别人能看到<br/>只有一个指针 d"] -->|"d 指向"| P["Engine::Private 对象<br/>只写在 engine.cpp 里<br/>真正的成员变量都在这里"]
完整例子,分三个文件。
engine.h:
#pragma once
#include <memory>
#include <QString>
class Engine {
public:
Engine();
~Engine();
void setName(const QString &name);
QString describe() const;
private:
class Private;
std::unique_ptr<Private> d;
};
engine.cpp:
#include "engine.h"
class Engine::Private {
public:
QString name = "未命名";
int changes = 0;
};
Engine::Engine() : d(std::make_unique<Private>()) {}
Engine::~Engine() = default;
void Engine::setName(const QString &name) {
d->name = name;
++d->changes;
}
QString Engine::describe() const {
return QString("%1, 改过 %2 次名字").arg(d->name).arg(d->changes);
}
pimpl_main.cpp:
#include <QDebug>
#include "engine.h"
int main() {
Engine engine;
engine.setName("温度采集");
qDebug() << engine.describe();
qDebug() << "sizeof(Engine) =" << sizeof(Engine) << ", 一个指针 =" << sizeof(void *);
return 0;
}
输出:
"温度采集, 改过 1 次名字"
sizeof(Engine) = 8 , 一个指针 = 8
逐段拆:
#pragma once:防止同一个头文件被 include 多次时重复定义。class Private;:前置声明(forward declaration),只告诉编译器“有一个叫 Private 的类”,不说它长什么样。只有前置声明时,可以定义指向它的指针,但不能访问它的成员。std::unique_ptr<Private> d;:独占智能指针(unique_ptr),它拥有所指的对象,自己销毁时自动 delete 那个对象,不用手写 delete。- engine.cpp 里
class Engine::Private { ... };:在这里才真正定义私有类。别人 include engine.h 时看不到这些成员。 d(std::make_unique<Private>()):在初始化列表里创建私有对象。std::make_unique<类型>()是创建 unique_ptr 的推荐写法。Engine::~Engine() = default;:析构函数用默认实现,但必须写在 .cpp 里。原因:析构 Engine 时 unique_ptr 要 delete Private,delete 需要知道 Private 的完整定义;如果析构函数在头文件里(包括不写、让编译器自动生成),编译别人的代码时只看到前置声明,就会编译报错。这是 PIMPL 最常见的坑。sizeof(Engine) = 8:sizeof给出类型占多少字节。Engine 只占一个指针的大小,以后 Private 里加多少成员都不变。
Qt 源码里的写法: Qt 用宏把这套写法固定下来。例如 QObject 里有一个 d_ptr,成员函数开头写 Q_D(QObject); 就得到一个名叫 d 的私有类指针;私有类里写 Q_Q(QObject); 能反过来拿到公开类的指针 q。面试时能说出“Q_D 取私有指针、Q_Q 取公开指针、目的是二进制兼容、代价是多一次堆分配和一次间接访问”就够了。
QSettings 和 JSON
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSettings>
#include <QDebug>
int main() {
QString path = QDir::temp().filePath("qtjob_demo.ini");
QFile::remove(path);
{
QSettings settings(path, QSettings::IniFormat);
settings.setValue("serial/port", "/dev/ttyUSB0");
settings.setValue("serial/baudRate", 115200);
settings.setValue("alarm/limit", 80.5);
}
QSettings settings(path, QSettings::IniFormat);
qDebug() << "波特率:" << settings.value("serial/baudRate", 9600).toInt();
qDebug() << "没存过的键, 用默认值:" << settings.value("serial/parity", "none").toString();
QFile ini(path);
if (!ini.open(QIODevice::ReadOnly)) return 1;
qDebug().noquote() << "ini 文件内容:\n" + QString::fromUtf8(ini.readAll());
QJsonObject device{{"name", "TH-01"}, {"limit", 80.5}, {"channels", QJsonArray{1, 2, 3}}};
QByteArray json = QJsonDocument(device).toJson(QJsonDocument::Compact);
qDebug() << "生成 JSON:" << json;
QJsonParseError error;
QJsonDocument doc = QJsonDocument::fromJson(json, &error);
qDebug() << "读回 name:" << doc.object().value("name").toString()
<< "第 2 个通道:" << doc.object().value("channels").toArray().at(1).toInt();
QJsonDocument::fromJson("{\"name\": \"TH-01\",", &error);
qDebug() << "半截 JSON 的错误:" << error.errorString() << "位置" << error.offset;
return 0;
}
输出:
波特率: 115200
没存过的键, 用默认值: "none"
ini 文件内容:
[alarm]
limit=80.5
[serial]
baudRate=115200
port=/dev/ttyUSB0
生成 JSON: "{\"channels\":[1,2,3],\"limit\":80.5,\"name\":\"TH-01\"}"
读回 name: "TH-01" 第 2 个通道: 2
半截 JSON 的错误: "unterminated object" 位置 17
逐段拆:
QSettings settings(path, QSettings::IniFormat):用 ini 格式存到指定文件。不传路径时(QSettings settings;)按公司名和程序名存到系统默认位置:Windows 上是注册表,Linux 上是~/.config下的文件。setValue("serial/port", ...):键名里的/表示分组,ini 文件里就是[serial]这一节。- 第一段放在花括号
{ }里:这是一个作用域(scope),出了花括号 settings 对象被销毁,销毁时把数据写进文件。 value("serial/baudRate", 9600).toInt():第二个参数是默认值,键不存在时返回它。value 返回的是 QVariant,要用 toInt、toString 转换。qDebug().noquote():打印字符串时不加引号,多行内容更好看。QJsonObject device{{"name", "TH-01"}, ...}:用花括号列出键值对直接构造 JSON 对象,QJsonArray{1, 2, 3}是数组。toJson(QJsonDocument::Compact):生成紧凑格式(不换行)。注意输出里键按字母排了序,QJsonObject 不保留插入顺序。fromJson(json, &error):解析。一定要传 QJsonParseError 进去再检查,解析失败时 fromJson 返回一个空文档,不检查的话后面读到的全是空值,看不出是哪里错了。最后一行故意解析半截 JSON,error 说明了原因和出错位置。
数据库 QSql
#include <QCoreApplication>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QDebug>
void insertRows(QSqlDatabase &db, int count, bool useTransaction) {
QSqlQuery query(db);
if (useTransaction) db.transaction();
query.prepare("INSERT INTO samples(value) VALUES(?)");
for (int i = 0; i < count; ++i) {
query.addBindValue(i);
query.exec();
}
if (useTransaction) db.commit();
}
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QString path = QDir::temp().filePath("qtjob_demo.db");
QFile::remove(path);
{
QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", "main");
db.setDatabaseName(path);
if (!db.open()) {
qDebug() << "打不开数据库";
return 1;
}
QSqlQuery query(db);
query.exec("CREATE TABLE samples(id INTEGER PRIMARY KEY, value INTEGER)");
QElapsedTimer clock;
clock.start();
insertRows(db, 500, false);
qDebug() << "不开事务插 500 行:" << clock.elapsed() << "ms";
clock.restart();
insertRows(db, 500, true);
qDebug() << "开事务插 500 行:" << clock.elapsed() << "ms";
query.exec("SELECT COUNT(*), MAX(value) FROM samples");
query.next();
qDebug() << "总行数:" << query.value(0).toInt() << "最大值:" << query.value(1).toInt();
}
QSqlDatabase::removeDatabase("main");
return 0;
}
一次实际输出:
不开事务插 500 行: 134 ms
开事务插 500 行: 0 ms
总行数: 1000 最大值: 499
逐段拆:
QSqlDatabase::addDatabase("QSQLITE", "main"):用 SQLite 驱动创建一个连接(connection),名字叫 main。SQLite 是一个存在单个文件里的小型数据库,Qt 自带驱动,不用装数据库服务器。QSqlQuery query(db):在这个连接上执行 SQL 语句的对象。prepare("... VALUES(?)")+addBindValue(i)+exec():预处理语句(prepared statement),SQL 只解析一次,?处每次填不同的值。比每次拼字符串快,也能防止 SQL 注入。db.transaction()…db.commit():事务(transaction),把中间所有操作打包成一次提交。不开事务时,SQLite 每插一行都要确保写进磁盘,500 次磁盘同步;开了事务只同步一次。实测 134 毫秒对 0 毫秒,数据量越大差距越明显。§4.9 里存储线程“攒一批写一次”,写的时候就应该开事务。query.next():移到结果的第一行,value(0)、value(1)取第 1、2 列。- 为什么 db 放在花括号里、最后单独
removeDatabase:removeDatabase 时如果还有 QSqlDatabase 或 QSqlQuery 对象在用这个连接,Qt 会警告“connection is still in use”。放进作用域,出了花括号它们都已销毁,再移除连接就干净了。
多线程的规矩:一个连接只能在创建它的线程里使用。 存储线程要写数据库,就在存储线程里用不同的连接名自己 addDatabase。
多语言 tr()
界面要同时支持中文和英文时,代码里所有给用户看的文字都用 tr() 包起来:
openButton->setText(tr("打开"));
之后的流程:
flowchart LR
A["代码里 tr(#quot;打开#quot;)"] -->|"lupdate 扫描源码"| B[".ts 文件<br/>待翻译清单"]
B -->|"翻译人员用 Qt Linguist 填英文"| C[".ts 填好译文"]
C -->|"lrelease 编译"| D[".qm 文件"]
D -->|"程序启动时 QTranslator 加载"| E["界面显示 Open"]
- lupdate:扫描所有
tr(),生成或更新 .ts 文件(XML 格式的翻译表)。 - Qt Linguist:给翻译人员用的工具,逐条填译文。
- lrelease:把 .ts 编译成程序能快速读取的 .qm 文件。
- QTranslator:程序启动时
translator.load("app_en.qm"),再app.installTranslator(&translator),之后所有 tr 都返回英文。 - 运行时切换语言:重新加载翻译后,Qt 会给每个窗口发
LanguageChange事件,在changeEvent里把所有文字重新 setText 一遍(用 .ui 的话调用ui.retranslateUi(this))。
坑: tr 用类名区分翻译上下文,类里要写 Q_OBJECT;不在类里的普通函数用 QCoreApplication::translate("上下文", "文字")。拼接句子不要写 tr("共") + n + tr("条"),要写 tr("共 %1 条").arg(n),因为别的语言语序可能不一样。
第五部分 追问检查表
| 你刚答完 | 下一个追问 | 答案要点 |
|---|---|---|
| QMainWindow | 中央区域要放多个控件怎么办 | 先放一个 QWidget,在它上面装布局 |
| QAction | 为什么不直接用按钮 | 菜单、工具栏、快捷键共用一份状态和同一个槽 |
| .ui 文件 | uic 生成的是什么 | 一个 Ui 类,setupUi 里 new 控件、设属性、建布局 |
| .ui 文件 | 为什么不用 on_xxx_clicked 自动连接 | 改名后不报错、悄悄断开;看不到 connect,难读 |
| 布局 | 窗口变大,某个控件不跟着变 | 查拉伸系数、尺寸策略、有没有弹簧抢空间 |
| exec | exec 期间程序卡住了吗 | 没有,exec 里开了新的事件循环 |
| exec | 那有什么风险 | 重入:期间别的槽被执行,可能删掉对象或再弹窗口 |
| 窗口通信 | 子窗口能不能直接改主窗口的控件 | 不应该,子窗口发信号,主窗口自己改 |
| QSS | 改了属性样式不变 | 要 unpolish 再 polish |
| QSS | 自定义控件背景色不生效 | paintEvent 里用 QStyleOption + drawPrimitive(PE_Widget) |
| QSS | 父控件设了背景,子控件全变了 | 没写选择器,样式被子控件继承;用 #objectName 选中 |
| 自定义控件 | 放进布局后被压成一条线 | 没重写 sizeHint / minimumSizeHint |
| 自定义控件 | 怎么让 QSS 改颜色 | Q_PROPERTY 暴露,QSS 里 qproperty-属性名 |
| 分层 | 业务层怎么通知界面 | 发信号;业务层不 include 界面头文件 |
| 分层 | 串口换成 TCP 要改多少 | 只换通信层,两者都是 QIODevice |
| 单元测试 | 怎么测一个信号有没有发 | QSignalSpy 记录次数和参数 |
| 单元测试 | 界面怎么测 | QTest::mouseClick、keyClicks 模拟操作,再检查状态 |
| 串口 | readyRead 一次能读到一整帧吗 | 不能保证,要缓冲 + 按协议拆帧 |
| 协议 | 校验失败怎么处理 | 只丢帧头 2 字节,重新找帧头,不能整段清空 |
| 协议 | 设备不应答怎么办 | 单次定时器超时重发,超过次数报通信故障 |
| 串口 | USB 串口线被拔了 | errorOccurred(ResourceError),关闭后定时重连 |
| 三线程 | 线程之间怎么传数据,要加锁吗 | Queued 信号拷贝参数,不用自己加锁 |
| 三线程 | 数据太多界面卡 | 定时批量刷新;降采样 |
| 三线程 | 存储跟不上采集 | 监控积压、批量写 + 事务、必要时丢旧数据 |
| 三线程 | 为什么 QTimer 在 start 里创建 | 保证它属于采集线程;成员变量形式的 QTimer 没有 parent,不会跟着 moveToThread 搬走,跨线程 start 会失败 |
| 跨平台 | 配置文件存哪 | QStandardPaths 或不带路径的 QSettings,不要写死 |
| 移植 | Qt 6 交叉编译和 Qt 5 有什么不同 | 需要一份本机 Qt 提供 moc、uic,用 -qt-host-path 指定 |
| 移植 | 板子上中文是方框 | 缺中文字体,拷字体或 addApplicationFont |
| 日志 | 日志里没有文件名行号 | 定义 QT_MESSAGELOGCONTEXT |
| 日志 | 多线程写日志有问题吗 | 处理函数要加锁;每条 flush,防止崩溃丢日志 |
| 崩溃 | 现场崩了怎么查 | Linux core dump + gdb bt;Windows minidump;保留符号文件 |
| 内存 | 常见泄漏点 | 没 parent 的 new、QNetworkReply、关闭不删的对话框、重复 connect |
| d 指针 | 为什么析构函数要写在 cpp 里 | unique_ptr 析构私有类需要完整定义,头文件里只有前置声明 |
| d 指针 | Q_D 和 Q_Q 是什么 | Q_D 取私有类指针 d,Q_Q 从私有类取公开类指针 q |
| QSql | 多线程能共用一个连接吗 | 不能,每个线程用自己的连接名 addDatabase |
| QSql | 批量插入慢 | 开事务 + 预处理语句 |
| 多语言 | 运行时切换语言 | 重新 installTranslator,changeEvent 里处理 LanguageChange,retranslateUi |
第六部分 闭卷自测
先自己答,再点开对照。
1. 菜单里的“保存”和工具栏上的“保存”按钮,要求没打开文件时都不能点。怎么写最省事?
答案
用同一个 QAction 分别 addAction 到菜单和工具栏。没打开文件时 saveAction->setEnabled(false),两处一起变灰,快捷键也一起失效。
2. 下面代码想让标签在报警时变红,但界面上一直是绿的。为什么?怎么改?
label->setProperty("state", "error");
样式表里有 QLabel[state="error"] { background: red; }。
答案
改动态属性不会让 Qt 重新计算样式。要在 setProperty 之后调用 label->style()->unpolish(label); label->style()->polish(label);。
3. 设置对话框用 exec() 弹出。用户还没点确定时,串口的 readyRead 槽会被执行吗?这可能带来什么问题?
答案
会执行。exec 里开了一个新的事件循环,所有事件和信号照常处理。风险是重入:如果这期间的槽函数删掉了弹出对话框的那个对象,或者又弹出另一个对话框,exec 返回后外层代码可能访问已删除的对象。更稳妥的写法是 open() 加 accepted / finished 信号。
4. 设置对话框里改了端口,要通知主窗口。同事的写法是把 MainWindow 指针传进对话框,对话框里直接调用 mainWindow->m_portLabel->setText(...)。有什么问题?
答案
对话框依赖了主窗口的内部细节:主窗口改控件名、换布局,对话框也得改;对话框也没法在别的地方复用。应该让对话框发 portChanged(int) 信号,主窗口连接这个信号,自己更新自己的控件。
5. 串口一次 readyRead 读到了 55 02 01 01 07 09 AA,你的拆帧器应该拆出几帧?缓冲区里剩什么?
答案
0 帧(假设缓冲区原来是空的)。前面找不到完整的帧头 AA 55,55 02 01 01 07 09 都是垃圾要丢掉;最后一个 AA 可能是下一个帧头的前半个,要留在缓冲区里。
6. 为什么校验失败时只丢帧头 2 个字节,而不是把按长度算出来的整帧都丢掉?
答案
这个“帧头”可能是数据里碰巧出现的 AA 55,它的长度字段也是假的。按假长度丢掉一整段,可能把紧跟在后面的真帧一起丢了。只丢 2 个字节再重新找帧头,最多多找几次,不会误伤好帧。
7. 采集线程每毫秒一个数据,界面上有一条实时曲线。同事在收数据的槽里每收到一个点就调用一次 chart->update(),界面很卡。怎么改?
答案
收数据的槽只把点存进缓冲区;另开一个 30~50 毫秒的 QTimer,到点了取出缓冲区里的数据,一次性更新曲线再调一次 update。人眼看不出区别,重绘次数从每秒 1000 次降到 20~30 次。数据点太多时还可以降采样。
8. Worker 类里有一个成员变量 QTimer m_timer;。Worker moveToThread 之后,在它的槽里调用 m_timer.start(100),控制台打印 Timers cannot be started from another thread,定时器不工作。为什么?怎么改?
答案
moveToThread 只搬对象本身和它的子对象。m_timer 是成员变量,没有设置 parent,不是 Worker 的子对象,所以还留在主线程;在工作线程里 start 它,就是跨线程启动定时器,Qt 不允许。改法任选一种:写成 new QTimer(this),让它成为子对象跟着搬;或者在构造函数的初始化列表里给它 parent:m_timer(this);或者干脆在连接到 QThread::started 的槽里再创建定时器。
9. 现场反馈程序偶尔崩溃,但日志最后几行总是缺的。可能是什么原因?
答案
写日志后没有 flush,数据还在内存缓冲区里,程序一崩溃就丢了。每条日志写完都 flush(或者至少 WARN 以上级别 flush)。另外多线程写日志没加锁的话,还会出现两行日志互相穿插。
10. 用 PIMPL 写了一个类,头文件里没写析构函数,结果别的文件 include 它时编译报错,说不完整的类型不能 delete。为什么?
答案
没写析构函数时,编译器在每个用到这个类的地方自动生成析构函数,里面要 delete 私有类;但头文件里只有私有类的前置声明,编译器不知道它的完整定义,没法 delete。解决办法是在头文件里声明 ~Engine();,在 .cpp 里(私有类定义之后)写 Engine::~Engine() = default;。
11. 存储线程往 SQLite 里每秒插 2000 行,CPU 不高但就是跟不上。第一步查什么?
答案
查有没有开事务。不开事务时每一行都单独提交、单独同步磁盘,瓶颈在磁盘同步上,CPU 当然不高。改成攒一批、开事务、用预处理语句批量插入,实测能快两个数量级。
12. 程序拷到 ARM 板子上,启动报 could not find the Qt platform plugin "xcb"。怎么办?
答案
板子上没有 X11 桌面环境,xcb 插件用不了。启动时加 -platform linuxfb(纯软件绘制)或 -platform eglfs(有 GPU 时),也可以设置环境变量 QT_QPA_PLATFORM。同时确认 Qt 的 plugins/platforms 目录已经拷到板子上。
附录 A 编译这些例子
CMake 模板(在上一篇附录 A 的基础上,多了 AUTOUIC、Test、Sql):
cmake_minimum_required(VERSION 3.16)
project(qtjob LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Test Sql)
add_compile_options(-Wall -Wextra -Wpedantic)
add_executable(main_window main_window.cpp)
target_link_libraries(main_window Qt6::Widgets)
add_executable(designer_form designer_form.cpp login_form.ui)
target_link_libraries(designer_form Qt6::Widgets)
add_executable(toggle_switch toggle_switch.cpp)
target_link_libraries(toggle_switch Qt6::Widgets Qt6::Test)
add_executable(temperature_test temperature_test.cpp)
target_link_libraries(temperature_test Qt6::Core Qt6::Test)
add_executable(logger logger.cpp)
target_compile_definitions(logger PRIVATE QT_MESSAGELOGCONTEXT)
target_link_libraries(logger Qt6::Core)
add_executable(pimpl pimpl_main.cpp engine.cpp)
target_link_libraries(pimpl Qt6::Core)
add_executable(sqlite_demo sqlite_demo.cpp)
target_link_libraries(sqlite_demo Qt6::Core Qt6::Sql)
其余例子照着加一行 add_executable 和 target_link_libraries 即可:用到界面类的链接 Qt6::Widgets,只用 QObject、QString 这些的链接 Qt6::Core。
.ui文件要写进 add_executable 的源文件列表,AUTOUIC 才会处理它。- 构建命令、macOS 上的 SDK 问题,和上一篇附录 A 一样。
- 本文的界面例子是用
QT_QPA_PLATFORM=offscreen(不弹出窗口)运行得到的输出。§4.4 的对话框例子在这个模式下会多打印一行This plugin does not support propagateSizeHints(),那是 offscreen 插件的提示,和程序本身无关。 - 串口例子需要 SerialPort 模块,macOS 上
brew install qtserialport安装;§4.8 的拆帧器不依赖它,可以直接编译。
附录 B 把调试助手改成一个能讲的项目
Qt 岗面试有一半时间在追问项目。qt-net-assistant 是一个能跑的起点,但它是教学版:所有逻辑都在 MainWindow 里。按下面的顺序改造,每一步都对应本篇的一节,做完以后你就有了一个“为什么这么设计”都答得上来的项目。
| 顺序 | 改什么 | 对应小节 | 做完能回答的问题 |
|---|---|---|---|
| 1 | 把 TCP 收发从 MainWindow 拆出去,建 Connection 类(通信层),MainWindow 只连它的信号 | §4.7 | 你的程序怎么分层 |
| 2 | 加协议模式:发送时组帧,接收时用 FrameParser 拆帧,统计丢弃字节数 | §4.8 | 半包、粘包、坏帧怎么处理 |
| 3 | 装上 SerialPort 模块,加串口模式,和 TCP 共用同一套协议层 | §4.8 | 换通信方式要改多少代码 |
| 4 | 给 FrameParser 和业务逻辑写 QTest 单元测试 | §4.7 | 你怎么保证改代码不改坏 |
| 5 | 用 QSettings 保存上次的模式、端口、窗口位置 | §4.12 | 配置存在哪 |
| 6 | 加日志文件:带文件名行号、多线程加锁、按天切分 | §4.11 | 现场出问题怎么查 |
| 7 | 用 --auto-send 1 压测,把接收和日志表刷新改成定时批量 | §4.9 | 数据量大时界面为什么不卡 |
| 8 | 提供深色主题的 QSS,状态指示用动态属性切换 | §4.5 | 样式怎么管理 |
| 9 | 在 Linux 上编译运行一次,处理路径和串口名的差异 | §4.10 | 跨平台遇到过什么问题 |
每一步都记下遇到的问题:现象是什么、怎么查到的、最后怎么改的。面试时被问“项目里遇到过什么难点”,这些记录就是答案,比任何背出来的标准答案都可信。
简历上怎么写:只写做过的。做完这张表,可以写“熟悉 Qt 常用模块,自学完成串口 / TCP 调试工具(协议解析、多线程、单元测试)”。项目来源如实说是自学练习项目,把重点放在你为它做的设计决定上。