🔥 本文专栏:Qt
🌸作者主页:努力努力再努力wz

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

💪 今日博客励志语录别人跑得快,你就别把时间花在羡慕上;你要做的是确认方向,然后把脚下这一公里跑完。


思维导图

在这里插入图片描述

引入

在此前的学习中,我们已经认识了几个简单的 Qt 组件,比如按钮、标签、单行输入框等。Qt 程序最终运行出来的结果,本质上就是一个可交互的图形化界面,而这个图形化界面又是由多个界面元素共同组成的。

由于 Qt 是基于 C++ 实现的一套应用程序框架,因此这些界面元素在 Qt 中都会被抽象成对应的类。我们想要创建一个具体的界面结构,就需要实例化对应的界面对象。

同时,这些界面对象之间并不是完全独立的。一个组件通常会显示在另一个组件的内部区域中,此时这个被包含的组件就可以看作是父组件的子对象。因此,Qt 中的组件对象之间天然存在父子关系。

为了统一管理这些对象,Qt 将很多公共能力抽象到了公共基类中。比如 QObject 就是 Qt 对象体系中非常核心的基类,它提供了父子对象管理、信号与槽机制、事件机制基础、对象名以及运行时元对象信息等能力。也就是说,一个类只要继承自 QObject,它就可以接入 Qt 的对象系统,具备父子关系管理、信号槽连接等 Qt 对象机制。

而对于图形化界面组件来说,仅仅具备对象机制还不够,它们还需要能够显示在窗口中。因此,Qt 又提供了 QWidget 这个类。 QWidget 这一层主要提供了界面组件的通用可视化能力和事件处理能力,比如组件的位置、大小、显示、隐藏、鼠标键盘事件、绘制事件等。像 QPushButtonQLabelQLineEdit 这些具体组件之所以能够显示在窗口中,本质上就是因为它们都继承自 QWidget,从而获得了作为界面组件的基础能力。

至于信号与槽机制,则来自 QObject。由于 QWidget 继承自 QObject,所以 QWidget 以及它的子类也自然具备信号与槽机制。

所以可以这样理解:QObject 主要提供 Qt 对象体系中的通用机制,而 QWidget 则是在这些对象机制的基础上,进一步提供界面组件的可视化能力和事件处理能力。

当然,Qt 中并不是所有类都会继承 QWidget。只有那些需要作为界面元素显示出来的组件,比如按钮、标签、输入框、窗口等,才需要继承 QWidget。而像 QRect 这类用于描述矩形区域的数据类,本身并不需要显示在界面上,因此它并不会继承 QWidget

此外,还需要注意的是,QWidget 并不只继承了 QObject,它还继承了 QPaintDevice。也就是说,QWidget 在 Qt 中是一个多继承类。

其中,QPaintDevice 表示“可以被绘制的设备或目标”。由于 QWidget 继承了 QPaintDevice,所以一个窗口组件本身就可以作为绘图目标,被 QPainter 绘制。

默认情况下,Qt 已经为我们提供了很多内置组件,比如按钮、标签、输入框等。这些组件都有自己默认的显示效果,并且我们也可以通过接口修改它们的可视化属性,比如位置、大小、文本内容等。

但是,有些时候我们并不满足于 Qt 组件默认的显示效果,而是希望在某个组件的显示区域中绘制我们自己想要的内容。比如绘制文字、图片、线条、矩形,甚至是更加复杂的自定义图形。这个时候,就需要使用 Qt 的绘图机制。

对于 QWidget 来说,当组件需要重新绘制时,Qt 会触发该组件的 paintEvent() 函数。我们可以通过重写 paintEvent(),并在其中创建 QPainter 对象,对当前组件的显示区域进行自定义绘制。

这里需要注意,paintEvent() 这个绘制事件处理函数并不是 QObject 提供的,而是 QWidget 这一层提供的。QObject 更偏向于提供 Qt 对象体系中的通用机制,比如父子对象管理、信号与槽机制、事件分发基础等;而 QWidget 则是在 QObject 的基础上,进一步提供界面组件所需的可视化能力和事件处理能力。由于绘制事件本质上是服务于界面显示的,所以它属于 QWidget 这一层的能力。当一个组件需要重新绘制时,Qt 会在合适的时机调用该组件的 paintEvent(),我们就可以在这个函数中使用 QPainter 对组件的显示区域进行自定义绘制。

所以QPaintDevice 提供的是“可被绘制”的目标能力,QPainter 才是真正执行绘制操作的工具,而 paintEvent() 则是 QWidget 提供给我们进行自定义绘制的入口函数。

因此,这三者之间的关系可以简单理解为:

QObject
提供 Qt 对象机制:父子对象管理、信号与槽、事件机制基础等

QWidget
继承 QObject,进一步提供界面组件的可视化能力和事件处理能力

QPaintDevice
提供“可被绘制”的目标能力,使 QWidget 可以作为绘图目标

QPainter
真正执行绘制动作的工具

paintEvent()
QWidget 的重绘入口,我们可以在其中使用 QPainter 完成自定义绘制

通过这条继承关系,我们就能更加清楚地理解 QWidget 的定位:它既是一个 Qt 对象,能够参与父子对象管理、信号与槽和事件机制;同时它又是一个可视化组件,能够显示在窗口中;并且它还可以作为绘图目标,让我们通过 QPainter 对组件的显示区域进行自定义绘制。


当我们希望在组件的显示区域中绘制自定义内容时,就不能简单地依赖 Qt 已经提供好的默认接口,而是需要进入 Qt 的绘图机制。对于 QWidget 类型的组件来说,自定义绘制通常是通过重写 paintEvent() 这个虚函数来完成的。不过需要注意的是,paintEvent() 并不是由我们主动调用的函数,而是 Qt 在组件需要重新绘制时自动调用的重绘入口。也就是说,我们要做的不是直接调用 paintEvent(),而是通过 update() 函数通知 Qt:当前组件的显示内容发生了变化,需要重新绘制。

update() 的作用可以理解为向 Qt 提交一个重绘请求。当我们调用 update() 之后,Qt 并不会立刻执行绘制,而是会先将当前组件标记为需要重绘,然后在事件循环运行到合适时机时,再触发该组件的 paintEvent() 函数。在 paintEvent() 中,我们就可以创建 QPainter 对象,并通过 drawText()drawPixmap()drawLine()drawRect() 等接口,在当前组件的显示区域中绘制文字、图片、线条、矩形等内容。

这里还需要区分两种情况:如果我们调用的是 Qt 内置组件提供的接口,比如 QLabel::setText()QPushButton::setText() 等,那么通常不需要我们手动调用 update(),因为这些接口内部已经知道组件的显示内容发生了变化,会自动触发后续的重绘流程。但是如果组件的显示内容依赖我们自己维护的成员变量,比如图形的位置、颜色、进度值、图片数据等,那么当这些数据发生变化时,Qt 并不知道它们会影响最终的界面显示,因此就需要我们手动调用 update(),通知 Qt 重新绘制当前组件。

例如,在下面这个例子中,圆的位置由成员变量 x 决定。当 x 的值发生变化后,界面上圆的位置也应该发生变化。但 Qt 并不知道这个普通成员变量会影响界面显示,因此我们需要在修改 x 之后调用 update(),通知 Qt 重新触发 paintEvent()

class MyWidget : public QWidget
{
    Q_OBJECT

public:
    MyWidget(QWidget *parent = nullptr)
        : QWidget(parent)
    {
    }

protected:
    void paintEvent(QPaintEvent *event) override
    {
        QPainter painter(this);

        // 根据成员变量 x 的值绘制一个圆
        painter.drawEllipse(x, 50, 50, 50);
    }

public slots:
    void moveCircle()
    {
        // 修改影响界面显示的数据
        x += 20;

        // 通知 Qt:当前组件的显示内容发生了变化,需要重新绘制
        update();
    }

private:
    int x = 0;
};

