Note

本篇讲解 Qt 的 2D 绘图系统:paintEvent 与 QPainter 如何配合、QPen/QBrush/QFont 等绘图工具、基本图形绘制、抗锯齿与坐标变换,最后实现一个可以拖动画线的简易画板。适合想自绘控件或做图形小工具的初学者。

paintEvent:绘图的唯一入口

Qt 规定:所有界面绘制都必须在 paintEvent 里进行。当控件第一次显示、被遮挡后重新露出、调用 update()repaint() 时,Qt 就会向控件发送 QPaintEvent,触发 paintEvent(QPaintEvent *)。你在别处直接创建 QPainter 画窗口,通常是不允许的。

“是什么→为什么→怎么用”一句话说清:paintEvent 是控件的重绘回调;把它单独收口是为了让 Qt 统一调度重绘时机、合并多次无效的刷新请求;用法就是在子类中重写它,并在其中创建 QPainter 开始作画。

void MyWidget::paintEvent(QPaintEvent *event)
{
    Q_UNUSED(event);
    QPainter painter(this);          // 以当前控件为画布
    painter.setPen(Qt::blue);
    painter.drawText(50, 50, "Hello QPainter");   // 在 (50,50) 处画文字
}

Warning

想刷新画面时调用 update() 而不是 repaint()update() 会把多次请求合并成一次重绘,效率高;repaint() 立即重绘,除非万不得已不要用。另外,不要在 paintEvent 里再调用 update(),会造成无限重绘。

绘图四件套:Pen、Brush、Font、Color

QPainter 本身是”画家的手”,真正的工具是这几个类:

  • QColor:颜色,支持名字(Qt::red)、RGB(QColor(255,0,0))、带透明度的 RGBA。
  • QPen:画笔,控制线条的颜色、宽度、线型(实线、虚线、点线)和端点样式。
  • QBrush:画刷,控制填充:封闭图形内部用什么颜色或图案(纯色、渐变、纹理)填满。
  • QFont:字体,画文字时指定字族、字号、加粗、斜体。

一句话记忆:QPen 管轮廓,QBrush 管填馅

void MyWidget::paintEvent(QPaintEvent *)
{
    QPainter painter(this);
 
    // 画一条红色虚线:QPen 控制线条样式
    QPen pen(Qt::red, 3, Qt::DashLine);
    painter.setPen(pen);
    painter.drawLine(20, 20, 280, 20);
 
    // 画一个矩形:轮廓用蓝色画笔,内部用黄色画刷填充
    painter.setPen(Qt::blue);
    painter.setBrush(QBrush(Qt::yellow));
    painter.drawRect(20, 50, 120, 80);
 
    // 不设置画刷(Qt::NoBrush)则只画轮廓不填充
    painter.setBrush(Qt::NoBrush);
    painter.drawEllipse(160, 50, 120, 80);   // 椭圆由外接矩形定义
 
    // 画文字:QFont 控制字体
    painter.setFont(QFont("微软雅黑", 14, QFont::Bold));
    painter.setPen(Qt::black);
    painter.drawText(20, 170, "加粗的中文文字");
 
    // 画图片:QPixmap / QImage 加载后直接绘制
    QPixmap pix(":/images/logo.png");        // 资源文件路径
    if (!pix.isNull()) {
        painter.drawPixmap(20, 190, pix);    // 原尺寸绘制
        // painter.drawPixmap(150, 190, 100, 100, pix);  // 指定目标矩形,自动缩放
    }
}

抗锯齿:告别锯齿边

默认情况下 QPainter 用”最短路径”渲染,斜线和圆弧边缘会出现明显的锯齿。开启抗锯齿后画质平滑,代价是略微变慢:

painter.setRenderHint(QPainter::Antialiasing, true);   // 图形抗锯齿
painter.setRenderHint(QPainter::TextAntialiasing, true); // 文字抗锯齿

Success

在 paintEvent 开头统一设置一次抗锯齿即可,对之后所有绘制生效。做图表、画板这类”线条密集”的应用,默认就该开着。

坐标变换:save 与 restore 成对出现

QPainter 有一个当前坐标系(原点默认在控件左上角)。通过 translate()(平移)、rotate()(旋转)、scale()(缩放)可以改变它,让”画一个正方形”变成”画一个旋转了 45 度的正方形”。

变换会累积作用。所以 Qt 提供了 save()restore():save 把当前状态(坐标系、画笔、画刷)压栈,restore 弹栈恢复,保证一段试验性的绘制不会污染后续画面:

