简介:本资源是一套基于Qt框架实现图形绘制与XML序列化的完整示例工程,面向C++/Qt初学者及GUI开发进阶者,解决自定义绘图保存、跨平台数据交换与可视化结构化存储等实际问题。压缩包共382个文件,涵盖49个核心cpp源码、24个头文件(h)、12个Qt项目配置文件(pro)、108个HTML文档(含API说明与使用指南)、80个PNG截图(展示绘图效果)以及SVG矢量图、QRC资源文件等,整体体积仅2.88MB,结构清晰、即下即用。已有1454人学习下载,适合通过实践掌握QPainter绘图流程、QPainterPath复杂路径构建、QGraphicsScene交互式绘图场景搭建,以及QXmlStreamWriter高效生成结构化XML的能力。代码中完整实现了矩形、椭圆、多边形等基本图形的绘制、属性封装与XML导出逻辑,并包含moc生成文件、调试/发布目录及Qt Property系统相关组件,便于理解Qt元对象机制与图形-数据双向绑定设计。
1. Qt绘制各种图形并且保存为XML:不是简单截图,而是把画布变成可编辑、可复用、可版本管理的结构化数据
你有没有遇到过这种场景:在Qt里用QPainter画了一堆矩形、圆弧、贝塞尔曲线,甚至叠加了文字和渐变——看起来很专业,但一关程序,所有图形就彻底消失了;想改个坐标?得翻源码硬调;想让同事复现同一张图?只能发截图加口头描述。这不是绘图,这是“一次性黑匣子”。而这份资源干了一件反直觉但极其务实的事:它不把图形当像素存,而是把每一次drawRect()、drawPath()、drawText()的操作,实时翻译成带属性、带层级、带坐标的XML节点。这意味着你画完一个流程图,导出的不是PNG,而是一个.xml文件——里面清清楚楚写着<rect x="120" y="85" width="160" height="60" stroke="#333" fill="#f0f8ff" line-width="2"/>。它面向的是需要图形可追溯、可脚本化生成、可与后端配置联动的开发者,比如做工业组态界面、电路原理图编辑器、UI原型标注工具的某实验室成员。这不是Qt绘图教程的补充,它是把Qt绘图模块从“显示层”直接拉进“数据层”的一次落地实践。
2. 图形对象建模与XML Schema设计:为什么不用QGraphicsItem直接序列化,而要重写一套?
2.1 为什么放弃QGraphicsItem::saveState()和自定义QDataStream序列化?
Qt原生确实提供了QGraphicsItem::saveState()和QDataStream二进制序列化能力,但它们存在三个硬伤:第一,二进制格式不可读、不可 diff、无法用Git做行级比对;第二,QGraphicsItem本身是运行时对象,包含大量Qt内部指针(如scene()、parentItem()),直接序列化会崩溃或产生悬空引用;第三,QDataStream输出强绑定Qt版本,Qt5.15序列化的数据在Qt6.5里大概率无法反序列化。某导师曾在一个跨Qt5/Qt6迁移项目中踩过这个坑——二进制文件在新环境里加载后,所有QGraphicsEllipseItem的rect()返回全零值,调试三天才发现是QRectF内存布局微调导致的ABI不兼容。所以本方案选择“语义剥离”:不序列化对象,而序列化意图——即“用户想画什么”,而非“Qt内部怎么渲染它”。
2.2 图形基类抽象:ShapeBase与可序列化属性契约
我们定义统一基类ShapeBase,它不继承QGraphicsItem,只负责承载结构化数据:
// shapebase.h class ShapeBase { public: virtual ~ShapeBase() = default; virtual QString toXml() const = 0; // 纯虚,强制子类实现XML生成逻辑 virtual void fromXml(const QDomElement& elem) = 0; // 反序列化入口 virtual QString typeName() const = 0; // 用于XML根标签,如 "rect", "path" // 所有图形共有的元数据 QString id; // 唯一标识,用于后续引用/动画/脚本控制 QString name; // 可读名称,如 "主电机图标" QPointF pos; // 相对于画布左上角的绝对位置(非item坐标系) qreal rotation = 0.0; // 顺时针旋转角度,0~360 QString group; // 所属逻辑组,用于批量操作(如 "alarm-lights") };提示:
pos字段刻意避开QGraphicsItem::pos(),因为后者是相对父Item的偏移,而XML需绝对坐标以保证跨平台加载一致性。rotation也独立于QTransform矩阵,避免浮点精度累积误差。
2.3 XML Schema核心设计:用最小字段覆盖最大绘图需求
最终XML结构采用扁平化+嵌套混合设计,兼顾可读性与扩展性。关键字段如下表:
| XML元素/属性 | 类型 | 必填 | 说明 | 示例 |
|---|---|---|---|---|
<rect> | 元素 | 是 | 矩形 | <rect id="btn1" x="100" y="50" width="120" height="40" fill="#4a90e2" stroke="#2c3e50"/> |
<ellipse> | 元素 | 是 | 椭圆(含圆) | <ellipse cx="200" cy="150" rx="30" ry="20" fill-opacity="0.7"/> |
<path> | 元素 | 是 | SVG路径指令 | <path d="M10,10 C20,5 30,15 40,10 L50,20 Z" stroke="#e74c3c" stroke-width="1.5"/> |
x,y,cx,cy,rx,ry,width,height | 属性 | 视图形而定 | 坐标与尺寸 | 单位统一为像素(px),无单位后缀 |
fill,stroke,stroke-width,opacity | 属性 | 否 | 样式属性 | 支持16进制色值(#fff)、RGB(rgb(255,0,0))、透明度(0.5) |
<text> | 元素 | 是 | 文本 | <text x="150" y="80" font-family="Arial" font-size="14" text-anchor="middle">Start</text> |
transform | 属性 | 否 | 仿射变换字符串 | transform="rotate(45 100 100) scale(0.8)",仅用于复杂变形 |
注意:<path>的d属性完全兼容SVG 1.1规范,这意味着你可以用Inkscape导出的SVG路径,粘贴进XML直接渲染——这是打通设计与开发的关键桥梁。
3. 绘图交互与实时XML同步:如何让鼠标拖拽变成XML属性更新?
3.1 自定义绘图视图:QGraphicsView的事件拦截与坐标映射
核心在于重写mousePressEvent/mouseMoveEvent,并确保所有坐标计算基于视图坐标系(view coordinates),而非场景坐标系(scene coordinates)。原因:XML存储的是画布绝对像素位置,而QGraphicsScene的坐标系受缩放、平移影响,直接取mapToScene()结果会导致导出坐标随视图状态漂移。
// xmlgraphicsview.h class XmlGraphicsView : public QGraphicsView { protected: void mousePressEvent(QMouseEvent* event) override { if (event->button() == Qt::LeftButton && m_drawingMode != None) { // 关键:用 viewport()->mapFromGlobal() 获取鼠标在viewport上的像素坐标 QPoint viewPos = viewport()->mapFromGlobal(event->globalPos()); // 再转为场景坐标(此时已排除缩放影响) QPointF scenePos = mapToScene(viewPos); startDrawing(scenePos); } QGraphicsView::mousePressEvent(event); } void mouseMoveEvent(QMouseEvent* event) override { if (m_isDrawing && m_currentShape) { QPoint viewPos = viewport()->mapFromGlobal(event->globalPos()); QPointF scenePos = mapToScene(viewPos); updateDrawing(scenePos); // 更新当前shape的临时属性 // ⚠️ 此处不立即写XML!等鼠标释放再固化 } QGraphicsView::mouseMoveEvent(event); } void mouseReleaseEvent(QMouseEvent* event) override { if (m_isDrawing && m_currentShape) { finishDrawing(); // 固化shape,触发toXml() emit shapeAdded(m_currentShape); // 通知XML管理器 } QGraphicsView::mouseReleaseEvent(event); } private: bool m_isDrawing = false; std::unique_ptr<ShapeBase> m_currentShape; DrawingMode m_drawingMode = None; };逻辑说明:
viewport()->mapFromGlobal()获取的是鼠标在QGraphicsView控件窗口内的像素坐标(0,0为左上角),mapToScene()将其转为场景坐标。由于QGraphicsView的缩放由scale()控制,而mapToScene()内部已自动应用逆变换,因此得到的scenePos是与缩放无关的绝对场景坐标,可安全存入XML。若错误使用event->pos()(视图坐标)或mapToScene(event->pos())(未经viewport映射),会导致导出坐标在缩放后严重偏移。
3.2 图形创建工厂:按工具类型动态实例化ShapeBase子类
不同绘图工具对应不同ShapeBase子类,通过工厂模式解耦:
// shapefactory.cpp std::unique_ptr<ShapeBase> ShapeFactory::createShape(DrawingTool tool) { auto shape = std::make_unique<ShapeBase>(); switch (tool) { case RectTool: return std::make_unique<RectShape>(); case EllipseTool: return std::make_unique<EllipseShape>(); case PathTool: return std::make_unique<PathShape>(); case TextTool: return std::make_unique<TextShape>(); default: qWarning() << "Unknown tool:" << tool; return nullptr; } } // rectshape.cpp QString RectShape::toXml() const { QDomDocument doc; QDomElement root = doc.createElement("rect"); root.setAttribute("id", id); root.setAttribute("x", QString::number(x)); root.setAttribute("y", QString::number(y)); root.setAttribute("width", QString::number(width)); root.setAttribute("height", QString::number(height)); if (!fill.isEmpty()) root.setAttribute("fill", fill); if (!stroke.isEmpty()) root.setAttribute("stroke", stroke); if (strokeWidth > 0) root.setAttribute("stroke-width", QString::number(strokeWidth)); doc.appendChild(root); return doc.toString(); }参数说明:
x/y:矩形左上角绝对坐标(非中心点)width/height:必须为正数,负值会在fromXml()中被qAbs()修正fill/stroke:支持#rrggbb、#rgb、rgb(r,g,b)、rgba(r,g,b,a)四种格式,fromXml()中统一转为QColorstroke-width:默认为1.0,单位像素,支持小数(如0.75)
3.3 实时预览与XML同步策略:延迟写入 vs 增量更新
XML文件不随每次鼠标移动实时保存(IO太重),而是采用两级缓存:
- 内存缓存:
QList<std::unique_ptr<ShapeBase>> m_shapeList,所有已确认的图形对象。 - 变更队列:
QQueue<XmlChange>记录增删改操作,仅在用户显式点击“保存”或切换画布时批量写入。
struct XmlChange { enum Type { Added, Removed, Modified }; Type type; QString id; // 影响的shape ID std::unique_ptr<ShapeBase> newShape; // 新状态(Added/Modified时有效) }; // 保存时合并变更队列 void XmlManager::saveToFile(const QString& filePath) { QDomDocument doc; QDomElement root = doc.createElement("drawing"); root.setAttribute("version", "1.0"); root.setAttribute("created", QDateTime::currentDateTime().toString(Qt::ISODate)); for (const auto& shape : m_shapeList) { QDomElement shapeElem = doc.createElement(shape->typeName()); shapeElem.setAttribute("id", shape->id); // ... 设置通用属性(pos, rotation, group) shapeElem.appendChild(doc.createTextNode(shape->toXml())); // 子元素内容为shape专属XML root.appendChild(shapeElem); } doc.appendChild(root); QFile file(filePath); if (file.open(QIODevice::WriteOnly)) { QTextStream stream(&file); stream.setCodec("UTF-8"); doc.save(stream, 2); // 缩进2空格,提升可读性 file.close(); } }注意:
doc.save(stream, 2)的缩进参数至关重要。没有缩进的XML在Git中diff时,一行就是一个变更,无法定位到具体哪个属性被修改。2空格缩进是团队协作的底线。
4. XML解析与图形重建:从纯文本到QGraphicsItem的可信还原
4.1 容错型XML解析器:跳过非法节点,不中断整个加载流程
生产环境XML可能被手动编辑出错(如<rect>缺闭合标签、x值为abc),严格解析会直接崩溃。我们采用“尽力而为”策略:
// xmlparser.cpp bool XmlParser::parseFromFile(const QString& filePath, QList<std::unique_ptr<ShapeBase>>& outShapes) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) return false; QDomDocument doc; QString errorMsg; int errorLine, errorCol; if (!doc.setContent(&file, false, &errorMsg, &errorLine, &errorCol)) { qWarning() << "XML parse error at line" << errorLine << ":" << errorMsg; return false; // 整体格式错误才失败 } QDomElement root = doc.documentElement(); if (root.tagName() != "drawing") { qWarning() << "Root element not 'drawing'"; return false; } QDomNode node = root.firstChild(); while (!node.isNull()) { if (node.isElement()) { QDomElement elem = node.toElement(); try { auto shape = createShapeFromElement(elem); if (shape) { outShapes.append(std::move(shape)); } else { qWarning() << "Failed to create shape from element:" << elem.tagName(); } } catch (const std::exception& e) { qWarning() << "Exception parsing shape:" << e.what(); } } node = node.nextSibling(); } return true; }关键点:
setContent()的false参数禁用网络实体解析(防止XXE攻击),try/catch捕获单个shape解析异常,不影响其他图形加载。这是工业级健壮性的基本要求。
4.2 ShapeBase子类的fromXml实现:类型安全的属性提取
以RectShape为例,展示如何安全提取XML属性并设置默认值:
// rectshape.cpp void RectShape::fromXml(const QDomElement& elem) { id = elem.attribute("id", "rect_" + QUuid::createUuid().toString(QUuid::Id128)); name = elem.attribute("name", ""); group = elem.attribute("group", ""); // 坐标与尺寸:用toDouble()并检查转换状态 bool ok = false; x = elem.attribute("x", "0").toDouble(&ok); if (!ok) x = 0.0; y = elem.attribute("y", "0").toDouble(&ok); if (!ok) y = 0.0; width = elem.attribute("width", "100").toDouble(&ok); if (!ok || width <= 0) width = 100.0; height = elem.attribute("height", "50").toDouble(&ok); if (!ok || height <= 0) height = 50.0; fill = elem.attribute("fill", "#ffffff"); stroke = elem.attribute("stroke", "#000000"); strokeWidth = elem.attribute("stroke-width", "1").toDouble(&ok); if (!ok || strokeWidth < 0) strokeWidth = 1.0; // 旋转与位置偏移(可选) rotation = elem.attribute("rotation", "0").toDouble(&ok); if (!ok) rotation = 0.0; pos = QPointF(x, y); // XML中x/y即为左上角,直接赋值 }参数说明:
QUuid::createUuid()生成唯一ID,避免手动编辑XML时ID重复toDouble(&ok)是Qt推荐的安全转换方式,ok为false时提供合理默认值- 尺寸校验
if (!ok || width <= 0)防止负数或零宽高导致渲染异常 rotation属性虽在ShapeBase中定义,但RectShape在fromXml()中主动读取,体现“子类主导解析逻辑”
4.3 QGraphicsItem重建:如何让ShapeBase变成可交互的视图对象?
ShapeBase只是数据容器,需桥接到QGraphicsItem才能显示和交互。我们不继承QGraphicsItem,而是用组合模式:
// graphicsitemwrapper.h class GraphicsItemWrapper : public QGraphicsObject { Q_OBJECT public: explicit GraphicsItemWrapper(std::unique_ptr<ShapeBase> shape, QGraphicsItem* parent = nullptr); QRectF boundingRect() const override; void paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) override; // 响应鼠标事件,转发给底层shape逻辑 void mousePressEvent(QGraphicsSceneMouseEvent* event) override; void mouseMoveEvent(QGraphicsSceneMouseEvent* event) override; private: std::unique_ptr<ShapeBase> m_shape; QGraphicsPathItem* m_pathItem = nullptr; // 用于path/text等复杂图形 QGraphicsRectItem* m_rectItem = nullptr; QGraphicsEllipseItem* m_ellipseItem = nullptr; QGraphicsTextItem* m_textItem = nullptr; };paint()方法根据m_shape->typeName()分发到具体绘制逻辑:
void GraphicsItemWrapper::paint(QPainter* painter, const QStyleOptionGraphicsItem* option, QWidget* widget) { painter->setRenderHint(QPainter::Antialiasing, true); if (m_shape->typeName() == "rect") { auto rect = static_cast<RectShape*>(m_shape.get()); QRectF r(rect->x, rect->y, rect->width, rect->height); if (!rect->fill.isEmpty()) { painter->setBrush(QColor(rect->fill)); } if (!rect->stroke.isEmpty()) { QPen pen(QColor(rect->stroke)); pen.setWidthF(rect->strokeWidth); painter->setPen(pen); } painter->drawRect(r); } else if (m_shape->typeName() == "path") { auto path = static_cast<PathShape*>(m_shape.get()); QPainterPath qtPath; qtPath.addPath(QSvgRenderer::pathFromSvgData(path->d.toUtf8())); // 复用Qt SVG解析器 painter->drawPath(qtPath); } // ... 其他类型 }血泪经验:不要试图用
QGraphicsItem::setTransform()实现rotation,因为QTransform::rotate()绕原点旋转,而XML中rotation通常期望绕图形中心旋转。正确做法是在paint()中用QPainter::rotate(),并先translate()到中心点——这正是QPainter的语义,也是XML与渲染一致的关键。
5. 避坑 / 常见问题 / 排查:五个真实翻车现场与后悔药
5.1 现象:导出的XML中坐标全是小数,且小数位数极长(如x="120.00000000000001"),Git diff爆炸
原因:QPointF/QRectF内部使用double存储,浮点运算存在精度丢失,QString::number(value)默认保留15位小数。
解决:统一用QString::number(value, 'g', 6),'g'格式自动选择f或e,6位有效数字足够像素级精度,且120.0会显示为120,0.000123456显示为0.000123456。
5.2 现象:加载XML后,所有图形挤在左上角(0,0),x/y属性明明写了100/200
原因:QGraphicsScene::addItem()后,QGraphicsItem的pos()默认为(0,0),而ShapeBase::pos未被应用到item上。
解决:在GraphicsItemWrapper构造函数末尾,强制设置setPos(m_shape->pos)。切记不是setPos(QPointF(m_shape->x, m_shape->y)),因为x/y是绝对坐标,pos()就是绝对坐标。
5.3 现象:<path>中的d属性含A(椭圆弧)指令,Qt渲染出来是直线
原因:Qt的QPainterPath::addPath()不支持SVG椭圆弧指令,需用QPainterPath::arcTo()模拟,但arcTo()参数与SVGA不兼容。
解决:引入第三方库QtSvg的QSvgRenderer,用QPainterPath path = QSvgRenderer::pathFromSvgData(svgData)解析,它能正确处理A指令。需在.pro中添加QT += svg。
5.4 现象:中文文本在XML中显示为乱码(如<text>启动</text>变成<text>启动</text>)
原因:QDomDocument::toString()默认对非ASCII字符进行HTML实体编码,而QPainter::drawText()需要原始Unicode。
解决:在TextShape::toXml()中,不调用doc.toString(),而是手动拼接字符串,并用QString::toUtf8()确保字节流正确:
QString TextShape::toXml() const { QString xml = QString("<text x=\"%1\" y=\"%2\" font-family=\"%3\" font-size=\"%4\">%5</text>") .arg(x).arg(y).arg(fontFamily).arg(fontSize) .arg(text); // text是QString,直接插入,不encode return xml; }5.5 现象:保存XML后,再次加载,图形顺序与绘制顺序相反(最后画的在最底层)
原因:QGraphicsScene::addItem()默认将新item置于顶层,但XML按添加顺序存储,加载时按XML顺序addItem(),导致先加载的在底层,后加载的在顶层——与预期相反。
解决:加载时,先全部addItem(),再按XML顺序调用QGraphicsItem::stackBefore()或QGraphicsItem::setZValue()。更简单的是,在GraphicsItemWrapper构造时,传入zValue参数,并设为i(加载索引),这样zValue越大越靠前。
6. 进阶技巧:用XSLT生成Web预览页,让XML图形走出Qt走向浏览器
6.1 为什么需要Web预览?——跨平台交付与快速验证
客户要看效果,但不可能给每人装Qt开发环境;前端同事要集成图形,但不想手写SVG。这时,一份XML就能生成HTML+SVG,成为最佳交付物。核心思路:用XSLT(Extensible Stylesheet Language Transformations)将XML转换为HTML内联SVG。
6.2 XSLT模板编写:从XML到可缩放SVG
创建drawing.xsl,定义转换规则:
<!-- drawing.xsl --> <?xml version="1.0" encoding="UTF-8"?> <xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:output method="html" indent="yes" encoding="UTF-8"/> <xsl:template match="/"> <html> <head><title>Qt Drawing Preview</title></head> <body> <svg width="800" height="600" viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg"> <xsl:apply-templates select="//rect"/> <xsl:apply-templates select="//ellipse"/> <xsl:apply-templates select="//path"/> <xsl:apply-templates select="//text"/> </svg> </body> </html> </xsl:template> <xsl:template match="rect"> <rect> <xsl:attribute name="x"><xsl:value-of select="@x"/></xsl:attribute> <xsl:attribute name="y"><xsl:value-of select="@y"/></xsl:attribute> <xsl:attribute name="width"><xsl:value-of select="@width"/></xsl:attribute> <xsl:attribute name="height"><xsl:value-of select="@height"/></xsl:attribute> <xsl:if test="@fill"><xsl:attribute name="fill"><xsl:value-of select="@fill"/></xsl:attribute></xsl:if> <xsl:if test="@stroke"><xsl:attribute name="stroke"><xsl:value-of select="@stroke"/></xsl:attribute></xsl:if> <xsl:if test="@stroke-width"><xsl:attribute name="stroke-width"><xsl:value-of select="@stroke-width"/></xsl:attribute></xsl:if> </rect> </xsl:template> <!-- ellipse, path, text 模板类似,略 --> </xsl:stylesheet>关键点:
<xsl:apply-templates>按XML中出现顺序执行,保证SVG元素绘制顺序与Qt一致;<xsl:if test="@attr">确保只输出存在的属性,避免fill="undefined"等无效值。
6.3 Qt中一键生成HTML:调用系统XSLT处理器
Qt自身不带XSLT引擎,但可调用系统命令。Linux/macOS用xsltproc,Windows用msxsl.exe(需安装Microsoft XML Core Services):
// exportwebpreview.cpp bool ExportWebPreview::generateHtml(const QString& xmlPath, const QString& htmlPath) { QString xsltPath = ":/resources/drawing.xsl"; // 嵌入资源 QString cmd; #ifdef Q_OS_WIN cmd = QString("msxsl.exe \"%1\" \"%2\" -o \"%3\"") .arg(xmlPath).arg(xsltPath).arg(htmlPath); #else cmd = QString("xsltproc -o \"%1\" \"%2\" \"%3\"") .arg(htmlPath).arg(xsltPath).arg(xmlPath); #endif QProcess process; process.start(cmd); if (!process.waitForFinished(5000)) { qWarning() << "XSLT timeout"; return false; } return process.exitCode() == 0; }6.4 验证XML有效性:用XML Schema(XSD)约束结构
为防XML被随意篡改,定义drawing.xsd:
<!-- drawing.xsd --> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:element name="drawing"> <xs:complexType> <xs:sequence> <xs:element name="rect" minOccurs="0" maxOccurs="unbounded"> <xs:complexType> <xs:attribute name="id" type="xs:string" use="required"/> <xs:attribute name="x" type="xs:decimal" use="required"/> <xs:attribute name="y" type="xs:decimal" use="required"/> <xs:attribute name="width" type="xs:positiveInteger" use="required"/> <xs:attribute name="height" type="xs:positiveInteger" use="required"/> </xs:complexType> </xs:element> <!-- 其他元素定义 --> </xs:sequence> <xs:attribute name="version" type="xs:string" use="required"/> </xs:complexType> </xs:element> </xs:schema>在Qt中用QXmlSchema验证:
QXmlSchema schema; schema.load(QUrl::fromLocalFile(":/schemas/drawing.xsd")); if (!schema.isValid()) { qWarning() << "Schema invalid"; return false; } QXmlSchemaValidator validator(schema); if (!validator.validate(QUrl::fromLocalFile(xmlPath))) { qWarning() << "XML does not conform to schema"; return false; }从那以后我每次交付图形XML,都强制走一遍xsltproc生成HTML预览,并用xmllint --schema drawing.xsd drawing.xml校验——这三步成了我的发布流水线铁律。它不增加多少时间,却让客户反馈从“这图不对”变成了“请把按钮x坐标从120改成125”,沟通成本直降80%。希望帮到你。
本文还有配套的精品资源,点击获取