ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从QPainter到XML:Qt绘图图形对象的结构化存储与序列化方案

从QPainter到XML:Qt绘图图形对象的结构化存储与序列化方案

简介:面向使用Qt框架的开发者,这是一份完整的绘图与XML序列化示例代码资源,演示如何通过重写paintEvent并使用QPainter绘制矩形、椭圆、多边形等基本图形,并利用QPainterPath构建复杂路径;同时借助QXmlStreamWriter将图形属性写入XML文档,实现结构化存储、数据交换与后续处理。压缩包内含382个文件,以cpp/h源文件、pro工程文件、qrc资源文件为主体,并附带html格式的帮助文档、png格式的预览图片以及编译生成的obj、exe等运行产物,整体大小仅2.88MB,目录结构清晰。资源发布以来已有1454人学习,适合Qt初学者、需要实现图形数据持久化的项目开发者以及桌面应用进阶者查阅参考。包中工程可完整编译运行,代码覆盖自定义绘图、QGraphicsScene交互视图、QGraphicsItem图形项管理、XML读写以及属性浏览器集成等关键技巧,内置configure.bat等构建脚本,便于直接运行调试、按需修改和二次扩展。

1. 项目定位:把QPainter画出的临时图形变成可交换的XML数据

做绘图工具的人多半都有过这种经历:画布上辛辛苦苦拖出来的矩形、椭圆、线条,截图存成PNG之后就成了死图片,想改一个圆角半径都只能重画。我拆过不少Qt绘图Demo,大多数都停在“能画”这一步,很少有人把“画完的东西”真正结构化存下来。这个项目的价值恰恰在第二半——用Qt的QPainter绘制矩形、椭圆、线条、文本等多种图形,绘制完成后保存为XML文件,重新加载能还原出可独立编辑的图形对象,而不是一张位图。

项目没有引入任何第三方库,绘图走QPainter,序列化走QXmlStreamWriter,整个数据链路从鼠标按下到XML落盘都是Qt原生能力。对想做流程图草稿工具、教学用绘图示例、或者CAD类数据前端的开发者来说,这是一套可以照着抽代码的最小骨架。新手能在一个下午跑通全流程,熟手也能从图形抽象和序列化设计里看到自己的改造点。

2. 架构与数据设计:图形对象不落到画布上,而是落到XML节点上

拆这个项目时,我第一个注意到的是它没有用QGraphicsView那一套场景框架,而是用QWidget + QPainter硬画。这个选择不是偷懒,而是对当前功能量级做过取舍后的结果。

2.1 选用QWidget当画布而不是QGraphicsView

常见的Qt绘图Demo里,QGraphicsView + QGraphicsScene是主流方案,因为它自带图元管理、碰撞检测和视图变换。但在这个项目里,图形对象只有矩形、椭圆、线条、文本这几种,数量不超过几十个,交互也只涉及拖拽绘制和重绘,不需要层级关系、不需要选中框、不需要缩放视图时同步更新图元位置。这时候引入QGraphicsScene反而会把问题绕远:你得把QGraphicsItem序列化成自定义结构,再映射到XML,中间多了一层翻译。

用QWidget做画布,paintEvent里用一个for循环把所有图形draw一遍,状态管理只靠两个成员变量:一个当前绘制模式枚举,一个当前正在绘制的图形指针。图形数据直接作为业务对象存进QList,序列化时遍历QList逐项写出,加载时逐项读回,逻辑路径短而直。当绘制功能扩展到一个中等复杂度时再迁移到QGraphicsView也来得及,数据层Shape对象是可以平移复用的。

项目里画布的鼠标事件做了三件事:mousePressEvent里记录起点并实例化一个图形对象,mouseMoveEvent里更新终点并触发update()重绘,mouseReleaseEvent里把图形对象正式加入QList。这就是整个交互层的核心,没有额外状态机框架,三个事件函数三档状态,够用且容易调试。

2.2 Shape抽象基类与三种派生图形

