☰
Qt嵌入UI界面实战:QUiLoader加载.ui与外部窗口嵌入
2026/10/26 12:48:24 网站建设 项目流程

简介:面向C++与Qt初学者的界面嵌入练习工程,围绕在Visual Studio 2022中配置Qt 15.2开发环境、把Qt Designer可视化设计的界面接入C++项目展开,适合希望掌握界面文件组织、窗口加载、控件访问和事件处理的入门到进阶读者。压缩包共87个文件、约108.06MB,包含10个cpp与8个h源码、4个ui界面文件,以及工程文件、解决方案文件、资源脚本和属性配置,另带编译产物、调试文件与少量日志,便于直接查看完整目录结构和构建结果。已有464人学习。工程演示多窗口界面设计、主窗口加载、控件查找、信号与槽连接等关键环节,可帮助读者理解从布局设计到事件响应的完整链路;也可对照项目属性中的Qt路径、插件与编译配置,排查环境搭建和链接错误,积累多窗口桌面应用界面搭建相关经验。

1. Qt 嵌入 UI 界面 Demo:先分清要嵌的是 .ui 文件还是别人的窗口

接手一个设备调试工具的时候,最省事的做法通常不是把界面推倒重画,而是把已经画好的那个.ui直接装进宿主窗口跑起来。Qt 嵌入 UI 界面 Demo 这份资源做的就是这个事,它把「嵌入」拆成两条完全不同的路:一条是把 Qt Designer 产出的.ui文件在运行时读进来,塞进一个占位容器里;另一条是拿到外部程序的窗口句柄,用QWidget::createWindowContainer把别人的窗口套进自己的布局。前者适合把设计稿变成可复用的面板模块,后者适合把那些改不动、没有源码的老工具塞进同一个壳里统一管理。

两条路的坑完全不一样:前者栽在路径、编码、样式表和自定义控件注册上,后者栽在 DPI、焦点、黑边和进程生命周期上。这份 Demo 的价值不在于代码有多长,而在于它把两条路都打通了一遍,让你照着改的时候知道该在哪儿停手。

2. 工程骨架与构建选型:CMake、AUTOUIC 和 QUiLoader 怎么分工

2.1 目录结构:把窗体、加载器、宿主拆开

Demo 的目录我建议按「谁负责什么」来分,不按文件类型分。forms/放.ui源文件,src/放 C++ 代码,src/ui/单独放加载器封装,third_party_bin/放待嵌入的外部程序。这样做的直接好处是:.ui要热更新的时候你只关心forms/,加载逻辑出问题只看src/ui/,两条线互不干扰。

ui-embed-demo/ ├── CMakeLists.txt ├── forms/ │ ├── panel_device.ui # 设备参数面板,会被 uic 编进来 │ └── panel_log.ui # 日志面板,运行时用 QUiLoader 加载 ├── src/ │ ├── main.cpp │ ├── mainwindow.h/.cpp # 宿主窗口,负责挂载 │ └── ui/ │ ├── uimanager.h/.cpp # QUiLoader 封装 │ └── windowembedder.h/.cpp # 外部窗口嵌入封装 └── third_party_bin/ └── legacy_tool.exe # 起个外部进程,回传自己的 winId

这个划分有一个隐含约定:forms/下的文件分成两类,编译期确定的用uic生成ui_xxx.h,需要运行期换皮的走QUiLoader。同一个.ui不应该两种方式都用,否则改一处漏一处,界面上会出现两个不同步的控件实例。

2.2 构建系统:AUTOUIC 管编译期,UiTools 管运行期

CMake 这边关键是把AUTOUIC打开,并且把.ui文件写进add_executable的源列表。很多人漏掉这一步,uic就不跑,编译时报ui_mainwindow.h: No such file or directory,翻半天以为是 Qt 装坏了。

cmake_minimum_required(VERSION 3.16) project(UiEmbedDemo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) # 构建期调用 uic,把 forms/*.ui 编成 ui_*.h set(CMAKE_AUTORCC ON) # Widgets 是界面基础,UiTools 提供 QUiLoader,运行期加载 .ui 必须链它 find_package(Qt6 COMPONENTS Widgets UiTools REQUIRED) add_executable(ui_embed_demo src/main.cpp src/mainwindow.cpp src/mainwindow.h src/ui/uimanager.cpp src/ui/uimanager.h src/ui/windowembedder.cpp src/ui/windowembedder.h forms/panel_device.ui # 写进源列表,AUTOUIC 才会处理 ) target_link_libraries(ui_embed_demo PRIVATE Qt6::Widgets Qt6::UiTools) target_include_directories(ui_embed_demo PRIVATE src)

