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 只负责”贴底图 + 画增量”。遵循这个分工,再复杂的绘图程序结构也不会乱。
自测:三个问题检验掌握程度
自测:三个问题检验掌握程度
- 为什么不能在自己的普通成员函数里直接创建 QPainter 画窗口?想刷新画面应该调用哪个函数?
- QPen 和 QBrush 分别控制什么?drawRect 画出的矩形哪部分归 QPen 管、哪部分归 QBrush 管?
- 画板程序里 QPixmap 底图起什么作用?拖动预览时为什么要把预览线画在 paintEvent 里而不是直接画到底图上?
(答案都在上文)