Qt图形绘制与XML持久化:从数据模型到序列化完整实践
2026/9/10 1:21:09 网站建设 项目流程

简介:Qt绘图与XML持久化实战资源,面向需要掌握QPainter绘制、QPainterPath复杂路径及QXmlStreamWriter序列化的C++/Qt开发者。内含完整可编译的Qt工程源码,通过重写paintEvent绘制矩形、圆形等图形,并演示将坐标、大小、颜色等属性保存为XML文档,覆盖从绘图到数据交换全流程。资源包共382个文件,以cpp、h、pro源码为主,配合html、png文档与运行截图,另附可执行文件便于直接体验,整体仅2.88MB。内容还涉及QGraphicsScene/QGraphicsView交互框架和QGraphicsItem自定义图元,适合数据可视化、图形编辑器等项目参考。已有1452人学习下载,是入门Qt自定义绘图与XML格式输出的实用案例包。 去年我接了一个内部工具的需求:用Qt做一个图形标注面板,要求能画直线、矩形、椭圆和多边形,画完之后要能把内容保存下来,下次打开接着改。需求单上写的就是“Qt绘制各种图形并且保存为XML”。当时我第一反应是“这不就是画几个形状再写个文件的事”,实际做起来才发现,画图只是表皮,真正花时间的是怎么把图形数据做得干净、可扩展、能可靠落盘。这篇文章就把我整个实现过程拆开讲,包括数据模型设计、QPainter绘制、XML序列化方案,以及几个让我半夜调bug的坑。

如果你也准备做类似的小型矢量图形编辑器,或者正被“图形类对象如何持久化”这个问题卡住,可以参考我这套做法。我会用尽量短的代码说清楚核心思路,不会贴一大坨工程代码,但每个关键环节都会给出能直接用的写法。

1. 需求落地前先想清楚:图形存储的最小闭环是什么

做这种项目,最忌讳一上来就写界面。先理清楚数据流:用户交互产生图形对象,图形对象保存在内存里,然后序列化成XML文件;反过来,启动时读XML,重建图形对象,再交给绘制模块显示。这个闭环是整件事的地基。

1.1 为什么选择XML而不是另起炉灶

我见过不少项目用自定义的文本格式保存图形,比如每行一个“RECT 10,10,100,60”。这种格式写起来快,但后续扩展非常痛苦:加个字段要改读写逻辑,别人根本读不懂,调试靠猜。XML的优势是:

  • 纯文本,可以手工打开修改,也方便写脚本批量处理。
  • 结构清晰,形状、颜色、坐标、图层这些信息天然是树状关系。
  • Qt对XML的接口很成熟,读写的代码量不大,也不依赖第三方库。
  • 后续就算换到SVG、QSS这类格式,转换起来也比自造格式容易。

你可能会问,JSON不是更简洁吗?JSON当然也可以,但对“图形数据”而言,XML的属性和子元素能把“这个形状是什么”和“它的具体参数是什么”分得很清楚。尤其当图形类型越来越多,XML的可读性和容错性会更好。当然,如果你已经在项目里重度使用JSON,也可以把本文的XML思路照搬到JSON,核心不变。

1.2 项目模块怎么切分

我最终把程序拆成了四块:

  • Shape抽象基类:所有图形类的父接口,定义绘制和序列化的纯虚函数。
  • 具体图形类:LineShapeRectShapeEllipseShapePolygonShape,各自实现绘制和属性读写。
  • CanvasWidget:负责显示和鼠标交互,内部维护QList<QSharedPointer<Shape>>
  • XmlStore:独立的保存/加载工具类,不依赖任何图形具体类型,通过QVariantMap中转数据。

这套分法让我后面新增图形的时候非常轻松:写一个新的Shape子类,然后在工厂里注册一下,保存和加载代码完全不用动。

2. 图形数据模型:先把“图形”在内存里定义清楚

保存XML之前,必须先想明白一个图形对象在内存里长什么样。很多人栽在“画得好好的,序列化时却发现属性拿不全”,根源就是建模阶段漏了字段。

2.1 Shape基类与公共属性

我把公共属性放在基类里:画笔颜色、画笔宽度、是否填充、填充颜色、图形标识。代码大概是这样:

class Shape { public: enum class Type { Line, Rect, Ellipse, Polygon }; virtual ~Shape() = default; virtual Type type() const = 0; virtual void draw(QPainter &painter) const = 0; virtual QVariantMap toVariant() const = 0; virtual void fromVariant(const QVariantMap &data) = 0; QColor penColor{Qt::black}; int penWidth{2}; QColor brushColor{Qt::white}; bool filled{false}; QString id; };