这段代码表达的流程就是:

调用 moveCircle()
        ↓
修改成员变量 x
        ↓
调用 update() 提交重绘请求
        ↓
Qt 在合适时机调用 paintEvent()
        ↓
paintEvent() 根据新的 x 值重新绘制圆

这里我们使用了 QPainter 提供的 drawEllipse() 函数来绘制圆形。需要注意的是,drawEllipse() 从函数名上看是绘制椭圆,但它的本质是:在指定的矩形区域中绘制一个内切椭圆。

例如下面这行代码:

painter.drawEllipse(x, 50, 50, 50);

其中,前两个参数 x50 表示绘制区域左上角的坐标,后两个参数 5050 表示绘制区域的宽度和高度。也就是说,这行代码表示在当前组件的绘制区域中,以 (x, 50) 作为左上角,创建一个宽度为 50、高度为 50 的矩形区域,然后在这个矩形区域内部绘制一个内切椭圆。

(x, 50)
  ↓
  ┌──────────┐
  │          │
  │   圆      │
  │          │
  └──────────┘

矩形宽度 = 50
矩形高度 = 50

由于这里矩形的宽度和高度相等,都是 50,所以最终绘制出来的图形就是一个圆。如果将宽度和高度设置为不同的值,比如:

painter.drawEllipse(x, 50, 100, 50);

那么此时绘制出来的就是一个普通椭圆。

因此,在这个例子中,x 实际上控制的是圆形绘制区域的横向位置。当我们修改 x 的值之后,圆对应的绘制位置也会发生变化。此时再调用 update() 通知 Qt 重新绘制,Qt 就会在后续合适的时机重新触发 paintEvent(),并根据新的 x 值绘制圆形。这样从视觉效果上看,圆就发生了移动。


所以从更底层的角度来看,不管是调用 setText() 修改组件文本,还是在 paintEvent() 中使用 QPainter 自定义绘制图片和图形,最终都要变成显示器上的像素。显示器本质上是由大量像素点组成的二维显示区域,而应用程序中的窗口和组件想要显示到屏幕上,就必须经过操作系统窗口系统的统一管理。Qt 为我们屏蔽了不同操作系统之间的底层差异,程序员只需要面对 QWidgetupdate()paintEvent()QPainter 这一套跨平台接口;而在底层,Qt 会将绘制结果更新到窗口对应的缓冲区中,再通过操作系统窗口系统、图形系统以及显示驱动等机制,最终把窗口内容呈现到显示器上。

因此,整个流程可以简单理解为:当组件显示内容发生变化时,我们通过 update() 向 Qt 提交重绘请求;Qt 在合适时机调用 paintEvent();我们在 paintEvent() 中使用 QPainter 描述具体要绘制的内容;随后 Qt 和操作系统窗口系统协作,将这些绘制结果最终显示到屏幕上。这个过程中,update() 负责“通知需要重绘”,paintEvent() 负责“提供绘制入口”,QPainter 负责“执行具体绘制”,而操作系统窗口系统则负责窗口管理和最终显示。

在这里插入图片描述

QWidget 属性详解:组件交互、窗口几何与资源文件机制

enabled 属性:控制组件是否处于可交互状态

根据上文,我们已经认识到,Qt 中的组件可以作为绘制目标。也就是说,我们可以在组件的显示区域中自行绘制图形,例如圆形、矩形、图片等。这样一来,我们就不再只能依赖 Qt 已经提供好的默认组件效果,而是可以更加灵活地定制图形化界面的显示内容。

而在此之前,我们已经接触过几个基本组件,比如按钮、标签、单行输入框等。它们都直接或间接继承自 QWidget,因此具备了作为界面组件显示出来的能力。QWidget 这一层为组件提供了很多和可视化相关的通用能力,比如组件的位置、大小、显示、隐藏、事件处理以及绘制入口等。也就是说,按钮、标签、输入框这些具体组件,正是通过继承 QWidget,才获得了这些通用的界面组件能力。

接下来,我们就来进一步认识 QWidget 相关的几个常用属性。这些属性会直接影响组件在界面中的显示效果和交互状态。我们可以通过调用组件对象继承自 QWidget 的相关接口,来修改这些属性,从而控制组件的显示和行为。

首先要介绍的是 enabled 属性。该属性表示组件当前是否处于可用状态。需要注意的是,enabled 并不是用来控制组件是否显示的属性。即使一个组件被禁用,它仍然可以显示在图形化界面中,只不过通常会呈现出灰色等禁用态样式,并且不能再正常响应用户的输入事件。

例如,如果我们将一个按钮组件的 enabled 属性设置为 false,那么这个按钮仍然会显示在界面上,但是它会变成禁用状态。此时用户即使点击这个按钮,也不会触发按钮的正常点击逻辑。

这里可以结合事件处理流程来理解。用户点击鼠标时,底层仍然会产生输入事件:鼠标的物理按下动作会通过硬件设备传递给操作系统,操作系统再通过驱动程序和输入子系统整理出标准输入事件,随后交给窗口系统进行分发,最终再传递到对应的 Qt 程序中。

但是,对于一个处于禁用状态的组件来说,Qt 不会让它按照正常可用状态去响应这些输入事件。以按钮为例,clicked 并不是一个底层直接产生的原始鼠标事件,而是 QPushButton 在内部处理鼠标按下和鼠标释放事件之后,判断这是一次有效点击,才发出的语义信号。如果按钮处于禁用状态,它就不会进入正常的点击判定流程,因此也就不会发出 clicked 信号。即使我们已经给这个按钮的 clicked 信号绑定了槽函数,这个槽函数也不会因为点击禁用按钮而被触发。

这里需要注意,底层鼠标输入事件仍然会产生,比如鼠标按下和鼠标释放。但是当按钮处于禁用状态时,它不会按照正常可交互状态去处理这些输入事件。对于按钮来说,正常情况下它需要根据鼠标按下、鼠标释放的位置判断用户是否完成了一次有效点击,然后才会发出 pressed()released()clicked() 等信号。而按钮被禁用后,这一套交互判断流程不会正常执行,因此这些信号也不会被发出,对应绑定的槽函数自然不会执行。

禁用状态不是让鼠标输入事件消失,而是让组件不再对这些输入事件执行正常的交互处理逻辑。

用户点击鼠标
        ↓
操作系统产生底层输入事件
        ↓
窗口系统将事件交给 Qt 程序
        ↓
事件被投递到目标按钮的 event() 入口
        ↓
QWidget::event() 检查 isEnabled(),发现处于 disabled 状态
        ↓
对鼠标 / 键盘等输入类事件直接 return false
        ↓
mousePressEvent / mouseReleaseEvent 根本不会被调用
        ↓
QAbstractButton 的点击判定流程不会进入
        ↓
pressed() / released() / clicked() 信号都不会发出
        ↓
绑定的槽函数自然不会执行

enabled 属性相关的常用接口主要有两个:

bool isEnabled() const;
void setEnabled(bool);

其中,isEnabled() 用来获取当前组件是否处于可用状态。如果组件可用,则返回 true;如果组件被禁用,则返回 false。而 setEnabled() 则用于设置组件的可用状态。

下面可以通过一个简单实验来观察 enabled 属性的效果。这里创建两个按钮:button1 被点击时会打印一条日志;button2 被点击时会切换 button1 的可用状态。如果 button1 当前是可用状态,就将它设置为禁用;如果它当前是禁用状态,就重新设置为可用。

#include "widget.h"
#include "ui_widget.h"
#include <QPushButton>
#include <QDebug>

Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);

    button1 = new QPushButton(this);
    button1->setText("button1");
    button1->setGeometry(100, 100, 150, 200);

    button2 = new QPushButton(this);
    button2->setText("button2");
    button2->setGeometry(400, 400, 150, 200);

    connect(button1, &QPushButton::clicked, this, &Widget::handleClicked1);
    connect(button2, &QPushButton::clicked, this, &Widget::handleClicked2);
}

