Bootstrap

Qt底层原理:深入解析QWidget的绘制技术细节(1)

在Qt5中,QWidget的绘制流程比较分散,网上介绍的文章也很少,因此写一篇文章总结记录一下这部分的知识点。

笔者使用的是Qt5.15.2的源码。

基本的绘制流程:从update到合成

  1. 更新请求(Invalidate):
    当一个QWidget需要被重绘时(比如大小改变、数据更新等),会调用update()方法来标记该widget为需要重绘。update一般会到repaintManager->markDirty,如果当前正在绘制,则通过事件QUpdateLaterEvent进行重绘。这部分逻辑代码如下:
    在这里插入图片描述

  2. 重绘区域计算(Dirty Region Calculation):

    • Qt有一个优化机制,它会合并多个重绘请求以减少重绘的次数和区域。重绘区域的计算由Qt的QWidgetRepaintManager负责,该系统维护了一个脏区域(dirty regions),这是所有需要重绘的区域的集合。主要逻辑在QWidgetRepaintManager::markDirty。这部分逻辑稍微复杂,但不是重点,感兴趣的读者可以自行翻阅源码,此处不再列出。
  3. 事件处理(Event Processing):

    • 在QWidgetRepaintManager::sendUpdateRequest,会生成一个QEvent::UpdateRequest的事件,即使指定了UpdateNow,也会根据这次更新是否距离上次更新大于60fps而降低这次绘制的优先级。 这部分逻辑代码如下:在这里插入图片描述
  4. 事件循环(Event Loop):
    Qt的事件循环在QCoreApplication::exec()调用后运行,负责处理事件队列中的事件。对于绘制事件,事件循环会传递给QWidget的event()方法。这部分不是本文章重点,不列出详细细节。

  5. 事件处理(Event Handling):
    QWidget的event()方法会检查事件的类型。如果是绘制事件QEvent::UpdateRequest或者QEvent::UpdateLater,会转调到QWidgetPrivate::paintOnScreen函数,接着使用QWidgetRepaintManager类提供的功能,转调到每个Widget::paintEvent函数。 这部分逻辑代码如下:在这里插入图片描述

  6. 绘制逻辑paintAndFlush:这部分是本文的重点,也比较复杂,在后文详细展开。

  7. 绘制(Painting):
    paintEvent()方法中,一般使用QPainter对象,它是Qt中负责绘制的类。QPainter可以绘制各种图形元素,如文本、线条、形状等。

  8. 绘图设备(Paint Device):
    QPainter对象会被绑定到一个绘图设备(QPaintDevice),比如QWidget本身,或者一个QPixmapQImageQPicture等。QWidget通过其paintEngine()方法提供了一个QPaintEngine对象,这是实际进行绘制操作的底层接口。

  9. 绘图引擎(Paint Engine):
    QPaintEngine是一个抽象基类,它定义了绘图操作的接口。Qt提供了多种绘图引擎,比如QRasterPaintEngineQOpenGLPaintEngine等,具体使用哪个引擎取决于QWidget的绘制设备以及平台特性。

在源代码层面,以下是几个关键类和它们在绘制流程中的作用:

  • QWidget: 作为所有UI组件的基类,管理绘制和事件。
  • QPaintEvent: 继承自QEvent,封装了绘制事件的信息。
  • QPainter: 提供了一组API来执行绘制操作。
  • QPaintDevice: 是一个抽象类,QWidget和其他一些类比如QImage、QPixmap都是这个类的子类,用于表示可以被绘制的对象。
  • QPaintEngine: 抽象基类,定义了底层绘图操作的接口。
  • QWidgetRepaintManager:主要绘制流程的管理类。

Qt提供了QWidget::setUpdatesEnabled()方法,允许开发者禁用或启用控件的更新。这可以用来在批量修改控件时暂时禁用更新,以避免不必要的重绘。