这里我特意把toVariantfromVariant设计成纯虚函数。为什么不用一个更具体的XML序列化接口?比如让每个类自己写XML节点?因为那样会让图形类和XML格式强耦合,以后想改成存JSON或都存数据库,还得动每个图形类。QVariantMap是Qt自己的通用数据容器,转换为XML、JSON都很自然。

2.2 具体图形类的实现要点

RectShape为例,它有自己独有的QRectF rect属性。核心函数实现如下:

class RectShape : public Shape { public: Type type() const override { return Type::Rect; } void draw(QPainter &painter) const override { painter.save(); QPen pen(penColor, penWidth); painter.setPen(pen); if (filled) { painter.setBrush(QBrush(brushColor)); } else { painter.setBrush(Qt::NoBrush); } painter.drawRect(rect); painter.restore(); } QVariantMap toVariant() const override { QVariantMap map; map["type"] = QStringLiteral("rect"); map["id"] = id; map["pen.color"] = penColor.name(QColor::HexArgb); map["pen.width"] = penWidth; map["brush.color"] = brushColor.name(QColor::HexArgb); map["filled"] = filled; map["rect.x"] = rect.x(); map["rect.y"] = rect.y(); map["rect.w"] = rect.width(); map["rect.h"] = rect.height(); return map; } void fromVariant(const QVariantMap &map) override { id = map.value("id").toString(); penColor = QColor(map.value("pen.color", QStringLiteral("#000000")).toString()); penWidth = map.value("pen.width", 2).toInt(); brushColor = QColor(map.value("brush.color", QStringLiteral("#ffffff")).toString()); filled = map.value("filled", false).toBool(); rect = QRectF(map.value("rect.x").toDouble(), map.value("rect.y").toDouble(), map.value("rect.w").toDouble(), map.value("rect.h").toDouble()); } QRectF rect; };

几个关键点:

  • QColor::name(QColor::HexArgb)可以输出带透明度的颜色,比如#80ff0000。如果直接用默认的name(),会丢掉alpha值,出现“保存前有半透明,读取后变实心”的诡异问题。
  • fromVariant里所有value()都要给默认值,因为XML可能被手工改坏,也可能从旧版本文件里读数据,不能指望每个字段都存在。
  • 每个图形类都要在draw()里用painter.save()painter.restore()把画笔状态隔离,否则后面画其他图形时会串样式。

2.3 为什么用QVariantMap做“中转站”

这是我这次最想强调的一点。如果让RectShape::toXml()直接返回字符串,等我要增加一个“旋转角度”字段时,就得同时改字符串拼接和XML解析。用QVariantMap之后,序列化器只认这个统一接口:

for (auto it = data.constBegin(); it != data.constEnd(); ++it) writer.writeAttribute(it.key(), it.value().toString());

新增字段只需要在toVariant里加一行,保存/加载自动适配。这种“属性表”的思路,本质上是把强类型对象转成了键值对,既保留了可读性,又降低了各模块间的耦合。后面的XML读写器,完全依赖QVariantMap作为输入输出,不认识任何具体图形类。

3. QPainter绘制:把图形真正显示到屏幕上

数据模型定义好之后,绘制反而成了最简单的一步。CanvasWidget继承自QWidget,我重写它的paintEvent,把所有图形的draw调用一遍。

3.1 重写paintEvent并开启抗锯齿

void CanvasWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); painter.fillRect(rect(), Qt::white); for (const auto &shape : std::as_const(shapes)) { if (shape) shape->draw(painter); } }

画布底色填充成白色,可以避免透明背景在部分Linux桌面下显示成黑块。抗锯齿必须开,不然直线和椭圆边缘会像锯齿一样难看。如果你的图形很多,还可以把AntialiasingTextAntialiasing分开设置,但一般画简单图形只需要前者。

3.2 坐标系统的一个隐藏问题

Qt的QWidget坐标原点在左上角,x轴向右,y轴向下。如果你用鼠标从左上拖到右下,QRectFnormalized()方法会确保矩形坐标不为负。我在保存前都会调用一次rect = rect.normalized(),避免用户反向拖拽时保存的矩形是“负宽度”,读取后画不出来。

如果之后要支持缩放画布,我建议把所有图形存成“逻辑坐标”,在paintEvent里通过painter.scale()变换,而不是把缩放后的屏幕坐标写进XML。我最初图省事直接存了控件坐标,结果一加滚动条就全乱套,后来才统一改成逻辑坐标。

3.3 鼠标交互只为生成图形服务

虽然这篇文章重点是“绘制+XML”,但图形数据总得有来源。我在CanvasWidget里维护了一个currentShape指针,按下鼠标时根据当前模式创建对应的Shape对象,移动时更新它的几何参数,松开时把currentShape加入shapes列表并调用update()