Widget::~Widget()
{
    delete ui;
}

void Widget::handleClicked1()
{
    qDebug() << "button1 被点击了";
}

void Widget::handleClicked2()
{
    button1->setEnabled(!button1->isEnabled());
}

在这段代码中,handleClicked2() 是整个实验的关键:

void Widget::handleClicked2()
{
    button1->setEnabled(!button1->isEnabled());
}

这行代码的含义是:先通过 isEnabled() 获取 button1 当前是否可用,然后再通过 setEnabled() 将其设置为相反状态。也就是说,如果 button1 当前可用,就将它禁用;如果 button1 当前已经被禁用,就重新启用它。

程序运行后,当 button1 处于可用状态时,点击它会触发 clicked 信号,从而执行 handleClicked1(),并在调试输出中打印日志。然后点击 button2,就可以将 button1 设置为禁用状态。此时 button1 虽然仍然显示在界面上,但会呈现出禁用态样式,并且用户再点击它时,不会触发 clicked 信号,也就不会执行对应的槽函数。
在这里插入图片描述

因此,enabled 属性的核心作用可以概括为:它控制的是组件是否处于可交互状态。组件被禁用后并不会从界面上消失,而是仍然显示在窗口中,只是不能再正常响应用户输入事件。对于按钮这类组件来说,禁用状态下不会完成正常的点击判定流程,因此也不会发出 clicked 信号。

geometry 属性:组件几何信息与位置尺寸控制

根据上文,我们已经认识了 enabled 属性。enabled 主要用来控制组件是否处于可交互状态:当组件被禁用后,它仍然会显示在界面中,但是不能再正常响应用户的鼠标、键盘等输入事件。

接下来,我们继续学习 QWidget 中另一个常用属性:geometry 属性。

enabled 不同,geometry 主要描述的是组件在界面中的几何信息,也就是组件的位置和大小。可以简单理解为:enabled 更偏向于控制组件的交互状态,而 geometry 更偏向于控制组件在父组件中的显示区域。

对于一个子组件来说,geometry 描述的是该组件在父组件坐标系中的矩形区域。这个矩形区域主要包含四个信息:组件左上角的横坐标、组件左上角的纵坐标、组件的宽度以及组件的高度。

也就是说,一个组件显示在父组件的哪个位置,以及它占据多大的区域,都可以通过 geometry 来描述。

QWidget 提供了 geometry() 接口来获取组件当前的几何信息:

QRect rect = button->geometry();

该接口的返回值是一个 QRect 对象。我们可以将 QRect 理解为一个矩形区域对象,它内部保存了矩形左上角的位置以及矩形的宽度和高度。

例如:

QRect rect = button->geometry();

qDebug() << rect;
qDebug() << rect.x();
qDebug() << rect.y();
qDebug() << rect.width();
qDebug() << rect.height();

其中,rect.x()rect.y() 表示组件左上角在父组件坐标系中的位置,rect.width()rect.height() 分别表示组件的宽度和高度。

这里使用 qDebug() 输出 QRect 对象时,Qt 能够直接打印出其中的内容。这是因为 qDebug() 对 Qt 自己的一些常用类型提供了较好的支持,比如 QStringQPointQSizeQRect 等。因此,我们可以比较方便地通过 qDebug() 观察组件当前的几何信息。

除了获取组件的几何信息之外,我们还可以通过相关接口修改组件的位置和大小。常见的接口主要有三个:

move(x, y);                 // 只修改组件的位置
resize(width, height);      // 只修改组件的大小
setGeometry(x, y, w, h);    // 同时修改组件的位置和大小

首先是 move() 接口。该接口用于修改组件左上角的位置,例如:

button->move(100, 100);

这行代码表示将 button 的左上角移动到父组件坐标系中的 (100, 100) 位置。需要注意的是,move() 只会修改组件的位置,不会修改组件的宽度和高度。

也就是说,调用 move() 后,组件只是发生了移动,它占据的区域大小并不会发生变化。

如果我们只想修改组件的大小,可以使用 resize() 接口:

button->resize(150, 50);

这行代码表示将 button 的宽度设置为 150,高度设置为 50resize() 只会修改组件的尺寸,不会修改组件左上角的位置。

而如果我们既想修改组件的位置,又想修改组件的大小,就可以使用 setGeometry()

button->setGeometry(100, 100, 150, 50);

这行代码表示:将 button 的左上角设置到父组件坐标系中的 (100, 100) 位置,同时将它的宽度设置为 150,高度设置为 50

因此,setGeometry() 实际上可以同时描述一个组件的完整矩形区域:

x = 100
y = 100
width = 150
height = 50

除了传入四个参数之外,setGeometry() 也可以直接接收一个 QRect 对象:

QRect rect(100, 100, 150, 50);
button->setGeometry(rect);

这种写法和直接传入四个参数的效果是一样的。

当然,如果我们想使用 setGeometry() 达到和 move() 类似的效果,也就是只修改组件的位置,而不修改组件的大小,那么就需要保持原来的宽度和高度不变。例如:

button->setGeometry(200, 100, button->width(), button->height());

这行代码中,前两个参数 200100 用来设置新的左上角坐标,而后两个参数仍然使用组件当前的宽度和高度。因此,最终效果就是只改变组件的位置,不改变组件的大小。

所以,geometry 属性的核心作用可以概括为:它描述的是组件在父组件坐标系中的矩形区域,包括组件左上角的位置以及组件的宽度和高度。通过 geometry() 可以获取这些信息,通过 move() 可以修改组件位置,通过 resize() 可以修改组件大小,而通过 setGeometry() 则可以同时修改组件的位置和尺寸。

客户区与非客户区:geometry()frameGeometry() 的区别

认识了 geometry 属性之后,我们还需要补充一个和窗口坐标相关的概念:客户区非客户区

当我们运行一个普通的 Qt 窗口程序时,可以看到窗口顶部通常会有标题栏,窗口周围也可能会有边框。对于一个顶层窗口来说,它最终显示出来的区域并不完全都由我们自己绘制。通常可以把窗口区域分成两部分:一部分是客户区,另一部分是非客户区。

所谓客户区,可以理解为应用程序真正用于显示内容的区域。比如我们在窗口中放置按钮、标签、单行输入框,或者在 paintEvent() 中使用 QPainter 自定义绘制文字、图片、圆形、矩形等内容,这些通常都发生在客户区内部。

而非客户区则主要包括标题栏、窗口边框等窗口装饰部分。默认情况下,这些区域通常由操作系统的窗口系统或窗口管理器负责生成和管理。也就是说,在普通 QWidget 窗口中,我们通过paintEvent() 绘制的内容作用于窗口的客户区,不能直接绘制标题栏和窗口边框这些非客户区内容。

这里还需要注意,客户区和非客户区的区别,主要针对带有窗口装饰的顶层窗口才有明显意义。对于普通子控件来说,比如按钮、标签、单行输入框等,它们只是显示在父组件客户区中的一块区域,本身并不会拥有操作系统级别的标题栏和窗口边框。因此,对于普通子控件来说,一般不需要特别区分客户区和非客户区。

正是因为顶层窗口存在客户区和非客户区的区别,所以 Qt 中也提供了不同的接口来描述窗口的几何信息。前面介绍的 geometry() 返回的是不包含窗口边框和标题栏的几何区域,也就是更偏向客户区本身的几何信息;而 frameGeometry() 返回的则是包含窗口边框和标题栏在内的整个窗口外框区域。

因此,对于普通子控件来说,geometry() 描述的就是它在父组件坐标系中的位置和大小;而对于顶层窗口来说,如果我们只关心客户区的位置和大小,可以查看 geometry();如果希望获取包含标题栏和边框在内的整个窗口区域,则需要使用 frameGeometry()