所有图形对象继承同一个抽象基类Shape,基类里定了三个纯虚函数:draw(QPainter&)负责把自己画到画布,toVariant()负责把自己转成QVariantMap中间结构,fromVariant()负责从中间结构还原自身。toVariant和fromVariant是成对出现的,它们不直接碰XML,而是把对象转成一张键值表,XML读写只认键值表。这个设计我在后面章节会展开讲,但你现在可以把它理解为一个序列化契约:任何新图形类型只要实现这三个函数,不需要改动XML读写模块。

矩形、椭圆、线条分别对应RectShape、EllipseShape、LineShape三个子类。对于线条来说起点和终点就是两个点,矩形和椭圆存的是左上角和右下角两个点,绘制时调用painter.drawRect(QRectF(start, end).normalized())来消除负宽度问题。文本图形稍有不同,除了位置还多存了一个字符串,在paintEvent里用painter.drawText(rect, text)直接画到指定区域。

图形对象的属性比较克制,只包含坐标、画笔颜色、画笔宽度、填充色四项。这四项在保存成XML时都变成了属性节点,加载时全部能从XML里恢复,这保证了“保存—加载—重绘”是一条不丢数据的完整链路。

2.3 XML节点结构:属性驱动,不搞嵌套子元素

XML格式的设计决定了解析复杂度。项目里没有在shape节点下再嵌套一堆子节点,而是把所有属性平铺成一个节点下的若干attribute,结构很干净:

<?xml version="1.0" encoding="UTF-8"?> <canvas width="960" height="600" version="1.0"> <shape type="line" x1="20" y1="30" x2="180" y2="150" pen="#333333" penWidth="2" fill="#FFE066"/> <shape type="rect" x1="40" y1="60" x2="200" y2="240" pen="#3366FF" penWidth="2" fill="#FFFFFF"/> <shape type="ellipse" x1="300" y1="120" x2="420" y2="260" pen="#CC0000" penWidth="3" fill="#E0E0E0"/> </canvas>

根节点canvas保存画布尺寸和文件版本,每个shape节点靠type属性区分具体图形类型,其余属性是各图形特有数据。这种做法有一个明显好处:解析端不需要维护一套针对每种类型的硬编码读取分支,只需把attributes()整体转成QVariantMap,再交给Shape工厂按type创建对象。新增图形类型时,XML读写模块一行都不用改。

表格整理一下当前支持的图形类型与XML属性的对应关系:

类型type子类XML特有属性绘制函数
lineLineShapex1, y1, x2, y2drawLine
rectRectShapex1, y1, x2, y2drawRect
ellipseEllipseShapex1, y1, x2, y2drawEllipse
textTextShapex1, y1, w, h, textdrawText

所有图形都共享pen、penWidth、fill三个公共属性。序列化契约保证了新增图形类型无需变动保存和加载的框架代码,这某种意义上也是这个项目最值钱的部分。

2.4 源码目录结构:拿到手先看这四个文件

项目是典型的Qt Widget工程,文件数量不多,按职责做了分离。我在拆解时关注的文件顺序是这样的:

draw-to-xml/ ├── CMakeLists.txt ├── main.cpp ├── MainWindow.h / MainWindow.cpp ├── CanvasWidget.h / CanvasWidget.cpp ├── Shape.h / Shape.cpp ├── LineShape.h / LineShape.cpp ├── RectShape.h / RectShape.cpp ├── EllipseShape.h / EllipseShape.cpp ├── TextShape.h / TextShape.cpp └── XmlSerializer.h / XmlSerializer.cpp

CanvasWidget承担画布和鼠标事件,MainWindow负责工具栏、图形类型切换和保存/加载按钮,XmlSerializer是独立的序列化模块。这里我特意看了一下CMakeLists,发现它没有链接额外的XML库——因为QXmlStreamWriter和QXmlStreamReader本就属于QtCore模块,不需要单独find_package一个Qt6Xml,只有用QDomDocument那套旧API才需要额外链接。这个细节很多人会踩坑,后面避坑章节单独说。

再看一遍整体数据流:MainWindow通过组合框切换当前绘制类型,CanvasWidget里画笔落下生成一个Shape实例,松开时实例进入QList容器,保存按钮触发XmlSerializer::save遍历QList写出XML,加载按钮触发load读取XML后清空QList再填充新对象。UI层、图形层、序列化层各自只依赖Shape基类,没有交叉依赖。