例如,QPushButton的paintEvent堆栈如下:在这里插入图片描述

绘制半透明的控件:父子Widget绘制细节

在Qt中,重绘一个子控件默认不会导致父控件重绘。但是,如果子控件是半透明的(具有alpha通道不是完全不透明的颜色),那么会导致父控件重绘内容作为背景来正确地绘制子控件。
这部分的逻辑比较复杂,核心逻辑在QWidgetRepaintManager::paintAndFlush里,这个函数的源码不在此贴出,但是分析这个函数内部的主要逻辑。

QWidgetRepaintManager::paintAndFlush

QWidgetRepaintManager::paintAndFlush 这个函数的逻辑,具体可以分解为以下步骤:

  1. 检查更新是否被禁用
    如果 QWidget 的 updatesEnabled 属性为 false,则不进行任何绘制操作。

  2. 检查并更新脏区域
    如果窗口的大小已更改,并且更新没有被禁用,函数会检查是否有静态内容(不需要重绘的部分)。如果有,它会只将新可见的部分添加到脏区域;否则,它会标记整个窗口为需要重绘。

  3. 调整后台存储的大小
    如果后台存储(store)的大小与窗口大小不一致,它会被调整以匹配窗口的大小。

  4. 绘制和清理脏区域
    函数创建一个包含所有需要重绘的区域的 QRegion 对象。然后它遍历所有标记为脏的控件,并根据是否有透明的重叠兄弟控件,将其分为可直接绘制和需要合成的控件。

  5. 处理特殊的绘制情况
    对于具有 render-to-texture 特性的控件(如 OpenGL 小部件),它们会被特别处理,因为它们的绘制可以直接在纹理上完成,不需要经过常规的后台存储绘制过程。

  6. 发送绘制事件
    遍历所有需要绘制的控件,并为它们发送 QPaintEvent 事件。这些事件触发控件的 paintEvent 方法,从而完成实际的绘制工作。

  7. 绘制不透明的非重叠控件
    直接在后台存储上绘制那些不透明且没有被兄弟控件重叠的控件。

  8. 合成
    如果需要,将所有剩余的控件绘制到后台存储上,并处理任何必要的合成操作,以确保正确的层叠和透明度效果。

  9. 结束绘制
    调用 store->endPaint() 表示绘制操作的结束。

  10. 刷新
    将后台存储的内容刷新到屏幕上。如果启用了双缓冲,这将涉及到将后台缓冲区的内容复制到前台缓冲区,并在适当的时间将其展示到屏幕上。

这个函数体现了 Qt 绘制的一些核心概念,包括脏区域管理、后台存储、控件的绘制事件、以及绘图设备和绘图引擎的使用。所有的绘制操作都是在主线程中进行的,即使是那些涉及 OpenGL 或其他渲染技术的绘制也不例外。

QWidgetPrivate::drawWidget

QWidgetRepaintManager::paintAndFlush在顶层处理主要的绘制流程,除了这个函数,QWidgetPrivate::drawWidget 函数也包含大量绘制流程的实现细节,这个函数作为第二层处理绘制细节。同样地,这个函数的源码不在此贴出,但是总结这个函数内部的主要流程:

QWidgetPrivate::drawWidget 函数是一个内部函数,用于在给定的绘制设备(pdev)上绘制一个控件及其子控件。这个函数处理了许多绘制相关的细节,包括处理图形效果、设置裁剪区域、绘制背景以及发送绘制事件。以下是函数的主要逻辑步骤:

  1. 检查是否有内容需要绘制
    如果传入的区域(rgn)为空,则没有内容需要绘制,函数立即返回。

  2. 记录绘制操作的日志信息
    使用 qCInfo 记录绘制区域、控件、偏移量、目标绘制设备以及标志。

  3. 处理图形效果
    如果控件有启用的图形效果,那么绘制流程会交给图形效果处理器。它可能会修改绘制的方式,例如添加阴影或模糊效果。

  4. 计算需要绘制的区域
    根据控件的属性和标志计算出实际需要绘制的区域(toBePainted)。可能会考虑是否绘制根控件、是否绘制到屏幕上、是否递归绘制子控件以及是否绘制不可见控件。

  5. 预处理绘制设备
    设置或重定向绘制目标,并设置系统裁剪区域。

  6. 绘制背景
    如果需要,绘制控件的背景。这可能涉及到自动填充背景、绘制不透明的绘制事件或处理窗口系统背景。

  7. 处理渲染到纹理的控件
    如果控件渲染到纹理(例如使用 OpenGL),则相应地处理,可能是通过绘制一个透明矩形来为纹理"打孔",或者将纹理复制到屏幕上。

  8. 发送绘制事件
    如果没有跳过绘制事件,发送一个 QPaintEvent 给控件,这将触发控件的 paintEvent 方法。

  9. 标记需要刷新
    如果有 repaintManager,则调用 markNeedsFlush 来标记区域为需要刷新。

  10. 恢复状态
    恢复重定向的绘制设备和系统裁剪区域到原始状态,并清除激活状态的绘制标志。

  11. 递归绘制子控件
    如果设置了递归标志并且控件有子控件,递归地绘制这些子控件。

整个函数的逻辑很大程度上是关于准备好绘制上下文,然后根据需要绘制控件本身或者委托给图形效果和子控件的绘制。这个函数是 Qt 控件绘制流程中的核心部分,它确保了控件及其子控件能够正确地在屏幕上渲染。

总体而言,Qt体系的绘制实现基本可以在这两个函数中体现出来。相关的数据结构和逻辑也逃不出QWidgetRepaintManagerQWidgetPrivate,感兴趣的读者可以深入了解这两个类。

如何判断一个Widget是否半透明?

核心在这个函数里:

void QWidgetPrivate::updateIsOpaque()
{
    // hw: todo: only needed if opacity actually changed
    setDirtyOpaqueRegion();

#if QT_CONFIG(graphicseffect)
    if (graphicsEffect) {
        // ### We should probably add QGraphicsEffect::isOpaque at some point.
        setOpaque(false);
        return;
    }
#endif // QT_CONFIG(graphicseffect)

    Q_Q(QWidget);
    if (q->testAttribute(Qt::WA_OpaquePaintEvent) || q->testAttribute(Qt::WA_PaintOnScreen)) {
        setOpaque(true);
        return;
    }

    const QPalette &pal = q->palette();

    if (q->autoFillBackground()) {
        const QBrush &autoFillBrush = pal.brush(q->backgroundRole());
        if (autoFillBrush.style() != Qt::NoBrush && autoFillBrush.isOpaque()) {
            setOpaque(true);
            return;
        }
    }

    if (q->isWindow() && !q->testAttribute(Qt::WA_NoSystemBackground)) {
        const QBrush &windowBrush = q->palette().brush(QPalette::Window);
        if (windowBrush.style() != Qt::NoBrush && windowBrush.isOpaque()) {
            setOpaque(true);
            return;
        }
    }
    setOpaque(false);
}

QWidgetPrivate::updateIsOpaque 函数的工作流程如下:

  1. 设置脏不透明区域
    调用 setDirtyOpaqueRegion 方法,这通常意味着标记控件的不透明区域需要更新。这个区域是指控件中不需要考虑透明度处理的部分。

  2. 检查是否有图形效果
    如果控件应用了 QGraphicsEffect,函数立即将控件标记为非不透明(因为图形效果可能会引入透明度),然后返回。图形效果可能包括模糊、阴影等,这些都可能改变控件的不透明度。

  3. 检查控件属性
    函数检查控件是否具有 Qt::WA_OpaquePaintEventQt::WA_PaintOnScreen 属性。这些属性通常由开发者设置,用来指示控件的绘制事件是不透明的,或者控件直接在屏幕上绘制。如果有任何一个属性被设置,函数将控件标记为不透明并返回。

  4. 检查自动填充背景
    如果控件的 autoFillBackground 属性为真,表示控件在绘制前会自动用背景色填充。函数会检查用于自动填充的画刷是否不透明。如果是,控件被标记为不透明。

  5. 检查窗口属性
    如果控件是一个窗口,并且没有设置 Qt::WA_NoSystemBackground 属性(这意味着窗口系统不会自动填充背景),函数会检查窗口背景画刷是否不透明。如果是,窗口被标记为不透明。

  6. 设置为非不透明
    如果之前的检查都没有导致函数返回,最后将控件标记为非不透明。