可以简单概括为:

geometry()
描述客户区本身的几何信息,不包含标题栏和窗口边框

frameGeometry()
描述整个窗口外框的几何信息,包含标题栏和窗口边框

也就是说,geometry() 更关注应用程序可以直接绘制和布局内容的区域,而 frameGeometry() 更关注整个窗口在屏幕上的完整占用区域。

上面提到 frameGeometry() 的左上角坐标位于屏幕全局坐标系,但对于顶层窗口来说,geometry() 返回的左上角坐标,使用的同样也是屏幕全局坐标系。也就是说,这两个接口在坐标系上其实是一致的,二者的差异并不在于"用了不同的坐标系",而在于"描述的是窗口上不同的两个点"。

frameGeometry() 量到的是整个窗口外框的左上角,也就是窗口最外圈那条边的角点,这里包含了标题栏和边框;而 geometry() 量到的则是客户区的左上角,这个点已经位于标题栏下方、左右边框的内侧。两个点都是从屏幕原点 (0, 0) 出发测量得到的,只不过它们在屏幕上所落的位置并不相同。

在这里插入图片描述

frameGeometry() 返回的是整个窗口外框区域的几何信息,其中左上角坐标表示:包含标题栏和边框在内的整个窗口左上角,相对于屏幕全局坐标系的位置

这里还需要补充一点:普通子控件虽然也可以调用 geometry()frameGeometry(),但是对于普通子控件来说,这两个接口返回的结果通常是相同的。因为子控件只是显示在父组件客户区中的一块区域,它本身并不具有操作系统级别的标题栏和窗口边框,也就不存在所谓的非客户区。因此,子控件的 geometry()frameGeometry() 一般都可以理解为描述该控件在父组件坐标系中的位置和大小。真正需要区分 geometry()frameGeometry() 的场景,主要是带有窗口装饰的顶层窗口。


根据上文,我们已经认识到,一个普通的顶层窗口通常可以分为两个部分:客户区非客户区

其中,客户区是应用程序真正用于显示内容的区域。比如我们在窗口中放置按钮、标签、单行输入框,或者在 paintEvent() 中使用 QPainter 自定义绘制文字、图片、圆形、矩形等内容,这些通常都发生在客户区内部。

而非客户区则主要包括标题栏、窗口边框等窗口装饰部分。默认情况下,这些区域通常由操作系统的窗口系统或者窗口管理器负责生成和管理。也就是说,在普通的 QWidget 顶层窗口中,paintEvent() 绘制的是客户区内容,而不是系统标题栏和窗口边框这些非客户区内容。

这里还需要注意一点:虽然我们可以在 Qt 程序中设置客户区的大小、位置,并且可以在客户区内部绘制内容,但是窗口最终能够显示到屏幕上,仍然需要经过底层窗口系统的管理。

一开始,我们在程序中创建的只是一个 C++ 层面的 QWidget 对象。此时它只是存在于程序内存中的一个对象,还没有真正作为一个窗口显示到屏幕上。只有当我们调用 show() 函数之后,Qt 才会向底层窗口系统发送显示请求,使这个顶层 QWidget 和操作系统窗口系统中的窗口资源建立关联。随后,窗口系统会为该窗口分配显示区域,并生成标题栏、边框等窗口装饰信息,最终将窗口显示到屏幕上。

也正是因为这个原因,在窗口真正显示出来之前,geometry()frameGeometry() 的结果可能是相同的。因为此时窗口系统还没有为这个顶层窗口生成完整的窗口外框信息,Qt 也就无法准确获得标题栏和边框带来的额外尺寸。而当窗口显示出来之后,标题栏和窗口边框等非客户区已经存在,此时再调用 frameGeometry(),就可以看到它和 geometry() 之间的差异。

下面可以通过一个简单实验来观察这一点。我们在构造函数中打印一次窗口的几何信息,然后在按钮的槽函数中再次打印。由于构造函数执行时窗口还没有真正显示出来,而按钮槽函数执行时窗口已经显示出来,所以两次打印结果可能会有所不同。

#include "widget.h"
#include "ui_widget.h"
#include <QDebug>

Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);

    qDebug() << "geometry:" << this->geometry();
    qDebug() << "frameGeometry:" << this->frameGeometry();
    qDebug() << "size:" << this->size();
    qDebug() << "frameSize:" << this->frameSize();
    qDebug() << "pos:" << this->pos();
    qDebug() << "geometry.topLeft:" << this->geometry().topLeft();
    qDebug() << "-------------------";
}

Widget::~Widget()
{
    delete ui;
}

void Widget::on_pushButton_clicked()
{
    qDebug() << "geometry:" << this->geometry();
    qDebug() << "frameGeometry:" << this->frameGeometry();
    qDebug() << "size:" << this->size();
    qDebug() << "frameSize:" << this->frameSize();
    qDebug() << "pos:" << this->pos();
    qDebug() << "geometry.topLeft:" << this->geometry().topLeft();
}

在这里插入图片描述

在这段代码中,构造函数中的打印语句会在窗口真正显示之前执行。此时顶层窗口还没有完成和底层窗口系统的关联,标题栏和窗口边框等非客户区信息可能还没有确定,因此 geometry()frameGeometry() 的结果可能看起来是一样的。

而当我们点击按钮时,窗口已经显示在屏幕上,操作系统窗口系统也已经为它生成了标题栏和边框等窗口装饰。此时再次打印几何信息,就可以发现 geometry()frameGeometry() 之间可能出现差异。

其中,geometry() 描述的是窗口客户区对应的几何信息,不包含标题栏和窗口边框;而 frameGeometry() 描述的是整个窗口外框的几何信息,包含标题栏和窗口边框。因此,对于顶层窗口来说,frameGeometry() 更能反映这个窗口在屏幕上实际占用的完整区域。

可以简单概括为:

构造函数中:
QWidget 只是 C++ 层面的对象,
窗口还没有真正显示,
非客户区信息可能还没有确定。

调用 show() 之后:
Qt 请求底层窗口系统显示窗口,
窗口系统生成标题栏、边框等窗口装饰。

窗口显示之后:
geometry() 描述客户区几何信息,
frameGeometry() 描述包含非客户区在内的整个窗口外框信息。

因此,geometry()frameGeometry() 的差异,本质上来自顶层窗口的客户区和非客户区之分。对于普通子控件来说,由于它们没有系统标题栏和窗口边框,所以通常不需要特别区分这两个接口;而对于顶层窗口来说,如果我们只关心客户区,可以使用 geometry(),如果我们想获取整个窗口外框在屏幕上的区域,则应该使用 frameGeometry()

setWindowTitle():修改顶层窗口的标题栏文本

根据上文,我们已经认识到,对于一个普通的顶层窗口来说,它通常由两个部分组成:客户区非客户区

其中,客户区是 Qt 程序真正用于放置控件和绘制内容的区域。比如我们在窗口中添加按钮、标签、单行输入框,或者在 paintEvent() 中使用 QPainter 绘制文字、图片、圆形、矩形等内容,这些都属于客户区内部的显示内容。

而非客户区则主要包括标题栏、窗口边框等窗口装饰部分。默认情况下,这些区域通常由操作系统的窗口系统或者窗口管理器负责生成和管理。也就是说,在普通 QWidget 顶层窗口中,我们不能通过 paintEvent()QPainter 直接去绘制系统标题栏和窗口边框。

但是,这并不意味着 Qt 程序完全无法影响非客户区。更准确地说,Qt 不能像绘制客户区那样,通过 paintEvent()QPainter 直接绘制系统标题栏和窗口边框;但是操作系统窗口系统通常会允许应用程序修改部分与非客户区显示相关的属性,比如标题栏文本、窗口图标等。Qt 则在这些底层能力之上进行了封装,为我们提供了对应的接口。