AUTOUIC的行为需要说清楚:它扫描源文件里出现的#include "ui_xxx.h",然后去找同名的xxx.ui,在构建目录生成头文件。所以forms/panel_device.ui必须能让 CMake 找到,稳妥做法是把forms/加进target_include_directories或者给AUTOUIC_SEARCH_PATHS设值。Qt6::UiTools是另一条路——它不参与构建,只在运行时解析 XML,所以要单独链。两条路的库依赖不一样,这也是新手最容易混的地方。

提示:Qt5 里UiTools模块要写find_package(Qt5 COMPONENTS Widgets UiTools REQUIRED),链接目标叫Qt5::UiTools;Qt6 换成Qt6::前缀,其余写法一致。

2.3 版本与依赖矩阵:Qt5 与 Qt6 的差异点

Demo 在 Qt5.15 和 Qt6.5 上都能跑,但有四处必须做条件处理,否则换个环境就翻车。下面这张表是我实际对照过的差异点,改代码前先扫一眼能省掉很多无谓的调试。

差异点Qt5 写法Qt6 写法不改的后果
高 DPI 缩放Qt::AA_EnableHighDpiScaling默认开启,属性已废弃嵌入窗口在 150% 缩放下错位
QRegExp可用移除编译直接失败
QWindow::fromWinId可用可用但返回值语义更严空指针未判会崩
字符串转换QString::fromUtf8同上与版本无关,但中文路径必须显式指定

高 DPI 那条最坑。Qt6 里Qt::AA_EnableHighDpiScaling这个属性还在,但设置了没效果,因为默认就开了。如果你从 Qt5 迁移过来还留着这行QCoreApplication::setAttribute,代码能编过、能跑,但嵌入的外部窗口尺寸算出来永远是错的,因为缩放因子已经在别处应用过一次。

3. 运行时装载 .ui:QUiLoader 封装与宿主容器接入

3.1 UiLoader 封装:把错误信息留住

QUiLoader::load()失败时返回空指针,但如果你不主动读errorString(),界面上表现就是「点了没反应」,这属于典型的黑匣子。封装的第一件事就是把错误传出来。

// src/ui/uimanager.h #pragma once #include <QObject> #include <QString> class QWidget; class UiManager : public QObject { Q_OBJECT public: explicit UiManager(QObject *parent = nullptr); // uiPath: .ui 文件的绝对路径,调用方负责拼好,不要在这里猜 // error: 非空时写入失败原因,供上层提示 QWidget *load(const QString &uiPath, QString *error = nullptr); private: QString m_lastDir; // 记住上次成功目录,方便二次加载 };
// src/ui/uimanager.cpp #include "uimanager.h" #include <QDir> #include <QFile> #include <QUiLoader> #include <QWidget> UiManager::UiManager(QObject *parent) : QObject(parent) {} QWidget *UiManager::load(const QString &uiPath, QString *error) { QFile file(uiPath); // 用 ReadOnly | Text,避免 Windows 下 CRLF 影响 XML 解析 if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { if (error) *error = QStringLiteral("打开失败: %1").arg(file.errorString()); return nullptr; } QUiLoader loader; // 如果 .ui 里用了项目自定义控件,必须在这里注册, // 否则加载出来的是 QWidget 占位,所有自定义属性全部丢失 // loader.registerCustomWidget(new MyGaugeWidget); QWidget *root = loader.load(&file); file.close(); if (!root) { if (error) *error = QStringLiteral("解析失败: %1").arg(loader.errorString()); return nullptr; } m_lastDir = QFileInfo(uiPath).absolutePath(); return root; }

代码里两个细节值得单独说。第一,QIODevice::Text不是可选项——.ui是 XML,Windows 上编辑器保存的 CRLF 在纯二进制读取时偶尔会让QXmlStreamReader报莫名的解析错误,加上Text让 Qt 帮你规整换行。第二,registerCustomWidget必须在load()之前调用,而且注册的对象生命周期由 loader 接管(Qt 内部会takeOwnership),别自己在外面再delete一遍,那是标准的双重释放现场。

load()返回的root是一个没有父对象的QWidget,这意味着你必须自己管它的销毁。我一般在挂载时就设好父对象,让 Qt 的对象树去回收,避免漏删。

3.2 挂载到 QLayout、QSplitter 或 QStackedWidget

挂载方式决定了后续能不能动态替换。Demo 里给了三种,选哪种取决于面板要不要被换掉。