在大型复杂界面中和性能敏感的应用中,我们要避免过多的不透明控件可以减少绘制负担。

绘制逻辑的复用:标准控件绘制与QStyle的细节

在Qt中,QStyle类负责控件的外观和行为。这包括控件的绘制(如按钮、滑块、复选框等),以及控件的尺寸、布局和交互行为(如鼠标悬停、按下状态的视觉反馈)。QStyle提供了一种机制,通过它可以统一控制应用程序中所有控件的外观,而无需在每个控件的绘制逻辑中单独实现这些。

QStyle是一个抽象基类,它定义了一套API,用于绘制标准的GUI组件以及获取与风格相关的属性和尺寸信息。Qt自带了几种风格,如QWindowsStyleQMacStyleQFusionStyle等,它们实现了在不同平台下的本地外观和行为。可以通过继承QStyle来创建自定义风格。

绘制标准控件

当一个标准控件(例如QPushButton)需要被绘制时,它会调用其paintEvent()函数。在paintEvent()中,控件通常不直接进行绘制,而是将绘制任务委托给当前的QStyle对象。这是通过调用style()方法来获取当前应用程序风格,然后使用QStyle的绘制函数来完成的。

例如,一个按钮会这样使用QStyle来进行绘制:
在这里插入图片描述
这里,QStylePainterQPainter的一个特殊版本,专门用于风格绘制。QStyleOptionButton是一个包含按钮状态和属性的结构体。drawControl()函数是QStyle的一个方法,用于绘制控件元素(Control Element),在这个例子中是一个按钮。

风格元素和选项

QStyle类定义了多个枚举,用于指定控件的哪一部分需要绘制,以及如何绘制。这些枚举包括ControlElementPrimitiveElementComplexControl等。

  • ControlElement: 这些是高级UI元素,如整个按钮、工具栏、滚动条等。
  • PrimitiveElement: 这些是构成控件的基本图形元素,如按钮的边框、复选框的勾选标记等。
  • ComplexControl: 这些是由多个交互部分组成的控件,如组合框或滑块。

QStyleOption类及其派生类携带了关于如何绘制控件的信息。QStyleOption包含了状态信息(如是否被按下、是否有焦点等),而派生类则包含了更具体的信息。例如,QStyleOptionButton包含了按钮特有的信息,如是否是默认按钮、是否是复选按钮等。

自定义风格

要创建自定义风格,你可以继承QStyle或者任何已有的风格类,并重写相应的绘制和尺寸计算方法。例如,你可能会重写drawControl()drawPrimitive()sizeFromContents()等方法来自定义控件的绘制和布局。

应用风格

可以通过调用QApplication::setStyle()方法来为整个应用程序设置风格。这个风格会被所有控件使用,除非某个控件显式地设置了不同的风格。QStyle负责定义和实现Qt控件的外观和行为,而具体的控件类则通过委托给QStyle来执行实际的绘制操作。这种设计使得Qt的外观和感觉可以非常灵活地被定制和更换,而不需要修改每个控件的实现代码。

QWidget绘制体系为什么这么设计【重点】

请跳转第二篇《Qt底层原理:深入解析QWidget的绘制技术细节(2)

;