简介:这份Qt5示例工程面向正在学习桌面界面开发、需要掌握多页面组织与弹窗交互的初学者和中级开发者,围绕“多UI界面切换、弹窗显示”给出可编译运行的实践样例,可用于课程作业、功能原型或项目中的界面框架参考。压缩包共29个文件、约57KB,以cpp与h源码、ui界面文件和pro工程配置为主,另含编译中间文件与可执行程序,便于直接打开qmake工程查看窗口类、布局及构建关系。工程通过主窗口承载多个独立界面,借助按钮触发显示与隐藏完成切换,并演示QMessageBox提示框以及继承QDialog的自定义模态弹窗;窗口之间的打开关闭、数据共享与事件传递可结合信号槽机制理解。已有182人学习,适合用来对照梳理多窗口管理、界面切换流程和弹窗调用方式,并在此基础上扩展自己的Qt应用。
1. 一个 QStackedWidget 卡死的下午:Qt5 多 UI 界面切换到底难在哪
做 Qt UI 的人多半碰过这种场面:主窗口里三个页面来回切,切到第五次界面白了,日志停在一行看不出问题的 qDebug 上。Qt5 的多界面切换从来不是 API 问题——QStackedWidget::setCurrentIndex就一行——难的是页面什么时候创建、什么时候销毁、弹窗打开时父窗口还在不在。这里拆的是一套能直接照做的做法:用 QStackedWidget 承载页面,.ui 交给 uic 编译,用一个注册表把「切哪一页」从硬编码下标换成字符串键,最后把模态与非模态弹窗的打开时机收口。适合已经能拖控件、但一做多页面就被野指针和重复信号拖住的人。
2. 三条切换路线:堆栈、懒加载与运行期装载怎么选
Qt5 里做多界面,能走的路其实只有三条:把所有页面塞进QStackedWidget一次性建好、按需 new 出来再塞进去、或者干脆用QUiLoader在运行期读 .ui。三条路都能跑出「点击菜单切换界面」的效果,但代价差得很远——启动时间、内存占用、对象归属、出问题后的排查难度,全都不一样。选之前先想清楚一件事:你的页面是固定的三五个,还是可能按配置增减;页面里的控件是 Designer 拖的,还是代码写的自定义控件。这两个答案基本决定了路线。
2.1 QStackedWidget:默认解,以及它的隐藏成本
QStackedWidget是一个内部只显示一个子控件的容器,setCurrentIndex或者setCurrentWidget决定谁露脸。它最省事的地方在于:所有页面同时在对象树上,谁也不会被销毁,切回来的时候滚动位置、输入框内容都还在,不需要写任何状态保存逻辑。代价是启动时所有页面都得构造一遍,页面多了会有明显的首屏延迟。
// mainwindow.cpp —— 最朴素的堆栈式切换 MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) { ui->setupUi(this); m_stack = new QStackedWidget(this); setCentralWidget(m_stack); // 交给 QMainWindow 持有,析构时一并释放 m_stack->addWidget(new LoginPage); // index 0,addWidget 会自动重设父对象 m_stack->addWidget(new WorkPage); // index 1 m_stack->addWidget(new SettingPage); // index 2 connect(ui->actionLogin, &QAction::triggered, this, [this]{ m_stack->setCurrentIndex(0); }); connect(ui->actionWork, &QAction::triggered, this, [this]{ m_stack->setCurrentIndex(1); }); connect(ui->actionSetting, &QAction::triggered, this, [this]{ m_stack->setCurrentIndex(2); }); }addWidget会把传入控件的父对象改成 stack 自己,所以new LoginPage后面不写 parent 也不会泄漏。真正需要注意的是下标:一旦后面加了懒加载,addWidget的顺序会变,写死的setCurrentIndex(2)就会指错页面。这一点先记住,第 3 章会把这个隐患直接掐掉。
隐藏成本还有一条:被切走的页面只是hide(),不是停摆。里面的QTimer照跑,网络请求的回调照样进槽函数,如果槽里访问了当前页面才有的数据,就是一堆莫名其妙的空指针。后面第 5 章会专门说这个。
2.2 懒加载独立页面:启动快,但生命周期要自己扛
页面超过五六个、或者某些页面只有管理员才用得到,就不该在构造里全建出来。常见做法是把页面做成独立的QWidget子类,第一次切到它的时候才new,之后缓存在一个QHash里。启动时间能砍掉一大截,代价是你得自己管对象归属,还要防止「以为还在、其实已经被删了」。
// mainwindow.cpp —— 首次访问才创建 PageBase *MainWindow::ensurePage(const QString &key) { if (PageBase *hit = m_pages.value(key).data()) // m_pages 的 value 是 QPointer return hit; PageBase *p = PageRegistry::instance().create(key, m_stack); if (!p) { qWarning() << "[MainWindow] page not registered:" << key; return nullptr; } m_stack->addWidget(p); m_pages.insert(key, p); return p; }这里有两个参数值得说。create(key, m_stack)的第二个实参是父对象,写成m_stack而不是this,是为了让页面跟着 stack 一起析构,主窗口析构时不会漏。m_pages用QPointer<PageBase>做值而不是裸指针,是因为以后如果加了「关闭并释放某个页面」的功能,裸指针会变野指针,而QPointer在被指对象析构时会自动置空,value(key).data()拿到 nullptr 就重新建,逻辑天然自洽。
页面基类本身很薄,只留两个生命周期钩子:
// pagebase.h #pragma once #include <QWidget> class PageBase : public QWidget { Q_OBJECT public: explicit PageBase(QWidget *parent = nullptr) : QWidget(parent) {} virtual void onEnter() {} // 页面被切到前台 virtual void onLeave() {} // 页面被切走 };Q_OBJECT不能省,否则qobject_cast<PageBase *>直接返回 nullptr,而且 moc 不会处理这个头文件,编译期就会在链接阶段报 vtable 未定义。这也是新手最常撞的一堵墙。
2.3 编译期 uic 还是运行期 QUiLoader
Designer 画出来的 .ui 是一份 XML,Qt5 给了两种消费方式。uic 在构建时把 XML 翻译成 C++ 头文件,Ui::WorkPage这种类名就是它生成的;QUiLoader则是在运行时解析 XML 直接建控件树。两者的差别不只是快慢:
| 对比项 | uic 编译期生成 | QUiLoader 运行期加载 |
|---|---|---|
| 生成时机 | 构建阶段产出 ui_xxx.h | 程序运行中读文件 |
| 额外模块 | 无 | 需要 QtUiTools |
| .ui 改动 | 必须重新编译 | 换文件即可,无需重编 |
| 自定义提升控件 | 直接支持 | 需要类名能被元对象系统识别,常出问题 |
| 启动开销 | 无额外开销 | 每次加载都要解析 XML |
| 适用场景 | 页面固定、随程序发布 | 界面需要外部配置或插件化 |
懒加载路线下,我一般还是用 uic。原因很实际:提升控件(promote)在 uic 下是编译期就确定的类型,写错立刻报错;换成QUiLoader,一个提升控件加载失败可能只是默默退化成基类,运行时才发现按钮点不动,排查成本高得多。真要用运行期加载,代码是这样:
#include <QtUiTools/QUiLoader> #include <QFile> #include <QDebug> QWidget *loadUiFile(const QString &path, QWidget *parent) { QFile f(path); if (!f.open(QIODevice::ReadOnly)) { qWarning() << "[UiLoader] open failed:" << path; return nullptr; } QUiLoader loader; QWidget *w = loader.load(&f, parent); // 第二个参数决定归属,不传就得自己 delete f.close(); return w; }loader.load(&f, parent)里的parent一定要传,否则返回的控件没有任何归属,关闭窗口时不会释放。构建侧对应要加QT += uitools(qmake)或Qt5::UiTools(CMake),忘了加会在链接期报QUiLoader未定义,这个报错信息还算友好,不难定位。
3. 把 Designer 的 .ui 接进工程:构建配置、页面注册与信号收口
.ui 文件本身不参与编译,它得先被 uic 处理成ui_xxx.h,再被你的窗口类#include进来。这一步配置错了,后面所有切换逻辑都无从谈起。工程里最常见的组合是「主窗口一个 .ui + 每个页面各一个 .ui」,每个页面类里持有一个Ui::XxxPage *ui指针,构造函数里ui->setupUi(this)。下面把构建、注册、信号三块串起来。
3.1 CMake 与 qmake 两套 uic 配置
CMake 侧最省心的做法是开AUTOUIC,它会扫描源码里的#include "ui_xxx.h"自动找到同名 .ui 并生成。但有个前提:.ui 文件要出现在add_executable的源列表里,否则扫描器在某些 CMake 版本下找不到。
cmake_minimum_required(VERSION 3.16) project(multi_ui_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 处理 Q_OBJECT set(CMAKE_AUTOUIC ON) # 处理 .ui -> ui_xxx.h set(CMAKE_AUTORCC ON) # 处理 .qrc 资源 find_package(Qt5 REQUIRED COMPONENTS Widgets) add_executable(multi_ui_demo main.cpp mainwindow.cpp mainwindow.h mainwindow.ui pagebase.h loginpage.cpp loginpage.h loginpage.ui workpage.cpp workpage.h workpage.ui settingpage.cpp settingpage.h settingpage.ui ) target_link_libraries(multi_ui_demo PRIVATE Qt5::Widgets)AUTOUIC和手写的qt5_wrap_ui()不要同时用,两套机制会生成同名的ui_xxx.h,谁覆盖谁取决于构建顺序,表现出来的现象是「改了 .ui 不生效」,很容易误判成缓存问题。如果坚持手写:
qt5_wrap_ui(UI_HEADERS mainwindow.ui workpage.ui) add_executable(multi_ui_demo main.cpp mainwindow.cpp ${UI_HEADERS})qmake 侧就一行:
QT += widgets FORMS += mainwindow.ui loginpage.ui workpage.ui settingpage.ui HEADERS += mainwindow.h pagebase.h loginpage.h workpage.h settingpage.h SOURCES += main.cpp mainwindow.cpp loginpage.cpp workpage.cpp settingpage.cppFORMS是 qmake 专有关键字,等价于「这些文件要跑 uic」。把 .ui 错写进HEADERS是最常见的配置错误,表现为ui_workpage.h: No such file or directory。
页面类的头文件里,Ui命名空间用前置声明就够,不必把生成的ui_xxx.h塞进头文件:
// workpage.h namespace Ui { class WorkPage; } class WorkPage : public PageBase { Q_OBJECT public: explicit WorkPage(QWidget *parent = nullptr); ~WorkPage() override; void onEnter() override; private: Ui::WorkPage *ui; };Ui::WorkPage用指针而不是值成员,是为了让头文件不依赖生成的 ui 头,编译依赖少,改一个 .ui 不会牵动半个工程重编。
3.2 页面注册表:把 setCurrentIndex(2) 换成字符串键
下标切换的问题是脆弱:插入一个新页面,所有写死的数字全错位,而且错得无声无息。换成一个字符串到工厂函数的映射,切换用名字,页面内部用setCurrentWidget,索引变化就不再有影响。
// pageregistry.h #pragma once #include <QHash> #include <QString> #include <functional> class PageBase; class PageRegistry { public: using Factory = std::function<PageBase *(QWidget *parent)>; static PageRegistry &instance(); void reg(const QString &key, Factory f) { m_map.insert(key, std::move(f)); } PageBase *create(const QString &key, QWidget *parent) const; private: QHash<QString, Factory> m_map; };// pageregistry.cpp #include "pageregistry.h" #include "pagebase.h" #include <QDebug> PageRegistry &PageRegistry::instance() { static PageRegistry s; // C++11 起局部静态初始化是线程安全的 return s; } PageBase *PageRegistry::create(const QString &key, QWidget *parent) const { const auto it = m_map.constFind(key); if (it == m_map.constEnd()) { qWarning() << "[PageRegistry] unregistered page:" << key; return nullptr; } return it.value()(parent); }注册动作放在一个显式函数里,由main()调用,而不是写成全局静态对象的构造函数:
// mainwindow.cpp void registerBuiltinPages() { auto &r = PageRegistry::instance(); r.reg(QStringLiteral("login"), [](QWidget *p) -> PageBase * { return new LoginPage(p); }); r.reg(QStringLiteral("work"), [](QWidget *p) -> PageBase * { return new WorkPage(p); }); r.reg(QStringLiteral("setting"), [](QWidget *p) -> PageBase * { return new SettingPage(p); }); } // main.cpp int main(int argc, char *argv[]) { QApplication a(argc, argv); registerBuiltinPages(); // 必须在 MainWindow 构造之前 MainWindow w; w.show(); return a.exec(); }如果偷懒写成static bool s = []{ ... }();这种全局注册,会碰到静态初始化顺序问题——某个 .cpp 里的全局注册对象先于instance()内部的静态对象执行,注册表就是空的,日志里一堆 unregistered page,但代码看起来完全正常。这种玄学问题查一下午不奇怪。
切换函数本身要做三件事:确保页面存在、通知旧页面离开、通知新页面进入。
bool MainWindow::gotoPage(const QString &key) { PageBase *target = ensurePage(key); if (!target) return false; QWidget *from = m_stack->currentWidget(); if (from == target) return true; if (auto *p = qobject_cast<PageBase *>(from)) p->onLeave(); // 旧页面收尾:停定时器、断开临时连接 m_stack->setCurrentWidget(target); // 用控件指针,不用索引 target->onEnter(); // 新页面刷新数据 return true; }setCurrentWidget是关键改动,它按指针定位,懒加载乱序插入也不会切错。qobject_cast这里要求from是PageBase派生并且有Q_OBJECT,主窗口自己的中央控件如果是别的类型,cast 会返回 nullptr,所以判空不能省。
3.3 切换时的信号连接与数据回填
多页面工程里「按钮点一次响应两次」几乎全是因为 connect 写在了onEnter里。每次切回页面就多连一根线,第三次切回来就是三倍响应。规矩很简单:connect只放在构造函数里执行一次,onEnter只负责刷数据。
WorkPage::WorkPage(QWidget *parent) : PageBase(parent) , ui(new Ui::WorkPage) { ui->setupUi(this); connect(ui->btnRefresh, &QPushButton::clicked, this, &WorkPage::refreshTable); connect(ui->btnRename, &QPushButton::clicked, this, &WorkPage::onRename); } void WorkPage::onEnter() { // 程序化赋值会触发 currentIndexChanged,用 QSignalBlocker 屏蔽掉(Qt 5.3+) const QSignalBlocker blocker(ui->comboType); ui->comboType->clear(); ui->comboType->addItems(loadTypes()); ui->comboType->setCurrentIndex(0); refreshTable(); // 每次进入只查一次,而不是在构造函数里查 }QSignalBlocker是 RAII 的,作用域结束自动恢复信号,比手动blockSignals(true/false)可靠——后者一旦中间 return 就会永久屏蔽,那个控件之后彻底没反应,属于典型的后悔药场景。如果 Qt 版本低于 5.3,只能用blockSignals加 try/guard 的写法,或者干脆在槽函数里判断sender()是否处于初始化状态。
数据回填的粒度也要控制:onEnter里全量刷新表格在几十行数据时没感觉,几千行就会让切页有明显卡顿。常见做法是记一个「脏标记」,数据没变就直接跳过查询,切页只做ui->table->viewport()->update()。
4. 弹窗的四种姿态:exec、open、show 与自绘遮罩
弹窗在 Qt5 里就QDialog一个类,但打开方式有四种,选错了轻则界面卡住,重则析构两次。区别集中在两点:调用点是否被阻塞,以及弹窗挡住的是一整个应用还是只有一个窗口。这三者的语义差别,比很多教程里写的要大。
4.1 exec/show/open 的差别与返回值语义
| 接口 | 调用点是否阻塞 | 模态范围 | 结果怎么拿 | 典型场景 |
|---|---|---|---|---|
exec() | 是,启动局部事件循环 | 应用模态 | 返回值 Accepted/Rejected | 确认框、一次性短表单 |
open() | 否 | 窗口模态 | finished(int)/accepted()信号 | 同一个弹窗反复用 |
show() | 否 | 非模态 | 自己连业务信号 | 日志窗、颜色面板 |
setModal(true)+show() | 否 | 窗口模态 | 信号 | 与 open() 等价 |
exec()的写法最顺手,代价是它在调用点开了一个嵌套事件循环。这期间用户还能点主窗口的菜单(除非设了应用模态),如果那个动作把主窗口关了,栈上的dlg会被父对象带走析构一次,函数返回时再析构一次,程序当场退出。
void WorkPage::onRename() { QPointer<WorkPage> guard(this); // 记录本页是否还活着 RenameDialog dlg(this); dlg.setName(m_name); if (dlg.exec() != QDialog::Accepted) return; if (!guard) // exec 期间本页被销毁,成员一律不能再碰 return; m_name = dlg.name(); refreshTitle(); }guard只在exec()返回后检查一次,不影响性能。dlg用栈对象的好处是不用管释放,坏处是它同时挂在this的对象树上,父对象先死就会双删——所以真正稳妥的做法是给栈上的对话框传 nullptr 父对象,再手动居中到父窗口位置。这一点在第 5 章会作为一条独立的坑展开。
4.2 非模态弹窗的对象树与 QPointer 兜底
需要反复打开的弹窗(日志、属性面板)用show(),但必须解决「什么时候释放」。用裸指针加手动delete是最容易出事的组合:用户点叉关闭后指针还在,第二次点按钮再show()就是访问已释放内存。
class WorkPage : public PageBase { Q_OBJECT // ... private: Ui::WorkPage *ui; QPointer<LogDialog> m_logDlg; // 对话框析构后自动变 nullptr }; void WorkPage::onShowLog() { if (!m_logDlg) { m_logDlg = new LogDialog(this); m_logDlg->setAttribute(Qt::WA_DeleteOnClose); // 关闭即 deleteLater } m_logDlg->show(); m_logDlg->raise(); // 已打开时提到最前,不要重复 new m_logDlg->activateWindow(); }Qt::WA_DeleteOnClose让close()走deleteLater(),对象在事件循环下一轮才真正释放,所以关闭过程中触发finished()的槽函数里访问它仍然是安全的。这个属性和栈对象互斥——给栈上的对话框设它是标准的未定义行为。QPointer只对QObject派生类有效,普通结构体用不了。
4.3 不阻塞事件循环的假模态遮罩
有些场景要「挡住下层、但不阻塞」:遮罩期间后台还在收数据,只是不许用户点别的按钮。用exec()会把事件循环卡在局部,后台回调虽然还能进,但调用点的后续代码全被挂起;用setWindowModality(Qt::ApplicationModal)又太重。这时候一个铺满父窗口的半透明子控件更合适。
class MaskWidget : public QWidget { Q_OBJECT public: explicit MaskWidget(QWidget *host) : QWidget(host), m_host(host) { setObjectName(QStringLiteral("maskLayer")); setStyleSheet(QStringLiteral("#maskLayer { background: rgba(0,0,0,110); }")); host->installEventFilter(this); hide(); } void popup() { syncSize(); show(); raise(); } void dismiss() { hide(); } protected: bool eventFilter(QObject *o, QEvent *e) override { if (o == m_host && e->type() == QEvent::Resize) syncSize(); return QWidget::eventFilter(o, e); } void mousePressEvent(QMouseEvent *e) override { e->accept(); } // 吃掉点击,不透传 private: void syncSize() { setGeometry(m_host->rect()); } QWidget *m_host; };mousePressEvent里必须accept()而不是忽略,否则事件继续向父控件传播,用户能点到底下的按钮。installEventFilter是为了让遮罩跟随父窗口大小变化,不装的话窗口一拖动遮罩就只盖住左上角一块。样式表里用#maskLayer这种 ID 选择器,是为了避免把背景色传染给子控件——直接写background:而不带选择器,遮罩上的按钮会一起变黑。
5. 排查:界面切换与弹窗最常见的五个翻车现场
这一节是从实际项目里攒出来的。每个现象单独看都像是 Qt 的 bug,追下去基本都是对象生命周期和信号连接的问题。
5.1 切走之后定时器还在跑
现象:从工作页切到设置页,工作页的QTimer仍然在刷新表格,日志还在滚,切回来时表格已经跳了好几次。
原因:QStackedWidget切页只是hide(),控件对象还活着,QTimer的计时器句柄绑在对象上,跟可见性无关。很多人以为隐藏了就不执行,这是最典型的误判。
解决:用showEvent/hideEvent管住所有周期性任务,不要依赖构造函数和析构函数。
void WorkPage::showEvent(QShowEvent *e) { PageBase::showEvent(e); if (!m_timer->isActive()) m_timer->start(1000); } void WorkPage::hideEvent(QHideEvent *e) { PageBase::hideEvent(e); m_timer->stop(); }showEvent/hideEvent在首次show()和每次切换时都会触发,比onEnter更底层,也不依赖你自己写的切页函数。注意基类的同名函数必须先调,否则样式和布局的默认处理会被跳过,表现为页面第一次显示时控件位置错乱。
5.2 第二次点按钮,弹窗直接让程序挂掉
现象:日志弹窗第一次能正常打开、关闭;第二次点按钮,程序崩在show()或者某个成员函数里。
原因:成员是裸指针LogDialog *m_logDlg,关闭时因为WA_DeleteOnClose被释放了,但指针没置空。第二次进来if (!m_logDlg)判断为假,直接对已释放对象调show()。
解决:成员一律用QPointer,或者干脆在destroyed信号里把指针置空。用QPointer更省事,但要记得它的data()才是原始指针,直接if (m_logDlg)判断也是走隐式转换,两者等价。另外不要把WA_DeleteOnClose和栈对象混用,构造在栈上的对话框关掉之后栈帧还在,析构时又是一次 double free。
5.3 exec() 期间主窗口被关,对话框析构两次
现象:点「确定」后程序直接退出,调用栈里看到QDialog::~QDialog出现两次。
原因:RenameDialog dlg(this);是栈对象,同时被挂进了this的对象树。exec()的嵌套事件循环里,某个动作把主窗口关掉了,父对象析构时把子对话框一起删了;exec()返回后函数作用域结束,栈对象再析构一次。
解决:三种改法任选。一是栈对话框不传父对象RenameDialog dlg(nullptr);,再用dlg.move(mapToGlobal(rect().center()) - QPoint(dlg.width()/2, dlg.height()/2));摆到父窗口中间;二是改用open()加堆对象,彻底避开嵌套事件循环;三是在主窗口的closeEvent里拦截,等弹窗关完再真正退出。我一般选第二种,因为它顺带解决了「弹窗打开时用户还能操作主界面」的问题。
5.4 改了 .ui 完全没反应
现象:Designer 里加了个按钮,重新构建运行,界面上找不到;或者报ui_workpage.h: No such file or directory。
原因:三种可能——.ui 没加进add_executable的源列表,AUTOUIC扫不到;同时用了AUTOUIC和qt5_wrap_ui(),生成的头文件互相覆盖;构建目录里有旧的ui_xxx.h残留,增量构建没清。
解决:先确认 .ui 在目标源列表里,再确认工程里只有一套 uic 机制。最直接的办法是删掉构建目录重新来一遍,然后在构建产物里搜ui_workpage.h是否真的生成、时间戳是不是最新。若用 qmake,检查FORMS而不是HEADERS。这类问题占了我早期排查时间的一大半,后来养成习惯:动过 .ui 就先cmake --build . --target clean再编。
5.5 切页后某个按钮点一次响应两次
现象:第一次进页面正常,切走再切回来,点一次按钮槽函数执行两遍;第三次切回来执行三遍。
原因:connect写在了onEnter()里,每次进入都新建一条连接。Qt 默认允许同一个信号连同一个槽多次,不会去重。
解决:把connect全部挪回构造函数;确实需要在运行时动态连接的,加Qt::UniqueConnection:
connect(ui->btnRefresh, &QPushButton::clicked, this, &WorkPage::refreshTable, Qt::UniqueConnection);Qt::UniqueConnection只对成员函数指针形式的 connect 有效,用 lambda 做槽时不起作用——lambda 每次都是新对象,Qt 没法判重。这是血泪经验:动态连接配 lambda,等于给重复触发开了后门,只能自己在连接前disconnect一次。
6. 进阶:给切换加一层可回退的历史栈
页面一多,用户会想要「返回上一页」。把 `gotoPage
本文还有配套的精品资源,点击获取