例如,如果我们想修改顶层窗口标题栏中显示的文本,就可以调用 QWidget 提供的 setWindowTitle() 接口:

this->setWindowTitle("我的 Qt 窗口");

这里需要注意,setWindowTitle()QWidget 提供的接口,因此只要是继承自 QWidget 的对象,在语法上都可以调用这个函数。比如普通子控件也可以调用:

button->setWindowTitle("button title");

但是,普通子控件只是显示在父窗口客户区中的一块区域,本身并没有操作系统级别的标题栏。因此,给普通子控件设置 windowTitle,通常不会在界面上看到明显效果。

真正能够把 windowTitle 显示到标题栏中的,通常是作为顶层窗口显示的 QWidget 对象。因为只有顶层窗口才会拥有操作系统窗口系统管理的窗口装饰,比如标题栏和窗口边框。

所以,当我们在顶层窗口中调用:

this->setWindowTitle("我的 Qt 窗口");

它的本质并不是让 Qt 自己去绘制标题栏文字,而是修改这个顶层窗口的标题属性。随后 Qt 会通过底层平台相关接口,把这个标题变更请求传递给操作系统窗口系统,最终由窗口系统更新标题栏中显示的文本。

因此,可以这样理解:

普通 paintEvent():
用于绘制客户区内容,不能直接绘制系统标题栏和窗口边框

setWindowTitle():
用于修改顶层窗口的标题属性,最终由窗口系统更新标题栏显示

也就是说,Qt 不能通过普通的 paintEvent() 直接绘制系统非客户区,但可以通过 setWindowTitle() 这类接口,修改窗口系统允许修改的非客户区相关属性。

窗口图标设置:QIcon、图片路径与资源加载

根据上文,我们已经认识到,可以通过 setWindowTitle() 修改顶层窗口标题栏中的文本。除了窗口标题之外,还有一个常见的非客户区相关属性就是窗口图标

当我们启动一个图形化客户端程序时,通常可以在窗口标题栏、任务栏或者应用切换界面中看到一个图标。这个图标一般就和窗口图标属性有关。具体显示在哪些位置、以什么样式显示,会受到操作系统和窗口管理器的影响。

如果我们没有显式设置窗口图标,那么程序通常会使用 Qt 或操作系统默认提供的图标。反过来,如果我们希望窗口显示自己的图标,就可以通过 Qt 提供的接口进行设置。

在 Qt 中,设置窗口图标通常需要借助 QIcon 类。QIcon 是 Qt 中专门用于描述图标的类,我们可以使用图片文件路径、Qt 资源路径或者 QPixmap 等对象来初始化一个 QIcon 对象。最常见的方式,就是将图片路径传递给 QIcon 的构造函数:

QIcon icon("D:/images/logo.png");

这里传入的字符串表示图片所在的路径。需要注意的是,在 Windows 平台下,系统路径中常见的分隔符是 \,但是在 C++ 字符串中,\ 会被当作转义字符使用。因此,如果直接书写 Windows 路径,需要写成:

QIcon icon("D:\\images\\logo.png");

不过在 Qt 中,通常也可以直接使用 / 作为路径分隔符:

QIcon icon("D:/images/logo.png");

这种写法更加简单,也更适合跨平台场景。

这里图片路径又可以分为两种情况。第一种是普通的磁盘文件路径,例如:

QIcon icon("D:/images/logo.png");

这种情况下,程序运行时会根据这个路径从磁盘中加载图片文件。也就是说,如果程序运行时该路径下不存在对应图片,或者路径书写错误,那么图标就无法正常加载。

这里还可以进一步补充一下 QIconQPixmapQImage 之间的关系。

从底层角度看,PNG、JPG 这类图片文件本质上是一段经过特定格式编码或压缩后的二进制数据。也就是说,图片文件中保存的并不是可以直接显示到屏幕上的原始二维像素矩阵。只有经过图片格式解析和解码之后,才能还原出用于显示的像素数据。解码后的图像数据可以简单理解为一个二维像素矩阵,这个矩阵由一行一行的像素点组成,每个像素点都保存着对应位置的颜色信息。

第 0 行:pixel pixel pixel pixel ...
第 1 行:pixel pixel pixel pixel ...
第 2 行:pixel pixel pixel pixel ...
...

当 Qt 需要使用这张图片时,会先根据图片格式调用对应的图像读取和解码机制,将这些编码或压缩后的数据还原成可以用于显示的图像数据。

在 Qt 中,QImage 可以理解为 Qt 中保存图像像素数据的对象。它内部保存了解码后的像素数据,同时还包含图片的宽度、高度、像素格式等必要信息因此,QImage 更适合用来读取、访问和修改像素。例如我们可以通过 QImage 获取某个坐标位置的像素颜色,也可以对图片中的像素数据进行处理。

QPixmap 同样表示一张图像,但它更偏向于界面显示。和 QImage 不同,QPixmap 的内部像素数据通常不会像 QImage 那样直接暴露给程序员访问,它更适合交给 Qt 的绘图系统使用,比如通过 QPainter::drawPixmap() 绘制到窗口或控件上。简单来说,QImage 更偏向于“像素数据处理”,而 QPixmap 更偏向于“界面显示”。

这里还可以再追问一步:都是图像数据,为什么QImageQPixmap 会有“偏向处理”和“偏向显示”这种差异呢?

从底层实现角度看,可以近似理解为:这两个对象对图像数据的管理方式不同。

QImage 的像素数据通常存放在程序自己的内存中,也就是普通的进程内存。因此,程序可以比较自然地按行按列遍历它、读取它、修改它。它更像是一段程序侧可以直接操作的图像缓冲区。但是,如果要把这份数据真正显示到屏幕上,最终仍然需要交给 Qt 的绘图系统或底层图形系统进行绘制。

QPixmap 则更偏向于界面显示。它的底层数据由 Qt 的绘图系统或平台相关后端管理,QPixmap 的底层数据由 Qt 的绘图系统或平台相关后端管理,可能会按照底层窗口系统、图形后端或显卡绘制更方便的方式进行组织。因此,从程序员的使用视角看,它不再像 QImage 那样表现为一块可以暴露给程序的一个直接按行、按列访问的像素缓冲区。因此,QPixmap的优势在于更适合作为显示资源使用,例如通过 QPainter::drawPixmap() 绘制到窗口或控件上;而它的劣势在于,QPixmap 并不像 QImage 那样向程序暴露直接访问底层像素缓冲区的接口,因此不适合进行逐像素级别的读取和修改。如果需要进行像素级图像处理,通常应该先使用 QImage 完成处理,之后再转换为 QPixmap 用于界面显示。

理解了这一点,前面“QImage 偏向像素处理、QPixmap 偏向界面显示”这个结论,就不再是一句需要硬记的话,而是一个可以自己推导出来的结果:想要修改某个位置的像素,数据要以程序方便访问的形式存在,所以选 QImage ;想要快速把图像绘制到界面上,数据最好以更适合图形系统使用的形式存在,所以选QPixmap

两者也因此可以互相转换:通过 QPixmap::fromImage() 可以把 QImage 转换为 QPixmap,通过 QPixmap::toImage() 又可以反过来。

至于 QIcon,它并不是单纯的一张图片,而是图标资源的一层封装。一个图标在不同场景下可能需要不同尺寸、不同状态的显示效果,比如标题栏小图标、任务栏图标、普通状态图标、禁用状态图标等。因此,QIcon 可以理解为面向图标场景的资源管理对象。它会根据具体的显示场景、尺寸和状态,提供合适的图像表示,底层通常会生成或返回对应的 QPixmap 用于界面显示。

因此,这三者可以简单概括为:

QImage
偏向图像数据本身,保存解码后的像素数据,适合像素访问和图像处理

QPixmap
偏向界面显示,保存适合绘制到窗口或控件上的图像资源

