10_Qt Designer 与 UI 文件(.ui 原理、uic 编译、动态加载)

Qt Designer 与 UI 文件:.ui 原理、uic 编译、动态加载

引言

前几章我们一直用 C++ 创建控件、设置属性、添加布局。这样做能把 Qt 的基础 API 练熟,但界面一复杂,代码就会迅速变成长长的"控件清单"。Qt Designer 解决的正是这个问题:把界面交给可视化编辑器,把行为和数据留在 C++。

本文面向 Qt 6 Widgets,按"先会用,再理解原理"的顺序学习。读完后应能:

  • 用 Qt Designer 设计一个带布局的窗口,并知道 objectName 为什么重要;
  • 读懂 .ui 文件中最常见的 XML 节点;
  • 解释 uic 如何把 .ui 变成 ui_*.h,以及 setupUi(this) 做了什么;
  • 在 CMake 中开启 AUTOUIC,让构建系统自动生成并编译 UI 代码;
  • QUiLoader 在运行时加载 .ui,理解这种"动态"的边界、优点与代价。

图 1:同一个 .ui 文件可以走编译期路径,也可以走运行时路径。初学时先掌握左侧的设计流程,再理解右侧两条加载路线。

一、先会用:在 Qt Designer 里做出一个登录窗口

1.1 新建表单并选择基类

在 Qt Creator 中右键项目,选择"添加新文件 → Qt → Qt Designer Form"。常见模板有:

  • Dialog without Buttons:适合自定义按钮的对话框;
  • Main Window:带菜单栏、工具栏、中央窗口区域的主窗口;
  • Widget :最普通的 QWidget,适合作为自定义页面或嵌入式面板。

这里选择 Dialog without Buttons,文件名保存为 login_dialog.ui。文件名会影响生成头文件名:

text 复制代码
login_dialog.ui  →  ui_login_dialog.h

1.2 拖控件、设属性、先放布局

从左侧 Widget Box 拖入:两个 QLineEdit、一个 QPushButton 和若干 QLabel。在 Object Inspector 中把对象名objectName改成:

text 复制代码
accountEdit
passwordEdit
loginButton

objectName 不是显示给用户看的文字,而是 Qt 对象的程序标识。在编译期 UI 中,它常用于 findChild()、QSS、自动连接和测试等场景;在 QUiLoader 动态加载中,它尤其重要,因为业务代码通常需要通过 findChild() 根据对象名找到控件。

显示文字应该设置在 text 属性中,例如"登录"。

选中这些控件,点击工具栏中的"垂直布局"或"网格布局"。布局要尽早设置,不要先用鼠标把控件摆到 "看起来合适" 的位置再补布局;那样容易留下固定几何尺寸,窗口缩放时就会变形。

可在 Property Editor 中设置:

  • windowTitle:窗口标题;
  • placeholderText:输入框提示;
  • echoMode = Password:密码输入框显示圆点;
  • minimumSize / sizePolicy:控件的尺寸约束;
  • layoutSpacinglayoutMargin:布局内部间距和边缘间隔。

1.3 预览,而不是运行

Ctrl+R(或菜单"Form → Preview")可以直接预览当前表单。预览只验证界面描述和布局,不会执行你的业务代码。按钮点击没有反应是正常的,因为还没有连接 C++ 槽函数。

可以在 Designer 中编辑信号槽连接,但入门阶段更推荐在 C++ 中连接:这样编译器能够帮助检查函数签名,业务逻辑也不会隐藏在 XML 里。

二、.ui 文件到底是什么

2.1 它是 XML,不是"二进制界面"

用文本编辑器打开 login_dialog.ui,会看到类似结构:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
 <class>LoginDialog</class>
 <widget class="QDialog" name="LoginDialog">
  <property name="windowTitle">
   <string>用户登录</string>
  </property>
  <layout class="QVBoxLayout" name="verticalLayout">
   <item>
    <widget class="QLineEdit" name="accountEdit">
     <property name="placeholderText">
      <string>账号</string>
     </property>
    </widget>
   </item>
  </layout>
 </widget>
</ui>

可以把它读成一棵对象树:

text 复制代码
QDialog(LoginDialog)
└── QVBoxLayout(verticalLayout)
    ├── QLineEdit(accountEdit)
    └── QPushButton(loginButton)

