拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Qt图形绘制与XML持久化:从数据模型到序列化完整实践

简介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抽象基类所有图形类的父接口定义绘制和序列化的纯虚函数。具体图形类LineShape、RectShape、EllipseShape、PolygonShape各自实现绘制和属性读写。CanvasWidget负责显示和鼠标交互内部维护QListQSharedPointerShape。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; };这里我特意把toVariant和fromVariant设计成纯虚函数。为什么不用一个更具体的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桌面下显示成黑块。抗锯齿必须开不然直线和椭圆边缘会像锯齿一样难看。如果你的图形很多还可以把Antialiasing和TextAntialiasing分开设置但一般画简单图形只需要前者。3.2 坐标系统的一个隐藏问题Qt的QWidget坐标原点在左上角x轴向右y轴向下。如果你用鼠标从左上拖到右下QRectF的normalized()方法会确保矩形坐标不为负。我在保存前都会调用一次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 QSharedPointerRectShape::create(); static_castRectShape*(currentShape.get())-rect QRectF(startPoint, startPoint); } // 其他类型类似 } void CanvasWidget::mouseMoveEvent(QMouseEvent *event) { if (currentShape currentShape-type() Shape::Type::Rect) { QRectF r(startPoint, event-position()); static_castRectShape*(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 QListQSharedPointerShape 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 version1.0 encodingUTF-8? shapes version1.0 shape typerect id pen.color#ff000000 pen.width2 brush.color#ffffffff filledfalse rect.x20 rect.y30 rect.w100 rect.h60/ shape typeline id pen.color#ff0000ff pen.width3 start.x5 start.y5 end.x120 end.y80/ /shapes我为每个形状都用统一的shape标签然后所有属性平铺成XML属性。这个方法对矩形、椭圆这种属性少的图形很友好但对多边形这种点列表特别长的图形会产生一个巨长的属性字符串既不美观也不好调试。如果你遇到这种情况可以把点列表提取到子元素里shape typepolygon pen.color#ff000000 points10,20 30,40 50,10/points /shape不过这样就得在读取时区分“属性模式”和“子元素模式”复杂一点。我图省事多边形的点使用分号分隔存成单个属性也能正常工作。4.3 编码和声明别踩坑writer.setCodec(UTF-8)这行很容易被人忽略。如果不设置QXmlStreamWriter在Windows上会用本地编码写文件出来的XML头可能写着encodingUTF-8但正文却是GBK其它程序一读就乱码。我建议在写任何XML之前都强制指定UTF-8。另外writeStartDocument()默认会输出?xml version1.0?但不会带上encoding。只要前面设置了codecQt才会在XML声明里自动补上encodingUTF-8。我在项目里显式调用writer.setCodec(UTF-8)之后生成的头部就正常了。5. 读取XML并还原图形反向操作要更小心读文件比写文件麻烦因为用户手里的XML可能是旧版本也可能被手工改过。解析代码必须足够健壮。5.1 用QXmlStreamReader做逐标签解析bool XmlStore::load(const QString fileName, QListQSharedPointerShape 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()); QSharedPointerShape 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 怎么用工厂创建具体图形工厂是个很简单的函数别把它想复杂了QSharedPointerShape ShapeFactory::create(Shape::Type type) { switch (type) { case Shape::Type::Line: return QSharedPointerLineShape::create(); case Shape::Type::Rect: return QSharedPointerRectShape::create(); case Shape::Type::Ellipse: return QSharedPointerEllipseShape::create(); case Shape::Type::Polygon: return QSharedPointerPolygonShape::create(); default: return nullptr; } }如果type字段在XML里被改成了不认识的字符串ShapeFactory返回空指针加载逻辑直接跳过这条记录。这样做比报错强至少用户还能打开文件看到其他正常的图形。5.3 读写回环测试是必须的我用最简单的自检方式存一个包含四种图形的文件然后立刻读取比较每个图形的toVariant()结果跟保存前是否一致。bool validateRoundTrip(const QString fileName, const QListQSharedPointerShape original) { if (!XmlStore::save(fileName, original)) return false; QListQSharedPointerShape 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做写入用工厂做对象重建这套组合已经在我自己的工具里跑了大半年没再出过文件损坏的问题。如果你也正在做类似需求可以直接照这个骨架搭能省不少弯路。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门