QIcon
偏向图标语义,负责根据不同尺寸、状态和显示场景提供合适的图标图像

所以在设置窗口图标时,我们通常不需要直接操作 QImageQPixmap,而是使用 QIcon 对图标资源进行封装

创建好 QIcon 对象之后,就可以调用 QWidget 提供的 setWindowIcon() 接口来设置窗口图标:

QIcon icon(":/icons/logo.png");
this->setWindowIcon(icon);

setWindowTitle() 类似,setWindowIcon() 本质上并不是让 Qt 通过 paintEvent() 直接绘制标题栏或者任务栏中的图标,而是设置顶层窗口的图标属性。对于顶层窗口来说,Qt 会通过底层平台相关接口,将这个窗口图标信息同步给操作系统窗口系统或窗口管理器。至于这个图标最终显示在标题栏、任务栏还是应用切换界面中,则由操作系统和窗口管理器决定。

在这里插入图片描述

因此,窗口图标的设置流程可以简单理解为:

准备图片文件或 Qt 资源文件
        ↓
使用 QIcon 创建图标对象
        ↓
调用 setWindowIcon() 设置顶层窗口图标属性
        ↓
Qt 将窗口图标信息同步给底层窗口系统
        ↓
窗口系统或窗口管理器决定图标最终如何显示

所以,setWindowIcon()setWindowTitle() 一样,都可以看作是 Qt 间接影响非客户区相关显示效果的一种方式。它并不是通过 QPainter 直接绘制非客户区内容,而是通过 Qt 封装好的窗口属性接口,将窗口图标这一属性交给底层窗口系统处理。


这里还需要注意图片路径的选择问题。

如果我们使用普通的绝对路径来初始化 QIcon,例如:

QIcon icon("D:/images/logo.png");

那么程序运行时会按照这个固定路径去磁盘中查找图片文件。也就是说,图片文件并不是被自动打包进程序内部,而是在程序运行阶段,根据这个路径从磁盘中加载。

这种方式在本机测试时可能没有问题,因为该图片确实存在于 D:/images/logo.png 这个位置。但是一旦程序拷贝到其他电脑上运行,对方电脑中不一定存在相同的磁盘目录和图片文件,此时就会导致图片资源加载失败,窗口图标也就无法正常显示。

因此,绝对路径的缺点非常明显:它强依赖当前机器的目录结构,不适合程序迁移和发布。

除了绝对路径之外,我们也可以使用相对路径,例如:

QIcon icon("images/logo.png");

相对路径并不是相对于源码文件所在目录,也不是一定相对于项目目录,而是相对于程序运行时的当前工作目录。也就是说,程序会将当前工作目录和 images/logo.png 拼接起来,得到最终要查找的完整路径。

比如,如果当前工作目录是:

D:/QtProject/build-Debug

那么:

QIcon icon("images/logo.png");

实际查找的就是:

D:/QtProject/build-Debug/images/logo.png

所以,相对路径虽然比绝对路径灵活一些,但它仍然依赖程序运行时的工作目录。如果图片文件没有放在与工作目录匹配的位置,或者程序从其他目录启动,就仍然可能出现找不到资源的问题。

也就是说,普通文件路径本质上都是在程序运行时去磁盘查找图片文件。绝对路径依赖固定目录,相对路径依赖当前工作目录,两者都会受到运行环境的影响。

绝对路径:
依赖本机固定目录,程序换环境后容易找不到资源

相对路径:
依赖程序运行时的当前工作目录,工作目录变化后也可能找不到资源

因此,对于窗口图标、按钮图片、背景图等程序内部固定资源,更推荐使用 Qt 的资源文件机制,也就是 .qrc 文件。

Qt 资源文件机制:.qrc 原理与窗口图标实战

Qt 资源文件机制:.qrcrcc 与虚拟路径查找原理

接下来,我们继续过渡到 Qt 的资源文件机制,也就是 .qrc 文件。

根据上文,我们已经认识到,如果使用绝对路径或者相对路径来初始化 QIcon 对象,那么程序在运行时仍然需要根据这个路径去磁盘中查找图片文件。绝对路径依赖本机固定的目录结构,相对路径依赖程序运行时的当前工作目录。一旦程序换到其他电脑上运行,或者运行时的工作目录发生变化,就可能出现图片资源找不到的问题。

为了解决这个问题,Qt 提供了资源文件机制。通过 .qrc 文件,我们可以将图片、图标等资源纳入 Qt 的资源系统中,使程序运行时不再依赖外部磁盘路径,而是通过 Qt 资源系统提供的虚拟路径访问这些资源。

所谓 .qrc 文件,本质上是一个 XML 格式的资源描述文件。它本身并不是直接保存图片二进制数据的文件,而是用来描述:哪些外部资源文件需要加入 Qt 资源系统,以及这些资源在程序运行时应该通过什么虚拟路径访问。

例如,一个简单的 .qrc 文件可能长这样:

<RCC>
    <qresource prefix="/icons">
        <file>logo.png</file>
    </qresource>
</RCC>

其中,<file> 标签描述了需要加入资源系统的图片文件,而 prefix 则描述了资源路径的前缀。这样一来,程序运行时就可以通过如下路径访问该图片资源:

QIcon icon(":/icons/logo.png");

这里需要注意,:/icons/logo.png 并不是一个真实的磁盘路径。这里的 :/ 表示当前访问的是 Qt 资源系统中的资源,而不是磁盘上的真实文件路径。它不是用来定位 C 盘、D 盘或者项目目录中的某个文件,而是 Qt 资源系统中的虚拟路径。也就是说,这个路径最终会交给 Qt 资源系统解析,由 Qt 在程序内部的资源索引中查找对应的资源数据。

从构建过程来看,当项目进行编译构建时,Qt 的 rcc 工具会读取 .qrc 文件,并根据 .qrc 中记录的资源文件路径,找到磁盘中对应的图片文件。随后,rcc 会将这些资源文件的数据和索引信息转换成 C++ 代码,即生成一个 C++ 源文件。这个文件通常会类似于:

qrc_xxx.cpp

该源文件会和其他源文件一起参与编译,最终进入程序的可执行文件或相关资源模块中。

为了让"被嵌入"这件事在脑子里更具体,我们可以稍微看一下 rcc 生成的 qrc_xxx.cpp 大致长什么样。简化看,它的核心其实就是几块静态常量数组:

// rcc 生成的 qrc_xxx.cpp(极度简化示意)

static const unsigned char qt_resource_data[] = {
    0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,  // PNG 文件头
    0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,  // ……
    // …… logo.png 这个文件的每一个字节都在这里
};

static const unsigned char qt_resource_name[] = {
    // 路径中各级名称("icons"、"logo.png" 等)以字节数组形式编进去
};

static const unsigned char qt_resource_struct[] = {
    // 资源树本身的扁平化表示,后面会展开
};

在项目构建完成之后,我们可以到项目对应的构建目录中观察 rcc 生成的资源源文件。比如在 Qt Creator 中,通常可以在类似下面的目录中找到生成文件:

D:\Qt\windowicon\build\Desktop_Qt_6_11_0_MinGW_64_bit-Debug\debug

在这个构建目录中,rcc 工具会根据 .qrc 文件生成对应的 C++ 源文件,文件名通常类似于:

qrc_xxx.cpp

例如:

qrc_resource.cpp
qrc_icons.cpp
qrc_images.cpp

我们可以使用 Qt Creator 或者普通文本编辑器打开这个 qrc_xxx.cpp 文件。打开之后可以看到,文件内部通常会包含一些静态数组。这些静态数组中保存的内容,正是 .qrc 文件中记录的资源文件数据以及相关的索引信息。

在这里插入图片描述

可以简单理解为:

logo.png 文件
        ↓
rcc 读取该文件的原始字节数据
        ↓
生成 qrc_xxx.cpp
        ↓
以静态数组的形式保存到 C++ 源文件中
        ↓