几个高频节点的含义如下:

XML 节点 作用 Designer 中对应的概念
class 生成代码时使用的类名提示 Form 的类名
widget 创建一个 QWidget 对象 控件和父子关系
layout 描述布局类型和布局对象 水平、垂直、网格布局
property 设置对象属性 Property Editor 中的一行
item 把控件或子布局放入布局 布局中的一个项目
connection 描述信号到槽的连接 Signal/Slot Editor
resources 引用资源集合 .qrc 资源文件

.ui 的价值在于"描述",而不是保存运行时对象。打开文件不会得到一个已经存在的 QPushButton;程序必须经过后面的生成或解析步骤,才能创建真正的 C++ 对象。

2.2 .uiUi::LoginDialogLoginDialog 三者是什么关系?

写 Designer 表单时,初学者常会同时看到这三个名字。它们名字相近,但处在三个不同阶段:

图 2:uic 不会替你写业务窗口类;它只生成一个负责创建控件、布局和静态属性的 Ui::LoginDialog 辅助类。

对应到代码,LoginDialog 是你亲手声明并实现的窗口类:

cpp 复制代码
class LoginDialog : public QDialog
{
    Q_OBJECT

public:
    explicit LoginDialog(QWidget *parent = nullptr);

private slots:
    void tryLogin();

private:
    std::unique_ptr<Ui::LoginDialog> ui;
};

它在构造函数中调用 ui->setupUi(this)Ui::LoginDialog 随后根据 .ui 的描述创建 accountEditpasswordEditloginButton 等子控件,并把它们安装到这个真正的 LoginDialog 窗口上。

.ui 是设计文件,Ui::LoginDialoguic 生成的辅助类,LoginDialog 才是我们真正编写业务逻辑的窗口类。

2.3 .ui 与业务代码应该分工

建议把职责分成三层:

text 复制代码
login_dialog.ui       → 控件、布局、静态属性
LoginDialog 类        → 读取输入、校验、发起登录
LoginService 类       → 网络请求、缓存、业务规则

如果把网络请求、数据库操作直接塞进 setupUi() 生成代码,下一次在 Designer 中保存表单就可能覆盖你的修改。ui_*.h 是构建产物,不要手工编辑

三、编译期路径:uic 如何把 .ui 变成 C++

3.1 手动运行一次 uic

uic(User Interface Compiler)是 Qt 提供的命令行工具。最小调用方式是:

bash 复制代码
uic login_dialog.ui -o ui_login_dialog.h

生成的头文件大致如下(省略细节):

cpp 复制代码
namespace Ui {
class LoginDialog
{
public:
    QVBoxLayout *verticalLayout;
    QLineEdit *accountEdit;
    QPushButton *loginButton;

    void setupUi(QDialog *LoginDialog)
    {
        if (LoginDialog->objectName().isEmpty())
            LoginDialog->setObjectName("LoginDialog");
        verticalLayout = new QVBoxLayout(LoginDialog);
        accountEdit = new QLineEdit(LoginDialog);
        verticalLayout->addWidget(accountEdit);
        loginButton = new QPushButton(LoginDialog);
        verticalLayout->addWidget(loginButton);
        retranslateUi(LoginDialog);
    }
    
    void retranslateUi(QDialog *LoginDialog)
    {
        LoginDialog->setWindowTitle(QCoreApplication::translate("LoginDialog", "用户登录"));
        loginButton->setText(QCoreApplication::translate("LoginDialog", "登录"));
    }
};
}

这里setupUi()本质上就是把Designer中的操作翻译成C++:

Designer 操作 .ui setupUi()
拖入 QPushButton <widget class="QPushButton"> new QPushButton()
修改标题 <property name="text"> setText()
设置布局 <layout class="QVBoxLayout"> new QVBoxLayout()
放入布局 <item> layout->addWidget()
设置对象名 name="loginButton" setObjectName()

真正的窗口类只需要持有一个 Ui::LoginDialog 对象:

cpp 复制代码
#include "login_dialog.h"
#include "ui_login_dialog.h"

LoginDialog::LoginDialog(QWidget *parent)
    : QDialog(parent), ui(std::make_unique<Ui::LoginDialog>())
{
    ui->setupUi(this);

    connect(ui->loginButton, &QPushButton::clicked,
            this, &LoginDialog::tryLogin);
}

