做 Qt 这行也有些年头了,平时忙于业务,很少系统性地把知识点串一遍。前阵子组里来了个转行做 C++ 桌面开发的同事,基础不错但面对 Qt 总是东一榔头西一棒子,今天查信号槽语法,明天查多线程用法,效率很低。我干脆带他把我平时积累的 Qt 知识体系过了一遍,从核心机制到实战坑点,从 Qt 5 到 Qt 6 的变化,尽量讲清楚每个设计背后的道理。整理完之后发现,这份总结不仅对新手有用,对写了好几年 Qt 的老手也有查漏补缺的价值,干脆整理成文字分享出来。
这篇总结不打算讲怎么安装环境、怎么拖控件,那些照着官方文档就能搞定,我更想聊的是 Qt 底层那套设计思想:信号槽怎么运作、对象树怎么管理内存、事件循环到底在转什么、Model/View 为何能撑起大型项目,以及多线程到底怎么写才不容易崩。理解这些底层逻辑之后,你再去看具体 API,会发现很多用法不用背,自然就推导出来了。适合刚入门想建立完整知识框架的人,也适合写了一阵子但总觉得哪里没吃透的朋友。
1 信号槽机制:Qt 组件协作的灵魂
信号槽(Signal & Slot)是 Qt 最核心的机制,也是很多从 C 语言回调风格转过来的开发者最初觉得别扭的地方。我常跟新人说,信号槽本质上就是一套类型安全的观察者模式。你别把它想得多玄乎,它就是"某个事件发生了,我要通知谁、通知完了之后要做什么"这么个东西。
1.1 底层原理:元对象系统与 MOC
信号槽能运作,靠的是 Qt 的元对象系统(Meta-Object System)。C++ 本身不具备反射能力,也就是程序运行时没有办法自动知道一个类有哪些方法、哪些属性。Qt 为了让信号槽能按名字匹配、跨对象通知,在编译前加了一个预处理步骤:MOC(Meta-Object Compiler,元对象编译器)。它会扫描头文件中带有Q_OBJECT宏的类,把信号和槽的元信息生成到一个额外的moc_xxx.cpp文件里,再与普通 C++ 代码一起参与编译。所以后面你用connect时传入的函数指针或字符串,能够被解析成元对象系统里注册的条目,最终通过调用QMetaObject::invokeMethod之类的能力去触达具体函数。
如果你之前写过 C 风格回调,那你会有体会:回调函数指针得保证类型完全一致,参数错了编不过,而且回调是单向的,一方注册另一方执行,没有解耦的可视化关系。信号槽相比之下更加灵活,connect一个信号可以连接多个槽,一个槽可以被多个信号触发,信号还可以连着信号。信号只是"广播一件事",具体谁来响应、哪些槽会被通知、连接关系是否还在,都由元对象系统动态管理。
1.2 连接语法演进:从宏到函数指针
Qt 5 之前主要用SIGNAL/SLOT宏来连接:
connect(sender, SIGNAL(clicked()), receiver, SLOT(onClicked()));这种写法本质上是字符串匹配,connect的时候只是把字符串拿去解析,如果拼写错误,运行时才报 "No such slot",这还挺让人抓狂的。到了 Qt 5,官方引入了基于函数指针的新语法:
connect(sender, &QPushButton::clicked, receiver, &MainWindow::onClicked);函数指针语法的好处是类型安全。如果你把槽函数参数写错了,编译阶段直接报错,不给你留到运行时的机会。新语法也支持 lambda 表达式,这让很多一次性处理逻辑变得非常顺手:
connect(button, &QPushButton::clicked, this, [this](bool checked) { qDebug() << "button toggled" << checked; });我也实测过,新语法在性能上也有优势,因为免去了字符串匹配的运算开销,虽然单次差别微小,但高频率信号下还是看得出来。旧语法在 Qt 6 里逐渐边缘化,新项目我直接要求组内成员用新语法。
这里有个细节容易踩坑:当信号或槽有重载时,直接用&Sender::signal可能导致编译歧义,编译器不知道你要取哪个重载。解决办法是显式指定:
connect(sender, QOverload<bool>::of(&QPushButton::clicked), receiver, &MainWindow::onClicked);我看过不少同事在这里卡壳,其实就是告诉编译器"我要的是带bool参数的那个 clicked 版本"。
1.3 连接类型:队列连接与直接连接
信号槽还有一个容易被忽略但很关键的维度:连接类型。Qt 里的自动连接(AutoConnection)会根据发送者和接收者是否在同一线程来决定行为。同一线程内,信号触发时槽函数会被直接调用,这叫直接连接(DirectConnection);跨线程时,信号触发后会被打包成事件投递到接收者所在线程的事件循环里,等到对方的事件循环轮询到再执行,这叫队列连接(QueuedConnection)。
这个机制对线程间通信有着极其重要的意义:你可以在后台工作线程发送信号,UI 线程通过槽函数更新界面,Qt 自动帮你完成了跨线程的数据投递,不需要你手动加锁。不过要记住,如果接收者所在线程没有跑事件循环(比如moveToThread之后线程内的exec()没启动),队列连接就不会被触发。另外Qt::BlockingQueuedConnection会阻塞发送线程直到槽函数执行完毕,用的时候要小心,容易卡死。
我在实际中做得最多的事就是给跨线程的信号传参,参数尽量传值类型。传指针意味着发送者销毁后指针可能悬空,接收端拿到野指针再去访问就崩溃了;而传QString、int这些值类型就没有这个问题,Qt 会做一次拷贝再入队。
2 对象模型与生命周期:理解 QObject 的内存管理规则
C++ 的内存管理是老大难,Qt 通过 QObject 的父子树机制给了大家一个相对省心的方案。但省心不等于不用理解,我见过太多 Java 转过来的人把 Qt 当成带垃圾回收的框架乱用,结果要么内存泄漏要么 double free。
2.1 父子对象树:谁 delete 谁
每一个 QObject 都可以有父对象。当你把某个子对象setParent给另一个对象时,或者像new QLabel(text, parent)这样在构造时传入 parent,就建立了一条父子关系。父对象析构时,会自动遍历并删除所有子对象。这套机制对 Widget 尤为关键:你在界面上拖了一堆按钮、标签,每个控件都有层级关系,窗口关闭的时候整棵对象树被释放,省得手动一个个 delete。
同样重要的是:Connection 也会随任一端对象销毁而自动断开。所以你不太需要担心信号连着连着,接收对象已经没了。这依赖 QObject 析构时的清理逻辑,元对象系统会把相关的连接都查出来移除。
有个我特别提醒新人的点:不要把栈上的对象作为子对象加入父对象。比如:
QPushButton button(&window);如果 window 是堆上的指针对象,而 button 是栈上变量,window 析构时会去 delete button,但 button 根本不在堆上,直接崩溃。这种情况传递&window的地址进去等于埋了一颗雷。Qt 的父子机制只对堆对象可靠。
2.2 deleteLater 与事件循环
有时候你并不想立刻删除一个对象,尤其在一个对象的槽函数里想去 delete 信号的发送者,直接 delete 会导致正在执行的函数体访问已经销毁的this,跑完多半要崩。Qt 提供了deleteLater(),它会向对象所属线程的事件循环投递一个 DeferredDelete 事件,等当前事件处理完毕、控制权回到事件循环之后才真正销毁对象。
我记得有次排查一个问题:点击按钮关闭窗口,窗口close()后资源没有被释放,后来发现是窗口是栈上创建,槽函数里又调用了deleteLater(),根本等不到释放就被作用域出栈销毁了。这里要注意,deleteLater 必须搭配事件循环,如果一个对象位于没有事件循环的线程中,垃圾删除事件会一直堆积,不会触发实际删除。
2.3 QPointer 与 qobject_cast 的使用技巧
还有两个 QObject 衍生工具,写工程时几乎天天用到。
一个是QPointer<T>。它是一个"智能指针",但它不做资源释放,只做悬空检测。指向的 QObject 被销毁后,QPointer 自动变成nullptr,你可以安全判断:
QPointer<QLabel> label = new QLabel(); delete label; if (label) { ... } else { qDebug() << "已销毁"; }这在异步操作中特别有用。比如你把一个对象指针传给后台线程的任务,后台可能跑了一段时间后回来访问这个指针,此时对象可能已经被用户关窗口销毁了,QPointer 能帮你优雅规避崩溃。
另一个是qobject_cast,它依赖元对象系统做安全的向下转型。举个例子,你在遍历一堆 QObject 时想找到其中某个 QPushButton,直接用static_cast一旦类型不对就 UB(未定义行为),而qobject_cast会在运行时比对元对象信息,类型不匹配则返回nullptr,安全很多。只要是继承自 QObject 且声明了 Q_OBJECT 的类,都可以用qobject_cast来转。它本质上就是 dynamic_cast 在 Qt 对象模型下的替代品,而且因为 Qt 有完整的类型字符串记录,可读性和性能都不错。
2.4 子类化注意事项
写一个自定义类继承 QObject,有几个细节要先做好:
- 类声明里必须写
Q_OBJECT宏,不然 signals 和 slots 关键字只是空壳,MOC 也不会处理这些类。 - QObject 禁止拷贝构造和赋值。MOC 生成了相关代码把拷贝禁掉了,右侧是 Qt 设计哲学之一。如果你需要的对象要在容器里飞来飞去,考虑用
QSharedPointer包裹。 - 构造函数里参数带 QObject *parent = nullptr,这是 Qt 惯例,让调用方方便控制生命周期。
我自己见过最迷的操作:有人试图把 QObject 子类放进QVector并期待自动深拷贝,编译一过就报错,这就是没理解 QObject 的"非拷贝"特性。正确的做法是用QList<QSharedPointer<T>>或者干脆用对象地址存指针。
3 事件系统:Qt 如何处理每一次点击与绘制
信号槽解决的问题是如何在对象间通信,那么系统底层的鼠标点击、键盘输入、绘制请求又是怎么流转到你的代码里的?这就要聊 Qt 的事件系统。事件与信号不是一个东西:事件是 QEvent 及其子类实例,由 Qt 内部或用户构造并派发;信号通常是在某个事件被处理后发送出来的通知。很多人的理解误区是把它们混为一谈,其实事件是"输入",信号是"输出"。
3.1 事件循环的本质
Qt 程序跑起来之后,main函数进入app.exec(),这不是一个"挂起"调用,而是进入一个while循环,不断地从事件队列里取出事件并派发。在没有事件时线程会进入休眠等待。这就是为什么你写一个死循环去阻塞主线程,界面会卡死——因为事件循环再也轮不到了,鼠标点击、重绘请求全堵在队列里。
事件循环是可以嵌套的。比如QDialog::exec()自己又开启了一个局部事件循环,这个细节解释了为什么exec()模态弹窗时,界面底层窗口还能响应部分重绘事件。写代码时如果有模拟阻塞等待的地方,可以考虑用局部QEventLoop配合QTimer来处理,避免整体卡死。
3.2 事件过滤与转发链条
一个事件产生后,会经过大致这么一条链路:系统事件进入 Qt 的事件队列,轮到它时被QCoreApplication::notify()派发给目标对象的event()方法,对象再根据事件类型调用对应的具体事件处理函数,比如mousePressEvent()、paintEvent()。在上面的每层环节中,你都可以插入"偷看"或拦截。
使用installEventFilter可以给某个对象安装事件过滤器,过滤器的eventFilter会在目标对象的event()前被调用。如果返回 true,事件就被截胡,后续都不会再执行。这在很多场景下非常有用,比如批量控件统一处理 Enter 按下、全局快捷键监听、日志埋点等。我写过最典型的一个场景:表格控件内禁止鼠标滚轮误触翻页,只要给滚动区域装一个过滤器,在eventFilter里判断QEvent::Wheel并直接 return true,一秒钟搞定。
另外重写QObject::event()是处理自定义事件的最直接方式。我要提醒一点:event()里如果不是你关心的类型,一定要记得调用父类的event()传给上层处理,不然很多默认行为会丢失,比如窗口绘制、焦点变化都可能出问题。
3.3 自定义事件类型
当你需要跨线程传递非 Qt 内置事件时,可以派发自定义事件。先要给自己的事件类型起一个唯一编号:
static const QEvent::Type MyEventType = static_cast<QEvent::Type>(QEvent::registerEventType());然后QCoreApplication::postEvent(receiver, new MyEvent(...))投递事件。postEvent是异步的,事件进入接收者所属线程的事件队列,由该线程的循环处理。自定义事件适合那些"让某个线程去执行特定动作"的场景,比如你希望 UI 线程在下一次事件循环里触发某个刷新动作,避免直接在子线程里操作 UI。
这里有个坑:postEvent 里的 Event 对象不能放在栈上,而且投递后不要再手动 delete,Qt 会在处理完后自动释放。很多人初学自定义事件时随手MyEvent e; postEvent(&receiver, &e);,事件还没被处理栈上的对象已经销毁,后面访问就是未定义行为。
3.4 线程与事件循环的对应关系
每个线程都可以有自己的事件循环,但默认不会自动有。只有你在该线程内调用了exec(),这个线程才有了队列和派发能力。像QThread子类重写run(),如果没有在 run 里调用exec(),那这个线程是不具备事件循环的,跨线程信号队列连接的槽函数永远不会被调用。这是一个没有经验的人很容易踩的坑,症状表现为:"我明明connect了跨线程的槽,为什么槽函数不执行"。
正确做法是把工作对象moveToThread到目标线程,并且确保该线程内有事件循环;再把相关联的启动信号从别的线程发出,Qt 的队列连接才能起作用。
4 模型视图框架:数据与界面分离的正确姿势
很多人写桌面应用喜欢直接用控件:QTableWidget、QListWidget往里面塞字符串,省事是真的,但项目一复杂就后悔了——数据、界面和交互逻辑全部耦合在一起,改一个字段要翻遍十几个文件。Qt 的 Model/View 框架就是为了解决这个问题,它借鉴了 MVC 思想:模型负责提供数据,视图负责展示,委托负责编辑。
4.1 从控件思维到模型思维
直接使用QTableWidget时你相当于用了一个既存储数据又包含视图的"大杂烩"。一旦后台数据结构变化,你要同步维护控件里的单元格,逻辑一多代码就乱。而QTableWidget内部其实也是一个 Model,只是帮你把数据存到它的内部模型里了。对于稍微复杂一点的项目,我更建议直接用QTableView + QAbstractTableModel。
用模型的核心思路是:模型里持有一份数据,视图通过index()获取模型索引,再向模型请求某一行某一列的数据。界面只是你的数据的"投影",数据变化时你只需通知视图刷新特定区域。
4.2 QAbstractItemModel 的接口本质
实现一个自定义模型,你必须重写这几个纯虚函数:
int rowCount(const QModelIndex &parent) const; int columnCount(const QModelIndex &parent) const; QVariant data(const QModelIndex &index, int role) const; QVariant headerData(int section, Qt::Orientation orientation, int role) const; QModelIndex index(int row, int column, const QModelIndex &parent) const; QModelIndex parent(const QModelIndex &index) const;很多人第一次看到这些接口会觉得抽象:为什么要有index和parent?因为模型要支持树形结构,item 与 item 之间存在父子关系。对简单的表来说,parent永远返回QModelIndex()即可,这个"无效索引"代表根节点;对树形结构,每一次父子查询都要给出真实层级索引。
data()中的 role 参数也很关键。同样的数据,在不同场景下视图会请求不同的角色:Qt::DisplayRole表示要显示的文字,Qt::EditRole是编辑时用的数据,Qt::DecorationRole是图标,Qt::ToolTipRole是悬停提示。你不需要为所有 role 都返回值,返回无效 QVariant 就好,视图会自动跳过。
4.3 数据更新与大数据量优化
我写模型时最容易犯的错误是数据变了就直接把整个 modelreset掉,导致视图刷新全量数据,闪烁且性能差。正确做法是定位变化范围,在修改数据前插入beginInsertRows/endInsertRows通知视图新增,修改数据后发射dataChanged指定变化的区域。例如:
beginInsertRows(QModelIndex(), row, row); m_data.insert(row, newItem); endInsertRows();两种变化通知方式有本质区别:dataChanged只是刷新指定单元格重绘;beginInsertRows会调整视图的可见范围。搞混了视图可能不会实时更新或直接越界崩溃。
对于超大列表(几百万条实时日志、股票行情),模型层可以做分页式懒加载:重写canFetchMore()和fetchMore(),视图滚动到底部再加载后面的数据。另外不要在data()里做耗时计算,这个函数会被视图高频调用,任何停顿都会被用户感知成卡顿。必要时可以先在模型内部预处理数据,data()只做读取返回。
4.4 委托:控制单元格的编辑交互
QTableView 默认单元格编辑输入的基本都是内嵌的QLineEdit,但真实业务中往往需要下拉框、日期选择器、勾选框,这就要自定义委托。你可以子类化QStyledItemDelegate,重写createEditor()返回自定义编辑控件,重写setEditorData()把模型数据填充进编辑器,重写setModelData()把编辑结果写回模型。
有时候组员会用setCellWidget往视图里塞控件,想定制交互。这个办法在个别场景能顶一下,比如固定几个按钮放在最后一列;但对大量行来说,创建海量 CellWidget 会给系统带来很大负担,而且无法随滚动复用。委托是编辑交互的标准答案,CellWidget 只适合少量静态内容的场景。
5 多线程编程:避免界面卡死的实战经验
桌面应用只要涉及耗时操作(文件读取、网络请求、复杂计算),一放到 UI 线程,用户就会看到窗口"未响应"。多线程是 Qt 开发避不开的课题。我见过不少人用QThread用得很生猛:继承 QThread 然后重写 run,在 while 循环里做轮询,用 sleep 来控制间隔,最后界面卡、线程逃逸、崩溃,各种问题堆在一起。
5.1 QThread 的使用模式:子类化还是 moveToThread
网上一搜 QThread 用法,一半是子类化重写 run 的教程。这种做法本身没有错,在 run 里执行批量计算任务也说得通。但它的局限是:如果你需要在任务执行过程中响应外界的信号(比如取消任务、更新参数),子类化会比较绕。更推荐的模式是工作对象 + moveToThread:
class Worker : public QObject { Q_OBJECT public slots: void doWork(); }; QThread *thread = new QThread; Worker *worker = new Worker; worker->moveToThread(thread); // 启动信号触发槽函数 connect(thread, &QThread::started, worker, &Worker::doWork); // 槽函数结束后退出线程 connect(worker, &Worker::finished, thread, &QThread::quit); // 线程结束后再释放对象 connect(thread, &QThread::finished, worker, &QObject::deleteLater);这套组合拳的核心是对象与线程的归属关系。moveToThread之前,worker 属于主线程;move 之后,worker 的事件处理、队列连接槽函数都归新线程管辖。注意:不要在moveToThread之后直接调用 worker 的槽函数,那仍然是在主线程执行;只有通过信号队列连接,槽函数才会跑在目标线程里。
我自己比较反感在一个 QThread 里做三件事:让它跑任务、又在外面直接thread->requestInterruption()配合线程内标志,代码一多容易乱。设计上我更愿意把任务拆小,通过isInterruptionRequested精心设计取消逻辑,或者干脆用 QtConcurrent 跑一个短任务,用 QFutureWatcher 管理完成信号。
5.2 线程间通信与数据竞争
跨线程传数据,Qt 给了你信号槽这么一套便利工具,但很多人用着用着把指针到处传,结果后台线程改数据,前台线程也在读数据,数据竞争和潜在崩溃就来了。
跨线程信号槽的参数必须使用Qt::QueuedConnection支持的参数类型。QString、int、QVector、自定义类型的 const 引用都可以,但你要确保该类型用Q_DECLARE_METATYPE注册过元类型。如果参数是可变的指针或者迭代器,Qt 无法保证线程安全,你就得自己加同步。
当多个工作线程同时访问一个共享容器时,用QMutex锁保护是最基础的手段。读写不均匀的话,可以换成QReadWriteLock,读多写少时性能更好。但锁太细容易死锁,太粗又损失并发,要在实际需求里找平衡。减少共享状态的另一个思路:把数据拷贝分发到各个线程,各自处理完再合并回来,避免多个线程同时写入同一块区域。
我处理后台任务返回结果时通常会这样安排:界面先调QtConcurrent::run在后台执行耗时计算;计算结果打包成一个struct,通过QFutureWatcher的 finished 信号带回主线程更新界面。全程不需要手动创建 QThread,也不会因为线程生命周期管理不当而崩溃。真正的逻辑代码分离,UI 线程只做展示。
5.3 QThread 的销毁与安全退出
QThread 对象在线程仍在运行时被直接 delete 会直接导致程序崩溃,这是新手最常踩的致命错误之一。Qt 文档里明确说:必须等线程结束后才能析构 QThread。所以我通常这样收尾:
thread->quit(); // 让线程退出事件循环 thread->wait(); // 阻塞等待线程结束,超时判断 delete thread;在槽函数里执行耗时任务时,不要让函数体通过 sleep 硬等,这样线程就无法响应 quit 信号。长时间任务应该有循环检查,比如每处理一段就判断一次isInterruptionRequested。我见过最极端的场景是后台线程里QThread::sleep(10)后直接 return,导致线程一直无法退出,主程序退出时挂了很久,全局变量析构顺序又出了问题。
在 Qt 6 里 QThread 的默认实现依然是run()里调用exec(),如果你没有重写 run,那这个线程默认就是一个"有事件循环的线程",很适合做 worker 对象常驻的线程载体。
5.4 原子操作与并发注意事项
多线程下多线程同时++sharedCounter是非原子操作,会丢失更新。可以用QAtomicInt处理计数器,但如果是要修改一整块业务数据,原子量就不够了,需要互斥锁。我比较喜欢把业务数据的读写封装成一个类,内部持有 QMutex,所有公开接口都走锁,这样调用方不容易误用。
另外,C++ 原生std::thread和 Qt 的 QThread 能混用吗?可以,但我不建议在一个项目里同时大量混用,会让线程生命周期管理变得很混乱。统一走 Qt 的线程模型,最大的优势是可以直接和信号槽、事件循环、moc 元对象系统无缝集成,不用操心跨框架协作的边界问题。
5.5 UI 线程与界面更新的铁律
所有 QWidget 相关的操作必须在主线程进行。这条规则几乎没有例外。后台线程直接操作 QWidget 会导致状态不一致、崩溃或界面假死。哪怕是简单的ui->label->setText(),在子线程里调用都可能引发未定义行为。
解决方案是让后台线程发送信号,主线程槽函数里再更新界面。信号携带的是值类型数据,不会产生悬空引用。如果再讲究一点,你可以先把任务里的中间结果分批 emit 出来,UI 每次接收后刷新进度条,用户就能看到实时反馈。pushButton 的 enabled 状态、进度条的值、QLabel 的文字,全部放在主线程槽函数里改,这条铁律我列在团队规范第一条。
6 Qt 6 的核心变化:新工程应该注意什么
到了 Qt 6,框架相比 Qt 5 有了不少调整,如果你要从 Qt 5 迁移到 Qt 6,或者干脆新项目直接基于 Qt 6,有几个变化我建议提前了解。
6.1 模块结构调整
Qt 6 做了一次较大的模块拆分。原来 Qt 5 里 widgets 模块还在QtWidgets里,但很多功能被移动、重命名。例如原来依赖QtGui的某些高 DPI 处理、窗口系统接口,现在细化到了 QtGui 与其他平台抽象层模块。这意味着你在 CMake 里find_package时要精确指定需要的模块,哪个模块缺失编译器直接报错。
具体到实际开发,你原来写#include <QWidget>在 Qt 5 和 Qt 6 都适用,但如果你想用 QML 和 Quick,Qt 6 里 Quick 模块改动不小,很多类型和属性都升级了版本。我自己接手一个从 Qt 5 迁移过来的项目时,编译错误有三分之一是头文件路径变了、三分之一是 API 改名,剩下的是废弃类被移除。建议新建工程直接用官方 CMake 模板,不用自己拼复杂的 qmake 文件了。
6.2 图形功能与渲染架构
Qt 6 的渲染架构转向了 RHI(Renderer Hardware Interface),它抽象了底层图形 API,使得同一份代码可以跑在 Vulkan、Metal、Direct3D 上。这对使用 Qt Quick 3D 做三维渲染或自绘场景有实际帮助。对纯 Widgets 项目来说,变化没那么直观,但在复杂自绘控件和特效方面,底层的改善也是实打实的。
6.3 字符串与数据类型的调整
Qt 6 里QString的设计做了优化,和QByteArray之间的转换更明确,部分旧 API 被删除。最明显的影响就是那些依赖QString::null的代码在 Qt 6 下无法编译,现在空字符串统一用QString()表示。如果你是老项目迁移,这一步要提前做兼容排查。
另外 QVariant 的元类型系统也有变化,很多自定义类型注册方式改了,不再需要qRegisterMetaType的复杂调用,新的Q_DECLARE_METATYPE配合模板重构后更简洁。
6.4 构建与生态工具链
Qt 6 的官方推荐构建工具是 CMake,qmake 已基本淡出。以前用 Qt 5.15 写 qmake 工程的项目,到 Qt 6 要么留旧版本,要么花时间迁移到 CMake。CMake 的 Qt 模块提供了qt_standard_project_setup、qt_add_executable等辅助函数,封装了大量样板代码。新写项目时我推荐这么起步:
cmake_minimum_required(VERSION 3.16) project(DemoApp) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 REQUIRED COMPONENTS Widgets) qt_standard_project_setup() qt_add_executable(DemoApp main.cpp MainWindow.cpp MainWindow.h ) target_link_libraries(DemoApp PRIVATE Qt6::Widgets)这套 CMake 写出来后,跨平台(Windows / macOS / Linux)构建都比较顺,不会再有 qmake 时代各种平台差异导致的目录混乱。
6.5 权限管理与高 DPI
现在的桌面系统对权限管得越来越严,Qt 6 在移动端和桌面端的 API 里加入了不少权限处理的接口。虽然 Windows 桌面应用大部分场景不涉及,但如果你做跨平台应用,建议先看官方文档里的权限章节。
高 DPI 支持上 Qt 6 默认开启Qt::AA_EnableHighDpiScaling对应机制。Windows 高分屏下如果发现界面缩放模糊,先检查自己有没有在 main 里设置等高 DPI 属性,或者用自适应平台插件。这个踩过的坑我记忆犹新,写 Qt 5 时经常因为高分屏缩放糊掉被测试提 bug,Qt 6 里用系统默认策略处理改善了很多。
6.6 Qt 5 与 Qt 6 迁移的兼容性建议
如果你的项目代码量很大,建议分阶段迁移:先升级到 Qt 5.15 LTS,把弃用 API 用新的替代掉,开启编译警告并逐个清理;然后再切换到 Qt 6,构建 error 量控制在可接受范围。Qt 提供了qt5compat模块,保留了一部分 Qt 5 的接口,新代码可以不用这些兼容层,只有老代码才建议依赖它,这是一种过渡方案,不要让它成为惯性。
实际迁移过程中,把 QRegExp 换成 QRegularExpression、QDesktopWidget换成 QScreen 接口、QTextCodec相关的调用调整成 Qt6 的编码处理方式,这些都是老项目最常见的改动点。
最后的个人体会
碍于篇幅,这篇总结只把最核心的知识框架搭出来了,很多细节展开来讲可以写一本书。以我的经验来看,信号槽理解透了,Qt 的一半思路就打开了;对象树和事件循环理解了,另一半也差不多了;多线程和 Model/View 属于进阶,决定了你能不能拿 Qt 撑起一个中等以上复杂度的产品。我见过太多人把 Qt 当成"会用控件"就算会了,结果一遇到自定义交互、大数据刷新、后台任务就开始翻车。真正值得反复琢磨的,从来不是那些 API 怎么调,而是 API 背后的那套机制为什么这么设计。顺着这篇文章的线索把代码敲一遍、把断点打一遍,会比你看十篇教程都管用。