3. 核心模块拆解:从鼠标按下到XML文件落盘的完整链路

这一章是整个项目最值得读的部分。我把一条数据从鼠标按下到XML落盘的过程拆成五个阶段,每个阶段对应一段可以直接抄的代码,并尽可能讲清楚参数是如何传递的。

3.1 鼠标绘制状态机:三种事件如何协作画出一个图形

画布内部维护一个当前绘制模式枚举和一个临时图形指针。MainWindow的下拉框改变时,通过信号槽把枚举值传进CanvasWidget,画布只知道当前该画什么类型的图形,不关心工具栏的UI细节。

void CanvasWidget::mousePressEvent(QMouseEvent* event) { if (event->button() != Qt::LeftButton) { return; } m_drawing = true; m_startPoint = event->pos(); m_tempShape = ShapeFactory::create(m_currentType); m_tempShape->setStartPoint(m_startPoint); m_tempShape->setEndPoint(m_startPoint); } void CanvasWidget::mouseMoveEvent(QMouseEvent* event) { if (!m_drawing) { return; } m_tempShape->setEndPoint(event->pos()); update(); } void CanvasWidget::mouseReleaseEvent(QMouseEvent* event) { if (event->button() != Qt::LeftButton || !m_drawing) { return; } m_drawing = false; m_tempShape->setEndPoint(event->pos()); m_shapeList.append(m_tempShape.release()); update(); }

三段事件代码里有一个容易被忽略的设计:临时图形指针用的是std::unique_ptr,mousePressEvent里new出来的对象在mouseReleaseEvent里release之后转交给了m_shapeList独占。用智能指针管理图形生命周期,能避免窗口关闭时手动遍历delete遗漏导致的内存泄漏,这是我在自己项目里也会坚持的做法。

m_startPoint和m_endPoint各是一个QPoint,表示逻辑坐标。也就是说图形对象里保存的是坐标系中的整数坐标,不掺和窗口在屏幕上的物理位置、不掺和高DPI缩放。这样保存到XML时数值是稳定的,换一台电脑打开坐标不会漂移。

画布在构造时需要主动开启mouseTracking吗?这个项目里没有开启,因为只有按下左键时才有绘图意图,松开后鼠标移动不需要触发重绘。未按下时move事件不进来,省掉大量无效update()调用,这也解释了为什么mouseMoveEvent第一行就要判断m_drawing。

3.2 序列化契约:toVariant / fromVariant / draw三个函数的配合

Shape基类把序列化设计成了一张“属性表”。toVariant返回一个QVariantMap,里面是扁平化的键值对;fromVariant接收同样的QVariantMap并赋值给成员变量。这样做的好处是对象自身的序列化逻辑与XML格式完全解耦,将来想改成JSON存储,只需要重写XmlSerializer,Shape类一行不动。

class Shape { public: virtual ~Shape() = default; virtual void draw(QPainter& painter) const = 0; virtual QVariantMap toVariant() const = 0; virtual void fromVariant(const QVariantMap& map) = 0; void setStartPoint(const QPoint& p) { m_start = p; } void setEndPoint(const QPoint& p) { m_end = p; } void setPenColor(const QColor& c) { m_pen.setColor(c); } void setPenWidth(int w) { m_pen.setWidth(w); } protected: QPoint m_start; QPoint m_end; QPen m_pen{QColor("#333333"), 2}; QBrush m_brush{QColor("#FFFFFF")}; };

以RectShape的实现为例,toVariant里把四个关键坐标和三个样式属性统一塞进map,fromVariant里按key逐个取回。type字段在这个环节就放进map了,XML写入时不需要再额外传入类型名。

QVariantMap RectShape::toVariant() const { QVariantMap map; map.insert("type", "rect"); map.insert("x1", m_start.x()); map.insert("y1", m_start.y()); map.insert("x2", m_end.x()); map.insert("y2", m_end.y()); map.insert("pen", m_pen.color().name()); map.insert("penWidth", m_pen.width()); map.insert("fill", m_brush.color().name()); return map; } void RectShape::fromVariant(const QVariantMap& map) { m_start.setX(map.value("x1").toInt()); m_start.setY(map.value("y1").toInt()); m_end.setX(map.value("x2").toInt()); m_end.setY(map.value("y2").toInt()); m_pen.setColor(QColor(map.value("pen").toString())); m_pen.setWidth(map.value("penWidth").toInt()); m_brush.setColor(QColor(map.value("fill").toString())); }