这里发生了三件事:

  1. new Ui::LoginDialog 只是创建"界面描述对象";
  2. setupUi(this).ui 中的顺序创建控件、布局并设置属性、设置可翻译文本;
  3. connect() 把业务行为接到生成好的控件上。

Ui::LoginDialog 本身不是窗口,也不是 QWidget,它只是 uic 生成的辅助类。真正的控件是在 setupUi(this) 执行过程中通过 new 创建出来的。

有些项目把 Ui::LoginDialog ui; 作为值成员,而不是 std::unique_ptr。两种写法都可以;值成员更简单,指针写法便于前置声明和延迟构造。关键点是:窗口类负责拥有 UI,UI 负责搭建控件,业务代码负责使用控件。

3.2 Designer 中的自动连接:QMetaObject::connectSlotsByName()

在 Designer 的"信号/槽编辑器"里创建连接,或给槽函数采用约定命名时,生成的 setupUi() 末尾通常会出现:

cpp 复制代码
QMetaObject::connectSlotsByName(LoginDialog);

它会从传入的窗口对象开始查找子控件,并按下面的命名规则自动建立连接:

text 复制代码
on_<对象名>_<信号名>(参数)

例如 Designer 中按钮的 objectNameloginButton,希望响应它的 clicked() 信号,就在 LoginDialog 中声明并实现:

cpp 复制代码
class LoginDialog : public QDialog
{
    Q_OBJECT

private slots:
    void on_loginButton_clicked();
};

void LoginDialog::on_loginButton_clicked()
{
    // 处理登录
}

不需要再手写 connect(ui->loginButton, &QPushButton::clicked, ...)setupUi(this) 调用的 connectSlotsByName() 会根据对象名和槽函数名找到这对关系,并使用 Qt 元对象系统连接它们。要注意,它省去的是 connect() 调用,不是槽函数本身;槽仍应在窗口类中声明并实现,且对象名、信号名和参数必须匹配。

自动连接的优点是上手快,但它依赖字符串式命名约定:控件改名或槽函数改名时,问题通常到运行时才暴露。现代 Qt C++ 开发中,更推荐显式 connect(),因为函数签名清晰、重构友好;自动连接更适合了解 Qt Designer 生成代码时作为知识点掌握。

3.3 CMake 自动调用 uic

Qt 6 项目推荐使用 CMake:

cmake 复制代码
cmake_minimum_required(VERSION 3.21)
project(LoginDemo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTORCC ON)

find_package(Qt6 REQUIRED COMPONENTS Widgets)

qt_add_executable(LoginDemo
    main.cpp
    login_dialog.cpp
    login_dialog.h
    login_dialog.ui
)

target_link_libraries(LoginDemo PRIVATE Qt6::Widgets)

CMAKE_AUTOUIC ON 的含义不是"把 .ui 编译成机器码",而是让 CMake 在生成构建规则时识别 .ui,在需要时调用 uic 生成 ui_*.h,再让 C++ 编译器编译包含它的源文件。

Qt 6 也可以使用:

cmake 复制代码
qt_standard_project_setup()

它会为 Qt 项目打开常用的自动处理能力,通常包括 AUTOMOCAUTOUICAUTORCC。为了让初学者清楚看到开关,本文仍保留显式写法。

图 3:CMake 负责发现和安排任务,uic 负责生成头文件,最终仍由普通 C++ 编译器完成编译。

3.4 怎样确认 AUTOUIC 真的生效

遇到 ui_login_dialog.h: No such file 时,按下面顺序检查:

  1. .ui 是否加入了 qt_add_executable() 或目标的源文件列表;
  2. 是否重新运行了 CMake 配置,而不是只点击编译;
  3. login_dialog.cpp#include "ui_login_dialog.h" 是否与表单文件名匹配;
  4. 查看构建目录(具体放在哪里可以查看专栏04文章第七节),确认存在 ui_login_dialog.h
  5. 打开详细构建输出,搜索 uic,确认命令实际被执行。

如果 .ui 放在 forms/ 等独立目录,可补充搜索路径:

cmake 复制代码
set(CMAKE_AUTOUIC_SEARCH_PATHS
    ${CMAKE_CURRENT_SOURCE_DIR}/forms
)

也可以使用目标级属性,避免影响其他目标:

