PERSONAL LAB / interview/从零开始图解
Interview 从零开始图解

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.hsetupUi(this) 之后才能用 ui.xxx 访问控件
布局控件交给布局管位置和大小,窗口缩放时自动调整多余空间给谁由拉伸系数(stretch)和尺寸策略(sizePolicy)决定
模态对话框exec() 弹出并等用户关掉才返回exec 里开了一个新的事件循环,定时器、网络照常工作,也可能引发重入
窗口之间传数据主窗口 → 子窗口调函数;子窗口 → 主窗口发信号子窗口不要直接拿主窗口指针改它的控件,会让两个类互相依赖
QSS语法像 CSS,按类型、objectName、属性、状态选中控件改了动态属性要 unpolish + polish,样式才会刷新
自定义控件进阶重写 paintEvent、鼠标事件、sizeHint,对外发信号Q_PROPERTY 暴露颜色等属性,QSS 里就能用 qproperty-xxx 设置
分层通信层 → 协议层 → 业务层 → 界面层,下层用信号通知上层业务层不 include 任何界面头文件,才能单独做单元测试
QTestQt 自带的单元测试框架,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(键, 默认值),没存过就返回默认值
QJsonQJsonDocument / QJsonObject / QJsonArray解析一定要传 QJsonParseError 检查是否成功
QSql数据库连接只能在创建它的线程用批量插入要开事务,实测快两个数量级
多语言代码里用 tr(),lupdate → Linguist → lrelease → QTranslatortr 按类名区分翻译,类里要写 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、析构里 deleteQt Creator 默认生成的写法,头文件里只需前置声明,改 .ui 时依赖它的文件编译得少
多继承class LoginForm : public QWidget, private Ui::LoginForm直接写 okButton,不用 ui. 前缀,现在很少用

2.3 改控件外观的三种办法

QSS 样式表QPalette 调色板重写 paintEvent
能改什么颜色、边框、圆角、间距、背景图、各种状态只能改颜色想画什么画什么
难度低,像写 CSS
缺点规则多了互相覆盖难排查;自定义控件要自己支持很多平台样式会忽略它工作量大
适合大部分换肤、美化需求简单改个前景背景色仪表盘、开关、波形图等标准控件没有的东西

2.4 串口的两种读法

readyRead 信号 + readAll()waitForReadyRead(毫秒)
同步还是异步异步,数据来了通知你同步,卡在这里等
能在主线程用吗能,推荐不能,会卡住界面
适合几乎所有情况在单独线程里写的简单测试工具

2.5 存数据用什么

QSettingsJSON 文件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()->unpolishpolish。自定义控件想让 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_WINQ_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。

练习

  1. 再加一个“保存”动作,快捷键 Ctrl+S,同时放进“文件”菜单和工具栏,触发时在状态栏显示“已保存”。
  2. 编辑框为空时禁用“清空”:连接 QTextEdit::textChanged 信号,在槽里根据内容是否为空调用 setEnabled

4.2 Designer 和 .ui 文件

口述见 §3.2。

为什么需要它

表单类界面(登录框、设置页、参数配置页)控件多、位置固定,一行行写 newaddWidget 很枯燥,改一个位置还要重新编译才能看到效果。Qt Designer(界面设计器) 可以用鼠标拖控件、摆布局,所见即所得。Qt Creator 里双击 .ui 文件打开的就是它。

官方概念

  • .ui 文件:Designer 保存的界面描述文件,内容是 XML(一种用尖括号标签描述数据的文本格式)。
  • uic(User Interface Compiler,界面编译器):编译时把 xxx.ui 转成 C++ 头文件 ui_xxx.h。CMake 里 set(CMAKE_AUTOUIC ON) 后自动运行。
  • Ui 类和 setupUiui_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 信号上。看着省事,但有两个问题:

  1. 在 Designer 里把按钮改名成 loginButton编译不报错,连接却悄悄没了,点按钮没反应,很难查。
  2. 看代码的人找不到 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() constvoid setPort(int port):对外只暴露“读端口”和“设端口”两个函数,外面的人不需要知道里面用的是 QSpinBox。这就是主窗口给子窗口传数据的方式:调子窗口的函数。
  • private: 下面的 m_spinBox:成员指针,构造函数里 new 出来,布局负责挂到对话框名下。
  • main 里的 QTimer::singleShot(100, ...):这里用定时器模拟用户操作,100 毫秒后改端口并点确定。
  • dialog.exec():弹出对话框,一直等到对话框关闭才返回,返回值是 QDialog::AcceptedQDialog::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 和它的子类
objectNameQPushButton#sendButtonobjectName 是 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

  1. 所有样式写在一个 style.qss 文件里,而不是散落在代码各处的 setStyleSheet
  2. 把 qss 文件加进资源文件(.qrc),编译进程序,代码里用 :/style.qss 这样的路径读取(上一篇 §4.10 讲过 rcc)。
  3. 程序启动时读出来,调用一次 app.setStyleSheet(内容)
  4. 换肤就是换一个 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

