简介这份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 0addWidget 会自动重设父对象 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用QPointerPageBase做值而不是裸指针是因为以后如果加了「关闭并释放某个页面」的功能裸指针会变野指针而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_castPageBase *直接返回 nullptr而且 moc 不会处理这个头文件编译期就会在链接阶段报 vtable 未定义。这也是新手最常撞的一堵墙。2.3 编译期 uic 还是运行期 QUiLoaderDesigner 画出来的 .ui 是一份 XMLQt5 给了两种消费方式。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 uitoolsqmake或Qt5::UiToolsCMake忘了加会在链接期报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::functionPageBase *(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: QHashQString, Factory m_map; };// pageregistry.cpp #include pageregistry.h #include pagebase.h #include QDebug PageRegistry PageRegistry::instance() { static PageRegistry s; // C11 起局部静态初始化是线程安全的 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_castPageBase *(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() { QPointerWorkPage 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; QPointerLogDialog 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::UniqueConnectionconnect(ui-btnRefresh, QPushButton::clicked, this, WorkPage::refreshTable, Qt::UniqueConnection);Qt::UniqueConnection只对成员函数指针形式的 connect 有效用 lambda 做槽时不起作用——lambda 每次都是新对象Qt 没法判重。这是血泪经验动态连接配 lambda等于给重复触发开了后门只能自己在连接前disconnect一次。6. 进阶给切换加一层可回退的历史栈页面一多用户会想要「返回上一页」。把 gotoPage本文还有配套的精品资源点击获取