void CanvasWidget::mousePressEvent(QMouseEvent *event) { startPoint = event->position(); if (currentType == Shape::Type::Rect) { currentShape = QSharedPointer<RectShape>::create(); static_cast<RectShape*>(currentShape.get())->rect = QRectF(startPoint, startPoint); } // 其他类型类似 } void CanvasWidget::mouseMoveEvent(QMouseEvent *event) { if (currentShape && currentShape->type() == Shape::Type::Rect) { QRectF r(startPoint, event->position()); static_cast<RectShape*>(currentShape.get())->rect = r.normalized(); update(); } } void CanvasWidget::mouseReleaseEvent(QMouseEvent *event) { if (currentShape) { shapes.append(currentShape); currentShape.clear(); update(); } }

这段代码只起了个带头作用。真正做交互时,你还要考虑右键取消、拖出控件边缘时的裁剪、多边形逐点收集等细节,不过这些和XML无关,这里就不展开了。

4. XML写入:用QXmlStreamWriter替代DOM方案

XML写文件这一步,不少人还在用QDomDocument。老项目这么写没问题,但新项目我更推荐QXmlStreamWriter

4.1 QXmlStreamWriter比QDomDocument强在哪

QDomDocument会先把整棵DOM树构建在内存里,一个上千个图形的XML文件,DOM对象数量也有上千,内存开销明显。而QXmlStreamWriter是流式写,一行一行输出,速度更快,内存占用也小。更关键的是,它的接口强制你按规范的“开始元素-属性-结束元素”顺序调用,不会写出格式错乱的XML。

不过它在读取时是解析器逐步往前推,你必须在正确的时机读取数据,不存在那种“拿到一个节点再慢慢取子节点”的便利。所以如果你更习惯DOM的一次性访问,用QDomDocument也不是不行,只是性能会差一些。我个人是“写入用Stream,读取用Stream,整个项目统一”,便于维护。

4.2 写入代码模板

我封装了一个XmlStore::save静态方法:

bool XmlStore::save(const QString &fileName, const QList<QSharedPointer<Shape>> &shapes) { QFile file(fileName); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) return false; QXmlStreamWriter writer(&file); writer.setAutoFormatting(true); writer.setAutoFormattingIndent(2); writer.setCodec("UTF-8"); writer.writeStartDocument(); writer.writeStartElement(QStringLiteral("shapes")); writer.writeAttribute(QStringLiteral("version"), QStringLiteral("1.0")); for (const auto &shape : shapes) { QVariantMap data = shape->toVariant(); writer.writeStartElement(QStringLiteral("shape")); for (auto it = data.constBegin(); it != data.constEnd(); ++it) { writer.writeAttribute(it.key(), it.value().toString()); } writer.writeEndElement(); } writer.writeEndElement(); writer.writeEndDocument(); return true; }

生成的XML大致长这样:

<?xml version="1.0" encoding="UTF-8"?> <shapes version="1.0"> <shape type="rect" id="" pen.color="#ff000000" pen.width="2" brush.color="#ffffffff" filled="false" rect.x="20" rect.y="30" rect.w="100" rect.h="60"/> <shape type="line" id="" pen.color="#ff0000ff" pen.width="3" start.x="5" start.y="5" end.x="120" end.y="80"/> </shapes>

我为每个形状都用统一的<shape>标签,然后所有属性平铺成XML属性。这个方法对矩形、椭圆这种属性少的图形很友好,但对多边形这种点列表特别长的图形,会产生一个巨长的属性字符串,既不美观也不好调试。如果你遇到这种情况,可以把点列表提取到子元素里:

<shape type="polygon" pen.color="#ff000000"> <points>10,20 30,40 50,10</points> </shape>

不过这样就得在读取时区分“属性模式”和“子元素模式”,复杂一点。我图省事,多边形的点使用分号分隔存成单个属性,也能正常工作。

4.3 编码和声明别踩坑

writer.setCodec("UTF-8")这行很容易被人忽略。如果不设置,QXmlStreamWriter在Windows上会用本地编码写文件,出来的XML头可能写着encoding="UTF-8",但正文却是GBK,其它程序一读就乱码。我建议在写任何XML之前都强制指定UTF-8。

另外,writeStartDocument()默认会输出<?xml version="1.0"?>,但不会带上encoding。只要前面设置了codec,Qt才会在XML声明里自动补上encoding="UTF-8"。我在项目里显式调用writer.setCodec("UTF-8")之后,生成的头部就正常了。

5. 读取XML并还原图形:反向操作要更小心

读文件比写文件麻烦,因为用户手里的XML可能是旧版本,也可能被手工改过。解析代码必须足够健壮。

5.1 用QXmlStreamReader做逐标签解析