看图: 实线是数据往上走,靠信号;虚线是命令往下走,靠函数调用。依赖只能往下:界面层知道业务层,业务层不知道界面层。好处有三个:

  1. 换界面(比如加一个网页版)不用动业务层;
  2. 串口换成 TCP,只换通信层,因为两者都是 QIODevice,上层代码不用改;
  3. 业务层不依赖界面,可以脱离界面单独测试

完整例子:业务层和它的单元测试

这是业务层的一个类:收到温度帧,算出温度,温度超过阈值时报警。下面连同它的测试一起写在一个文件里。

#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 会按顺序自动调用它们。initTestCasecleanupTestCase 是框架自动加的开始和收尾步骤。
  • 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 基本一样,区别只是成员默认公开。用来装“一帧”:命令和数据。
  • checksumfor (char c : data)范围 for 循环(range-based for),依次取出 data 里的每个字节放进 c。sum 是 quint8,加超过 255 会自动回绕(只保留最低 8 位),正好就是“求和取最低 8 位”。
  • makeFrame:按协议格式一段段拼出一帧,char(...) 把数字转成一个字节。这里用它造测试数据,真实程序里发送命令时也是这样组帧。
  • FrameParser 的成员 m_buffer缓冲区,还没拆完的字节都留在这里,下次来了新字节接着拆。
  • feed 里的 while (true) 循环,每一轮尝试拆一帧:
    1. indexOf(head) 找帧头 AA 55 的位置,找不到返回 -1。
    2. 找不到帧头:缓冲区全是垃圾,丢掉。但如果最后一个字节是 AA,它可能是下一个帧头的前半个,要留下。m_buffer.right(keep) 取最右边 keep 个字节。
    3. 找到帧头:帧头前面的 pos 个字节是垃圾,remove(0, pos) 删掉。
    4. 不够 4 个字节(帧头 2 + 长度 1 + 命令 1)就连长度都读不到,break 等下次。
    5. 读出长度,算出整帧应有 total 个字节;不够就 break 等下次,这就是处理半包
    6. 算校验。校验不对,只删掉帧头这 2 个字节continue 回到循环开头重新找帧头。不能把整段 total 个字节都删掉,因为这个“帧头”可能根本不是真帧头,只是数据里恰好出现的 AA 55,它后面可能紧跟着一个真正的好帧。
    7. 校验通过,frames.append({cmd, data}) 存起来,{cmd, data} 是用花括号直接构造一个 Frame;再删掉这一整帧,继续拆下一帧(这就是处理粘包)。
  • qsizetype:Qt 6 里表示大小、位置的整数类型。

按时间顺序:

  1. 第一次收到 5 字节 12 34 aa 55 02:找到帧头,丢掉前面 2 个垃圾字节;剩 3 字节,不够 4,等。
  2. 第二次收到 14 字节,缓冲区凑够了第一帧,校验 09 正确,拆出温度帧;接着找到第二个帧头,长度够了,但校验不对,丢掉帧头 2 字节;往后找,下一个 AA 55 前面还有 5 字节(02 01 03 84 89),丢掉;剩下 aa 55 02 不够,等。
  3. 第三次收到 4 字节,凑够第三帧,校验正确,拆出。
  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::finisheddeleteLater:线程结束时删除 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 上的差异

方面WindowsLinux在 Qt 里怎么写
路径分隔符\/代码里一律用 /,Qt 在 Windows 上也认;给用户看时用 QDir::toNativeSeparators
文件名大小写不区分区分代码和资源里的文件名大小写保持一致
配置和数据放哪C:/Users/名字/AppData/...~/.config/...~/.local/share/...QStandardPaths::writableLocation 自动给出
换行符\r\n\n文本文件用 QIODevice::Text 模式打开,Qt 自动转换
中文编码系统 API 是 GBKUTF-8源码统一 UTF-8,MSVC 加 /utf-8(上一篇 §4.10)
串口名COM3/dev/ttyUSB0用 QSerialPortInfo 列出来让用户选
高分屏缩放 150%、200% 常见看桌面环境图标用 SVG 或提供 @2x 图,不写死像素坐标
平台专属代码Q_OS_WINQ_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

逐段拆:

  • setOrganizationNamesetApplicationName:设置公司名和程序名,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_WINQ_OS_MACOSQ_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) = 8sizeof 给出类型占多少字节。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,难读
布局窗口变大,某个控件不跟着变查拉伸系数、尺寸策略、有没有弹簧抢空间
execexec 期间程序卡住了吗没有,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 5555 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_executabletarget_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 调试工具(协议解析、多线程、单元测试)”。项目来源如实说是自学练习项目,把重点放在你为它做的设计决定上。

Related · 从零开始图解
⎇ main interview/从零开始图解 41 节 230 notes UTF-8