这里需要注意QVariantMap和QVariant的边界行为:map.value("unknown")会返回一个无效QVariant,调用toInt()得到0,toQString()得到空字符串,不会崩溃但会产生脏数据。所以fromVariant进来的map必须保证键完备,而这正是XmlSerializer加载时要做校验的地方。如果读到type对应的工厂创建失败,应该丢弃该节点而不是创建脏对象。

3.3 XML保存:QXmlStreamWriter写属性时自动转义

保存模块独立在XmlSerializer类里,接收一个QList<Shape*>和一个文件路径。写入用的是QXmlStreamWriter,这是Qt中推荐用来写XML的流式API,它不会像QDomDocument那样在内存里维护一整棵DOM树,适合这个体量不大但需要快速落盘的项目。

bool XmlSerializer::save(const QList<Shape*>& shapes, const QString& filePath, const QSize& canvasSize) { QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { qWarning() << "保存失败,无法打开文件" << file.errorString(); return false; } QXmlStreamWriter xml(&file); xml.setAutoFormatting(true); xml.setAutoFormattingIndent(4); xml.writeStartDocument(); xml.writeStartElement("canvas"); xml.writeAttribute("width", QString::number(canvasSize.width())); xml.writeAttribute("height", QString::number(canvasSize.height())); xml.writeAttribute("version", "1.0"); for (const Shape* shape : shapes) { QVariantMap map = shape->toVariant(); xml.writeStartElement("shape"); for (auto it = map.constBegin(); it != map.constEnd(); ++it) { xml.writeAttribute(it.key(), it.value().toString()); } xml.writeEndElement(); } xml.writeEndElement(); xml.writeEndDocument(); return xml.hasError() == false; }

QXmlStreamWriter的writeAttribute会自动对特殊字符做XML转义,这意味着如果文本图形里包含“&”或者“<”,保存后文件依然合法,加载时读回来的字符串能原样还原,不会出现解析错乱。这里不需要手工replace,也不能用QString直接拼XML片段——自己拼字符串漏掉转义是XML保存最常见的翻车原因之一。

writeStartDocument默认会生成UTF-8声明的XML。在Qt 6里QXmlStreamWriter已经移除了setCodec接口,只支持UTF-8和UTF-16,统一用UTF-8实际上是更省心的选择:跨平台不会因为本地编码差异产生乱码,记事本、VS Code、浏览器都能正确显示。

xml.hasError()是保存函数最后一个检查点。文件打开成功、写操作正常结束时返回false,如果磁盘空间不足或者中途写坏,通过这个返回值能及时感知并提示用户,而不是在UI层无脑显示“保存成功”。

3.4 XML加载:读取节点并分发给工厂创建图形对象

加载是保存的逆过程,但逻辑反而更需要谨慎。读取时不能假设文件里的每个shape节点都合法,type属性可能写错,属性可能缺失,坐标可能越界,这些都要靠工厂的返回值来过滤。

bool XmlSerializer::load(const QString& filePath, QList<Shape*>& outShapes, QSize& outCanvasSize) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { qWarning() << "加载失败,无法打开文件"; return false; } QXmlStreamReader xml(&file); outShapes.clear(); while (!xml.atEnd()) { xml.readNext(); if (xml.isStartElement() && xml.name() == QLatin1String("shape")) { QVariantMap map; for (const QXmlStreamAttribute& attr : xml.attributes()) { map.insert(attr.name().toString(), attr.value().toString()); } Shape* shape = ShapeFactory::createFromVariant(map); if (shape) { outShapes.append(shape); } else { qWarning() << "未知图形类型,已跳过:" << map.value("type").toString(); } } } return !xml.hasError(); }