bool XmlStore::load(const QString &fileName, QList<QSharedPointer<Shape>> &shapes) { QFile file(fileName); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return false; QXmlStreamReader reader(&file); while (!reader.atEnd()) { reader.readNext(); if (reader.isStartElement() && reader.name() == QLatin1String("shape")) { QVariantMap data; const auto attrs = reader.attributes(); for (const auto &attr : attrs) { data.insert(attr.name().toString(), attr.value().toString()); } Shape::Type t = typeFromString(data.value(QLatin1String("type")).toString()); QSharedPointer<Shape> shape = ShapeFactory::create(t); if (shape) { shape->fromVariant(data); shapes.append(shape); } } if (reader.hasError()) { qWarning() << "XML解析错误:" << reader.errorString() << "行号:" << reader.lineNumber(); return false; } } return !reader.hasError(); }

关键点在于:reader.attributes()只在StartElement事件有效,进入下一个节点之后原本的attributes对象会被替换。所以我在判定isStartElement()的同一轮循环里就把它拷贝进QVariantMap,千万不要先readNext()再回头去取属性。

5.2 怎么用工厂创建具体图形

工厂是个很简单的函数,别把它想复杂了:

QSharedPointer<Shape> ShapeFactory::create(Shape::Type type) { switch (type) { case Shape::Type::Line: return QSharedPointer<LineShape>::create(); case Shape::Type::Rect: return QSharedPointer<RectShape>::create(); case Shape::Type::Ellipse: return QSharedPointer<EllipseShape>::create(); case Shape::Type::Polygon: return QSharedPointer<PolygonShape>::create(); default: return nullptr; } }

如果type字段在XML里被改成了不认识的字符串,ShapeFactory返回空指针,加载逻辑直接跳过这条记录。这样做比报错强,至少用户还能打开文件看到其他正常的图形。

5.3 读写回环测试是必须的

我用最简单的自检方式:存一个包含四种图形的文件,然后立刻读取,比较每个图形的toVariant()结果跟保存前是否一致。

bool validateRoundTrip(const QString &fileName, const QList<QSharedPointer<Shape>> &original) { if (!XmlStore::save(fileName, original)) return false; QList<QSharedPointer<Shape>> loaded; if (!XmlStore::load(fileName, loaded)) return false; if (original.size() != loaded.size()) return false; for (int i = 0; i < original.size(); ++i) { if (original[i]->toVariant() != loaded[i]->toVariant()) return false; } return true; }

别小看这段代码。我在写这个功能时,有一次就是QVariantMap里颜色用了QColor::name()导致透明度丢失,回环测试直接抓到差异。凡是涉及“序列化后还能还原”的代码,都应该用类似方式做自动化验证,别等到用户反馈才去查。

6. 我踩过的坑与后续扩展方向

最后一个部分,分享几个实际开发中非常容易出问题的点,以及我建议的下一步方向。

6.1 三个高频问题

第一,文件打开失败时,QFile::open返回false但不一定有详细错误提示。我习惯在写文件前先确保目标目录存在,并且用QFileInfo检查父目录,避免跨平台路径问题。

第二,手工编辑XML时,有人会把数字写成0x10这种形式,或者把penWidth写成字符串。QVariant::toInt()对合法数字字符串没有问题,但对"abc"会返回0。这一点在你从旧版本迁移数据时特别重要,最好在fromVariant里加上有效性判断,无效就给默认值。

第三,QXmlStreamReader对错误的XML会设置错误状态,此时循环要立刻退出,否则可能死循环。我在代码里先检查reader.hasError()再继续readNext,这个顺序不能反。另外,如果文件里有中文字符,保存时没写UTF-8,读取时也会出现乱码,这和编码那一节是同一个根。

6.2 从“能保存”到“更像产品”的扩展点

当前方案已经能跑通最小闭环。如果你还要继续往下做,这几个方向最值得考虑:

  • 增加<group>节点,支持图形分组和图层管理。这需要一个GroupShape容器,内部持有多个Shape。
  • 支持撤销重做。不要自己保存全量快照,最好在操作发生时记录命令,与本文的XML持久化并不冲突。
  • 导出成SVG或PNG。SVG本质上就是XML,把绘制命令映射成<rect><line>标签并不难。
  • QGraphicsScene替代自定义paintEvent。这样每个图形项都是QGraphicsItem,鼠标命中检测和移动更简单,但序列化时还是要提取属性,思路和我这套一致。

我做这个项目最大的感受是:绘图代码一天就能写完,但“能保存”和“能可靠地保存”是两码事。用QVariantMap做数据中转,用QXmlStreamWriter做写入,用工厂做对象重建,这套组合已经在我自己的工具里跑了大半年,没再出过文件损坏的问题。如果你也正在做类似需求,可以直接照这个骨架搭,能省不少弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询