简介:本资源是一套基于Qt框架的图形绘制与XML序列化完整实践项目,面向C++/Qt初学者及GUI开发进阶者,解决自定义图形界面构建、可视化元素持久化存储与跨平台数据交换等核心问题。项目涵盖从基础QPainter绘图(矩形、椭圆、路径等)、QGraphicsView交互场景搭建,到使用QXmlStreamWriter将图形属性结构化导出为XML的全流程实现,适用于数据可视化工具、图形编辑器原型或教学演示系统开发。压缩包共382个文件,含49个cpp与24个h源码文件构成主体逻辑,108个html文档提供API说明与示例,80个png用于界面效果展示,另有pro工程配置、qrc资源文件及大量Qt属性管理相关头文件与编译产物,整体大小2.88MB,结构完整、模块清晰。目前已有1454人学习下载,读者可直接运行exe程序体验绘图与导出功能,并通过源码深入理解Qt绘图机制、XML流式写入及QGraphicsItem体系设计。
1. 用 Qt 手绘流程图、电路草图、UI 原型,一键导出结构化 XML —— 这不是截图存档,是真正可解析、可回读、可二次编辑的矢量图形工程
你有没有遇到过这种场景:在调试嵌入式界面时,需要快速画一个带按钮+滑块+状态灯的控制面板草图,发给硬件同事确认布局;或者在教学生理解信号流向时,随手拖几个矩形、箭头、圆圈连成逻辑图,但画完只能截图——下次想改颜色、调间距、加个新节点?得重画。更糟的是,团队协作时没人能复用你的“手绘资产”。这个项目就是为这类真实痛点而生:它不是一个静态绘图工具,而是一个轻量级、可嵌入、全内存管理的 Qt 图形建模系统。所有图形元素(矩形、椭圆、折线、贝塞尔曲线、文本、连接线)都以对象形式存在,支持拖拽缩放、层级管理、选中高亮;最关键的是,整幅画布的状态能完整序列化为标准 XML,且该 XML 具备明确 schema:每个<shape>标签带id、type、x/y/width/height、fill、stroke等属性,连接线<connection>显式记录sourceId和targetId。这意味着——你画的不是图片,是数据。XML 可被 Python 脚本解析生成测试用例,可被 Web 前端加载渲染为 SVG,甚至能作为低代码平台的 UI 描述源。适合嵌入式 HMI 开发者、教育类软件作者、自动化测试图形建模人员,以及所有厌倦了“画完即失”的 Qt 中小型项目开发者。
2. 架构总览与核心模块拆解:从 QGraphicsScene 到 QDomDocument 的完整链路
这个项目没有引入 QML 或第三方绘图库,纯 Qt Widgets 实现,依赖清晰、体积可控(编译后主程序 < 3MB),非常适合集成进已有 Qt5/Qt6 桌面应用。整个架构围绕三个核心层展开:视图层(QGraphicsView + 自定义 Scene)、模型层(ShapeItem 抽象基类及具体子类)、序列化层(XmlExporter / XmlImporter)。它不追求 Photoshop 级功能,但把“可编程图形建模”这件事做扎实了:每个图形都是 QObject 派生,支持信号通知变更;所有坐标运算基于 scene 坐标系,规避 widget 坐标转换玄学;XML 导出不依赖外部 XSLT,完全由 QDomDocument 原生构建,保证跨平台一致性。下面逐层拆解关键实现。
2.1 场景管理:QGraphicsScene 的定制化扩展
项目未直接使用默认 QGraphicsScene,而是继承并重写了CustomGraphicsScene类,主要增强两点:元素唯一 ID 分配机制和连接线自动吸附逻辑。ID 分配不是简单自增,而是采用"shape_" + QString::number(qHash(this))方式生成哈希 ID,避免多实例间冲突;吸附逻辑则在mousePressEvent中触发,当鼠标靠近已有图形边缘 12px 内时,临时绘制吸附点(小圆圈),松开鼠标即创建连接线。关键代码如下:
// customgraphicsscene.h class CustomGraphicsScene : public QGraphicsScene { Q_OBJECT public: explicit CustomGraphicsScene(QObject *parent = nullptr); QString generateUniqueId(); void addConnectionItem(QGraphicsLineItem *line, const QString &sourceId, const QString &targetId); protected: void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; private: QSet<QString> m_usedIds; // 防止重复 ID QGraphicsEllipseItem *m_snapIndicator = nullptr; };提示:
qHash(this)在 Qt6 中已弃用,实际项目中我一般会改用QUuid::createUuid().toString(QUuid::WithoutBraces)替代,更稳定且语义清晰。如果你的项目需长期维护,建议在构造函数里就初始化一个static QAtomicInt s_idCounter并原子递增,比哈希更可控。
2.2 图形元素基类:ShapeItem 的抽象与多态设计
所有可绘制图形(矩形、椭圆、文本等)均继承自ShapeItem抽象基类。该类本身不绘制任何内容,只定义统一接口:toXml()(序列化)、fromXml()(反序列化)、getBoundingBox()(获取包围盒,用于连接线吸附计算)、setSelected()(选中状态管理)。这种设计让新增图形类型变得极其简单——比如你要加一个“三角形”,只需新建TriangleItem类,重写paint()和toXml()即可,无需改动场景或导出逻辑。ShapeItem关键成员如下:
// shapeitem.h class ShapeItem : public QGraphicsItem { Q_OBJECT public: enum Type { Rectangle, Ellipse, Text, Polyline, BezierCurve }; explicit ShapeItem(Type type, QGraphicsItem *parent = nullptr); virtual QDomElement toXml(QDomDocument &doc) const = 0; virtual bool fromXml(const QDomElement &element) = 0; virtual QRectF getBoundingBox() const override = 0; void setSelected(bool selected) override; Type getType() const { return m_type; } QString getId() const { return m_id; } void setId(const QString &id) { m_id = id; } signals: void propertyChanged(); protected: Type m_type; QString m_id; bool m_isSelected = false; };注意propertyChanged()信号——这是整个系统响应式更新的关键。当用户拖动图形、修改颜色时,ShapeItem发射此信号,上层MainWindow捕获后可触发状态栏更新、启用“保存”按钮,甚至实时预览 XML 结构变化。
2.3 连接线管理:ConnectionItem 与端点绑定机制
连接线(ConnectionItem)是本项目最具实用价值的设计。它不是简单画一条线,而是双向绑定两个 ShapeItem 的几何中心,并随其移动实时重绘。ConnectionItem内部持有sourceItem和targetItem的弱指针(QPointer<ShapeItem>),并在boundingRect()和paint()中动态计算端点坐标。更重要的是,它重写了itemChange(),监听ItemPositionHasChanged事件,一旦任一端点移动,立即调用updatePath()重算贝塞尔曲线控制点,实现平滑动画效果。其toXml()方法输出结构如下:
<connection id="conn_001" sourceId="shape_abc" targetId="shape_def" controlX="120" controlY="85" curve="true"/>controlX/controlY是贝塞尔曲线的控制点坐标,curve="true"表示启用曲线模式(否则为直线)。这个设计让流程图中的“弯曲箭头”不再是静态贴图,而是真正可参数化调节的矢量关系。
3. XML 序列化与反序列化:从 QDomDocument 到可验证 Schema
XML 不是目的,而是桥梁。本项目导出的 XML 必须满足两个硬性要求:可被 DOM 解析器无歧义加载、具备明确命名空间与版本标识,以便未来扩展。因此,XmlExporter类不直接拼接字符串,而是全程使用QDomDocumentAPI 构建树形结构,并在根节点<drawing>中声明version="1.0"和xmlns="http://example.com/qt-drawing-schema"(实际使用时可替换为内部私有 URI)。导出过程分三步:创建文档、遍历 scene 中所有ShapeItem子项、递归处理连接线。关键逻辑如下:
// xmlexporter.cpp QDomDocument XmlExporter::exportToXml(const QList<QGraphicsItem *> &items) { QDomDocument doc; QDomElement root = doc.createElement("drawing"); root.setAttribute("version", "1.0"); root.setAttribute("xmlns", "http://example.com/qt-drawing-schema"); doc.appendChild(root); // 第一步:导出所有基础图形 for (QGraphicsItem *item : items) { if (ShapeItem *shape = dynamic_cast<ShapeItem*>(item)) { QDomElement elem = shape->toXml(doc); if (!elem.isNull()) { root.appendChild(elem); } } } // 第二步:导出连接线(需在图形之后,确保 source/target id 已存在) for (QGraphicsItem *item : items) { if (ConnectionItem *conn = dynamic_cast<ConnectionItem*>(item)) { QDomElement elem = conn->toXml(doc); if (!elem.isNull()) { root.appendChild(elem); } } } return doc; }注意:
dynamic_cast在 Qt 中安全,但前提是ShapeItem继承自QGraphicsItem且启用了 RTTI(Qt 默认开启)。若你项目禁用 RTTI,应改用qgraphicsitem_cast宏,它是 Qt 提供的类型安全转换。
反序列化(XmlImporter)则严格按 schema 校验:先检查根节点名与 version 属性,再遍历所有<shape>子节点,根据type属性创建对应ShapeItem实例,调用fromXml()加载属性;最后处理<connection>,此时所有shape已加载完毕,可通过id查找目标对象。fromXml()内部会校验必填属性(如x,y,width,height),缺失则返回false并记录警告日志,避免静默失败。
4. 运行与调试:从零编译到交互验证的完整实操路径
项目结构极简,无 CMakeLists.txt 外部依赖,仅需 Qt5.15+ 或 Qt6.2+ 环境即可运行。以下步骤经实测(Ubuntu 22.04 + Qt6.5.3 / Windows 10 + Qt5.15.2)验证,全程无坑。
4.1 环境准备与项目拉取
首先确认 Qt 环境变量已配置:
# Linux/macOS 检查 which qmake qmake --version # 应输出 Qt 5.15.x 或 6.x.x # Windows 检查(PowerShell) Get-Command qmake qmake -v然后克隆项目(假设仓库名为qt-xml-drawing):
git clone https://github.com/your-org/qt-xml-drawing.git cd qt-xml-drawing ls -l # 你会看到:main.cpp MainWindow.h MainWindow.cpp CustomGraphicsScene.h ShapeItem.h XmlExporter.h resources/注意resources/目录下有预置的.xml示例文件(如demo_flowchart.xml),这是后续验证反序列化的关键素材。
4.2 编译与首次运行
项目使用 qmake 构建,无需额外配置:
qmake -o Makefile qt-xml-drawing.pro make -j4 # Linux/macOS # 或 Windows 下: nmake # VS 工具链 # 或 mingw32-make # MinGW 工具链编译成功后,执行:
./qt-xml-drawing # Linux/macOS # 或 qt-xml-drawing.exe # Windows窗口启动后,你会看到一个空白画布、左侧工具栏(矩形/椭圆/文本/连线图标)、顶部菜单栏(文件、编辑、帮助)。点击“矩形”图标,在画布上拖拽即可创建——此时图形已具备完整对象生命周期,可拖动、右键设置填充色、双击编辑文本内容。
4.3 保存与加载 XML:验证数据完整性
- 保存:绘制 2 个矩形、1 个椭圆、1 条连接线(从矩形指向椭圆)。点击菜单栏
文件 → 导出为 XML...,选择路径保存为test.xml。 - 验证 XML 结构:用任意文本编辑器打开
test.xml,确认根节点为<drawing version="1.0">,内含<shape type="Rectangle">、<shape type="Ellipse">、<connection sourceId="..." targetId="...">,且所有坐标值为浮点数(如x="100.5")。 - 加载验证:关闭当前窗口,重新启动程序。点击
文件 → 从 XML 加载...,选择刚保存的test.xml。程序应无报错加载全部图形,连接线自动绑定到对应图形,且选中任一图形时,其id属性与 XML 中一致。
提示:若加载后连接线未显示,请检查
test.xml中<connection>的sourceId和targetId是否与<shape>的id完全匹配(区分大小写、无空格)。这是新手最常翻车点——复制粘贴时多了一个不可见字符。
5. 避坑指南:5 个血泪经验总结的高频问题与排查方案
开发和交付过程中,我们反复踩过这些坑。它们看似琐碎,却足以让新手卡住半天。这里不讲原理,只给可立即执行的解决方案。
5.1 现象:程序启动后画布一片空白,工具栏点击无反应
原因:CustomGraphicsScene构造时未正确设置sceneRect,导致 QGraphicsView 无法计算可视区域。Qt 默认 sceneRect 为(0,0,0,0),所有图形绘制在负坐标或超大范围外。
解决:在CustomGraphicsScene构造函数末尾强制设置合理初始范围:
CustomGraphicsScene::CustomGraphicsScene(QObject *parent) : QGraphicsScene(parent) { // ... 其他初始化 setSceneRect(-1000, -1000, 2000, 2000); // 覆盖常见绘图区域 }5.2 现象:保存的 XML 中坐标全是整数(如x="100"),但实际拖动时图形位置是小数(如100.321)
原因:QGraphicsItem::pos()返回QPointF,但toXml()中错误地用了qRound(pos().x())强制取整。
解决:在ShapeItem::toXml()中,统一使用QString::number(pos().x(), 'g', 10)保留 10 位有效数字:
QDomElement elem = doc.createElement("shape"); elem.setAttribute("x", QString::number(pos().x(), 'g', 10)); elem.setAttribute("y", QString::number(pos().y(), 'g', 10)); // ... 其他属性5.3 现象:加载 XML 后,连接线显示为直线,无论 XML 中curve="true"如何设置
原因:ConnectionItem::fromXml()中未解析controlX/controlY属性,或解析后未调用updatePath()触发重绘。
解决:检查fromXml()实现,必须包含:
if (element.hasAttribute("controlX") && element.hasAttribute("controlY")) { m_controlPoint.setX(element.attribute("controlX").toDouble()); m_controlPoint.setY(element.attribute("controlY").toDouble()); m_isCurved = element.attribute("curve") == "true"; updatePath(); // 关键!否则路径不更新 }5.4 现象:在高 DPI 屏幕(如 200% 缩放)下,图形拖拽出现“跳变”或吸附不准
原因:Qt6 默认启用高 DPI 缩放,但QGraphicsView::mapToScene()返回的坐标未除以设备像素比(devicePixelRatio)。
解决:在CustomGraphicsScene::mousePressEvent()中,对event->scenePos()进行校正:
void CustomGraphicsScene::mousePressEvent(QGraphicsSceneMouseEvent *event) { QPointF correctedPos = event->scenePos(); if (QGuiApplication::primaryScreen()) { qreal ratio = QGuiApplication::primaryScreen()->devicePixelRatio(); correctedPos /= ratio; // 校正高 DPI 偏移 } // 后续使用 correctedPos 进行吸附计算 }5.5 现象:添加大量图形(>200 个)后,保存 XML 耗时超过 10 秒,UI 卡死
原因:QDomDocument::toString()在节点数多时性能急剧下降,且主线程阻塞。
解决:将 XML 生成移至QThread,并用QMetaObject::invokeMethod回调更新 UI:
// 在 MainWindow 中 void MainWindow::onExportClicked() { ExportWorker *worker = new ExportWorker(m_scene->items()); QThread *thread = new QThread; worker->moveToThread(thread); connect(thread, &QThread::started, worker, &ExportWorker::process); connect(worker, &ExportWorker::finished, thread, &QThread::quit); connect(worker, &ExportWorker::finished, worker, &ExportWorker::deleteLater); connect(thread, &QThread::finished, thread, &QThread::deleteLater); thread->start(); }6. 二次开发实战:3 个可立即落地的改造技巧与验证方法
这个项目的价值不在“能用”,而在“好改”。我接手过的 5 个实际项目中,有 3 个是在此基础上 2 小时内完成定制化改造。下面分享最常用、最稳妥的三种路径,每种都附带验证方法,确保改完即生效。
6.1 技巧一:为图形添加自定义属性(如“设备ID”、“报警阈值”),并透出到 XML
业务场景中,图形常需携带元数据。例如工业 HMI 中,一个“电机图标”需绑定 PLC 地址DB10.DBX2.0。直接修改ShapeItem基类最安全:
- 在
shapeitem.h中添加QMap<QString, QString> m_customProperties;成员; - 在
toXml()中遍历该 map,为每个键值对生成<property key="deviceId" value="MOTOR_A1"/>子节点; - 在
fromXml()中解析<property>标签并存入 map。
验证方法:
- 启动程序,创建矩形 → 右键菜单选择“设置自定义属性” → 输入
key=deviceId, value=PLC_M1; - 保存 XML,手动检查是否新增
<property key="deviceId" value="PLC_M1"/>; - 删除图形,重新加载该 XML,右键检查属性是否恢复。
6.2 技巧二:导出为 SVG 替代 PNG,支持无损缩放与 Web 集成
XML 是数据格式,SVG 是展示格式。二者可共存。新增SvgExporter类,核心是遍历ShapeItem列表,按类型生成<rect>,<ellipse>,<text>等 SVG 标签。关键在于坐标转换:SVG 原点在左上角,Qt scene 原点在中心,需用sceneRect().height() - y翻转 Y 轴。
验证方法:
- 修改
MainWindow::onExportClicked(),增加“导出为 SVG”菜单项; - 保存 SVG 后,用浏览器打开,Ctrl+滚轮放大,确认文字边缘无锯齿、线条平滑;
- 将 SVG 文件拖入 VS Code,确认其为纯文本,可被前端
fetch()加载并innerHTML注入。
6.3 技巧三:接入 Undo/Redo 栈,支持 Ctrl+Z 撤销图形增删与移动
Qt 自带QUndoStack,但需为每个操作封装QUndoCommand。最简方案是:
- 创建
AddShapeCommand(记录ShapeItem*指针,redo()添加到 scene,undo()从 scene 移除); - 创建
MoveShapeCommand(记录移动前/后坐标,redo()设为新坐标,undo()设为旧坐标); - 在
CustomGraphicsScene::mouseReleaseEvent()中,当检测到图形位置变化超过 1px,push()对应命令。
验证方法:
- 绘制 3 个图形 → 全选拖动 → 按 Ctrl+Z,应逐次还原到原始位置;
- 删除一个图形 → Ctrl+Z,应恢复该图形且保持原坐标;
- 查看
QUndoStack::count(),确认栈深度与操作次数一致。
从那以后我每次接到“需要图形化配置”的需求,第一反应不是去搜开源库,而是打开这个项目模板,花 15 分钟加一个CustomDeviceItem类,再补 3 行toXml()逻辑——因为我知道,XML 结构已稳,坐标系统已稳,连接关系已稳。剩下的只是业务字段的搬运工。希望帮到你。
本文还有配套的精品资源,点击获取