参与编译,最终进入可执行文件

这段 C++ 源文件参与编译之后,最终会被链到可执行文件的只读数据段里。所以一旦构建完成,logo.png 这个文件本身就不再需要随程序单独分发——它的每一个字节已经是可执行文件的一部分了。运行时根本不需要去磁盘"打开 logo.png"——这些字节本来就在进程的内存映像里。程序换到另一台电脑、换一个工作目录,资源依然能正常加载,原因不过就是:资源根本就不在外部,它就是程序自己。

这里还需要特别区分一点:.qrc 机制嵌入的是图片文件本身的原始字节数据。比如我们加入资源系统的是一张 PNG 图片,那么被纳入资源系统的就是这个 PNG 文件经过格式编码和压缩后的文件数据,而不是已经解码完成的像素矩阵。

也就是说,.qrc 解决的是"资源文件如何随程序一起管理和访问"的问题,而不是提前完成图片解码。图片真正需要显示时,Qt 仍然会根据对应的图片格式进行解析和解码,将 PNG、JPG 等文件数据还原成可以用于显示的图像数据。

从使用者角度看,可以把 Qt 资源系统理解成一张资源映射表。它会将 :/icons/logo.png 这样的虚拟路径,映射到程序内部保存的某一段资源数据。这样我们在代码中传入虚拟路径时,Qt 就可以通过资源系统找到对应的图片文件数据,而不需要再去外部磁盘路径中查找。

更严格地说,这张"映射表"并不是一张扁平的哈希表,而是一棵结构和 .qrc 里写的目录层级一一对应的树——你在 .qrc 里怎么组织目录和文件,rcc 就照着原样给你搭出这棵树。查找一个虚拟路径,就是按路径分量自顶向下走一次树。

举个例子,如果 .qrc 描述了下面这些资源:

<RCC>
    <qresource prefix="/icons">
        <file>logo.png</file>
        <file>close.png</file>
    </qresource>
    <qresource prefix="/sounds">
        <file>click.wav</file>
    </qresource>
</RCC>

rcc 生成的资源树大致长这样:

root
├── icons       (目录节点:记录"我有几个孩子、孩子在哪")
│   ├── logo.png    (文件叶子:记录 [offset_A, length_A])
│   └── close.png   (文件叶子:记录 [offset_B, length_B])
└── sounds      (目录节点)
    └── click.wav   (文件叶子:记录 [offset_C, length_C])

这里还可以进一步补充一点:一个工程中并不一定只有一个 .qrc 文件。随着项目规模变大,我们完全可以按照资源用途拆分多个 .qrc 文件,比如图标资源、翻译文件、图片资源分别使用不同的 .qrc 文件进行管理。

从构建过程来看,每一个 .qrc 文件都会交给 Qt 的 rcc 工具处理。rcc 会读取 .qrc 文件中记录的资源路径,然后生成对应的 C++ 资源源文件。这个生成出来的源文件通常类似于:

resources.qrc      →  qrc_resources.cpp
icons.qrc          →  qrc_icons.cpp
translations.qrc   →  qrc_translations.cpp

也就是说,.qrc 文件可以理解为资源的组织单位,而 rcc 生成的 qrc_xxx.cpp 则是资源参与 C++ 编译的编译单位。

在这些由 rcc 生成的资源代码中,通常会包含两类重要信息:一类是资源文件本身的原始字节数据,另一类是虚拟路径和资源数据之间的索引关系。前者保存的是被加入资源系统的文件内容,比如 PNG、JPG、.qm 翻译文件等原始文件数据;后者则用于在程序运行时根据 :/icons/logo.png 这样的虚拟路径,定位到对应的资源数据。

从使用者角度看,可以把每个 .qrc 文件理解为注册到 Qt 资源系统中的一棵资源子树。例如:

resources.qrc
    └── /images/...

icons.qrc
    └── /icons/...

translations.qrc
    └── /tr/...

当这些 .qrc 文件都参与项目构建后,它们对应的资源代码会被编译进程序中。程序运行时,Qt 会将这些资源注册到统一的资源系统中。这样一来,我们在代码中就可以使用统一的 :/ 虚拟路径访问资源,例如:

QIcon icon(":/icons/logo.png");

Qt 会根据这个路径,在已经注册的资源数据中查找对应的文件内容。

因此,可以把 Qt 资源系统理解成这样一层结构:

Qt 全局资源系统
    │
    ├── 来自 resources.qrc 的资源子树
    │       └── /images/...
    │
    ├── 来自 icons.qrc 的资源子树
    │       └── /icons/...
    │
    └── 来自 translations.qrc 的资源子树
            └── /tr/...

这种组织方式也带来了一个工程上的好处:我们可以按照用途拆分资源文件。例如图标资源放到 icons.qrc,翻译文件放到 translations.qrc,其他图片资源放到 resources.qrc。这样做不仅结构更清晰,而且当某一类资源发生变化时,通常只需要重新处理对应的 .qrc 文件及其生成的资源源文件,而不会影响其他资源模块。

所以,可以这样总结:

.qrc 文件:
资源组织单位,用来描述哪些文件要加入 Qt 资源系统

qrc_xxx.cpp:
rcc 生成的 C++ 资源源文件,是资源参与编译的单位

资源子树:
每个 .qrc 文件对应的一组虚拟路径和资源数据映射

也就是说,.qrc 文件负责组织资源,rcc 负责把资源转换成可参与编译的 C++ 代码,Qt 资源系统则在运行时通过 :/ 虚拟路径为程序提供统一的资源访问入口。


这里有一个需要特别注意的细节:文件叶子节点中存的并不是一段独立的字节数组,而是一对偏移量和长度,指向前面 qt_resource_data 这块共享大数组里的某一段。换句话说,所有资源文件的字节数据都被拼成一块超大的字节数组,每个叶子各自记一下"我对应的字节从这块大数组的第几个字节开始、长度是多少"。叶子的角色更接近"切片描述符",而不是"切片本身"。

因此,运行时拿到 :/icons/logo.png 这条路径,资源系统做的事就是一次普通的树遍历:

路径切分:["icons", "logo.png"]
        ↓
从 root 开始,在子节点里查找名字为 "icons" 的目录节点
        ↓
进入 icons 节点,在它的子节点里查找 "logo.png" 文件节点
        ↓
找到叶子,读出它记的 offset 和 length
        ↓
返回 qt_resource_data + offset 这段长度为 length 的字节

把视角再拉高一点,qrc_xxx.cpp 里实际上有三块配合工作的静态数组

qt_resource_name
保存节点名字:icons、logo.png、images、bg.png ...

qt_resource_struct
保存树形索引:谁是谁的子节点、子节点有几个、子节点从哪里开始、节点是目录还是文件

qt_resource_data
保存真正的资源文件字节数据:png/jpg/ico 等文件内容

三者一拼,整个资源系统在内存里就是一个完全静态、完全 const、零运行时分配的查找结构。这也是为什么 .qrc 在性能上几乎没有任何代价——所有路径解析和树构建工作 rcc 在构建期就替我们做完了,运行时只剩下纯粹的数组下标跳转。

因此,使用 .qrc 文件之后,整个资源访问流程可以简单理解为:

编写 .qrc 文件
        ↓
在 .qrc 中描述需要加入资源系统的图片文件
        ↓
构建阶段由 rcc 工具读取 .qrc 文件
        ↓
rcc 将资源文件的原始字节连同资源树一起转换成 C++ 资源代码
(qt_resource_data + qt_resource_name + qt_resource_struct)
        ↓
资源代码参与编译,被链入可执行文件的只读数据段
        ↓
运行时通过 :/ 开头的虚拟路径访问资源
        ↓
Qt 资源系统按路径分量自顶向下遍历资源树
        ↓
找到对应叶子节点,按其记录的偏移和长度,
从 qt_resource_data 中取出对应字节数据

