简介:这是一份面向高校C++与Qt开发初学者的课程设计级绘图软件项目,适用于软件工程、K12信息技术拓展及C++期末实践教学场景。项目实现了完整的图形编辑功能:支持文本、线段、矩形、椭圆、箭头等基础图元的添加、删除、移动、旋转与缩放;具备视图拖拽缩放、图元连接、图纸导出为图片/PDF、配置文件导入导出等实用能力。资源包共44个文件,含9个核心cpp源码、8个h头文件构成模块化架构(如circuitbaseobject、circuittextobject等),1个ui界面文件、1个pro工程配置及qss样式文件,辅以20张运行截图和1份docx使用说明,整体仅247KB,轻量易读。目前已有313人学习下载,代码结构清晰、注释规范,完整呈现Qt信号槽机制、图形项管理(QGraphicsItem)与场景视图(QGraphicsScene/QGraphicsView)的典型应用,是理解Qt绘图框架与面向对象设计思想的优质入门范例。
1. 这不是“画图板”,而是一套可扩展的 Qt 图元管理框架:C++ 课设里真正能跑通、能调试、能交差的绘图系统
很多同学拿到“Qt 绘图软件”课设题时,第一反应是打开 Qt Creator 新建 Widget 项目,拖几个按钮,QPainter一画了事——结果鼠标拖拽卡顿、图元缩放后线条模糊、删除一个矩形连带箭头断连、导出 PDF 字体乱码。这套drawing-qt项目恰恰反其道而行:它不依赖QGraphicsView的默认渲染管线,而是用QGraphicsItem自定义图元类(circuitbaseobject.h及其子类)构建完整对象模型,所有图元(Text/Line/Rect/Ellipse/Arrow)均继承自统一基类CircuitBaseObject,支持独立坐标系、局部变换矩阵、连接锚点绑定与事件拦截。它解决的不是“怎么画一条线”,而是“如何让 5 类图元在缩放、旋转、移动时保持拓扑关系一致、属性可序列化、视图交互无撕裂”。适合软件工程方向学生理解 MVC 分离、面向对象设计原则,也适合作为 K12 教学工具链中图形化编程模块的底层支撑——你改一行circuittextobject.cpp的paint()实现,就能看到所有文本图元实时响应;导出配置时xml/目录下生成的是标准 DOM 结构而非二进制 blob;打印功能直接调用QPrinter+QPainter::setRenderHint(QPainter::Antialiasing),规避了常见 Qt 打印失真问题。
2. 图元生命周期与事件驱动机制:从CircuitBaseObject到CircuitTextObject的继承链解析
2.1 基类CircuitBaseObject的核心契约设计
circuitbaseobject.h定义了所有图元必须实现的最小接口集,它不是抽象基类(未声明纯虚函数),而是通过virtual函数提供可覆盖行为。关键成员包括:
class CircuitBaseObject : public QGraphicsItem { public: explicit CircuitBaseObject(QGraphicsItem *parent = nullptr); virtual ~CircuitBaseObject() override; // 必须重写:返回图元类型标识(用于序列化/反序列化) virtual ObjectType type() const { return ObjectType::Unknown; } // 必须重写:绘制逻辑(由 QGraphicsView 调用) virtual void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override = 0; // 必须重写:返回包围盒(影响碰撞检测与重绘区域) virtual QRectF boundingRect() const override = 0; // 可选重写:鼠标按下事件(支持拖拽起点记录) virtual void mousePressEvent(QGraphicsSceneMouseEvent *event) override; // 可选重写:鼠标移动事件(支持实时拖拽更新) virtual void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; // 可选重写:鼠标释放事件(触发位置提交与连接线重算) virtual void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; protected: // 内部状态:是否处于选中态(影响绘制高亮边框) bool m_selected{false}; // 内部状态:是否启用连接锚点(Arrow/Line 需要) bool m_hasAnchorPoints{false}; // 内部状态:当前缩放比例(用于动态调整画笔宽度) qreal m_scaleFactor{1.0}; };提示:
m_scaleFactor不是 Qt 内置属性,而是项目自定义字段。它在CircuitBaseView::scale()中被同步更新,并通过CircuitBaseObject::setScaleFactor(qreal)通知所有图元。这意味着图元缩放不是简单调用setScale(),而是主动感知视图缩放并调整自身QPen宽度——这正是摘要中“图元的放缩致使画笔也应该随之变化”的实现基础。
2.2 文本图元CircuitTextObject的典型实现细节
circuittextobject.h和circuittextobject.cpp展示了如何将抽象契约落地。其paint()函数不直接调用QPainter::drawText(),而是先计算文本布局:
void CircuitTextObject::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { // 1. 启用抗锯齿(避免文字边缘发虚) painter->setRenderHint(QPainter::Antialiasing, true); // 2. 根据当前缩放因子动态设置字体大小(核心优化点) QFont font = m_font; font.setPointSizeF(m_font.pointSizeF() * m_scaleFactor); // 关键:字体随缩放等比放大 // 3. 计算文本实际渲染区域(考虑 DPI 缩放) QRectF textRect = boundingRect(); textRect.adjust(2, 2, -2, -2); // 留出内边距 // 4. 设置画笔颜色(支持选中态高亮) QPen pen = m_pen; if (m_selected) { pen.setColor(Qt::red); pen.setWidthF(2.0 / m_scaleFactor); // 画笔宽度反比于缩放,保证视觉粗细一致 } painter->setPen(pen); // 5. 渲染文本(居中对齐) painter->setFont(font); painter->drawText(textRect, Qt::AlignCenter, m_text); }2.2.1boundingRect()的精确计算逻辑
文本图元的包围盒不能简单返回QFontMetrics::boundingRect(),因为该函数返回的是逻辑像素尺寸,未考虑m_scaleFactor。CircuitTextObject::boundingRect()实现如下:
QRectF CircuitTextObject::boundingRect() const { QFontMetricsF fm(m_font); // 获取未缩放时的原始尺寸 QRectF baseRect = fm.boundingRect(m_text); // 按当前缩放因子等比放大 return QRectF( baseRect.x() * m_scaleFactor, baseRect.y() * m_scaleFactor, baseRect.width() * m_scaleFactor, baseRect.height() * m_scaleFactor ); }注意:
QFontMetricsF使用浮点精度,避免整数截断导致的尺寸偏差。baseRect的x()/y()是负值(因字体基线偏移),直接乘以m_scaleFactor保证了锚点位置缩放一致性。
2.3 连接线图元CircuitLineObject的锚点绑定机制
circuitlineobject.h定义了startAnchor()和endAnchor()两个QPointF成员,它们不直接存储绝对坐标,而是指向被连接图元的锚点 ID(如"top"、"bottom")。CircuitLineObject::updatePosition()在图元移动时被调用:
void CircuitLineObject::updatePosition() { if (!m_startItem || !m_endItem) return; // 1. 查询起始图元的 top 锚点世界坐标 QPointF startWorldPos = m_startItem->mapToScene( m_startItem->anchorPoint("top") // 返回局部坐标 ); // 2. 查询结束图元的 bottom 锚点世界坐标 QPointF endWorldPos = m_endItem->mapToScene( m_endItem->anchorPoint("bottom") ); // 3. 更新线段端点(触发重绘) prepareGeometryChange(); m_line.setP1(startWorldPos); m_line.setP2(endWorldPos); }anchorPoint()在CircuitBaseObject中默认返回(0,0),但CircuitRectObject重写了它:
QPointF CircuitRectObject::anchorPoint(const QString &anchorName) const { QRectF rect = boundingRect(); if (anchorName == "top") return QPointF(rect.center().x(), rect.top()); if (anchorName == "bottom") return QPointF(rect.center().x(), rect.bottom()); if (anchorName == "left") return QPointF(rect.left(), rect.center().y()); if (anchorName == "right") return QPointF(rect.right(), rect.center().y()); return QPointF(0, 0); }这种设计使连接线完全解耦于具体图元形状——只要图元提供锚点,线就能自动吸附。这也是摘要中“图元移动时,连线也跟随移动”的技术保障。
3. 视图控制与交互逻辑:CircuitBaseView如何协调缩放、拖拽与图元选择
3.1CircuitBaseView的三大核心重载函数
circuitbaseview.h继承自QGraphicsView,其wheelEvent()、mousePressEvent()、mouseMoveEvent()构成交互主干。与默认QGraphicsView不同,它禁用了内置缩放(setTransformationAnchor(NoAnchor)),转而手动控制:
void CircuitBaseView::wheelEvent(QWheelEvent *event) { // 1. 获取滚轮角度(单位为 1/8 度) int delta = event->angleDelta().y(); // 2. 计算缩放因子增量(每滚轮格 1.1 倍) qreal scaleFactor = 1.1; if (delta < 0) scaleFactor = 1.0 / scaleFactor; // 3. 以鼠标位置为中心缩放(关键:避免视图平移错位) QPointF centerBefore = mapToScene(event->pos()); scale(scaleFactor, scaleFactor); QPointF centerAfter = mapToScene(event->pos()); QPointF offset = centerBefore - centerAfter; translate(offset.x(), offset.y()); // 4. 同步所有图元的 m_scaleFactor foreach (QGraphicsItem *item, scene()->items()) { if (auto *obj = dynamic_cast<CircuitBaseObject*>(item)) { obj->setScaleFactor(transform().m11()); // transform().m11() 即 x 方向缩放系数 } } }3.1.1transform().m11()的物理意义
QTransform矩阵结构为:
[ m11 m12 dx ] [ m21 m22 dy ] [ 0 0 1 ]其中m11是 x 轴缩放系数,m22是 y 轴缩放系数。项目假设等比缩放(m11 == m22),故仅读取m11。该值直接传给图元setScaleFactor(),驱动paint()中字体/画笔的动态调整。
3.2 多图元选择与批量操作的实现策略
mainwindow.cpp中on_actionSelect_triggered()并非简单调用scene()->selectedItems(),而是通过CircuitBaseObject::setSelected(bool)显式控制:
void MainWindow::on_actionSelect_triggered() { QList<QGraphicsItem*> items = scene->selectedItems(); foreach (QGraphicsItem *item, items) { if (auto *obj = dynamic_cast<CircuitBaseObject*>(item)) { obj->setSelected(true); // 触发图元内部重绘(高亮边框) } } }CircuitBaseObject::setSelected()内部会:
- 设置
m_selected = true - 调用
update()请求重绘 - 若为连接线,递归调用
startItem->setSelected(true)和endItem->setSelected(true),确保连接关系可视化
删除操作 (on_actionDelete_triggered()) 同样显式遍历:
void MainWindow::on_actionDelete_triggered() { QList<QGraphicsItem*> items = scene->selectedItems(); foreach (QGraphicsItem *item, items) { if (auto *obj = dynamic_cast<CircuitBaseObject*>(item)) { // 1. 断开所有连接到此图元的线 obj->disconnectAllLines(); // 2. 从场景移除 scene->removeItem(item); // 3. 删除对象(注意:QGraphicsItem 析构需手动 delete) delete item; } } }disconnectAllLines()在CircuitBaseObject中遍历m_connectedLines(QList<CircuitLineObject*>),调用line->setStartItem(nullptr)和line->setEndItem(nullptr),再从line的m_startItem/m_endItem反向列表中移除自身——形成双向解绑闭环。
3.3 打印与导出功能的技术路径对比
| 功能 | 实现方式 | 关键参数说明 |
|---|---|---|
| 图片导出 | QPixmap::grabWidget()+QPixmap::save() | grabWidget()捕获当前视图区域,save("out.png", "PNG", 100)100% 质量 |
| PDF 导出 | QPrinter+QPainter+scene()->render() | QPrinter::setOutputFormat(QPrinter::PdfFormat),render()指定目标区域 |
| 配置导出 | QDomDocument+QFile,序列化每个图元的type()、pos()、rotation()等 | QDomElement层级结构严格对应CircuitBaseObject子类字段,支持 XML Schema 验证 |
scenes/circuitbasescene.cpp中exportToXml()函数生成的 XML 示例:
<drawing version="1.0"> <text id="1" x="100.5" y="200.0" text="Hello" font-size="12" rotation="0"/> <rect id="2" x="300" y="150" width="100" height="60" fill="#ffcc00" stroke="#000000"/> <line id="3" x1="100" y1="200" x2="300" y2="150" stroke="#0000ff" width="2"/> </drawing>提示:
QDomDocument的save()方法默认 UTF-8 编码,中文文本无需额外处理。但QFile打开时需指定QIODevice::Text模式,否则 Windows 下可能产生 BOM 头导致解析失败。
4. 配置导入与暗黑模式切换:基于QSS与resources.qrc的主题热加载
4.1resources.qrc的资源组织逻辑
resources.qrc文件将qss/目录下的样式表打包为 Qt 资源系统的一部分:
<RCC> <qresource prefix="/styles"> <file>qss/light.qss</file> <file>qss/dark.qss</file> <file>images/logo.ico</file> </qresource> </RCC>编译后,可通过":/styles/light.qss"路径访问。mainwindow.cpp中applyStyleSheet()函数实现热切换:
void MainWindow::applyStyleSheet(const QString &styleName) { QFile file(QString(":/styles/%1.qss").arg(styleName)); if (file.open(QFile::ReadOnly | QFile::Text)) { QTextStream stream(&file); QString style = stream.readAll(); qApp->setStyleSheet(style); // 全局应用 file.close(); } }light.qss与dark.qss的差异集中在QGraphicsView、QMenuBar、QStatusBar的background-color和color属性。例如dark.qss中:
QGraphicsView { background-color: #2d2d2d; border: 1px solid #444; } QMenuBar::item:selected { background-color: #4a4a4a; }4.2 配置导入时的图元重建流程
importFromXml()函数解析 DOM 后,根据<text>、<rect>等标签名创建对应图元实例:
void CircuitBaseScene::importFromXml(const QString &filePath) { QDomDocument doc; QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) return; if (!doc.setContent(&file)) { file.close(); return; } file.close(); QDomElement root = doc.documentElement(); QDomNodeList items = root.elementsByTagName("text"); for (int i = 0; i < items.length(); ++i) { QDomElement elem = items.at(i).toElement(); CircuitTextObject *text = new CircuitTextObject(); text->setPos(elem.attribute("x").toDouble(), elem.attribute("y").toDouble()); text->setText(elem.attribute("text")); text->setFont(QFont("Arial", elem.attribute("font-size").toInt())); text->setRotation(elem.attribute("rotation").toDouble()); addItem(text); } // ... 同理处理 rect/line/ellipse/arrow }注意:
addItem()将图元加入QGraphicsScene,但不会自动调用CircuitBaseObject::setScaleFactor()。因此导入后需显式遍历所有图元,调用obj->setScaleFactor(view->transform().m11())同步缩放状态,否则新图元在缩放视图中尺寸异常。
4.3 暗黑模式下图元绘制的兼容性处理
CircuitBaseObject::paint()中,QPen颜色需适配主题。CircuitTextObject重写了penColor():
QColor CircuitTextObject::penColor() const { // 检测当前应用样式表是否含 dark 关键字 QString styleSheet = qApp->styleSheet(); if (styleSheet.contains("dark.qss") || styleSheet.contains("#2d2d2d")) { return Qt::white; // 暗黑模式用白色文字 } return Qt::black; // 默认用黑色 }CircuitRectObject::paint()同理,fillColor()根据主题返回#ffcc00(亮色)或#8a6d3b(暗色),确保图元在两种模式下均有足够对比度。
5. 课设交付与调试技巧:快速验证图元行为、定位缩放失真、修复连接断连
5.1 三步法验证图元缩放一致性
当发现缩放后文字模糊或线条过细时,按顺序检查:
确认
CircuitBaseView::wheelEvent()是否正确调用setScaleFactor()
在wheelEvent()末尾添加日志:qDebug() << "Scale factor:" << transform().m11();滚动鼠标,观察输出是否随缩放递增(如 1.0 → 1.1 → 1.21 → 1.331)。
确认图元
paint()中字体/画笔是否使用m_scaleFactor
在CircuitTextObject::paint()开头添加:qDebug() << "Text scale:" << m_scaleFactor << "font size:" << m_font.pointSizeF() * m_scaleFactor;拖拽图元时若
m_scaleFactor为 1.0,则说明setScaleFactor()未被调用。确认
boundingRect()返回值是否匹配缩放后尺寸
在CircuitTextObject::boundingRect()返回前添加:qDebug() << "Bounding rect:" << baseRect << "scaled:" << QRectF(...);对比
baseRect与scaled的宽高比,应严格等于m_scaleFactor。
5.2 连接线断连的定位与修复表
| 现象 | 根本原因 | 修复指令 |
|---|---|---|
| 移动图元后连线消失 | CircuitBaseObject::mouseMoveEvent()未调用updatePosition() | 在mouseMoveEvent()末尾添加if (m_connectedLines.size()) updateConnectedLines(); |
| 连线端点漂移至图元中心 | anchorPoint()返回(0,0)而非实际锚点坐标 | 检查CircuitRectObject::anchorPoint()是否正确计算rect.center().x()等表达式 |
| 删除图元后连线仍显示悬空 | disconnectAllLines()未清空m_connectedLines | 在CircuitBaseObject::~CircuitBaseObject()中添加qDeleteAll(m_connectedLines); |
updateConnectedLines()实现:
void CircuitBaseObject::updateConnectedLines() { foreach (CircuitLineObject *line, m_connectedLines) { line->updatePosition(); // 强制重算端点 } }5.3 K12 教学场景下的轻量化改造建议
针对中小学信息课需求,可快速启用以下功能:
- 禁用复杂操作:注释
mainwindow.ui中actionRotate、actionScale对应的triggered连接,保留actionSelect/actionDelete/actionPrint。 - 简化图元面板:修改
mainwindow.cpp的initToolButtons(),只保留Text、Rect、Line三个按钮。 - 强制亮色模式:删除
qss/dark.qss,在main()中移除applyStyleSheet("dark")调用,避免学生误操作切换主题。
最终交付包只需包含bin/(可执行文件)、images/(图标)、qss/light.qss三个目录,体积压缩至 2MB 以内,满足校园机房快速部署要求。
本文还有配套的精品资源,点击获取