Qt:面试速记 + 从零学习
这份文档分层使用:
| 场景 | 读哪里 |
|---|---|
| 完全没碰过 Qt | 先读第四部分 4.0,装好环境跑通第一个程序 |
| 面试前 10 分钟 | 第一部分速记页 + 第二部分易混对照 |
| 面试前一天 | 第三部分口述稿,出声念一遍 |
| 平时学习 | 第四部分逐个详解,一天一两节,自己把代码敲一遍 |
| 被追问时心里有底 | 第五部分追问检查表 |
| 检验是否真会 | 第六部分闭卷自测 |
第一次学 Qt,前三部分先整块跳过。 那里是学完以后复习用的答案,还没学的时候读,只会看到一堵词墙。直接从第四部分 4.0 开始按顺序读,标了“第一遍可跳过”的小节留到第二遍。
先说一个判断:Qt 的 API 确实好上手,拖控件、连按钮一两天就会。面试拉开差距的是下面三件事:
- 信号槽底下是怎么实现的,跨线程时槽函数到底在哪个线程执行;
- 事件循环是什么,界面为什么会卡死;
- 对象树怎么管理内存,什么时候会重复释放。
这三块吃透,Qt 面试一半的题都能答。其余是 Model/View、绘图、网络、打包这些“做过项目就知道”的内容。
这一篇只讲原理。界面开发、串口和协议、程序分层、多线程数据处理、日志和排障这些“干活”的内容在续篇 Qt岗位实战从零开始图解,投以 Qt 为主的岗位时两篇都要看。
文中标“完整例子”的代码都在 Qt 6.11 + CMake 下实测编译(-Wall -Wextra -Wpedantic 零警告),输出是真实运行结果。Qt 5 和 Qt 6 的差别会单独说明,因为很多公司还在用 Qt 5。
第一部分 速记页
1.1 一页表
| 知识点 | 一句话答案 | 最容易漏掉的点 |
|---|---|---|
| Qt 是什么 | C++ 跨平台应用框架,一份代码编译到 Windows / Linux / macOS / 嵌入式 | 不只是界面库,还有网络、线程、数据库、JSON 等模块 |
| 信号槽 | 对象状态变了就发信号,连上的槽函数自动被调用 | 发送方不需要知道谁在接收 |
| 信号槽原理 | moc 在编译前扫描带 Q_OBJECT 的类,生成元对象代码;emit 最终调用 QMetaObject::activate 查连接表逐个调用槽 | emit 是空宏,信号本身是 moc 生成函数体的普通成员函数 |
| 连接方式 | Auto(默认)/ Direct / Queued / BlockingQueued,另有 Unique、SingleShot 标志 | Auto 在发信号那一刻判断:接收者在当前线程就 Direct,否则 Queued |
| 跨线程信号槽 | Queued 连接把参数拷贝一份,打包成事件投递到接收者所在线程,由那个线程的事件循环执行槽 | 接收线程必须有事件循环在跑;参数类型要能被元类型系统识别 |
| Q_OBJECT | 让类进入元对象系统,信号槽、qobject_cast、属性、tr() 都依赖它 | 漏写时信号没有函数体,链接报错;写了但 moc 没重跑,报 “undefined vtable” |
| 对象树 | 给对象指定 parent,parent 析构时自动 delete 所有子对象 | 栈上对象挂了 parent 且析构顺序不对 → 重复释放崩溃 |
| deleteLater | 不马上删除,等控制权回到事件循环再删 | 在槽函数里删除发送者、或删除还可能有事件要处理的对象时用它 |
| 事件循环 | exec() 里是一个循环:取事件 → 分发给对象 → 再取 | 主线程做耗时操作 → 循环停转 → 界面不刷新不响应 = “卡死” |
| 事件 vs 信号 | 事件多来自外部(鼠标、键盘、定时器、系统),信号是对象主动通知“我变了” | 跨线程的 Queued 信号底层就是用事件实现的 |
| 事件过滤器 | installEventFilter 后,事件先到过滤器,返回 true 就拦截 | 多个过滤器时后装的先执行 |
| 多线程两种写法 | 继承 QThread 重写 run();或 Worker 对象 moveToThread | QThread 对象本身不在新线程里,它的槽默认在创建它的线程执行 |
| 子线程改界面 | 不允许,界面控件只能在主线程操作 | 子线程发信号,主线程的槽里更新界面 |
| update vs repaint | update 排队、可合并多次请求;repaint 立即同步重绘 | 大多数时候用 update |
| 自定义控件 | 继承 QWidget 重写 paintEvent,在里面用 QPainter 画 | QPainter 画控件只能在 paintEvent 里 |
| Model/View | Model 管数据,View 管显示,Delegate 管单元格怎么画和编辑 | 视图只向模型要可见行的数据,所以百万行也能不卡 |
| 隐式共享 | QString、QList 等拷贝时只加引用计数,改的时候才真拷贝(写时复制) | 对非 const 容器用范围 for 可能触发一次不必要的拷贝 |
| TCP 粘包 | TCP 是字节流,没有“一条消息”的边界,要自己加长度头拆包 | readyRead 触发一次不代表来了一条完整消息 |
| 中文乱码 | 源码编码、编译器读源码的编码、运行时转换三处不一致 | MSVC 加 /utf-8;和系统 API 打交道用 fromLocal8Bit |
| 打包部署 | Windows 用 windeployqt,macOS 用 macdeployqt | 缺 platforms 插件目录会报 “no Qt platform plugin” |
| Widgets vs QML | Widgets 用 C++ 写传统桌面界面;QML 是声明式语言,适合动画、触屏、嵌入式大屏 | QML 和 C++ 靠 Q_PROPERTY、Q_INVOKABLE、信号通信 |
| 许可证 | 开源版主要是 LGPLv3,动态链接可以做闭源商业软件 | 部分附加模块开源版只给 GPL;静态链接要求更多 |
1.2 如果只背一段
信号槽是 Qt 的“通知机制”:发送方只管喊一声,谁连上了谁就被调用。它靠 moc 在编译前生成代码实现。同一线程里默认直接调用;跨线程时参数被打包成事件,交给接收者线程的事件循环去执行。所以跨线程通信用信号槽是安全的,而主线程做耗时操作会把事件循环堵住,界面就卡了。
这一段把信号槽、事件循环、多线程、卡顿四个高频题串起来了,面试官顺着任何一个往下问,你都能接上。
第二部分 易混对照
2.1 DirectConnection 和 QueuedConnection
| Direct 直接连接 | Queued 队列连接 | |
|---|---|---|
| 槽什么时候执行 | emit 那一行当场执行完才返回 | emit 立即返回,槽之后才执行 |
| 槽在哪个线程执行 | 发信号的线程 | 接收者所在的线程 |
| 参数 | 按引用传也行,不拷贝 | 拷贝一份放进事件 |
| 需要事件循环吗 | 不需要 | 接收线程必须有事件循环 |
| 像什么 | 普通函数调用 | 往对方信箱里塞一封信 |
AutoConnection(默认)不是第三种行为,它只是在发信号时二选一:接收者和当前线程是同一个就 Direct,否则 Queued。
BlockingQueuedConnection:像 Queued 一样投到对方线程,但发送方会停下来等槽执行完。两个对象在同一线程时用它会死锁(自己等自己)。
2.2 事件和信号
| 事件 event | 信号 signal | |
|---|---|---|
| 从哪来 | 大多来自外部:操作系统、窗口系统、定时器,或 postEvent | 对象自己 emit |
| 谁处理 | 只交给一个目标对象(可以被过滤、可以往父控件传) | 可以连给任意多个槽 |
| 典型用途 | 鼠标点击、按键、重绘、窗口大小改变 | “按钮被点了”“数值变了”“下载完了” |
| 关系 | 按钮收到鼠标事件后,内部判断是一次点击,再发出 clicked 信号 |
一句话:事件是“发生了什么”,信号是“我因此变成了什么”。
2.3 sendEvent 和 postEvent
| sendEvent | postEvent | |
|---|---|---|
| 同步还是异步 | 同步,处理完才返回 | 异步,放进队列就返回 |
| 事件对象放哪 | 栈上即可 | 必须 new 在堆上,Qt 负责删除 |
| 能跨线程吗 | 不能随便用 | 可以,投递到目标对象所在线程 |
2.4 update 和 repaint
| update() | repaint() | |
|---|---|---|
| 何时重绘 | 回到事件循环后 | 立刻 |
| 连续调用 10 次 | 通常合并成 1 次重绘 | 真画 10 次 |
| 推荐 | 默认用它 | 只在必须立刻看到结果时用 |
两个都不要在 paintEvent 里调用:repaint 会递归,update 会让界面不停重画。
2.5 继承 QThread 和 moveToThread
| 继承 QThread 重写 run() | Worker + moveToThread | |
|---|---|---|
| 写法 | 把要做的事写进 run() | 普通 QObject 写槽函数,挪到 QThread 里 |
| 新线程里有事件循环吗 | 你的 run() 不调 exec() 就没有 | 有(默认 run() 就是 exec()) |
| 能接收信号吗 | run() 里的代码不能靠信号驱动 | 能,信号来一次干一次 |
| 常见坑 | 在 QThread 子类里写的槽,其实跑在主线程 | Worker 不能有 parent,否则挪不动 |
| 适合 | 一次性的长循环任务,比如一直读串口 | 需要接收多次命令的后台服务 |
2.6 delete、deleteLater、对象树自动删除
| 何时释放 | 适合 | |
|---|---|---|
delete obj | 立刻 | 确定没有待处理事件、不在它自己的信号调用链里 |
obj->deleteLater() | 回到事件循环时 | 槽函数里删除 sender、网络回复对象 QNetworkReply 等 |
| 给 parent | parent 析构时 | 界面控件,基本都这么管 |
2.7 几种“指针”
| 作用 | 对应标准库 | |
|---|---|---|
QPointer<T> | 盯住一个 QObject,对象被删后自动变成空 | 类似 std::weak_ptr,但只能用于 QObject,不管释放 |
QSharedPointer<T> | 引用计数,最后一个释放时删除 | std::shared_ptr |
QScopedPointer<T> | 离开作用域删除,不可拷贝 | std::unique_ptr |
新代码里非 QObject 的资源直接用标准库智能指针就行。QObject 优先靠对象树管理。
2.8 qobject_cast 和 dynamic_cast
两者都是“把基类指针安全地转成子类指针,失败返回空”。区别是 qobject_cast 查的是 moc 生成的元对象信息,不依赖编译器的 RTTI(运行时类型信息,有些嵌入式项目会关掉它),速度也更快;但只能用于继承 QObject 且写了 Q_OBJECT 的类。
2.9 connect 的老写法和新写法
connect(spin, SIGNAL(valueChanged(int)), label, SLOT(setNum(int)));
connect(spin, &QSpinBox::valueChanged, label, qOverload<int>(&QLabel::setNum));
| 老写法 SIGNAL/SLOT 宏 | 新写法 函数指针(Qt 5 起) | |
|---|---|---|
| 检查时机 | 运行时按字符串查找,写错只在控制台打警告 | 编译时检查,写错编译不过 |
| 能连 lambda 吗 | 不能 | 能 |
| 参数隐式转换 | 不支持 | 支持 |
| 重载函数 | 字符串里写清参数即可 | 要用 qOverload<参数类型>(...) 选定哪个重载 |
上面第二行 QLabel::setNum 有 int 和 double 两个重载,所以要 qOverload<int> 指明。
2.10 Widgets 和 QML
| Qt Widgets | Qt Quick / QML | |
|---|---|---|
| 写界面用 | C++(或 Designer 拖 .ui 文件) | QML 声明式语言,语法像 JSON + JavaScript |
| 渲染 | 主要 CPU 绘制 | GPU 场景图 |
| 适合 | 传统桌面工具、工控上位机、表格密集的界面 | 动画多、触屏、车机、嵌入式大屏 |
| 业务逻辑 | C++ | 仍建议 C++,QML 只管界面 |
第三部分 面试口述稿
每段固定四句:是什么 / 怎么工作 / 用在哪 / 代价和坑。出声念熟即可,别背成书面语。详解见第四部分对应小节。
3.1 信号槽(详解 §4.1)
信号槽是 Qt 的对象间通信机制,一个对象状态变化时发出信号,连接到它的槽函数会被自动调用,发送方不需要知道接收方是谁。实现上,带 Q_OBJECT 的类在编译前会被 moc 处理,生成元对象代码,信号的函数体也是 moc 生成的,里面调用 QMetaObject::activate,按连接表逐个调用槽。它用在界面事件响应、模块解耦和跨线程通知,一个信号可以连多个槽,多个信号也能连同一个槽。代价是比直接函数调用慢,官方说大约是直接调用的十倍,但跟槽里做的实际工作比通常可以忽略;另外用 lambda 连接时要传上下文对象,否则对象被删后 lambda 还可能访问野指针。
3.2 连接方式和跨线程(详解 §4.5)
connect 的第五个参数是连接方式,默认 Auto,在发信号时判断:接收者在当前线程就直接调用,不在就排队。Queued 会把参数拷贝一份,包装成事件投递到接收者所在线程的事件队列,由那个线程的事件循环调用槽,所以槽总是在接收者的线程执行。跨线程通信、后台线程通知界面刷新,都是靠这个机制,不需要自己加锁。要注意接收线程必须在跑事件循环,自定义参数类型要注册到元类型系统;BlockingQueued 在同一线程用会死锁。
3.3 元对象系统(详解 §4.1)
元对象系统是 Qt 在 C++ 之上补的一层运行时反射能力,能在运行时查到一个类的名字、信号、槽、属性。它由三部分组成:QObject 基类、Q_OBJECT 宏、moc 代码生成器。信号槽、qobject_cast、Q_PROPERTY 属性系统、QML 调 C++、tr() 翻译都建在它上面。代价是多一步代码生成,类必须继承 QObject,而 QObject 不能拷贝;漏写 Q_OBJECT 或改了头文件没重新跑 moc,会出现链接错误。
3.4 对象树和内存管理(详解 §4.2)
QObject 可以有一个 parent,构造时传入或调用 setParent,parent 内部记录所有子对象。parent 析构时会自动 delete 它的全部子对象;子对象先被删的话,会把自己从 parent 的列表里移除。所以界面代码里 new 出来的控件只要挂在窗口上,窗口关了一起释放,不用手动 delete。坑在于栈上对象:如果一个栈对象挂在另一个栈对象下面,而 parent 先析构,就会 delete 一个栈对象导致崩溃;需要延迟删除时用 deleteLater。
3.5 事件循环和界面卡顿(详解 §4.3)
事件循环就是 exec() 里那个不停转的循环:从队列里取出事件,分发给目标对象处理,处理完再取下一个,没事件就睡着等。鼠标、键盘、重绘、定时器、跨线程信号都靠它驱动。界面卡死的根本原因,是主线程在某个槽里做了耗时操作,循环转不回来,重绘和点击事件都在排队。解决办法是把耗时操作挪到线程或线程池,结果用信号送回主线程;processEvents 能临时缓解,但容易引起重入问题,不是正解。
3.6 事件处理和事件过滤器(详解 §4.4)
一个事件到对象手里要经过几层:先是装在 QApplication 上的全局过滤器,再是装在这个对象上的过滤器,然后是对象的 event(),最后分到 mousePressEvent 这类具体处理函数。事件过滤器用 installEventFilter 安装,在 eventFilter 里返回 true 表示拦截,事件不再往下走。它适合“不改控件类,统一处理一批控件的某类事件”,比如给多个输入框加回车跳转。注意多个过滤器后装的先执行;鼠标键盘事件如果被 ignore(),会继续传给父控件。
3.7 多线程(详解 §4.5)
Qt 多线程常用三种写法:继承 QThread 重写 run;写一个 Worker 对象 moveToThread;用 QtConcurrent 或 QThreadPool 把任务丢进线程池。QThread 对象本身是线程的管理者,它自己住在创建它的线程里,只有 run 里的代码才在新线程执行。界面控件只能在主线程操作,后台线程要改界面就发信号,由主线程的槽去更新。坑包括:在 QThread 子类里定义的槽其实跑在主线程;退出线程要用 quit 加 wait 或 requestInterruption,不要用 terminate;数据库连接只能在创建它的线程里用。
3.8 绘图和自定义控件(详解 §4.6)
自定义控件一般继承 QWidget,重写 paintEvent,在里面创建 QPainter 画图,再按需要重写鼠标、尺寸相关的事件。想刷新时调 update(),Qt 会在回到事件循环时安排一次重绘,并把多次请求合并。常见用途有仪表盘、波形图、状态指示灯这类标准控件没有的东西。坑是不要在 paintEvent 里做耗时计算或调 update/repaint,复杂图形可以先画到 QPixmap 缓存里,数据变了才重画缓存。
3.9 Model/View(详解 §4.7)
Model/View 把数据和显示分开:Model 负责数据,View 负责显示,Delegate 负责单个格子怎么画、怎么编辑。View 需要显示哪一格,就调用 Model 的 data(index, role) 去要,role 区分要的是显示文字、图标还是提示。好处是同一份数据可以接多个视图,而且视图只要可见范围的数据,百万行的表也能流畅滚动。大数据量时要自己继承 QAbstractTableModel 按需计算,不要用 QStandardItemModel 一行一个对象;批量改数据用一次 begin/end 包起来,而不是每行发一次通知。
3.10 隐式共享(详解 §4.8)
隐式共享就是写时复制:QString、QByteArray、QList、QImage 等拷贝时并不复制数据,只是共用同一块并把引用计数加一,谁要修改谁才真正复制一份。所以 Qt 里按值传递和返回这些类型很便宜,跨线程发信号拷贝参数也不贵。引用计数是原子操作,多个线程各自持有副本是安全的。坑是对非 const 的共享容器调用非 const 函数会触发“分离”(真正复制),比如用范围 for 遍历一个非 const 的 QList,这时用 std::as_const 包一下。
3.11 网络和粘包(详解 §4.9)
Qt 网络是异步的,QTcpSocket 有数据到了就发 readyRead 信号,不会阻塞界面。TCP 是字节流,发送方发了两条消息,接收方可能一次收到一条半,也可能分三次收到,这就是常说的粘包拆包。做法是自己定协议:每条消息前面加固定长度的长度头,收到数据先追加进缓冲区,再循环判断缓冲区够不够一条完整消息,够就切出来。HTTP 请求用 QNetworkAccessManager,返回的 QNetworkReply 用完要 deleteLater。
3.12 打包部署和中文乱码(详解 §4.10)
Qt 程序发布时除了自己的 exe,还要带上 Qt 的动态库和插件,Windows 上用 windeployqt 自动收集,macOS 用 macdeployqt。最常见的报错是缺 platforms 插件导致程序起不来。中文乱码通常是编码没对齐:源码存成 UTF-8,但 MSVC 默认按系统编码 GBK 读,解决是加 /utf-8 编译选项;和 Windows API、控制台打交道时用 fromLocal8Bit/toLocal8Bit 转换。许可证方面开源版主要是 LGPL,动态链接可以做闭源商业软件,静态链接或改 Qt 源码要承担更多义务,有些附加模块只有 GPL。
第四部分 逐个详解
4.0 Qt 是什么,先跑通第一个程序
Qt 是什么
Qt(读作 cute)是一个 C++ 跨平台应用框架(cross-platform application framework):同一份 C++ 代码,可以编译成 Windows、Linux、macOS、Android、嵌入式 Linux 上的程序。
框架(framework) 和普通的库(library)的区别:库是你调用它;框架是它调用你。Qt 程序的 main 最后一行一般是 return app.exec();,之后程序的控制权交给 Qt 的事件循环,Qt 在合适的时候调用你写的函数。
Qt 按功能分成很多模块(module),面试常见的是:
| 模块 | 管什么 |
|---|---|
| Qt Core | 非界面的基础:QObject、信号槽、事件循环、QString、容器、线程、定时器、文件 |
| Qt Gui | 窗口系统对接、图像、字体、QPainter 的底层 |
| Qt Widgets | 传统控件:按钮、输入框、表格、对话框 |
| Qt Network | TCP/UDP、HTTP |
| Qt Concurrent | 线程池上的高层并发接口 |
| Qt Sql | 数据库 |
| Qt Quick / QML | 声明式界面 |
| Qt SerialPort | 串口(工控上位机常用) |
环境搭建(macOS)
brew install qtbase
qtbase 包含 Core、Gui、Widgets、Network、Concurrent、Sql 等基础模块。写代码可以用 Qt Creator(Qt 官方 IDE),也可以用任意编辑器加 CMake。完整的 CMake 模板见附录 A。
第一个程序:一个按钮
#include <QApplication>
#include <QPushButton>
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QPushButton button("点我退出");
QObject::connect(&button, &QPushButton::clicked, &app, &QApplication::quit);
button.show();
return app.exec();
}
逐行拆:
#include <QApplication>:引入应用程序对象这个类。Qt 的类名都以大写 Q 开头。int main(int argc, char *argv[]):C++ 程序入口。argc是命令行参数个数,argv是参数内容,Qt 需要它们来处理一些启动参数。QApplication app(argc, argv);:创建应用程序对象(application object)。每个带界面的 Qt 程序有且只有一个,它负责事件循环、全局设置。纯命令行程序用QCoreApplication,它不带界面部分。QPushButton button("点我退出");:创建一个按钮控件(widget,也叫部件),文字是“点我退出”。QObject::connect(...):连接信号和槽。四个参数依次是:谁发信号(按钮)、发哪个信号(被点击clicked)、谁接收(app)、调用接收者的哪个函数(quit退出)。&QPushButton::clicked是“QPushButton 类的 clicked 成员函数的地址”,这种写法叫成员函数指针(pointer to member function)。button.show();:没有父窗口的控件会成为一个独立窗口,show让它显示出来。return app.exec();:进入事件循环(event loop)。程序停在这里,不断等待并处理点击、重绘等事件,直到quit被调用,exec才返回。
按时间顺序走一遍:程序启动 → 创建 app 和按钮 → 登记“按钮被点就调 quit” → 窗口显示 → 进入 exec 等待 → 用户点击 → 按钮收到鼠标事件,判断是一次点击,发出 clicked 信号 → quit 被调用 → exec 返回 → main 结束。
这 7 行代码里已经有了信号槽、事件循环、控件三个核心概念。把“点一下按钮”画出来,就是下面这张图,它是整个第四部分的骨架:
flowchart TB
A["你点了一下鼠标"] --> B["操作系统把这次点击交给程序"]
B --> C["事件队列:发生的事在这里排队"]
C --> D["app.exec() 里的事件循环取出一件事<br/>(§4.3)"]
D --> E["交给按钮处理:按钮判断这是一次点击<br/>(§4.4)"]
E --> F["按钮发出 clicked 信号<br/>(§4.1)"]
F --> G["查连接登记表:clicked 连到了 app 的 quit"]
G --> H["quit 被调用,exec 返回,main 结束"]
D -. "处理完一件,回去取下一件" .-> C
看图: 你写的代码(比如 quit,或者以后自己写的槽函数)都是在事件循环“处理一件事”的中途被调用的。信号槽负责“谁来响应”,事件循环负责“什么时候响应”,对象树(§4.2)负责“这些按钮窗口什么时候被释放”。后面每一节补图里的一个环节。
4.1 信号槽和元对象系统
口述见 §3.1、§3.2、§3.3。
为什么需要它
没有信号槽时,一个“计数器”想在数值变化时通知“显示器”,只能在计数器里直接调用显示器:
void Counter::setValue(int v) {
m_value = v;
m_display->show(v);
}
问题是计数器被迫认识显示器。以后还要通知日志、通知网络上报,就得不停改计数器的代码。C 语言的常见做法是回调函数(callback):计数器存一个函数指针,变化时调它。回调能解耦,但只能存有限几个,类型检查弱,对象被删了回调还在,跨线程也要自己处理。
信号槽解决的就是这些:计数器只管喊“我变了”,谁关心谁自己连上来,数量不限,接收方被删除时连接自动断开。
官方概念
- 信号(signal):对象发出的通知,意思是“我身上发生了某件事”。比如按钮的
clicked、滑块的valueChanged。 - 槽(slot):收到通知时被调用的函数。就是一个普通的成员函数,也可以是 lambda(本节后面讲)。
- connect:登记一条“A 的某个信号 → B 的某个槽”。登记一次,以后 A 每发一次信号,B 的槽就被调用一次。
第一遍先学会用。信号槽底下怎么实现(moc、元对象)放在本节后半,标了“第一遍可跳过”。
先看一个带窗口的例子:滑块、数字框、进度条
这个例子只用 Qt 现成的控件,不用自己写类:
#include <QApplication>
#include <QProgressBar>
#include <QSlider>
#include <QSpinBox>
#include <QVBoxLayout>
#include <QWidget>
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QWidget window;
QVBoxLayout *layout = new QVBoxLayout(&window);
QSlider *slider = new QSlider(Qt::Horizontal);
QSpinBox *spinBox = new QSpinBox;
QProgressBar *bar = new QProgressBar;
layout->addWidget(slider);
layout->addWidget(spinBox);
layout->addWidget(bar);
QObject::connect(slider, &QSlider::valueChanged, spinBox, &QSpinBox::setValue);
QObject::connect(slider, &QSlider::valueChanged, bar, &QProgressBar::setValue);
QObject::connect(spinBox, &QSpinBox::valueChanged, slider, &QSlider::setValue);
window.show();
return app.exec();
}
运行后是一个小窗口,从上到下三个控件。拖动滑块,数字框和进度条跟着变;在数字框里输入 70,滑块跳到 70,进度条也走到 70%。
三条 connect 就是下面这张接线图:
flowchart LR
slider["滑块 slider"] -->|"valueChanged → setValue"| spin["数字框 spinBox"]
slider -->|"valueChanged → setValue"| bar["进度条 bar"]
spin -->|"valueChanged → setValue"| slider
看图: 一个信号可以连多个槽(滑块同时连了数字框和进度条),两个对象也可以互相连。你可能会担心:数字框改了值 → 滑块跟着改 → 滑块又发 valueChanged 去设数字框 → 数字框再发……会不会没完没了?不会。数字框发现新值和自己当前的值一样,就不再发信号,循环到这里就停了。这条“值没变就不发信号”的规矩,下面自己写信号时也要照着做。
逐行拆:
QWidget window;:一个空白窗口,当容器用。QVBoxLayout *layout = new QVBoxLayout(&window);:竖排布局(vertical box layout),放进去的控件从上往下排。括号里传&window,表示这个布局装在 window 上。QVBoxLayout *表示 layout 是一个指针,指向 new 出来的那个布局对象。new QSlider(Qt::Horizontal):新建一个横向滑块。QSpinBox是带上下箭头的数字框,QProgressBar是进度条。三个控件的默认范围都从 0 开始(滑块和数字框到 99,进度条到 100)。layout->addWidget(slider);:把控件加进布局。->是通过指针调用成员函数,和普通变量用.是一回事。- 这些 new 出来的东西都没有 delete:加进布局后,它们都挂到了 window 名下,window 析构时会把它们一起删掉。这就是 §4.2 要讲的对象树,现在只要知道“有人负责删”就够了。
QObject::connect(slider, &QSlider::valueChanged, spinBox, &QSpinBox::setValue):和 4.0 按钮例子是同一个格式:谁发、发什么信号、谁收、收到后调哪个函数。信号valueChanged(int)带着新的值,这个值原样传给setValue(int)。信号和槽的参数类型要对得上,编译器会检查,对不上直接编译报错。- slider 本身就是指针,所以前面不用加
&;4.0 里的 button 是普通变量,才要写&button取它的地址。
自己定义信号:计数器和显示器
Qt 控件的信号是现成的。自己写的类想发信号,要多做几件事:
#include <QCoreApplication>
#include <QObject>
#include <QDebug>
class Counter : public QObject {
Q_OBJECT
public:
int value() const { return m_value; }
void setValue(int v) {
if (v == m_value) return;
m_value = v;
emit valueChanged(v);
}
signals:
void valueChanged(int newValue);
private:
int m_value = 0;
};
class Display : public QObject {
Q_OBJECT
public slots:
void show(int v) { qDebug() << "Display 收到:" << v; }
};
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
Counter counter;
Display display;
QObject::connect(&counter, &Counter::valueChanged, &display, &Display::show);
counter.setValue(5);
counter.setValue(5);
counter.setValue(8);
return 0;
}
#include "signal_slot.moc"
输出:
Display 收到: 5
Display 收到: 8
这次没有窗口,结果用 qDebug() 打印在终端里,方便看清先后顺序。
逐段拆:
class Counter : public QObject:Counter 公有继承(public inheritance) QObject。要用信号槽,类必须直接或间接继承 QObject。Q_OBJECT:一个宏(macro),写在类的第一行。有了它,Qt 才会给这个类生成信号需要的代码。漏写的话,编译链接会报错。int value() const:const放在函数名后面,表示这个成员函数不修改对象,叫常成员函数。setValue里if (v == m_value) return;:值没变就不发信号。这是 Qt 的惯例,上面滑块和数字框能互相连接而不死循环,靠的就是控件内部的这一句。emit valueChanged(v);:发出信号。emit只是给读代码的人看的记号,意思是“这里在发信号”,写不写对编译结果没区别。signals:下面的void valueChanged(int newValue);:声明信号。信号返回值一般是void,你不能也不需要写它的函数体,Qt 会替你生成。public slots:下面的show:声明槽,就是一个普通成员函数。qDebug() << ...:Qt 的调试输出,类似std::cout,会自动换行,字符串会带引号。connect:计数器的 valueChanged 连到显示器的 show。这里 counter、display 是普通变量,所以要写&取地址。#include "signal_slot.moc":当Q_OBJECT类写在.cpp文件里时,要在文件末尾包含 Qt 生成的这个文件。类写在.h里时不需要这行,CMake 的 AUTOMOC 会自动处理。
按时间顺序:setValue(5) → 值从 0 变 5 → emit → 调用 display 的 show;第二次 setValue(5) → 值没变,直接 return,什么也不输出;setValue(8) → 再通知一次。
槽也可以是一个 lambda
有时收到信号只想做一两行事,专门写一个类、一个槽太麻烦,可以就地写一个函数:
QObject::connect(&counter, &Counter::valueChanged, [](int v) {
qDebug() << "lambda 收到:" << v;
});
- 这叫 lambda 表达式(lambda expression),就是就地写的、没有名字的函数。
[]是捕获列表,写在里面的外部变量才能在函数体里用,空着表示一个都不用;(int v)是参数;花括号里是函数体。 - 把它加到上面 main 里那个 connect 的下面,每次值变化会打印两行:先
Display 收到,再lambda 收到。一个信号连了多个槽时,按 connect 的先后顺序依次调用。
断开连接和生命周期
- 发送者或接收者任何一方被 delete,相关连接自动断开,不会调用到已删除的对象。
connect返回一个QMetaObject::Connection,可以用QObject::disconnect(conn)手动断开。- lambda 的坑:上面那个 lambda 的 connect 只有发送者,没有接收者,Qt 只能跟着发送者管这条连接。如果 lambda 里用到了别的对象的指针,那个对象先被删了,lambda 照样会被调用,访问的就是野指针(指向已经释放的内存的指针)。正确写法是多传一个上下文对象(context object):
QObject::connect(&counter, &Counter::valueChanged, &display, [&display](int v) {
display.show(v);
});
第三个参数 &display 就是上下文:display 被删除时,这条连接自动断开。[&display] 表示 lambda 里要用外面的 display 变量,前面的 & 表示直接用原来那个,不复制一份。上下文对象还决定 lambda 在哪个线程执行,见 §4.5。
原理:moc 到底生成了什么(第一遍可跳过)
先认识两个名词:
- moc(Meta-Object Compiler,元对象编译器):Qt 自带的代码生成工具,编译之前扫描源文件,看到
Q_OBJECT就生成一份额外的 C++ 代码。上面的signal_slot.moc就是它生成的。 - 元对象(meta-object):moc 为每个类生成的一份“类的说明书”,记录类名、有哪些信号槽、属性。运行时可以查询它,这就是 C++ 本身没有的**反射(reflection)**能力。
对 Counter,moc 生成的 valueChanged 大致是这样(简化):
void Counter::valueChanged(int _t1) {
void *_a[] = { nullptr, &_t1 };
QMetaObject::activate(this, &staticMetaObject, 0, _a);
}
- 把参数的地址放进一个数组
_a; 0是这个信号在类里的编号;QMetaObject::activate去查“这个对象的 0 号信号连了哪些槽”,逐个调用。同一线程里就是当场调用;发信号和接收者不在同一个线程时怎么办,见 §4.5。
emit 其实是一个空宏,展开后什么都没有,emit valueChanged(v) 就是调用上面这个函数。
所以面试回答“信号槽原理”,关键词是:moc 代码生成 → 元对象 → 连接表 → activate 分发。
Q_PROPERTY 和 qobject_cast(第一遍可跳过)
class Counter : public QObject {
Q_OBJECT
Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)
};
Q_PROPERTY 把 value 登记为属性(property):告诉元对象系统用哪个函数读、哪个函数写、变化时发哪个信号。QML 绑定、属性动画 QPropertyAnimation、Designer 都靠它。
qobject_cast<Counter*>(obj):把 QObject* 安全转换成 Counter*,类型不对返回 nullptr,与 dynamic_cast 的对比见 §2.8。
练习
- 把
if (v == m_value) return;删掉,再让两个 Counter 互相连接(A 的 valueChanged 连 B 的 setValue,B 的连 A 的),会发生什么? - 把 Display 改成
new出来的,在两次 setValue 之间delete它,确认不会崩溃。
4.2 对象树和内存管理
口述见 §3.4。
为什么需要它
一个窗口里可能有上百个按钮、输入框、标签,全是 new 出来的。如果每个都要手动 delete,漏一个就内存泄漏,删两次就崩溃。Qt 的做法是:让窗口“拥有”它里面的控件,窗口没了控件一起没。
官方概念
- 父对象(parent)/ 子对象(child):QObject 的构造函数可以接收一个 parent 指针。parent 在内部保存一个子对象列表,
children()可以取到。 - 对象树(object tree):父子关系连起来形成一棵树,根一般是主窗口。
- 所有权(ownership):parent 析构时 delete 所有子对象。
回头看 §4.1 的滑块例子,window 下面的对象树是这样的(实测 window.children() 正好是这 4 个):
flowchart TB
W["window<br/>main 里的普通变量,main 结束时析构"] --> L["layout 竖排布局"]
W --> S["slider 滑块"]
W --> SB["spinBox 数字框"]
W --> B["bar 进度条"]
看图: 布局是 new QVBoxLayout(&window) 时直接挂上去的;三个控件 new 的时候没有 parent,addWidget 时被布局转交给了 window。main 结束时 window 析构,顺着箭头把下面 4 个全部 delete,所以代码里一个 delete 都不用写。删掉一个节点,就是删掉以它为根的整棵子树。
完整例子:析构顺序
#include <QCoreApplication>
#include <QObject>
#include <QDebug>
class Node : public QObject {
public:
Node(const QString &name, QObject *parent = nullptr) : QObject(parent), m_name(name) {
qDebug() << "构造" << m_name;
}
~Node() override { qDebug() << "析构" << m_name; }
private:
QString m_name;
};
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
Node *window = new Node("窗口");
new Node("按钮", window);
new Node("输入框", window);
qDebug() << "窗口的子对象个数:" << window->children().size();
delete window;
qDebug() << "结束";
return 0;
}
输出:
构造 "窗口"
构造 "按钮"
构造 "输入框"
窗口的子对象个数: 2
析构 "窗口"
析构 "按钮"
析构 "输入框"
结束
逐段拆:
Node(const QString &name, QObject *parent = nullptr):构造函数有两个参数,第二个有默认参数(default argument)nullptr,不传就表示没有 parent。: QObject(parent), m_name(name):成员初始化列表(member initializer list),先用 parent 初始化基类 QObject,再初始化成员 m_name。把 parent 交给 QObject 的构造函数,挂树这件事就完成了。~Node() override:析构函数(destructor)。override表示它重写了基类的虚析构函数。- 这个类没写
Q_OBJECT:因为它没用信号槽,对象树功能只要继承 QObject 就有。 new Node("按钮", window);:new 出来的指针没有保存到任何变量里,这在普通 C++ 里是内存泄漏,但这里 window 记住了它。delete window;:只删了窗口,按钮和输入框也析构了。
注意输出顺序:先打印“析构 窗口”,再打印两个子对象。因为 C++ 析构时先执行派生类析构函数体(打印“析构 窗口”),再执行基类 QObject 的析构函数,QObject 的析构函数才去删子对象,按加入顺序删。
经典崩溃:栈对象和 parent
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QPushButton quit("Quit");
QWidget window;
quit.setParent(&window);
return 0;
}
C++ 规定局部变量按声明的相反顺序析构。这里 window 后声明,所以先析构,它把子对象 quit 也 delete 了;但 quit 是栈上的变量,不是 new 出来的,delete 栈对象是错误的。接着 quit 自己离开作用域还要再析构一次,程序崩溃。
把两行声明对调就没事:quit 先析构,析构时把自己从 window 的子列表移除,window 析构时就不会再碰它。实际项目里的简单规则:有 parent 的控件都 new 出来,只把最顶层窗口放在栈上。
deleteLater:推迟删除
完整例子:
#include <QCoreApplication>
#include <QObject>
#include <QPointer>
#include <QTimer>
#include <QDebug>
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QPointer<QObject> obj = new QObject;
obj->deleteLater();
qDebug() << "调用 deleteLater 后, 对象还在吗:" << !obj.isNull();
QTimer::singleShot(0, [&] {
qDebug() << "回到事件循环后, 对象还在吗:" << !obj.isNull();
app.quit();
});
return app.exec();
}
输出:
调用 deleteLater 后, 对象还在吗: true
回到事件循环后, 对象还在吗: false
QPointer<QObject> obj:QPointer 是 Qt 的“守护指针”,用法像普通指针,但它盯着的 QObject 被删除后会自动变成空。isNull()判断是否已空。deleteLater():往事件队列里放一个“删除我”的事件,自己立即返回。QTimer::singleShot(0, ...):0 毫秒后执行一次 lambda,也就是“下一轮事件循环时执行”。[&]表示按引用捕获所有用到的外部变量。
为什么需要推迟:比如在一个槽里处理网络回复 reply,这个槽正是 reply 自己发出的 finished 信号触发的。如果槽里直接 delete reply,槽返回后 Qt 还要回到 reply 的代码里继续执行,就访问了已删除的对象。reply->deleteLater() 等整条调用链结束再删,就安全了。
为什么 QObject 不能拷贝
QObject 用 Q_DISABLE_COPY 禁掉了拷贝构造和拷贝赋值。一个 QObject 有名字、parent、子对象、信号槽连接、所在线程,拷贝后这些该怎么算?两个对象共用 parent 还是复制子对象?没有合理答案,所以干脆禁止。因此 QObject 放进容器时存指针,传参时传指针或引用。
4.3 事件循环
口述见 §3.5。
官方概念
- 事件(event):一个
QEvent或其子类对象,描述“发生了什么”,比如QMouseEvent、QKeyEvent、QPaintEvent、QTimerEvent。 - 事件队列(event queue):每个有事件循环的线程都有一个,等待处理的事件在里面排队。
- 事件循环(event loop):
exec()里的循环,伪代码如下:
while (没有人调用 quit) {
如果队列空,就睡眠,直到操作系统通知有新事件(鼠标、网络数据、定时器到期)
取出一个事件
交给目标对象处理
}
“睡眠等待”在 Linux 上底层是 glib 主循环或 poll 这类系统调用,和你熟悉的 epoll 服务器是同一个思路:单线程 + 等待就绪 + 分发处理。
画成图:
flowchart LR
Q["事件队列<br/>鼠标、键盘、定时器、重绘……"] --> T["取出一件"]
T --> H["交给目标对象处理<br/>你写的槽函数就在这一步里执行"]
H -->|"处理函数返回了"| Q
看图: 这个圈只有一个线程在转。你的槽函数是在“交给目标对象处理”这一格里被调用的,它不返回,箭头就回不到队列,后面排队的点击、重绘全都只能等着。下面的例子就是故意让它 1 秒不返回。
完整例子:堵住事件循环会怎样
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QThread>
#include <QTimer>
#include <QDebug>
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QElapsedTimer clock;
clock.start();
QTimer ticker;
QObject::connect(&ticker, &QTimer::timeout, [&] {
qDebug() << "tick at" << clock.elapsed() << "ms";
});
ticker.start(100);
QTimer::singleShot(250, [] {
qDebug() << "开始 1 秒的耗时操作,事件循环被堵住";
QThread::msleep(1000);
qDebug() << "耗时操作结束";
});
QTimer::singleShot(1500, &app, &QCoreApplication::quit);
return app.exec();
}
一次实际输出(毫秒数每次略有不同):
tick at 106 ms
tick at 210 ms
开始 1 秒的耗时操作,事件循环被堵住
耗时操作结束
tick at 1261 ms
tick at 1357 ms
tick at 1452 ms
逐段拆:
QElapsedTimer clock; clock.start();:计时器,elapsed()返回从 start 到现在过了多少毫秒。QTimer ticker; ticker.start(100);:定时器(timer),每 100 毫秒发一次timeout信号。QTimer::singleShot(250, lambda):250 毫秒后执行一次 lambda,里面QThread::msleep(1000)让当前线程(也就是主线程)睡 1 秒,模拟“在槽里做了耗时操作”。- 最后一个 singleShot:1.5 秒后退出。
按时间顺序:100、200 毫秒正常 tick → 250 毫秒开始睡 → 300 到 1200 毫秒之间本该有 10 次 tick,一次都没出来 → 1250 毫秒醒来,事件循环恢复,定时器立刻补发了一次(1261),之后又按 100 毫秒节奏走。
两个结论:
- 事件循环被堵住时,一切都停了。换成界面程序,这 1 秒里窗口不会重绘,点击没反应,Windows 上标题栏会显示“未响应”。这就是“界面卡死”。
- QTimer 错过的 tick 不会补齐,只会补一次。所以 QTimer 不适合做精确计数,要算时间差请用 QElapsedTimer。另外 QTimer 默认是粗精度(
Qt::CoarseTimer,允许约 5% 误差),要更准可以设成Qt::PreciseTimer。
界面卡顿怎么办
| 做法 | 适合 | 注意 |
|---|---|---|
| 放进 QThread / QtConcurrent / QThreadPool | 真正的耗时计算、阻塞 IO | 首选,见 §4.5 |
拆成小块,用 QTimer::singleShot(0, ...) 一块块做 | 必须在主线程做的大量界面操作 | 每块要足够小 |
循环里调用 QCoreApplication::processEvents() | 临时救急 | 处理事件时用户可能又点了按钮,导致同一段代码重入(没执行完又被调用一次) |
| 改用异步 API | 网络、进程 | QTcpSocket、QProcess 本来就是异步的,不要用 waitForReadyRead 这类阻塞函数 |
4.4 事件处理和事件过滤器
口述见 §3.6。
一个事件的完整路线
flowchart TB
A[操作系统产生鼠标点击] --> B[QApplication::notify]
B --> C[QApplication 上安装的全局过滤器]
C --> D[目标对象上安装的过滤器 后装的先执行]
D --> E[目标对象的 event 函数]
E --> F[mousePressEvent 等具体处理函数]
F -->|ignore 了| G[交给父控件 重复 D 到 F]
看图: 越靠上的环节管得越宽、越早拿到事件。任何一环返回 true(或 accept)就表示“处理掉了”,后面不再走。
对应“处理事件的五种办法”,面试偶尔会问:
- 重写具体处理函数,如
mousePressEvent、keyPressEvent(最常用); - 重写
event(),在分发前统一处理,比如自定义事件、Tab 键; - 给对象装事件过滤器;
- 给 QApplication 装事件过滤器,拦截全程序的事件;
- 继承 QApplication 重写
notify()(很少用)。
完整例子:自定义事件和过滤器
#include <QCoreApplication>
#include <QEvent>
#include <QObject>
#include <QDebug>
const QEvent::Type MessageEvent = static_cast<QEvent::Type>(QEvent::User + 1);
class Receiver : public QObject {
protected:
bool event(QEvent *e) override {
if (e->type() == MessageEvent) {
qDebug() << "Receiver::event 处理了消息";
return true;
}
return QObject::event(e);
}
};
class Guard : public QObject {
public:
bool block = false;
protected:
bool eventFilter(QObject *watched, QEvent *e) override {
if (e->type() == MessageEvent) {
qDebug() << "Guard 先看到消息, 拦截 =" << block;
if (block) return true;
}
return QObject::eventFilter(watched, e);
}
};
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
Receiver receiver;
Guard guard;
receiver.installEventFilter(&guard);
QEvent e1(MessageEvent);
QCoreApplication::sendEvent(&receiver, &e1);
guard.block = true;
QEvent e2(MessageEvent);
QCoreApplication::sendEvent(&receiver, &e2);
return 0;
}
输出:
Guard 先看到消息, 拦截 = false
Receiver::event 处理了消息
Guard 先看到消息, 拦截 = true
逐段拆:
QEvent::User + 1:Qt 内置事件类型占用了QEvent::User(值 1000)以下的编号,自定义事件从 User 往上取。static_cast<QEvent::Type>(...)是把整数显式转换成枚举类型(enum) QEvent::Type。Receiver::event:protected表示只有自己和子类能调用。收到自定义事件就处理并return true;其他事件交给基类QObject::event按默认方式处理,这一句不能漏,否则所有其他事件都被你吞了。Guard::eventFilter(QObject *watched, QEvent *e):watched是被监视的对象(这里是 receiver),同一个过滤器可以装到多个对象上,靠它区分。receiver.installEventFilter(&guard);:让 guard 监视 receiver。QCoreApplication::sendEvent:同步把事件交给对象,处理完才返回。事件对象e1放在栈上即可。
按时间顺序:第一次 block 为 false,过滤器看一眼放行,Receiver::event 处理;第二次 block 为 true,过滤器返回 true,Receiver 根本没收到。
实际项目里的例子:给十几个输入框装同一个过滤器,统一把回车键变成“跳到下一个输入框”,而不用给每个输入框写子类。
鼠标键盘事件的冒泡
控件的 mousePressEvent 里调用 event->ignore(),表示“我不要”,这个事件会传给父控件再处理一遍。不写时默认 accept。比如在一个自定义按钮上点击,按钮不处理,就交给它所在的面板。
4.5 多线程
口述见 §3.7。
先记住三条规矩
- QThread 不是线程本身,是线程的管理者。QThread 对象住在创建它的那个线程里,只有
run()函数里的代码在新线程执行。 - 每个 QObject 有一个所属线程(thread affinity),就是创建它的线程,或者
moveToThread挪过去的线程。这个对象的槽(通过 Queued 连接调用时)、事件、定时器,都在所属线程执行。 - 界面控件只能在主线程创建和操作。QWidget 不是线程安全的。
先看图:线程和对象各住在哪
拿下面例子里的三个对象来画:
flowchart LR
subgraph mainT["主线程"]
T["thread(QThread 对象)<br/>只是管理者,自己住在主线程"]
C["controller"]
end
subgraph workT["工作线程(thread.start() 之后才有)"]
W["worker<br/>moveToThread 以后搬到这里"]
end
T -. "负责启动、停止" .-> workT
看图: 线程好比干活的工人,对象好比住在某个工人家里的住户。QThread 对象 thread 只是工作线程的管理者,它自己住在主线程;worker 用 moveToThread 搬到工作线程以后,发给它的信号才会在工作线程里执行。第 1、2 条规矩说的就是这张图。
同一线程和跨线程,信号怎么送到
flowchart TB
subgraph direct["同一线程:Direct 连接,像普通函数调用"]
A1["emit"] --> A2["当场执行槽"] --> A3["槽执行完,emit 这一行才返回"]
end
subgraph queued["跨线程:Queued 连接,像往对方信箱塞一封信"]
B1["主线程 emit"] --> B2["参数拷贝一份,打包成一个事件"]
B2 --> B3["放进工作线程的事件队列"]
B3 --> B4["工作线程的事件循环取出它,执行槽"]
B1 -.-> B5["emit 立刻返回,主线程接着往下走"]
end
看图: 同一线程里,信号就是普通的函数调用。跨线程时,Qt 不能在主线程里直接执行 worker 的槽(那就跑错线程了),于是把参数拷贝一份打包成事件,放进工作线程的事件队列,由工作线程自己的事件循环(§4.3 那个圈)取出来执行。所以接收方所在的线程必须有事件循环在跑,否则信塞进了信箱,却没人拆。
用哪种方式,由 connect 的第五个参数决定:
QObject::connect(sender, &S::sig, receiver, &R::slot, Qt::QueuedConnection);
| 连接方式 | 行为 |
|---|---|
Qt::AutoConnection | 默认。发信号时,接收者在当前线程就按 Direct,否则按 Queued |
Qt::DirectConnection | 在发信号的线程里当场调用 |
Qt::QueuedConnection | 投递到接收者线程的事件队列,稍后执行 |
Qt::BlockingQueuedConnection | 同 Queued,但发送线程阻塞等槽执行完;同线程使用会死锁 |
Qt::UniqueConnection | 标志位,和上面用 | 组合,防止重复连接;对 lambda 无效 |
Qt::SingleShotConnection | Qt 6 新增标志位,触发一次后自动断开 |
平时不写第五个参数,用默认的 Auto 就行,Qt 在发信号那一刻自动二选一。
§4.1 讲过的 lambda 上下文对象,在这里还有第二个作用:跨线程时,lambda 会在上下文对象所属的线程里执行。下面例子里的第二个 connect 就用到了这一点。
完整例子:Worker + moveToThread
#include <QCoreApplication>
#include <QObject>
#include <QThread>
#include <QTimer>
#include <QDebug>
class Worker : public QObject {
Q_OBJECT
public slots:
void doWork(int n) {
qDebug() << "doWork 运行在:" << QThread::currentThread()->objectName();
int sum = 0;
for (int i = 1; i <= n; ++i) sum += i;
emit finished(sum);
}
signals:
void finished(int result);
};
class Controller : public QObject {
Q_OBJECT
signals:
void start(int n);
};
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QThread::currentThread()->setObjectName("主线程");
QThread thread;
thread.setObjectName("工作线程");
Worker *worker = new Worker;
worker->moveToThread(&thread);
Controller controller;
QObject::connect(&controller, &Controller::start, worker, &Worker::doWork);
QObject::connect(worker, &Worker::finished, &app, [&](int result) {
qDebug() << "结果" << result << "在" << QThread::currentThread()->objectName() << "处理";
thread.quit();
});
QObject::connect(&thread, &QThread::finished, worker, &QObject::deleteLater);
QObject::connect(&thread, &QThread::finished, &app, &QCoreApplication::quit);
thread.start();
emit controller.start(100);
qDebug() << "emit 之后主线程立刻继续往下走";
int ret = app.exec();
thread.wait();
return ret;
}
#include "threads.moc"
输出:
emit 之后主线程立刻继续往下走
doWork 运行在: "工作线程"
结果 5050 在 "主线程" 处理
逐段拆:
QThread::currentThread():返回当前正在执行这行代码的线程对应的 QThread 指针。setObjectName给它起名,方便打印。Worker *worker = new Worker;:没有 parent。有 parent 的对象不能 moveToThread。worker->moveToThread(&thread);:把 worker 的所属线程改成 thread。之后通过 Queued 连接调用 worker 的槽,都会在工作线程执行。Controller:一个只负责发 start 信号的对象,住在主线程。- 第一个 connect:controller(主线程)→ worker(工作线程),Auto 连接在发信号时发现不在同一线程,自动变成 Queued。
- 第二个 connect:worker 的 finished → lambda,上下文对象是
&app(主线程),所以 lambda 在主线程执行,[&]捕获了 thread 的引用以便调用thread.quit()让工作线程的事件循环退出。 - 第三个 connect:线程结束时删除 worker。这是 Qt 官方推荐的写法。
- 第四个 connect:线程结束时退出程序。
thread.start();:真正创建系统线程,新线程里执行run(),默认的 run() 就是调用exec()开一个事件循环。thread.wait();:等工作线程完全结束再让 thread 对象析构。QThread 对象在线程还在跑时析构,程序会报错退出。
按时间顺序:主线程 emit start(100) → 因为是 Queued,参数被打包投递到工作线程,emit 立刻返回,所以“emit 之后”这行先打印 → 工作线程的事件循环取到这个调用,执行 doWork,算出 5050,emit finished → 又是跨线程,投递回主线程 → 主线程事件循环执行 lambda,打印结果,让工作线程 quit → 工作线程结束,发 finished → deleteLater 和 app.quit → exec 返回 → wait → 退出。
这个例子里没有一把锁,数据在线程间传递全靠 Queued 连接拷贝参数。这是 Qt 多线程最推荐的风格。
继承 QThread 的写法和它的坑
class ReadThread : public QThread {
Q_OBJECT
protected:
void run() override {
while (!isInterruptionRequested()) {
QByteArray data = readSerialBlocking();
emit received(data);
}
}
signals:
void received(const QByteArray &data);
public slots:
void setBaudRate(int rate);
};
run()里是一个长循环,适合“一直阻塞读设备”这种任务。- 退出线程:外部调用
requestInterruption(),循环里用isInterruptionRequested()检查,然后外部wait()等它结束。不要用terminate(),它会在任意位置杀掉线程,可能正拿着锁、正写一半文件。 - 坑:
setBaudRate这个槽不会在新线程执行。ReadThread 对象是在主线程创建的,它的所属线程是主线程,所以它的槽也在主线程跑。如果 setBaudRate 和 run() 访问同一个变量,就是两个线程同时读写,需要加锁。
线程池:QtConcurrent
完整例子:
#include <QCoreApplication>
#include <QFutureWatcher>
#include <QThread>
#include <QtConcurrent>
#include <QDebug>
long long slowSum(int n) {
long long sum = 0;
for (int i = 1; i <= n; ++i) sum += i;
QThread::msleep(300);
return sum;
}
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QFutureWatcher<long long> watcher;
QObject::connect(&watcher, &QFutureWatcher<long long>::finished, &app, [&] {
qDebug() << "后台算完了, 结果:" << watcher.result();
app.quit();
});
watcher.setFuture(QtConcurrent::run(slowSum, 1000000));
qDebug() << "主线程没有等, 继续处理别的事件";
return app.exec();
}
输出:
主线程没有等, 继续处理别的事件
后台算完了, 结果: 500000500000
QtConcurrent::run(slowSum, 1000000):把函数和参数交给 Qt 的**全局线程池(thread pool)**执行,立刻返回一个QFuture<long long>,代表“将来会有的结果”。QFutureWatcher<long long>:尖括号里是结果类型,这是**类模板(class template)**的写法。它盯住一个 QFuture,算完时在主线程发finished信号。watcher.result():取结果。
它和 std::async + std::future 是一个思路,区别是 QFutureWatcher 能用信号通知,不用主线程阻塞去 get()。
线程同步工具对照
| Qt | 标准库 | 用途 |
|---|---|---|
| QMutex + QMutexLocker | std::mutex + std::lock_guard | 互斥锁 |
| QReadWriteLock | std::shared_mutex | 读多写少 |
| QWaitCondition | std::condition_variable | 等待条件 |
| QSemaphore | std::counting_semaphore(C++20) | 计数信号量 |
| QAtomicInt | std::atomic<int> | 原子变量 |
其他线程相关的坑
- QTimer 只能在有事件循环的线程里用,并且只能在它所属线程里 start/stop。
- QSqlDatabase 的连接只能在创建它的线程里使用,多线程访问数据库要每个线程各开一个连接(连接名不同)。
- 跨线程传自定义类型:Queued 连接要把参数拷贝进事件,类型必须被元类型系统认识。出现 “Cannot queue arguments of type ‘X’” 时,用
Q_DECLARE_METATYPE(X)声明并qRegisterMetaType<X>()注册。 - 跨线程传指针:只拷贝了指针本身,对象的生命周期要自己保证。
4.6 绘图和自定义控件
口述见 §3.8。
完整例子:画一个红点
#include <QApplication>
#include <QPainter>
#include <QWidget>
#include <QDebug>
class Dot : public QWidget {
public:
int paintCount = 0;
protected:
void paintEvent(QPaintEvent *) override {
++paintCount;
QPainter p(this);
p.setBrush(Qt::red);
p.drawEllipse(rect().adjusted(10, 10, -10, -10));
}
};
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
Dot dot;
dot.resize(100, 100);
QImage image = dot.grab().toImage();
qDebug() << "paintEvent 次数:" << dot.paintCount;
qDebug() << "中心像素是红色:" << (image.pixelColor(50, 50) == QColor(Qt::red));
return 0;
}
输出:
paintEvent 次数: 1
中心像素是红色: true
paintEvent(QPaintEvent *):重绘事件处理函数。控件需要画的时候(第一次显示、被遮挡后露出来、调用了 update)Qt 调用它。参数名省略了,因为这里用不到。QPainter p(this);:创建一个**画家(painter)**对象,画在当前控件上。它在函数结束时析构,完成绘制。p.setBrush(Qt::red);:**画刷(brush)**决定填充颜色;对应的 画笔(pen) 决定线条。rect().adjusted(10, 10, -10, -10):控件的矩形区域,四边各往里缩 10 像素。dot.grab():把控件渲染成一张图片(这里用来在无窗口环境下验证画对了)。真实程序里写dot.show(); return app.exec();。
做一个真正能用的自定义控件,还要什么
| 需求 | 重写或调用 |
|---|---|
| 画内容 | paintEvent |
| 数据变了要刷新 | 在 setter 里调 update() |
| 响应鼠标 | mousePressEvent / mouseMoveEvent / mouseReleaseEvent |
| 放进布局时的推荐大小 | 重写 sizeHint() |
| 窗口缩放 | resizeEvent |
| 对外通知 | 自己定义信号,比如 valueChanged |
性能要点
- 一秒刷新几十次的波形图,不要在 paintEvent 里重算所有点。数据更新时把静态部分(坐标轴、网格)画进一张
QPixmap缓存,paintEvent 里只贴缓存再画动态部分。 update(QRect)只刷新一块区域,比整个重绘便宜。- 大量图元(几千上万个可以拖动、缩放的图形)改用 Graphics View 框架(QGraphicsScene + QGraphicsView),它自带空间索引,只画可见的图元。
布局和样式表
- 布局(layout):
QHBoxLayout横排、QVBoxLayout竖排、QGridLayout网格、QFormLayout表单。控件加进布局后,窗口缩放时自动调整位置和大小。控件加进布局,会自动以布局所在的窗口为 parent。 - 样式表(Qt Style Sheets,QSS):语法和网页 CSS 几乎一样,比如
button->setStyleSheet("QPushButton { color: white; background: #2d7ff9; }")。
4.7 Model/View
口述见 §3.9。
为什么需要它
最简单的列表控件 QListWidget,每一行是一个 QListWidgetItem 对象。一百万行就是一百万个对象,内存和创建时间都扛不住;而且同一份数据想同时显示在表格和图表里,得存两份、同步两份。
Model/View 的想法是:数据留在原地,视图要显示第几行,就去问模型要第几行。
官方概念
- 模型(model):继承
QAbstractItemModel或它的简化版QAbstractListModel(列表)、QAbstractTableModel(表格)。 - 视图(view):
QListView、QTableView、QTreeView。 - 委托(delegate):
QStyledItemDelegate,负责一个格子怎么画、双击后用什么编辑器。 - 索引(QModelIndex):指向模型中某一格的“坐标”,包含行、列、父节点。
- 角色(role):同一格可以有多种数据:
Qt::DisplayRole显示的文字、Qt::DecorationRole图标、Qt::ToolTipRole悬停提示、Qt::EditRole编辑用的值,自定义从Qt::UserRole开始。
视图和模型之间的对话是这样的:
sequenceDiagram
participant V as 视图 QListView
participant M as 模型 NumberModel
V->>M: rowCount(),一共几行?
M-->>V: 1000000
Note over V: 屏幕只放得下第 0 到 19 行
loop 只问看得见的 20 行
V->>M: data(第 i 行, DisplayRole)
M-->>V: 现算出“第 i 行”
end
Note over V: 滚动以后,只再问新露出来的那几行
看图: 视图从来不把一百万行全要过来,它只关心屏幕上看得见的那一屏。下面的例子没有真的开窗口,用一个 for 循环模拟视图的这 20 次提问。
完整例子:一百万行的模型
#include <QAbstractListModel>
#include <QDebug>
class NumberModel : public QAbstractListModel {
public:
explicit NumberModel(int count) : m_count(count) {}
int rowCount(const QModelIndex &parent = QModelIndex()) const override {
return parent.isValid() ? 0 : m_count;
}
QVariant data(const QModelIndex &index, int role) const override {
if (!index.isValid() || role != Qt::DisplayRole) return {};
++m_dataCalls;
return QString("第 %1 行").arg(index.row());
}
int dataCalls() const { return m_dataCalls; }
private:
int m_count;
mutable int m_dataCalls = 0;
};
int main() {
NumberModel model(1000000);
qDebug() << "行数:" << model.rowCount();
for (int row = 0; row < 20; ++row) model.data(model.index(row), Qt::DisplayRole);
qDebug() << "第 19 行:" << model.data(model.index(19), Qt::DisplayRole).toString();
qDebug() << "data() 被调用次数:" << model.dataCalls();
return 0;
}
输出:
行数: 1000000
第 19 行: "第 19 行"
data() 被调用次数: 21
逐段拆:
explicit:禁止用一个 int 隐式转换成 NumberModel,防止NumberModel m = 5;这种误用。rowCount:告诉视图一共多少行。parent.isValid()为真表示在问“某一行下面的子行”,列表没有子行所以返回 0。这是树形模型遗留下来的接口形式,列表模型照写即可。data:视图问某一格某个角色的数据。返回类型 QVariant 是 Qt 的“万能值”容器,能装 int、QString、QColor 等各种类型。return {};返回一个空 QVariant,表示“这个角色我没有数据”。QString("第 %1 行").arg(index.row()):把%1替换成行号,Qt 的字符串格式化写法。mutable int m_dataCalls:data是 const 函数,本来不能改成员;mutable 允许在 const 函数里修改这个成员,这里只用来计数。- main 里的循环模拟视图的行为:屏幕上能看到 20 行,就只要 20 行的数据。
结论:模型根本没有存一百万个字符串,数据是按需计算的。把它交给 QListView(view.setModel(&model)),视图滚动到哪里就只问哪几行。
数据变化要通知视图
| 操作 | 要调用的函数 |
|---|---|
| 修改已有格子 | 改完后 emit dataChanged(左上索引, 右下索引) |
| 插入行 | beginInsertRows(...) → 改数据 → endInsertRows() |
| 删除行 | beginRemoveRows(...) → 改数据 → endRemoveRows() |
| 整体换数据 | beginResetModel() → 改数据 → endResetModel() |
批量插入 1 万行要调用一次 begin/end,不要循环调用 1 万次,否则视图会刷新 1 万次。
大数据量的其他手段
QListView::setUniformItemSizes(true)/QTreeView::setUniformRowHeights(true):告诉视图每行一样高,不用逐行计算尺寸。canFetchMore/fetchMore:滚到底部时再加载下一批,适合日志、数据库分页。- 排序过滤用
QSortFilterProxyModel套在原模型外面,不改原数据。
4.8 隐式共享
口述见 §3.10。
完整例子
#include <QString>
#include <QDebug>
int main() {
QString a = "hello";
QString b = a;
qDebug() << "拷贝后共用同一块数据:" << (a.constData() == b.constData());
b.append(" world");
qDebug() << "修改 b 之后:" << (a.constData() == b.constData());
qDebug() << a << b;
return 0;
}
输出:
拷贝后共用同一块数据: true
修改 b 之后: false
"hello" "hello world"
constData():返回字符串内部数据的地址。两个地址相等说明两个 QString 指向同一块内存。QString b = a;:拷贝构造,但没有复制字符,只是引用计数从 1 变 2。b.append(...):要修改了,b 发现数据被共享,先复制一份自己的(这一步叫 detach,分离),再修改。a 不受影响。
这就是写时复制(copy-on-write,COW)。Qt 里支持隐式共享的类很多:QString、QByteArray、QList、QMap、QHash、QImage、QPixmap 等。
坑:意外的分离
QList<int> list = other;
for (int x : list) { }
for (int x : std::as_const(list)) { }
第二行对一个非 const 且正被共享的 QList 做范围 for,会调用非 const 的 begin(),触发一次整个列表的复制。第三行用 std::as_const 把它当 const 用,就不会复制。老代码里的 qAsConst 作用相同,Qt 6.6 起已不推荐。
Qt 容器和标准库容器
Qt 6 里 QVector 只是 QList 的别名,两者合并了。Qt 容器和 STL 容器可以互相转换,新代码里两种都可以用;和 Qt API 打交道(参数、返回值)时用 Qt 容器更顺手。
4.9 网络:QTcpSocket 和粘包
口述见 §3.11。
Qt 网络是异步的
connect(socket, &QTcpSocket::readyRead, this, [this] {
m_buffer.append(socket->readAll());
for (const QByteArray &msg : unpack(m_buffer)) handleMessage(msg);
});
readyRead 表示“有新数据可读了”,不表示“来了一条完整消息”。TCP 是**字节流(byte stream)**协议,只保证字节按顺序到达,不保留发送时的边界:发送方连发 hello 和 qt,接收方可能一次收到 helloqt(粘包),也可能先收到 hel(半包)。
完整例子:长度头拆包
#include <QByteArray>
#include <QDataStream>
#include <QDebug>
#include <QIODevice>
#include <QList>
QByteArray pack(const QByteArray &body) {
QByteArray packet;
QDataStream out(&packet, QIODevice::WriteOnly);
out << quint32(body.size());
packet.append(body);
return packet;
}
QList<QByteArray> unpack(QByteArray &buffer) {
QList<QByteArray> messages;
while (buffer.size() >= 4) {
QDataStream in(buffer.left(4));
quint32 length = 0;
in >> length;
if (buffer.size() < 4 + qsizetype(length)) break;
messages.append(buffer.mid(4, length));
buffer.remove(0, 4 + length);
}
return messages;
}
int main() {
QByteArray stream = pack("hello") + pack("qt");
QByteArray buffer;
buffer.append(stream.left(7));
qDebug() << "第一次收到 7 字节, 拆出:" << unpack(buffer);
buffer.append(stream.mid(7));
qDebug() << "第二次收到剩余字节, 拆出:" << unpack(buffer);
qDebug() << "缓冲区剩余:" << buffer.size();
return 0;
}
输出:
第一次收到 7 字节, 拆出: QList()
第二次收到剩余字节, 拆出: QList("hello", "qt")
缓冲区剩余: 0
逐段拆:
pack:每条消息前面加 4 个字节的长度。QDataStream是 Qt 的二进制序列化工具,out << quint32(...)按**大端字节序(big-endian)**写入一个 32 位无符号整数,这正是网络传输常用的字节序。quint32是 Qt 定义的 32 位无符号整数类型。unpack(QByteArray &buffer):参数是引用,因为要把已经拆出的部分从缓冲区里删掉,剩下的半包留给下次。while (buffer.size() >= 4):连长度头都不够 4 字节,就先不拆。if (buffer.size() < 4 + qsizetype(length)) break;:长度头读到了,但消息体还没收齐,等下次。qsizetype是 Qt 6 表示大小的有符号整数类型,这里转换是为了和size()的类型一致、避免有符号无符号比较的警告。buffer.mid(4, length):从第 4 字节开始取 length 个字节,就是消息体。buffer.remove(0, 4 + length):把这条消息从缓冲区删掉,继续循环看后面还有没有完整消息。
按时间顺序:两条消息打包后共 4+5+4+2 = 15 字节。第一次只收到前 7 字节:长度头说消息体 5 字节,但只有 3 字节,break,什么也不拆。第二次收到剩下 8 字节,缓冲区凑齐 15 字节,循环两轮拆出两条。
其他网络类
- QUdpSocket:UDP 保留消息边界,不存在粘包,但可能丢包、乱序。
- QNetworkAccessManager:HTTP 请求,
get()返回QNetworkReply*,在它的 finished 信号里读结果,读完reply->deleteLater()。 - QTcpServer:监听端口,
newConnection信号来了用nextPendingConnection()拿到客户端 socket。
4.10 工程化:构建、中文乱码、部署、许可证
口述见 §3.12。
构建工具
| 工具 | 做什么 | CMake 里怎么开 |
|---|---|---|
| moc | 为 Q_OBJECT 类生成元对象代码 | set(CMAKE_AUTOMOC ON) |
| uic | 把 Designer 画的 .ui 文件转成 C++ 头文件 ui_xxx.h | set(CMAKE_AUTOUIC ON) |
| rcc | 把 .qrc 里列的图片、图标等资源编译进程序,代码里用 :/icons/a.png 访问 | set(CMAKE_AUTORCC ON) |
qmake 是 Qt 自己的老构建工具(.pro 文件),Qt 6 起官方推荐 CMake,但很多老项目还在用 .pro,面试可能问“两者都用过吗”。
常见编译错误:“undefined reference to vtable for Xxx”,多半是类里加了 Q_OBJECT 但 moc 没重新运行。qmake 项目重新执行 qmake,CMake 项目重新配置一次。
中文乱码
出现乱码的三个环节:
- 源文件存成什么编码:建议统一 UTF-8。
- 编译器按什么编码读源文件:GCC、Clang 默认 UTF-8;MSVC 默认按系统编码(中文 Windows 是 GBK)读,没有 BOM 的 UTF-8 文件会被读错。解决:编译选项加
/utf-8。 - 运行时和外部打交道时的转换:QString 内部是 UTF-16。和 Windows API、控制台、老文件交互时,用
QString::fromLocal8Bit/toLocal8Bit按系统编码转换;网络、JSON、文件一般用fromUtf8/toUtf8。
部署
- Windows:
windeployqt app.exe,自动把依赖的 Qt dll 和插件复制到 exe 旁边。 - macOS:
macdeployqt App.app。 - Linux:常用第三方的 linuxdeployqt,或者直接依赖系统里的 Qt 包。
经典报错:“This application failed to start because no Qt platform plugin could be initialized.” 原因是缺少 platforms 目录下的平台插件(Windows 是 qwindows.dll),用 deploy 工具打包就能解决。
嵌入式 Linux 上 Qt 常用 eglfs 或 linuxfb 平台插件,程序直接全屏画到屏幕上,不需要桌面环境,启动时用 -platform linuxfb 或环境变量 QT_QPA_PLATFORM 指定。
许可证
- Qt 有商业许可和开源许可两种。
- 开源版大部分模块是 LGPLv3:动态链接 Qt 库时,你的程序可以闭源商用;修改了 Qt 本身的代码要公开修改部分;要让用户能替换 Qt 库。
- 静态链接在 LGPL 下义务更多(要提供能重新链接的目标文件),很多公司因此买商业版或坚持动态链接。
- 一些附加模块(如 Qt Charts、Qt Virtual Keyboard)开源版只有 GPLv3,用了它们整个程序要按 GPL 开源,商业闭源项目要注意。
进程间通信(偶尔被问)
| 类 | 用途 |
|---|---|
| QProcess | 启动外部程序,读它的输出 |
| QLocalServer / QLocalSocket | 本机进程通信(Windows 命名管道 / Unix 域套接字) |
| QSharedMemory + QSystemSemaphore | 共享内存 |
| QTcpSocket | 跨机器或本机都行 |
| Qt D-Bus | Linux 桌面常用 |
第五部分 追问检查表
面试官问完一题,下一句最可能问的:
| 你刚答完 | 下一个追问 | 答案要点 |
|---|---|---|
| 信号槽是什么 | 和回调有什么区别 | 解耦、多对多、类型检查、对象删除自动断开、自动跨线程;代价是慢一些 |
| 信号槽原理 | moc 生成了什么 | staticMetaObject(类名、信号槽表)、信号的函数体、qt_static_metacall 分发调用 |
| 连接方式 | Auto 是什么时候决定的 | 发信号那一刻,而不是 connect 的时候 |
| 跨线程信号槽 | 接收线程没有事件循环会怎样 | 事件永远没人处理,槽永远不执行 |
| 跨线程信号槽 | 参数是引用会怎样 | Queued 下仍然会拷贝一份,所以安全;指针只拷贝指针本身 |
| Q_OBJECT | 不写会怎样 | 声明的信号没有函数体,链接报错;qobject_cast 转成这个类会编译报错(Qt 有静态断言);metaObject()->className() 返回父类名 |
| 对象树 | 会不会内存泄漏 | 没 parent 的 new 对象要自己管;循环 parent 不可能(树结构) |
| 对象树 | 子对象先 delete 会怎样 | 没问题,子对象析构时会从 parent 列表里移除自己 |
| deleteLater | 事件循环没跑时调用会怎样 | 等事件循环开始运行后才删;循环已退出后调用可能永远不删 |
| 事件循环 | 界面卡顿怎么排查 | 找主线程里的耗时代码(计时打印、性能分析工具),挪到后台线程 |
| processEvents | 有什么问题 | 重入:还没处理完,用户又点了一次同一个按钮 |
| 事件过滤器 | 多个过滤器的执行顺序 | 后装的先执行,任何一个返回 true 就停 |
| 事件 | accept 和 ignore 的作用 | ignore 后鼠标键盘事件传给父控件 |
| 多线程 | QThread 子类里的槽在哪个线程执行 | QThread 对象所属的线程,通常是主线程 |
| 多线程 | 怎么安全停止线程 | requestInterruption + 循环检查 + wait;或 quit + wait;不用 terminate |
| 多线程 | 子线程能不能 new 一个 QWidget | 不能,界面只能在主线程 |
| update | 为什么多次 update 只画一次 | 重绘请求被合并,回到事件循环时统一处理 |
| Model/View | 百万行为什么不卡 | 视图只请求可见行;自定义模型按需计算,不预先建对象 |
| Model/View | 数据变了视图没刷新 | 忘了发 dataChanged,或插入删除没包 begin/end |
| 隐式共享 | 线程安全吗 | 引用计数是原子的,各线程持有各自的副本安全;同一个对象被多线程同时修改仍然不安全 |
| 粘包 | 除了长度头还有什么办法 | 特殊分隔符(如换行,要处理内容里出现分隔符)、固定长度 |
| 打包 | 程序在别人电脑上打不开 | 缺 dll 或 platforms 插件,用 windeployqt;再用依赖查看工具确认 |
| 为什么选 Qt | 和 MFC、Electron 比 | 跨平台、C++ 性能、嵌入式支持好;Electron 包体大、内存占用高;MFC 只能 Windows |
第六部分 闭卷自测
先自己答,再点开对照。
1. emit 这个关键字去掉,程序行为会变吗?为什么?
答案
不会变。emit 是一个空宏,展开后什么都没有。emit valueChanged(v) 实际就是调用 moc 生成的 valueChanged 成员函数,写 emit 只是为了让读代码的人一眼看出这是在发信号。
2. 主线程的对象 A 连接到工作线程里的对象 B(默认连接方式)。A 在主线程 emit 时,B 的槽在哪个线程执行?如果换成工作线程里另一个对象 C 去 emit 同一个信号呢?
答案
两种情况 B 的槽都在工作线程执行。第一种:发信号的是主线程,B 不在当前线程,Auto 变成 Queued,投递到工作线程。第二种:发信号的线程就是工作线程,和 B 同线程,Auto 变成 Direct,当场在工作线程执行。
关键:Auto 看的是“发信号时所在的线程”和“接收者所属线程”是否相同,不看发送者对象属于哪个线程。
3. 下面代码有什么问题?
void MainWindow::onButtonClicked() {
QByteArray data = downloadBlocking("http://example.com/big.zip");
m_label->setText("下载完成");
}
答案
槽函数在主线程执行,阻塞下载期间事件循环停转,界面卡死、不能重绘,Windows 显示“未响应”。改法:用 QNetworkAccessManager 异步请求,在 finished 信号里更新 label;或把下载放进工作线程,下载完发信号回主线程。
4. 下面代码退出时会怎样?怎么改?
QPushButton button("OK");
QWidget window;
button.setParent(&window);
答案
崩溃。局部变量按声明的反序析构,window 先析构并 delete 子对象 button,但 button 在栈上,不能 delete;随后 button 离开作用域又析构一次。改法:交换两行声明顺序,或者 new QPushButton("OK", &window)。
5. 继承 QThread 写了一个 run() 循环,又在这个类里加了一个槽 setSpeed(int),从主线程通过信号调用它。setSpeed 在哪个线程执行?有什么风险?
答案
在主线程执行,因为 QThread 对象本身属于创建它的主线程。风险是 setSpeed 修改的成员变量同时被 run() 在工作线程读,这是数据竞争(两个线程同时访问同一数据且至少一个在写),需要加 QMutex 或用原子变量;或者改用 Worker + moveToThread 的写法。
6. 为什么说 Qt 里按值传 QString 不贵?什么时候它会真的复制?
答案
QString 是隐式共享的,按值拷贝只增加引用计数,不复制字符。当某个副本要被修改(调用非 const 函数),且数据正被别的对象共享时,才会分离出自己的一份。
7. 一个表格要显示 50 万条日志,用 QTableWidget 很卡。怎么改?
答案
换成 QTableView + 自定义 QAbstractTableModel:数据存在自己的结构里(比如 std::vector),rowCount 返回条数,data 按行号现取现算,不给每个格子建对象。追加日志时批量 beginInsertRows/endInsertRows。行高固定时关掉逐行计算尺寸。需要过滤时套一层 QSortFilterProxyModel,数据特别大则考虑 fetchMore 分批加载。
8. 客户端收到 readyRead 后直接 QJsonDocument::fromJson(socket->readAll()),偶尔解析失败。原因和改法?
答案
TCP 是字节流,一次 readyRead 可能只到了半个 JSON,也可能到了一个半。改法:协议里加长度头(或用换行分隔),数据先追加到缓冲区,凑够一条完整消息再解析,剩下的留在缓冲区等下次。
9. 在 A 的 paintEvent 里调用 update(),会怎样?
答案
每次画完又请求一次重绘,界面会不停重画,CPU 占用飙高。动画应该用 QTimer 定时调用 update,或用 QPropertyAnimation。
10. 用 lambda 连接信号时,为什么建议传第三个参数“上下文对象”?
答案
两个作用:一是上下文对象被删除时连接自动断开,避免 lambda 访问已删除的对象;二是跨线程时 lambda 会在上下文对象所属的线程执行(Queued),不传的话 lambda 在发信号的线程直接执行,可能在子线程里碰了界面。
附录 A 本地编译环境
CMake 模板(本文所有例子都用它编译):
cmake_minimum_required(VERSION 3.16)
project(qtdemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Concurrent)
add_compile_options(-Wall -Wextra -Wpedantic)
add_executable(signal_slot signal_slot.cpp)
target_link_libraries(signal_slot Qt6::Core)
add_executable(paint paint.cpp)
target_link_libraries(paint Qt6::Widgets)
add_executable(concurrent concurrent.cpp)
target_link_libraries(concurrent Qt6::Core Qt6::Concurrent)
构建和运行:
cmake -S . -B build -DCMAKE_PREFIX_PATH=$(brew --prefix qtbase)
cmake --build build -j8
./build/signal_slot
CMAKE_PREFIX_PATH告诉 CMake 去哪找 Qt。find_package(Qt6 REQUIRED COMPONENTS ...)找到要用的 Qt 模块。target_link_libraries(... Qt6::Core)把模块链接进程序。- 在没有显示器的环境跑界面程序,可以加环境变量
QT_QPA_PLATFORM=offscreen。
本机踩过的坑:macOS 上命令行工具(Command Line Tools)的 SDK 比链接器新时,连最简单的 C++ 程序都会链接失败,报 tapi error: malformed file ... unknown architecture。这跟 Qt 无关,改用 Xcode 自带的 SDK 即可:
export SDKROOT=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk
附录 B 学习顺序和练手项目
只看文档过不了 Qt 岗的项目追问。建议按下面顺序,每一步都自己写出来:
| 顺序 | 内容 | 对应小节 | 验收 |
|---|---|---|---|
| 1 | 跑通按钮程序,用 Designer 拖一个登录窗口 | 4.0 | 能说出 exec 之后程序在干什么 |
| 2 | 信号槽、对象树的例子全部敲一遍 | 4.1、4.2 | 不看文档写出 Counter 例子 |
| 3 | 定时器卡顿例子,改成界面版亲眼看“未响应” | 4.3 | 能解释卡顿原因 |
| 4 | Worker + moveToThread 做一个后台计算 + 进度条 | 4.5 | 进度条走动时界面能拖动 |
| 5 | 自定义控件:一个仪表盘或波形图 | 4.6 | 数据变化时正确刷新 |
| 6 | QTableView + 自定义模型显示一个大日志文件 | 4.7 | 10 万行滚动不卡 |
| 7 | 综合练手项目(见下) | 4.9、4.10 | 能打包在另一台电脑上运行 |
综合练手项目:TCP/串口调试助手。 这是工控上位机岗位最经典的练习:一个界面,能作为 TCP 客户端或服务端收发数据,支持十六进制显示、定时发送、收发计数、日志保存。它一次覆盖了信号槽、异步网络、粘包处理、定时器、Model/View 日志表、多线程、打包部署。做完它,面试时就有了一个可以被追问细节的 Qt 项目。
仓库里已经有一个可以直接编译运行的参考实现:projects/qt-net-assistant,约 860 行,README 里有阅读顺序、一次发送的完整经过和 6 个练习。建议先自己写,卡住了再对照。写完以后,按续篇附录 B 的顺序把它改造成一个分层清楚、能经得起项目追问的版本。
最后一点:简历上只写真正做过的东西。没在工作中用过 Qt,可以如实说“自学并写过调试助手这类练习项目”,把你在嵌入式 Linux、C++ 上的经验和 Qt 的事件循环、多线程联系起来讲,比硬说“熟练使用 Qt”更经得起追问。