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

Qt:面试速记 + 从零学习

这份文档分层使用:

场景读哪里
完全没碰过 Qt先读第四部分 4.0,装好环境跑通第一个程序
面试前 10 分钟第一部分速记页 + 第二部分易混对照
面试前一天第三部分口述稿,出声念一遍
平时学习第四部分逐个详解,一天一两节,自己把代码敲一遍
被追问时心里有底第五部分追问检查表
检验是否真会第六部分闭卷自测

第一次学 Qt,前三部分先整块跳过。 那里是学完以后复习用的答案,还没学的时候读,只会看到一堵词墙。直接从第四部分 4.0 开始按顺序读,标了“第一遍可跳过”的小节留到第二遍。

先说一个判断:Qt 的 API 确实好上手,拖控件、连按钮一两天就会。面试拉开差距的是下面三件事:

  1. 信号槽底下是怎么实现的,跨线程时槽函数到底在哪个线程执行;
  2. 事件循环是什么,界面为什么会卡死;
  3. 对象树怎么管理内存,什么时候会重复释放。

这三块吃透,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 对象 moveToThreadQThread 对象本身不在新线程里,它的槽默认在创建它的线程执行
子线程改界面不允许,界面控件只能在主线程操作子线程发信号,主线程的槽里更新界面
update vs repaintupdate 排队、可合并多次请求;repaint 立即同步重绘大多数时候用 update
自定义控件继承 QWidget 重写 paintEvent,在里面用 QPainter 画QPainter 画控件只能在 paintEvent 里
Model/ViewModel 管数据,View 管显示,Delegate 管单元格怎么画和编辑视图只向模型要可见行的数据,所以百万行也能不卡
隐式共享QString、QList 等拷贝时只加引用计数,改的时候才真拷贝(写时复制)对非 const 容器用范围 for 可能触发一次不必要的拷贝
TCP 粘包TCP 是字节流,没有“一条消息”的边界,要自己加长度头拆包readyRead 触发一次不代表来了一条完整消息
中文乱码源码编码、编译器读源码的编码、运行时转换三处不一致MSVC 加 /utf-8;和系统 API 打交道用 fromLocal8Bit
打包部署Windows 用 windeployqt,macOS 用 macdeployqtplatforms 插件目录会报 “no Qt platform plugin”
Widgets vs QMLWidgets 用 C++ 写传统桌面界面;QML 是声明式语言,适合动画、触屏、嵌入式大屏QML 和 C++ 靠 Q_PROPERTYQ_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

sendEventpostEvent
同步还是异步同步,处理完才返回异步,放进队列就返回
事件对象放哪栈上即可必须 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
给 parentparent 析构时界面控件,基本都这么管

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::setNumintdouble 两个重载,所以要 qOverload<int> 指明。

2.10 Widgets 和 QML