由于资源树在 rcc 生成的代码中通常会被压平成数组,因此每个节点本质上可以理解为 qt_resource_struct 数组中的一个元素。对于目录节点来说,它会记录自己的子节点数量以及第一个子节点在数组中的起始位置。由于这些子节点通常连续存放,所以通过“起始下标 + 子节点数量”就可以确定该目录节点下所有子节点所在的数组范围。除此之外,节点还会记录一个名字索引,用于到 qt_resource_name 字符串池中找到自己的节点名称。对于文件节点来说,还会额外记录它对应的资源数据在 qt_resource_data 中的位置。

例如:

下标 0:根节点 :
       nameIndex = 根节点名字
       childStart = 1
       childCount = 2

下标 1:icons
       nameIndex = "icons" 在 qt_resource_name 中的位置
       childStart = 3
       childCount = 2

下标 2:images
       nameIndex = "images" 在 qt_resource_name 中的位置
       childStart = 5
       childCount = 1

下标 3:logo.png
       nameIndex = "logo.png" 在 qt_resource_name 中的位置
       dataIndex = logo.png 在 qt_resource_data 中的位置

下标 4:back.png
       nameIndex = "back.png" 在 qt_resource_name 中的位置
       dataIndex = back.png 在 qt_resource_data 中的位置

下标 5:bg.png
       nameIndex = "bg.png" 在 qt_resource_name 中的位置
       dataIndex = bg.png 在 qt_resource_data 中的位置

所以查找 :/icons/logo.png 时,大概就是:

从根节点开始
    ↓
根据 childStart 和 childCount 找到根节点的子节点范围:[1, 3)
    ↓
通过 nameIndex 去 qt_resource_name 中取名字
    ↓
匹配到 icons
    ↓
进入 icons 节点
    ↓
根据 icons 的 childStart 和 childCount 找到子节点范围:[3, 5)
    ↓
通过 nameIndex 继续匹配 logo.png
    ↓
匹配到文件节点
    ↓
根据 dataIndex 去 qt_resource_data 中找到 logo.png 的原始文件字节数据

在这里插入图片描述

所以,当我们使用:

QIcon icon(":/icons/logo.png");
this->setWindowIcon(icon);

这里传递给 QIcon 的字符串就不再是普通的绝对路径或相对路径,而是 Qt 资源系统中的虚拟路径。程序运行时,Qt 会通过这次树遍历在资源系统中查找 logo.png 对应的字节数据,然后在需要显示图标时,再通过图像解码和显示机制完成后续处理。

因此,.qrc 文件的核心作用可以概括为:它将程序所需的图片、图标等资源在构建期就以原始字节的形式编译进可执行文件,再由 Qt 在程序运行时维护一棵和 .qrc 目录结构同构的资源树,通过 :/ 开头的虚拟路径自顶向下定位到对应的字节数据。这样一来,资源真正"长在程序里",自然就不再受外部磁盘路径、工作目录、目标机器目录结构这些环境因素影响;而图片格式的解码工作仍然由 Qt 在运行时按需完成,.qrc 并不替代解码这一步。也正因为这个特点,对于程序内置的静态资源(窗口图标、按钮图片等),.qrc 通常是比绝对路径或相对路径更合适的选择。


.qrc 实战:通过资源文件设置窗口图标

认识了 Qt 资源文件机制之后,我们就可以动手实践一下,尝试通过 .qrc 文件来设置窗口图标。

首先,打开 Qt Creator,在项目中创建一个 Qt 资源文件。可以在项目上右键,或者通过左上角的 New File 选项,新建一个 Qt Resource File。创建完成后,左侧项目目录中就会出现一个 .qrc 文件。
在这里插入图片描述

这里需要注意,图片文件最好放在 .qrc 文件所在目录或者它的子目录中。因为 .qrc 文件中记录的资源文件路径,本质上是相对于 .qrc 文件位置的路径,而不是相对于程序运行时的工作目录。这样在构建阶段,rcc 工具才能根据 .qrc 中记录的路径找到对应的资源文件,并将其纳入 Qt 资源系统中。

比如项目结构是:

MyProject/
├── MyProject.pro
├── resources.qrc
└── logo.png

那么 .qrc 里写:

<file>logo.png</file>

就能找到。

如果结构是:

MyProject/
├── MyProject.pro
├── resources.qrc
└── icons/
    └── logo.png

那么 .qrc 里一般写:

<file>icons/logo.png</file>

创建好 .qrc 文件之后,打开该资源文件。接下来可以先添加一个资源前缀,例如:

/icons

这里的前缀并不是磁盘上的真实目录,而是 Qt 资源系统中的虚拟路径前缀。也就是说,它决定了资源文件在程序中最终通过什么虚拟路径进行访问。

设置好前缀之后,再点击添加文件,将图片文件加入到当前资源前缀下面。这个过程并不是把图片移动到某个真实的 :/icons 目录中,而是将图片文件记录到 .qrc 文件中,并建立一条虚拟路径和真实资源文件之间的映射关系。

例如,假设 .qrc 文件中的内容类似如下:

<RCC>
    <qresource prefix="/icons">
        <file>logo.png</file>
    </qresource>
</RCC>

那么程序中访问这个资源时,就可以写成:

QIcon icon(":/icons/logo.png");

在 Qt Creator 中,我们可以通过图形化方式编辑 .qrc 文件。创建好 .qrc 文件之后,可以在项目目录中右键点击该资源文件,然后选择使用 Resource Editor 打开。通过资源编辑器,我们可以添加资源前缀,也可以将图片文件加入到指定前缀下面。
在这里插入图片描述

这里的 :/icons/logo.png 就是 Qt 资源系统中的虚拟路径。其中,:/ 表示当前访问的是 Qt 资源系统中的资源,而不是磁盘上的真实路径;/icons 来自 .qrc 文件中设置的资源前缀;logo.png 则来自 <file> 标签中记录的资源文件名。

也就是说,虚拟路径可以简单理解为:

:/ + qresource 的 prefix + file 中记录的文件路径

例如:

prefix = /icons
file   = logo.png

最终访问路径:
:/icons/logo.png

最后,在代码中仍然按照之前的方式创建 QIcon 对象,只不过这次传入的不再是绝对路径或相对路径,而是 :/ 开头的资源虚拟路径。随后调用 setWindowIcon(),就可以将这个图标设置为顶层窗口的窗口图标:

#include "widget.h"
#include "ui_widget.h"
#include <QIcon>

Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);

    QIcon icon(":/icons/logo.png");
    this->setWindowIcon(icon);
}

Widget::~Widget()
{
    delete ui;
}

通过这种方式,窗口图标资源就不再依赖本机磁盘上的绝对路径,也不依赖程序运行时的当前工作目录。只要图片已经正确加入 .qrc 文件,Qt 就可以在构建阶段通过 rcc 工具将资源纳入资源系统,并在运行时通过 :/icons/logo.png 这样的虚拟路径找到对应的图片数据。

因此,使用 .qrc 文件设置窗口图标的核心流程可以概括为:

创建 .qrc 资源文件
        ↓
添加资源前缀,例如 /icons
        ↓
将图片文件加入该前缀下
        ↓
构建阶段由 rcc 工具处理资源文件
        ↓
代码中使用 :/icons/logo.png 访问资源
        ↓
通过 QIcon 初始化图标对象
        ↓
调用 setWindowIcon() 设置顶层窗口图标

这里要重点记住:.qrc 中的 prefix 是虚拟路径前缀,file 是真实资源文件路径,而代码中的 :/icons/logo.png 是 Qt 资源系统中的虚拟路径。通过这个虚拟路径,程序就可以稳定地访问内置资源,而不再受到运行环境和磁盘路径变化的影响。

在这里插入图片描述

结语

那么这就是本篇文章的全部内容,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!
在这里插入图片描述

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