// src/mainwindow.cpp 片段 void MainWindow::mountToLayout(const QString &uiPath) { QString err; QWidget *panel = m_uiManager->load(uiPath, &err); if (!panel) { statusBar()->showMessage(tr("加载失败: %1").arg(err), 5000); return; } // 占位容器上如果已经有旧面板,先摘掉再删,否则布局里会叠两层 if (QLayout *old = m_placeholder->layout()) { while (QLayoutItem *item = old->takeAt(0)) { if (QWidget *w = item->widget()) w->deleteLater(); delete item; } } else { auto *lay = new QVBoxLayout(m_placeholder); lay->setContentsMargins(0, 0, 0, 0); lay->setSpacing(0); } panel->setObjectName(QStringLiteral("embeddedPanel")); m_placeholder->layout()->addWidget(panel); m_currentPanel = panel; }

这里的关键是「先清空再添加」。直接addWidget两次,界面上看到的是两个面板上下挤在一起,而且旧的那个还在响应信号,属于典型的血泪经验。deleteLater()而不是delete,是因为当前可能还在这个控件的某个槽函数调用栈里,立刻析构会崩。

如果面板需要用户拖拽分隔,把占位容器换成QSplitter,addWidget换成splitter->addWidget(panel)即可,逻辑一样。如果面板要按标签切换,用QStackedWidget,每次addWidget后setCurrentWidget,旧面板留着不删,切回来还是原状态。

3.3 信号槽绑定:加载完再找控件

QUiLoader加载出来的控件没法用ui->btnStart这种编译期成员访问,只能靠findChild。这一步必须在load()成功之后做,而且要找的控件objectName必须和 Designer 里填的完全一致——包括大小写。