cmake 复制代码
set_property(TARGET LoginDemo PROPERTY AUTOUIC ON)

3.5 显式调用 uic:适合排错和特殊构建

大多数项目不需要手写自定义命令,但知道显式写法有助于排错:

cmake 复制代码
set(GENERATED_UI ${CMAKE_CURRENT_BINARY_DIR}/ui_login_dialog.h)

add_custom_command(
    OUTPUT ${GENERATED_UI}
    COMMAND Qt6::uic
            ${CMAKE_CURRENT_SOURCE_DIR}/login_dialog.ui
            -o ${GENERATED_UI}
    DEPENDS login_dialog.ui
    VERBATIM
)

target_sources(LoginDemo PRIVATE ${GENERATED_UI})

自动方式适合常规应用;显式方式适合需要固定生成目录、对生成文件做额外检查,或正在排查构建依赖的场景。两者不要同时对同一个 .ui 生效,否则可能生成两份文件。

四、运行时路径:QUiLoader 如何体现"动态"

4.1 先把 .ui 放进资源系统

动态加载不能依赖当前工作目录,否则从 IDE 启动和双击 exe 启动可能得到不同结果。推荐把表单加入 .qrc

xml 复制代码
<RCC>
  <qresource prefix="/forms">
    <file>login_dialog.ui</file>
  </qresource>
</RCC>

CMake:

cmake 复制代码
qt_add_executable(LoginDynamic
    main.cpp
    login_dynamic.cpp
    forms.qrc
)

find_package(Qt6 REQUIRED COMPONENTS Widgets UiTools)
target_link_libraries(LoginDynamic PRIVATE Qt6::Widgets Qt6::UiTools)

使用 .qrc 的主要价值不是 QUiLoader 的硬性要求,而是避免依赖当前工作目录,并且可以把 .ui 一起编译进程序资源,减少部署时的路径问题。

4.2 最小动态加载代码

cpp 复制代码
#include <QFile>
#include <QMessageBox>
#include <QUiLoader>

QWidget *loadLoginForm(QWidget *parent)
{
    QFile file(":/forms/login_dialog.ui");
    if (!file.open(QIODevice::ReadOnly)) {
        qWarning() << "open ui failed:" << file.errorString();
        return nullptr;
    }

    QUiLoader loader;
    QWidget *root = loader.load(&file, parent);
    file.close();

    if (!root) {
        qWarning() << "load ui failed:" << loader.errorString();
        return nullptr;
    }

    auto *loginButton = root->findChild<QPushButton *>("loginButton");
    auto *accountEdit = root->findChild<QLineEdit *>("accountEdit");
    if (!loginButton || !accountEdit) {
        root->deleteLater();
        return nullptr;
    }

    QObject::connect(loginButton, &QPushButton::clicked,
                     root, [accountEdit] {
        QMessageBox::information(nullptr, QObject::tr("提示"),
                                 accountEdit->text());
    });
    return root;
}

调用:

cpp 复制代码
if (QWidget *form = loadLoginForm(nullptr))
    form->show();

图 4:动态加载时没有 ui_*.h,程序在运行中读取 XML,并通过 Qt 元对象系统创建控件。

4.3 "动态"不等于"完全不写代码"

动态加载改变的是界面创建时机,不是业务逻辑的写法。

QUiLoader 并不是把 .ui 编译成 C++ 后再运行,而是在程序运行过程中直接解析 XML 描述,并根据其中的信息创建对象。

因为没有 ui->loginButton 这种编译期成员,通常通过 objectName 查找:

cpp 复制代码
auto *button = root->findChild<QPushButton *>("loginButton");

这带来一个明显边界:如果 Designer 中把 loginButton 改名,编译器不会报错,程序只会在运行时找不到对象。因此动态 UI 更需要:

  • 对关键对象名做集中常量定义;
  • 加载后立即检查必需控件;
  • .ui 做版本兼容或自动化加载测试。

自定义控件还要额外处理。QUiLoader 只认识 Qt 内置控件;遇到自定义类,通常需要继承 QUiLoader 并重写 createWidget(),或者在加载前注册可用的自定义控件。否则会出现"未知控件"或根对象为空。

五、编译期与运行时:该怎么选