加载过程中最核心的转发逻辑是ShapeFactory::createFromVariant。它是一个静态工厂函数,内部用if-else链判断map里的type值,创建对应子类对象,然后调用对象的fromVariant填充属性。工厂函数的存在让XmlSerializer不需要知道任何具体图形子类,只依赖Shape基类指针,这保持了模块间的弱耦合。

XML加载还有一个细节:QXmlStreamReader直接读取attributes()时拿到的值是QString,并不会自动转换类型。坐标在文件里是字符串“120”,需要fromVariant里的toInt()完成转换。如果某个属性缺失,toIn()返回0,不会让程序崩溃,但我建议在工厂里对map.value(“type”)做一次isEmpty校验,能把错误文件拒之门外而不是静默画出一个莫名其妙的图形。

3.5 坐标与画布尺寸:保存的是逻辑坐标,不是窗口像素

画一个图形看起来只是存了两个坐标点,但如果在窗口缩放之后重新加载同一份XML,坐标对不上就是这类项目最常见的数据错乱。拆这个项目时我确认了它的坐标系约定:CanvasWidget内部保存的始终是与窗口尺寸无关的整数逻辑坐标,只在paintEvent里绘制时把逻辑坐标直接作为设备坐标使用,不做任何变换。

void CanvasWidget::paintEvent(QPaintEvent*) { QPainter painter(this); painter.fillRect(rect(), Qt::white); painter.setRenderHint(QPainter::Antialiasing, true); if (m_tempShape && m_drawing) { m_tempShape->draw(painter); } for (const Shape* shape : m_shapeList) { shape->draw(painter); } }

这样做的代价是窗口缩放后图形大小不会跟着缩放,但换来的是保存数据在不同机器上的确定性。窗口拉伸只影响显示视口,不影响文件内容,这个约定会让XML数据在任何环境下都能稳定复现,远比追求“图形随窗口等比缩放”来得省心。

如果确实需要缩放,我一般会在CanvasWidget外层做一步统一的缩放变换,比如painter.scale(k, k),但写到XML里的仍然是不含k的原始坐标。这样一条规则能避开大量坐标换算的问题:XML只存模型坐标,显示变换留在paintEvent里,两者不混在一起。

4. 常见问题与避坑:Qt版本差异、编码问题和几处隐藏雷点

项目跑通的路径不长,但开发者实际动手时容易在几个具体环节卡住。这一章把运行准备和踩坑记录放在一起,你按顺序看完基本能一次跑通。

4.1 环境准备与两种构建方式

环境要求很简单:Qt 5.12以上或Qt 6.x均可,编译器用MinGW或MSVC都行。工程源码同时提供了CMakeLists.txt,项目没有引入QtXml模块,所以不需要额外安装XML组件。如果你打算改用QDomDocument保存,才需要在CMake里增加find_package来引入XML库。

用CMake构建时,CMakeLists核心部分是这个样子:

cmake_minimum_required(VERSION 3.16) project(DrawToXml) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) # 如果本机Qt是5.x,上面一行改成 find_package(Qt5 COMPONENTS Widgets REQUIRED) qt_add_executable(DrawToXml main.cpp MainWindow.cpp CanvasWidget.cpp Shape.cpp LineShape.cpp RectShape.cpp EllipseShape.cpp TextShape.cpp XmlSerializer.cpp ) target_link_libraries(DrawToXml PRIVATE Qt6::Widgets)

qt_add_executable是Qt 6.0引入的封装函数,Qt 5时代更常见的是add_executable配合target_link_libraries。如果你的项目需要同时兼容两个大版本,可以在CMake里先判断Qt版本再做分支处理,也可以保持add_executable的基础写法,手动加AUTOMOC后直接链接Qt6::Widgets或Qt5::Widgets,两步都能编译通过。

构建成功之后,运行界面是一个顶部带工具栏的窗口。工具栏里有图形类型下拉框、保存按钮和加载按钮。下拉框切换图形类型,画布上按下左键拖拽出对应图形,松开后图形进入列表并固定在画布上。点击保存弹出文件对话框,选择路径后生成XML。点击加载则读取XML并清空画布重新绘制。

4.2 避坑记录:五个让新手翻车的高频问题

现象1:保存后再次加载,画布变成一片空白,控制台输出“未知图形类型,已跳过”。