void MainWindow::bindPanelSignals(QWidget *panel) { // 按类型 + 名字查找,第二个参数加上 Qt::FindChildrenRecursively 才能搜到嵌套布局里的控件 auto *btnStart = panel->findChild<QPushButton *>(QStringLiteral("btnStart")); auto *logView = panel->findChild<QTextEdit *>(QStringLiteral("logView")); if (!btnStart || !logView) { qWarning() << "面板控件缺失,检查 .ui 里的 objectName"; return; } connect(btnStart, &QPushButton::clicked, this, [this, logView]() { logView->append(tr("开始采集...")); m_deviceWorker.start(); }); // 反向连接:宿主的状态回灌到嵌入面板 connect(m_deviceWorker.data(), &DeviceWorker::stateChanged, panel, [logView](const QString &s) { logView->append(s); }); }

参数说明一下:findChild<T*>(name)默认递归查找所有子对象,但只在QObject树里找,所以控件必须是panel的后代——load()返回的根节点就是这棵树。第二个connect把panel当上下文对象传,好处是面板被销毁时连接自动断开,不需要手动disconnect,这是 Qt 里最省心的一种写法。

还有一个容易被忽略的点:.ui里如果控件的objectName是中文,findChild在某些编码下匹配不上。我一般要求团队里.ui的objectName全用英文驼峰,中文只出现在text属性里。

4. 嵌入外部窗口:QWindow::fromWinId 与 QProcess 生命周期

4.1 句柄获取与容器创建

把别人的窗口嵌进来,本质是拿到对方的原生窗口句柄(Windows 上是HWND),用QWindow::fromWinId包一层,再交给createWindowContainer变成QWidget。整个过程不涉及对方进程的代码,属于纯外部操作。

// src/ui/windowembedder.cpp #include "windowembedder.h" #include <QWindow> #include <QWidget> QWidget *WindowEmbedder::wrap(quintptr nativeId, QWidget *parent) { if (nativeId == 0) return nullptr; // fromWinId 包出来的 QWindow 不拥有原生窗口,销毁时不会去关对方的窗口 QWindow *foreign = QWindow::fromWinId(WId(nativeId)); if (!foreign) return nullptr; // 去掉标题栏,否则容器里会多一条对方程序的系统边框 foreign->setFlags(foreign->flags() | Qt::FramelessWindowHint); QWidget *container = QWidget::createWindowContainer(foreign, parent); if (!container) { delete foreign; return nullptr; } container->setFocusPolicy(Qt::StrongFocus); container->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); return container; }

三个参数需要交代清楚。nativeId是对方窗口的整型句柄,Windows 上可以通过FindWindowW按窗口标题拿,也可以在外部程序启动时让它把自己的winId打到标准输出。parent传宿主窗口,容器会随父窗口一起销毁,但不会关闭外部进程——这是两件独立的事,后面会讲。Qt::FramelessWindowHint那行必须加,我见过不少人嵌完之后发现容器里有一圈灰色边框,以为是样式问题,其实是对方窗口自带的标题栏。

4.2 尺寸、焦点与 DPI:三处必须手动干预的地方

容器创建出来之后,Qt 不会自动帮你同步尺寸和缩放,尤其是外部程序自己也在处理 DPI 的时候,表现就是界面糊、按钮点不动、窗口只显示左上角一块。

尺寸问题的根源是原生窗口有自己的WS_风格,容器给它的尺寸不一定被接受。解决办法是给容器设一个合理的初始大小,并且在resizeEvent里同步。

void MainWindow::resizeEvent(QResizeEvent *e) { QMainWindow::resizeEvent(e); if (m_embedContainer) { // 减掉工具栏和状态栏的高度,避免容器盖住它们 QSize target = centralWidget()->size(); m_embedContainer->setMinimumSize(320, 240); m_embedContainer->resize(target); } }

焦点问题更隐蔽:嵌入的窗口默认不接收键盘事件,你点进去打字没反应。setFocusPolicy(Qt::StrongFocus)只解决了容器这一层,还要在容器上装事件过滤器,把FocusIn事件转发给内部的原生窗口。Demo 里用一个简单粗暴的办法:容器获得焦点时调一次foreign->requestActivate(),多数情况下够用。

DPI 这块,如果外部程序也是 Qt 写的,两边缩放因子一致就没事;如果对方是别的框架,宿主开了高 DPI 而它没开,嵌进来就会明显偏小。这种情况只能靠QWindow::setGeometry手动按devicePixelRatio换算尺寸,没有通用解法,属于嵌入方案本身的边界。

4.3 QProcess 拉起与退出回收

外部程序得先跑起来才有窗口。用QProcess拉起,约定让对方把句柄打到 stdout 第一行,宿主读一行就够。

void MainWindow::launchAndEmbed(const QString &exePath) { m_proc = new QProcess(this); // 约定:外部程序启动后把窗口句柄以十六进制打在 stdout 第一行 connect(m_proc, &QProcess::readyReadStandardOutput, this, [this]() { const QByteArray line = m_proc->readLine().trimmed(); bool ok = false; const quintptr id = line.toULongLong(&ok, 16); if (!ok) { qWarning() << "句柄解析失败:" << line; return; } m_embedContainer = m_embedder->wrap(id, m_splitter); if (m_embedContainer) m_splitter->addWidget(m_embedContainer); }); // 进程退出,容器必须跟着销毁,否则留下一个黑框,点它还会崩 connect(m_proc, &QProcess::finished, this, [this](int code, QProcess::ExitStatus status) { Q_UNUSED(code) Q_UNUSED(status) if (m_embedContainer) { m_embedContainer->deleteLater(); m_embedContainer = nullptr; } m_proc->deleteLater(); m_proc = nullptr; }); // 外部程序崩溃时也会走 finished,所以不需要额外监听 errorOccurred m_proc->start(exePath, QStringList()); }

finished里的清理是必须的。容器持有的是QWindow包装,底层原生窗口一旦销毁,容器再收到绘制事件就会访问野指针,Windows 上表现为整个宿主直接闪退,日志里什么都没有,非常难查。我一般还会在finished里把QProcess也置空,避免后续代码误用。

注意:QProcess默认不会杀子进程的子进程。如果外部程序还会再拉起别的进程,退出时要手动terminate()整棵进程树,否则宿主关了,后台还留着一串。

5. 排查清单:嵌入界面跑不起来的 5 个现场

5.1 现象:加载返回空指针,界面上毫无提示

原因通常是路径拼错、文件不存在,或者QFile::open成功但 XML 本身不合法。很多人只判空不读errorString,于是所有失败看起来都一样。

解决办法是把UiManager::load的error参数一路传到状态栏,并且打印绝对路径。我在调试期还会加一行qDebug() << QFileInfo(uiPath).absoluteFilePath(),九成的路径问题一眼就能看出来。如果是 XML 语法错误,errorString会给出具体行号,照着改.ui就行,别去改代码。

5.2 现象:面板加载出来了,但样式表完全不生效

原因是QUiLoader只还原控件树和属性,不处理宿主qApp->setStyleSheet的级联关系——尤其当你把样式写在QMainWindow上时,嵌入进来的控件是它的兄弟节点而非子节点,选择器匹配不到。

解决方式有两种:一是把全局样式表设置到qApp上而不是某个窗口上;二是给面板的根节点单独setStyleSheet。我倾向后者,因为嵌入面板本来就是独立模块,样式自包含更不容易互相污染。另外.ui里如果用了.qrc里的图片,记得资源文件也要一起编进目标,否则背景图加载不出来,界面看着像丢了一半。

5.3 现象:嵌入的外部窗口一片黑,或者频繁闪烁

黑屏一般是原生窗口还没完成首次绘制就被包进了容器,容器先画了背景。闪烁则是容器和原生窗口各画一次,互相覆盖。

解决:拿到句柄之后不要立刻wrap,等一个短延迟(比如QTimer::singleShot(300, ...))再包,给对方完成初始化的时间;闪烁的问题给容器设setAttribute(Qt::WA_NativeWindow)并且打开setUpdatesEnabled(false)直到首次paintEvent之后再打开。外部程序如果是自己写的,让它先ShowWindow再输出句柄,比宿主这边等待更可靠。

5.4 现象:高分屏下嵌入的界面错位、控件被裁掉

原因是宿主和外部的 DPI 感知级别不一致。宿主按 Qt 的缩放因子布局,外部窗口按系统原始像素处理,两者对「100 像素」的理解不同。

解决:先在main.cpp里明确 DPI 策略,Qt6 下不要手动设AA_EnableHighDpiScaling;然后对容器手动设置setMinimumSize并配合devicePixelRatio()换算。如果外部程序无法改,最省事的办法是让宿主迁就它——把宿主这一层的缩放关掉,整体用原始像素布局,代价是高 DPI 屏上字会小,属于取舍。

5.5 现象:本机跑得好好的,别人机器上找不到 .ui

这是运行期加载方案的通病:.ui是外部文件,编译时不会被打进可执行文件,拷过去就丢。

解决:要么把.ui放进.qrc资源文件里,用QUiLoader从QFile(":/forms/panel_log.ui")读;要么在部署脚本里把forms/目录一起带上,并且用QCoreApplication::applicationDirPath()拼路径,不要用相对路径。前者更稳,代价是改.ui必须重新编译;后者适合需要现场热更新的场景。我在项目里两种都留着,默认走资源,调试开关打开时走磁盘。

6. 二次开发:把它改成插件式界面装配台

Demo 跑通之后,最有价值的改造方向不是加功能,而是把「嵌哪个界面」变成配置驱动的。我一般会做一个panels.json,每一条描述一个面板的加载方式、挂载位置和显示条件,宿主启动时读配置逐条装配,这样换一套界面不用重新编译。

字段类型说明
idstring面板唯一标识,用于查找和去重
typestringui表示 .ui 文件,external表示外部进程
sourcestring.ui的资源路径或外部程序的可执行文件路径
slotstring挂载位置,left/right/bottom
enabledbool关掉时不加载,方便现场排查

读取和装配的代码不长,核心是把之前的两个封装统一到一个工厂里。

void PanelAssembler::assemble(const QString &configPath) { QFile f(configPath); if (!f.open(QIODevice::ReadOnly)) return; const QJsonArray items = QJsonDocument::fromJson(f.readAll()).array(); for (const QJsonValue &v : items) { const QJsonObject o = v.toObject(); if (!o.value("enabled").toBool(true)) continue; const QString id = o.value("id").toString(); if (m_loaded.contains(id)) continue; // 幂等,重复配置只装一次 QWidget *w = nullptr; if (o.value("type").toString() == "ui") w = m_uiManager->load(o.value("source").toString()); else w = m_embedder->launch(o.value("source").toString()); if (!w) { qWarning() << "面板装配失败:" << id; continue; } m_host->slotFor(o.value("slot").toString())->layout()->addWidget(w); m_loaded.insert(id); } }

验证方法很简单:改panels.json里某一条的source指向一个不存在的文件,重启后宿主应该打印「面板装配失败: xxx」但整体仍然可用——单个面板挂掉不能拖垮主界面,这是装配台和硬编码最大的区别。再改enabled为false,对应面板应该消失且不影响其他面板的布局。

有个坑得提前说:m_loaded的去重集合必须在面板真正销毁时同步移除,否则同一个 id 重新启用会被跳过。我一般把移除逻辑挂在面板的destroyed信号上,别图省事用QSet::clear()一刀切。

从那以后我每次做嵌入方案,都会先把「宿主挂了但子面板活着」和「子面板挂了但宿主不知道」这两种状态各手写一遍复现出来,确认清理链路完整,再接真实业务。嵌入这类东西,能跑起来只是及格线,能干净地退出才算交付。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询