对比项 编译期 uic 运行时 QUiLoader
UI 变化后 重新构建 替换 .ui 即可生效
错误暴露 许多问题在编译期发现 很多问题推迟到运行期
访问控件 ui->button,类型明确 findChild(),依赖名称
启动性能 通常更好 需要解析 XML 和创建对象
发布方式 .ui 可不随程序发布 .ui 必须随程序或进资源
适合场景 稳定产品界面、强类型开发 插件化、主题替换、可配置页面

入门阶段默认选择编译期路径。它的代码更容易被 IDE 补全、编译器检查和重构工具理解。只有当"无需重新编译就要换界面"确实是需求时,再引入 QUiLoader

六、从一个表单扩展到可维护项目

6.1 头文件与源文件的推荐结构

text 复制代码
forms/
  login_dialog.ui
src/
  login_dialog.h
  login_dialog.cpp
  login_service.h
  login_service.cpp
CMakeLists.txt

login_dialog.cpp 只关心界面行为:读取输入、显示错误、发出登录请求。login_service.cpp 负责业务和网络。这样 Designer 反复调整布局时,不会牵动业务层。

6.2 翻译与重新载入

uic 生成的 retranslateUi() 会集中设置可翻译文本。程序切换语言时,窗口类可以在 changeEvent(QEvent::LanguageChange) 中再次调用 ui->retranslateUi(this)(或重新实现相同逻辑),而不是到处手动修改标签文字。

动态加载也可以重新读取 .ui,但要先销毁旧根对象,再恢复业务状态;否则容易产生重复连接和悬空指针。换肤通常更适合使用 Qt Style Sheet 或资源系统,不必把整个 UI 改成动态加载。

6.3 生成文件、构建目录和版本控制

通常只提交 .ui、C++ 源码和 CMake 文件,不提交构建目录里的 ui_*.h。生成文件属于可重复产物:任何开发者都可以通过同一 Qt 版本和 CMake 配置重新得到它。

七、常见问题速查

问题 1:ui_xxx.h 找不到

优先检查 CMAKE_AUTOUIC.ui 是否加入目标源文件,以及是否重新配置 CMake。不要直接复制一份旧的 ui_xxx.h 到源码目录,这只会掩盖构建依赖问题。

问题 2:控件指针为空

编译期路径中通常是 objectName 或类名写错;动态路径中则是 findChild() 名字不匹配、类型不匹配,或 .ui 根对象加载失败。

问题 3:布局在预览中正常,运行时却挤压

检查是否真的给父容器设置了布局,是否残留了 setGeometry(),以及控件的 sizePolicy、最小尺寸和字体是否与预览环境不同。

问题 4:Designer 里能放自定义控件,动态加载却失败

Designer 的"提升为(Promoted to)"只是设计期信息;QUiLoader 运行时仍需要能创建这个类。为自定义类提供工厂、插件或 createWidget() 实现。

八、一句话总结

Qt Designer 负责把界面画出来,.ui 负责把界面描述成 XML,uic 负责在构建期把 XML 转成 ui_*.h,而 QUiLoader 则在运行期直接解析 XML 创建 QWidget。默认用编译期路径获得更早的错误检查;只有确实需要"替换界面而不重新编译"时,才选择动态加载。

现在我们已经基本了解Qt环境的组成,下一章可以在此基础上对常用UI控件进行了解学习


下一篇预告:《常用控件速查(上):QPushButton、QLabel、QLineEdit、QComboBox》

相关推荐
IT爱学堂18 分钟前
QT原理与源码分析可以学到什么 QT视频课程 QT课程推荐 最新推荐
开发语言·qt
躺着听Jay20 分钟前
QT交叉编译
开发语言·qt
Quz30 分钟前
QML 焦点导航:Tab 顺序与方向键移动
qt
程与留39 分钟前
11_常用控件速查(上):QPushButton、QLabel、QLineEdit、QComboBox
c++·qt
Quz43 分钟前
QML 按键事件之方向控制
qt
Demon--hx1 小时前
多态实现原理
开发语言·c++
水饺编程1 小时前
第5章,[Win32 章节] :绘制填充区域
c语言·c++·windows·visual studio
渡我白衣4 小时前
深入理解 Transformer:Transformer 究竟是什么?
java·linux·开发语言·c++·人工智能·深度学习·transformer
「QT(C++)开发工程师」12 小时前
C++ 11 常用for循环
开发语言·c++