Qt WidgetsQt 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_castQ_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 NetworkTCP/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() constconst 放在函数名后面,表示这个成员函数不修改对象,叫常成员函数
  • setValueif (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。

练习

  1. if (v == m_value) return; 删掉,再让两个 Counter 互相连接(A 的 valueChanged 连 B 的 setValue,B 的连 A 的),会发生什么?
  2. 把 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> objQPointer 是 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 或其子类对象,描述“发生了什么”,比如 QMouseEventQKeyEventQPaintEventQTimerEvent
  • 事件队列(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. 事件循环被堵住时,一切都停了。换成界面程序,这 1 秒里窗口不会重绘,点击没反应,Windows 上标题栏会显示“未响应”。这就是“界面卡死”。
  2. 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)就表示“处理掉了”,后面不再走。

对应“处理事件的五种办法”,面试偶尔会问:

  1. 重写具体处理函数,如 mousePressEventkeyPressEvent(最常用);
  2. 重写 event(),在分发前统一处理,比如自定义事件、Tab 键;
  3. 给对象装事件过滤器;
  4. 给 QApplication 装事件过滤器,拦截全程序的事件;
  5. 继承 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::eventprotected 表示只有自己和子类能调用。收到自定义事件就处理并 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。

先记住三条规矩

  1. QThread 不是线程本身,是线程的管理者。QThread 对象住在创建它的那个线程里,只有 run() 函数里的代码在新线程执行。
  2. 每个 QObject 有一个所属线程(thread affinity),就是创建它的线程,或者 moveToThread 挪过去的线程。这个对象的槽(通过 Queued 连接调用时)、事件、定时器,都在所属线程执行。
  3. 界面控件只能在主线程创建和操作。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::SingleShotConnectionQt 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 + QMutexLockerstd::mutex + std::lock_guard互斥锁
QReadWriteLockstd::shared_mutex读多写少
QWaitConditionstd::condition_variable等待条件
QSemaphorestd::counting_semaphore(C++20)计数信号量
QAtomicIntstd::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)QListViewQTableViewQTreeView
  • 委托(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_dataCallsdata 是 const 函数,本来不能改成员;mutable 允许在 const 函数里修改这个成员,这里只用来计数。
  • main 里的循环模拟视图的行为:屏幕上能看到 20 行,就只要 20 行的数据。

结论:模型根本没有存一百万个字符串,数据是按需计算的。把它交给 QListViewview.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)**协议,只保证字节按顺序到达,不保留发送时的边界:发送方连发 helloqt,接收方可能一次收到 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 里怎么开
mocQ_OBJECT 类生成元对象代码set(CMAKE_AUTOMOC ON)
uic把 Designer 画的 .ui 文件转成 C++ 头文件 ui_xxx.hset(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 项目重新配置一次。

中文乱码

出现乱码的三个环节:

  1. 源文件存成什么编码:建议统一 UTF-8。
  2. 编译器按什么编码读源文件:GCC、Clang 默认 UTF-8;MSVC 默认按系统编码(中文 Windows 是 GBK)读,没有 BOM 的 UTF-8 文件会被读错。解决:编译选项加 /utf-8
  3. 运行时和外部打交道时的转换: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 常用 eglfslinuxfb 平台插件,程序直接全屏画到屏幕上,不需要桌面环境,启动时用 -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-BusLinux 桌面常用

第五部分 追问检查表

面试官问完一题,下一句最可能问的:

你刚答完下一个追问答案要点
信号槽是什么和回调有什么区别解耦、多对多、类型检查、对象删除自动断开、自动跨线程;代价是慢一些
信号槽原理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能解释卡顿原因
4Worker + moveToThread 做一个后台计算 + 进度条4.5进度条走动时界面能拖动
5自定义控件:一个仪表盘或波形图4.6数据变化时正确刷新
6QTableView + 自定义模型显示一个大日志文件4.710 万行滚动不卡
7综合练手项目(见下)4.9、4.10能打包在另一台电脑上运行

综合练手项目:TCP/串口调试助手。 这是工控上位机岗位最经典的练习:一个界面,能作为 TCP 客户端或服务端收发数据,支持十六进制显示、定时发送、收发计数、日志保存。它一次覆盖了信号槽、异步网络、粘包处理、定时器、Model/View 日志表、多线程、打包部署。做完它,面试时就有了一个可以被追问细节的 Qt 项目。

仓库里已经有一个可以直接编译运行的参考实现:projects/qt-net-assistant,约 860 行,README 里有阅读顺序、一次发送的完整经过和 6 个练习。建议先自己写,卡住了再对照。写完以后,按续篇附录 B 的顺序把它改造成一个分层清楚、能经得起项目追问的版本。

最后一点:简历上只写真正做过的东西。没在工作中用过 Qt,可以如实说“自学并写过调试助手这类练习项目”,把你在嵌入式 Linux、C++ 上的经验和 Qt 的事件循环、多线程联系起来讲,比硬说“熟练使用 Qt”更经得起追问。

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