原因是XML文件被手动编辑过或者保存端写入的type属性和工厂里的判断字符串不一致。我见过有人把type写成“rect “带了一个空格,读取时字符串匹配失败,整个节点被工厂丢弃。解决方法是工厂里对类型值做trimmed处理,并从保存端验证写入的type确实是工厂已注册的类型。

现象2:直接将包含“<”或“&”的文本保存后,加载报错“Premature end of document”。

原因是保存时用QString手动拼接了XML字符串,而不是走QXmlStreamWriter.writeAttribute。手拼字符串时“<”会把XML标签弄坏。解决方法是统一走writeAttribute自动转义,不要相信用户输入的内容是安全的。

现象3:在Qt 6环境编译时报错“no member named setCodec in QXmlStreamWriter”。

这是Qt 6移除setCodec接口导致的,它不再让你手动指定编码,默认强制使用UTF-8。解决方法是去掉setCodec调用,把源码文件本身保存为UTF-8编码,保证中文字符串没问题。我在macOS和Windows上都验证过,统一UTF-8是跨平台最稳妥的做法。

现象4:加载旧版本XML后坐标偏移,图形跑到画布左下角甚至不可见。

原因大多是画布尺寸和XML里保存的尺寸不一致,旧文件里画布是640乘480,当前窗口默认是960乘600。加载坐标本身没有错,但超出新画布可视范围。解决方法是加载成功后根据XML根节点里的width和height重置画布大小,或者加载前弹窗确认是否适应画布尺寸。

现象5:程序连续点击保存多次,第二次生成的XML内容与第一次完全相同,但文件大小变化了。

这不算Bug,而是QXmlStreamWriter.setAutoFormatting(true)会对缩进和换行做美化处理。两次绘制内容一致时XML内容应该一致;如果多次保存出现内容不同,多半是m_shapeList里混入了重复对象,可以在保存函数开头加一个shapes.size()日志确认输入数据是否正常。

5. 二次开发:加一个新图形类型并保证旧XML还能读

这个项目的扩展点很清晰——增加新图形类型。整体只需要碰三个文件:新建一个子类、在工厂里注册类型、在MainWindow工具栏里加一个选项。其余模块完全不用动,这个廉价扩展能力归功于toVariant设计。

第一步,新建PolygonShape继承Shape,实现draw、toVariant、fromVariant。多边形比矩形复杂一点,要存一个QPolygon的点列表。toVariant不能只存四个整数了,我会把点列表按“x,y;x,y;x,y”这种简单格式拼成字符串,放进QVariantMap,读取时再按分号拆回QPolygon。draw里调用painter.drawPolygon完成绘制。

第二步,在ShapeFactory::createFromVariant里增加一行,当type等于“polygon”时new一个PolygonShape并调用它的fromVariant。工厂有了新分支,XmlSerializer不需要任何改动,因为XML保存模块只认识QVariantMap和Shape基类指针。

第三步,MainWindow的工具栏下拉框增加一个“多边形”选项,并在切换槽里把对应的枚举值传给CanvasWidget。这一步是唯一的UI改动。这样一次新增图形功能的改动面就收敛在三个文件之内。

改造时还有一件值得做的事:在XML根节点里加入version字段。当前项目的version是1.0,当你以后给图形对象增加新属性时,旧文件里没有这些属性的值,fromVariant里可以给默认值兜底。我一般会在fromVariant里用map.value("roundRadius", 0)这种带默认参数的写法,让旧文件加载时不因缺字段而异常,再在加载完成后针对低版本文件做一次数据迁移更新。这样向后兼容的兼容性从“能读”提升到“能读且有兜底值”,二次开发的体验会好很多。

验证一个新图形类型是否合格的唯一标准,是走一遍“绘制—保存—加载—重绘”闭环。我每次新增类型后的第一步就是在画布上画一个对象,保存到临时文件,再用VSCode打开看XML内容是否合理,最后加载回来确认图形位置和样式都一致。这个闭环跑通了,扩展才算真正完成。

从那以后,我每次做涉及XML序列化的功能,都会先写一个最小读写闭环用例,再往工程里集成。一个只有几十行的读写用例能省掉后面调试的一整个下午。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进