void MyWidget::paintEvent(QPaintEvent *)
{
    QPainter painter(this);
    painter.setRenderHint(QPainter::Antialiasing, true);
 
    painter.save();                    // 保存当前状态
    painter.translate(200, 150);       // 原点移到 (200,150)
    painter.rotate(45);                // 之后的图形顺时针转 45 度
    painter.drawRect(-40, -40, 80, 80); // 围绕新原点画正方形
    painter.restore();                 // 恢复到 save 时的状态
 
    // 这里坐标系已复原,画的是"正常"的圆
    painter.drawEllipse(280, 100, 60, 60);
}

Warning

save/restore 必须严格配对。restore 次数多于 save 会弹出警告并且行为未定义;写了 save 忘了 restore,变换会一直残留到这次 paintEvent 结束。

双缓冲:消灭闪烁

如果直接在屏幕上频繁画复杂图形(比如动画、拖拽预览),每帧先擦除再绘制会产生闪烁。双缓冲的思路是:先在一张内存中的 QPixmap 上把整幅画画完,再一次性地把这张”成品”贴到屏幕上。由于擦除和绘制都发生在内存里,用户看到的永远是一张完整画面。

其实 QWidget 在 Qt4 之后默认就是双缓冲的,真正需要手动双缓冲的场景是:绘图内容复杂且需要频繁叠加历史笔迹(比如画板——把已画的线保存到 QPixmap 上,每帧只把这张底图贴出来,再叠加正在拖动的线)。

实战:一个能拖动画线的画板

按下鼠标确定起点,拖动时预览”橡皮筋”线,松开后线条正式留在画板上:

// board.h
#ifndef BOARD_H
#define BOARD_H
 
#include <QWidget>
#include <QMouseEvent>
#include <QPainter>
 
class Board : public QWidget
{
    Q_OBJECT
public:
    explicit Board(QWidget *parent = nullptr);
 
protected:
    void mousePressEvent(QMouseEvent *event) override;   // 记录起点
    void mouseMoveEvent(QMouseEvent *event) override;    // 更新终点,预览
    void mouseReleaseEvent(QMouseEvent *event) override; // 提交线条
    void paintEvent(QPaintEvent *event) override;        // 负责所有绘制
 
private:
    QPixmap m_canvas;   // 双缓冲底图:保存已完成的线条
    QPoint m_start;     // 当前线条起点
    QPoint m_end;       // 当前线条终点(拖动中)
    bool m_drawing = false;
};
 
#endif // BOARD_H
// board.cpp
#include "board.h"
 
Board::Board(QWidget *parent) : QWidget(parent)
{
    setWindowTitle("简易画板");
    resize(500, 400);
    setMouseTracking(false);
}
 
void Board::mousePressEvent(QMouseEvent *event)
{
    if (event->button() == Qt::LeftButton) {
        m_start = event->pos();
        m_end = m_start;
        m_drawing = true;
    }
}
 
void Board::mouseMoveEvent(QMouseEvent *event)
{
    if (m_drawing) {
        m_end = event->pos();
        update();   // 触发重绘,画出预览线
    }
}
 
void Board::mouseReleaseEvent(QMouseEvent *event)
{
    if (m_drawing && event->button() == Qt::LeftButton) {
        m_drawing = false;
        // 把确认的线条画到底图上,永久保存
        QPainter painter(&m_canvas);
        painter.setRenderHint(QPainter::Antialiasing, true);
        painter.setPen(QPen(Qt::blue, 2));
        painter.drawLine(m_start, event->pos());
        update();
    }
}
 
void Board::paintEvent(QPaintEvent *event)
{
    Q_UNUSED(event);
    QPainter painter(this);
    painter.drawPixmap(0, 0, m_canvas);   // 1. 先贴底图(历史内容)
 
    if (m_drawing) {                      // 2. 再叠加正在拖动的预览线
        painter.setRenderHint(QPainter::Antialiasing, true);
        painter.setPen(QPen(Qt::gray, 2, Qt::DashLine));   // 虚线表示预览
        painter.drawLine(m_start, m_end);
    }
}
// main.cpp
#include <QApplication>
#include "board.h"
 
int main(int argc, char *argv[])
{
    QApplication app(argc, argv);
    Board board;
    board.show();
    return app.exec();
}

注意两个细节:

  • m_canvas 最初是空图,控件 resize 后应重新初始化它(填上背景色),否则第一次画线会画在透明区域上。可以在 resizeEvent()m_canvas = QPixmap(size()); m_canvas.fill(Qt::white);
  • 预览线用虚线灰色、正式线用实线蓝色,用户能直观区分”还没确定”和”已画上”。

Success

记住画板类程序的三层分工:mouseXXXEvent 收集输入,QPixmap 底图保存历史,paintEvent 只负责”贴底图 + 画增量”。遵循这个分工,再复杂的绘图程序结构也不会乱。

自测:三个问题检验掌握程度