ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Qt的图形绘制与XML序列化完整实践:自定义图元、交互与跨平台打包

基于Qt的图形绘制与XML序列化完整实践:自定义图元、交互与跨平台打包 简介面向Qt初、中级开发者的绘图与XML序列化实战资源包聚焦如何在QPainter机制中绘制基本图形并通过QXmlStreamWriter等组件把图形属性写入结构化XML同时延伸讲解QGraphicsScene/QGraphicsView交互式图形视图的用法。压缩包共382个文件大小约2.88MB核心以cpp、h源码及配套pro工程文件为主辅以HTML文档、PNG截图、编译生成的obj、exe以及样式表、配置文件等方便对照代码理解编译依赖与运行效果。已有1452人学习下载。资源涵盖从基础Canvas绘制到属性浏览器扩展的多种示例适合希望系统掌握Qt绘图、图形对象建模与XML数据交换的开发者作为参考工程从中提取可复用的绘图框架与序列化思路。 我最近把以前做的一个工具重构了一遍用Qt绘制矩形、圆、折线、曲线这些基础图形还能直接在画布上拖拽、缩放、删除最后把整个画布连同所有图元一起保存成XML文件。这个项目看着不大但做下来涉及的技术点很散自定义QGraphicsItem、事件处理、序列化设计、跨平台打包每一步都有坑。这篇文章把我的完整设计思路和实操过程整理出来代码片段可以直接抄适合正在用Qt做图形编辑类工具、或者想把界面图形数据落盘存储的朋友参考。1. 项目整体设计与技术选型拆解1.1 为什么选Qt做图形绘制做图形绘制这一块可选方案其实不少Windows上有GDI、WPF跨平台的有OpenGL、Skia还有Python那边的Tkinter、PyQt。但论开发效率和跨平台的平衡性Qt的QGraphicsView框架是真的很能打。它帮你把场景管理、图元选中、碰撞检测、视图变换这些底层的活全干了你只需要往场景里丢自定义Item剩下的事框架替你兜底。我这次的需求有明确特性图形种类会持续增加、图元需要支持交互编辑、最终要导出为结构化文件。QGraphicsView的体系结构天然适合这种场景它内部是一个经典的Model-View架构QGraphicsScene管数据QGraphicsView管显示每个图元是一个QGraphicsItem对象。保存文件时我只需要遍历Scene里的Item列表按类型序列化加载文件时按XML节点类型创建对应Item再塞回Scene里。整个映射关系非常直接几乎没有多余代码。对比一下如果直接用QPainter自绘所有图元的坐标、状态、命中检测都要自己维护等于把QGraphicsView已经做好的事重新造一遍轮子没有必要。1.2 为什么用XML而不是JSON或自定义格式存储图形数据最朴素的做法是自定义文本格式或二进制格式。早期我确实用过简单的“每行一条记录”的txt方案后来项目一复杂立刻崩溃属性一变旧文件全废而且没有任何校验机制。二进制格式性能好但调试靠Hex编辑器痛苦指数拉满。XML在这里的核心优势是自描述性和兼容性。每个图元的类型、坐标、画笔颜色、线宽、填充色、旋转角度、z-order本身都是键值对结构XML的标签嵌套天然能把“多个图元 图元属性 画布属性”这种层级关系表达出来。另一个考虑是后续可能需要接入其他系统——比如EDA工具、组态软件、机器人仿真环境——这些行业里XML交互格式很常见导出XML能把协作成本降到最低。当然JSON也可以做但Qt对XML有一套成熟高效的流式读写接口QXmlStreamReader / QXmlStreamWriter而Qt的JSON支持虽然在嵌套图元结构时代码可读性反而不如XML直观。所以我最终选了XML并且为它设计了一套稳定的Schema。1.3 整体模块划分整个程序我拆成了三个核心模块图形编辑模块基于QGraphicsView/QGraphicsScene负责图元创建、选中、拖拽、缩放、删除。所有图元继承自同一个自定义基类。序列化模块负责XML的写入和读取。写入时遍历Scene读取时解析XML并重建Scene这个模块要和图形模块完全解耦不依赖具体图元实现。文件与配置模块负责文件对话框、最近打开列表、以及画布背景色、网格等全局参数的存储。模块之间通过接口通信编辑模块不知道XML长什么样序列化模块也不关心图形是怎么画出来的它只调用基类提供的虚函数。这样新增一种图元时只需要实现“绘制 写XML 读XML”三个方法其他代码完全不用动。2. 环境准备与基础绘图实现2.1 开发环境选择与安装避坑我用的是Qt 5.15.2 MSVC2019 64位 Qt Creator。选5.15.2的原因很现实它是最后一个提供离线安装包的LTS版本之后的Qt 6虽然更现代但很多第三方库还在跟进中而且6.x版本默认不提供离线包对国内网络环境不友好。安装时有几个细节要特别说组件选择我勾了MSVC 2019 64-bit、Qt Charts后面做曲线图用、Qt Debug Symbols和Sources。如果只做基础图形前两个够用。必须同时安装对应版本的Visual Studio Build Tools否则MSVC套件编译会报找不到编译器。我见过太多新手卡在这里装完Qt之后编译一直报“No suitable kit found”其实就是没装VS或没装Windows SDK。镜像源问题Qt官方下载站速度不稳定可以用国内镜像具体地址就不写了搜索“QT镜像”第一个就是下载时注意校验SHA哈希。安装完成之后建议先随便创建一个QWidgets工程跑一遍确认Kit套件正常。遇到“no qt platform plugin could be initialized”这种报错大概率是platforms插件没找到后面第4章我会细说。2.2 自定义图元基类的设计所有可绘制图形我统一继承自QGraphicsObject而不是直接继承QGraphicsItem原因是QGraphicsObject支持信号槽和属性系统后续做图元属性面板会方便很多。基类核心定义如下class ShapeBaseItem : public QGraphicsObject { Q_OBJECT public: enum ShapeType { ShapeNone, ShapeRect, ShapeEllipse, ShapePolyline, ShapeCurve }; explicit ShapeBaseItem(QGraphicsItem *parent nullptr); // 序列化接口所有子类必须实现 virtual void writeToXml(QXmlStreamWriter writer) const 0; virtual void readFromXml(QXmlStreamReader reader) 0; // 返回图元类型 virtual ShapeType shapeType() const 0; // 图元选中时显示控制点 void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; QRectF boundingRect() const override; protected: QString m_id; // 全局唯一ID QColor m_penColor; // 画笔颜色 qreal m_penWidth; // 线宽 QColor m_brushColor; // 填充色 qreal m_rotationAngle; // 旋转角度 };paint函数里有个很实用的技巧通过option-state判断图元当前是否被选中选中时额外绘制一圈虚线边框和方块控制点交互反馈一目了然。void ShapeBaseItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(widget) const bool isSelected option-state QStyle::State_Selected; if (isSelected) { painter-setPen(QPen(Qt::gray, 1, Qt::DashLine)); painter-setBrush(Qt::NoBrush); painter-drawRect(boundingRect()); } }2.3 具体图形矩形、椭圆、折线、曲线矩形和椭圆是最简单的核心就是在paint里用drawRect和drawEllipse在boundingRect里考虑线宽留边。注意一个细节boundingRect要适当放大线宽的一半否则图元画出来会被View裁剪掉一部分边缘尤其是线宽较大的时候。折线的实现稍微复杂因为要支持鼠标连续点击加点。我在鼠标双击时结束折线录入并把折线的所有顶点存到QVectorQPointF里。绘制折线时用drawPolyline但有个坑折线不做闭合所以不要调用drawPolygon否则会默认连回起点。void PolylineItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option) Q_UNUSED(widget) painter-setPen(QPen(m_penColor, m_penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); painter-setBrush(Qt::NoBrush); if (m_points.size() 2) { painter-drawPolyline(m_points.constData(), m_points.size()); } }曲线我这里做的是平滑曲线也就是把鼠标轨迹上的采样点经过Catmull-Rom样条插值后绘制成路径。原理不复杂Catmull-Rom样条经过所有控制点和Bézier不一样适合“用户自由勾画”的场景。插值之后保存时要注意XML里保存的是原始采样点而不是插值后的路径因为插值算法在加载时可以重新计算这样文件体积更小而且修改一个顶点就能重新生成曲线。2.4 绘图交互鼠标事件与视图缩放QGraphicsView默认自带拖拽和滚轮缩放但默认行为不太满足需求我做了两处定制滚轮以鼠标光标为中心缩放而不是以视图中心缩放。实现方式是拿到滚轮事件的光标位置映射到场景坐标然后scale前后保持这个场景点不变。按住空格键 鼠标左键拖拽平移画布类似PS的手抓工具比拖动滚动条效率高得多。图元的创建方式我是这样实现的在View上点击工具栏的“矩形”按钮进入“绘制模式”。此时鼠标点击和拖拽不再操控视图而是创建一个临时的RubberBand风格图元等到鼠标释放时正式把图元添加到Scene里。这个模式用一个枚举变量m_currentMode控制在mousePressEvent、mouseMoveEvent、mouseReleaseEvent里分别判断。void GraphicsView::mousePressEvent(QMouseEvent *event) { if (m_currentMode DrawRect) { m_originPoint mapToScene(event-pos()); m_tempRect new RectItem(); m_tempRect-setPenColor(currentColor()); // 只添加到场景但不设置为可编辑 scene()-addItem(m_tempRect); return; } QGraphicsView::mousePressEvent(event); }还要提一个很多教程没人讲的细节绘制模式下要暂时禁用Scene的选择状态否则你每次点击都会同时触发图元选中。我的做法是在进入绘制模式时代码里先把scene()-clearSelection()然后设置视图的dragMode为NoDrag避免拖拽和绘制手势冲突。3. XML序列化与反序列化实操3.1 XML结构设计这是我整个项目里最核心的一部分XML结构设计得好后面扩展图元类型时能省很多事。我最终采用的XML结构如下?xml version1.0 encodingUTF-8? Canvas version1.0 width1280 height720 bgColor#ffffff Rect id1001 x50 y80 width200 height140 penColor#ff0000 penWidth2 brushColor#f0f0f0 rotation0.0 zValue0/ Ellipse id1002 cx400 cy200 rx120 ry80 penColor#0000ff penWidth1 brushColor#ffe0e0 rotation15.0 zValue1/ Polyline id1003 points10,10 40,50 100,30 160,90 penColor#00aa00 penWidth3 rotation0.0 zValue2/ Curve id1004 sampleRate100 color#444444 width2 data0.0,0.1,0.15,0.32,0.5,0.68,0.75,0.61,0.42,0.2,0.0/ /Canvas设计时我定了几条原则根节点Canvas保存画布的宽高和背景色version属性做版本兼容后续如果增加字段老文件升级时有据可依。每个图元一个标签标签名就是图元类型解析时直接看元素名就能判断不需要额外的type属性。几何属性全部平铺在标签属性里不用子节点。x/y/w/h、points这种都是基础类型用属性最简洁。曲线的采样点数据量可能比较大放在属性里不合适所以在Curve内部用子节点data保存以逗号分隔字符串存储。折线点数一般不会太多直接用points属性就够了。3.2 用QXmlStreamWriter保存文件Qt的XML序列化我用的是流式接口QXmlStreamWriter不推荐QDomDocument。原因QDomDocument会把整棵树加载到内存里图元一多内存占用很离谱QXmlStreamWriter是推模式边写边落盘几千个图元毫无压力。写入的代码骨架bool saveCanvasToXml(const QString filePath, QGraphicsScene *scene, const QSize canvasSize, const QColor bgColor) { QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; QXmlStreamWriter writer(file); writer.setAutoFormatting(true); writer.setAutoFormattingIndent(2); writer.writeStartDocument(); writer.writeStartElement(Canvas); writer.writeAttribute(version, 1.0); writer.writeAttribute(width, QString::number(canvasSize.width())); writer.writeAttribute(height, QString::number(canvasSize.height())); writer.writeAttribute(bgColor, bgColor.name()); const QListQGraphicsItem * items scene-items(); for (QGraphicsItem *item : items) { ShapeBaseItem *shapeItem dynamic_castShapeBaseItem *(item); if (shapeItem) shapeItem-writeToXml(writer); // 多态调用具体图元的写XML } writer.writeEndElement(); writer.writeEndDocument(); file.close(); return true; }这里有个非常重要的点scene-items()返回的列表是按z-value从高到低排列的默认上层图元排在前面。如果直接按这个顺序写XML加载时会因为插入顺序不同导致图元叠放层次错乱。解决方式可以分为两种写入时忽略顺序读取时严格按照zValue属性调用setZValue恢复层级。写入前先按zValue排序再遍历。我采用第一种方案因为zValue本身已经在XML里了读回来时逐个设回去即可且不会破坏原有层级结构。另一个细节颜色属性不要直接写QColor::name()因为name()只返回RGB形式不支持alpha通道。如果图形有半透明填充保存后再读回来颜色就变了。正确做法是把rgba()拆成整数写入或者用QColor::name(QColor::HexArgb)后者在Qt 5.15里可以直接用。3.3 用QXmlStreamReader加载文件加载是保存的逆过程但写法上有个反直觉的地方QXmlStreamReader是拉模式需要手动维护“当前读到什么阶段”这个状态。粗暴的做法是用readNextStartElement()循环判断但嵌套多的时候代码会乱成一锅粥。我的做法是按元素名分发bool loadCanvasFromXml(const QString filePath, QGraphicsScene *scene, QSize canvasSize, QColor bgColor) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) return false; QXmlStreamReader reader(file); while (!reader.atEnd()) { QXmlStreamReader::TokenType token reader.readNext(); if (token ! QXmlStreamReader::StartElement) continue; const QString elementName reader.name().toString(); if (elementName Canvas) { canvasSize QSize(reader.attributes().value(width).toInt(), reader.attributes().value(height).toInt()); bgColor QColor(reader.attributes().value(bgColor).toString()); } else if (elementName Rect) { RectItem *rect new RectItem(); rect-readFromXml(reader); scene-addItem(rect); } else if (elementName Ellipse) { // ... 类似 } } file.close(); return !reader.hasError(); }这样写有一个小问题readFromXml内部读的是reader当前位置的属性但外部循环也在用同一个reader。如果图元内部没有子节点这没问题但如果图元有子节点比如Curve的data读取完子节点后外部循环会继续读到子节点的结束标签需要额外处理。解决方法是让每个图元的readFromXml函数自己负责把整个StartElement到EndElement之间的内容消费完。函数里判断到EndElement并且名字匹配当前图元类型时return外部循环保持不动。这需要一定的自控力别在readFromXml里多读或少读否则解析就串烟了。3.4 新增图元类型时的扩展流程这个项目被我反复扩展过现在回看整个设计最舒服的一点就是加新图形特别顺。举个例子假设要新增一个“三角形”新建TriangleItem : public ShapeBaseItem。paint里用drawPolygon绘制三个顶点。实现writeToXml把三个顶点坐标写到points属性。实现readFromXml从属性里读出坐标。在保存函数旁的分发函数里加一个else if (elementName Triangle)。全部改动加起来不到50行。如果遇到需要自定义序列化的复杂图元就在它的writeToXml/readFromXml内部自由发挥不影响其他图元。这也是我为什么坚持“图元自己管自己序列化”而不是在主保存函数里写一个巨大的switch。4. 常见问题与排查技巧实录4.1 “no qt platform plugin could be initialized”报错这个报错我几乎每隔一段时间就会见到一次尤其是从Qt Creator里跑得好好的但把程序打包或者换台电脑运行时突然就发生了。原因只有一个程序在运行时没有找到对应平台的插件。Qt的窗口系统是基于插件的Windows下需要platforms/qwindows.dll这个插件如果目录结构不对或者DLL缺失Qt就不知道要往哪个平台创建窗口。解决办法很简单把文件放到和exe同级的platforms目录下。用官方的windeployqt工具自动拷贝依赖。命令行执行windeployqt 你的程序.exe它会自动分析依赖并把这些DLL和插件都拷过来。如果还是不行检查环境变量QT_QPA_PLATFORM_PLUGIN_PATH是否指向了错误的目录手动指定platforms所在路径也能临时解决。我还踩过一个更隐蔽的坑同一个目录下存在两个不同版本的Qt运行时比如Debug版和Release版的DLL串了windeployqt拷贝时不会覆盖已有文件导致插件和主程序版本不匹配。打包之前建议先清空输出目录再执行备份部署。4.2 使用QCustomPlot做时域转频域后如何导出XML很多做数据采集的朋友会结合QCustomPlot画时域波形然后做FFT转频域图。但QCustomPlot本身不提供文件保存功能它只是一个绘图控件内部维护的是数据向量QVectordoublekey和value。如果要把这类曲线也纳入我的XML存档系统处理方式有两种方法一在QCustomPlot的绘图回调里把数据点取出来构造一个CurveItem然后走统一的XML序列化流程。方法二直接把数据向量独立存成XML不经过图形层。比如保存时把采样率、FFT点数、频率数组、幅值数组写到XML里读取时再交给QCustomPlot重新绘图。我推荐方法二因为频域图的数据点是计算出来的不是用户手工画的重新计算比存储几千个点更可靠。QCustomPlot的FFT功能底层用的是kissfft库存数据时记得把原始时域采样数据也要存一下否则以后想换窗函数或者改FFT点数都没法重算。4.3 XML解析时报schema violation错误使用中常见的XML解析报错主要来自两处一是读取外部生成的XML文件二是自己写XML时把格式写错。我自己踩得最多的坑是属性值里的特殊字符。比如折线点的坐标字符串里如果包含空格或者分号没问题但如果你把描述信息放进了某个属性里面包含了、、引号等字符写XML时就必须转义。QXmlStreamWriter会自动帮你转义属性值但如果你是自己拼字符串构造XML很容易漏。另一个常见问题是编码声明不匹配。XML文件头写了encodingUTF-8但实际文件里保存的是GBK或者其他本地编码解析器直接报错。用QXmlStreamReader读文件时建议统一用UTF-8编码读写这样不存在歧义。遇到第三方XML文件解析失败时我一般会先打开文件看一遍用文本编辑器看是否有乱码再检查标签是否闭合、属性是否用到中文引号。90%的“XML parse error”都是这种低级问题。4.4 自定义图元保存后叠放顺序错乱这个问题最开始我没注意到直到用户反馈“我画的图形保存后再打开上面的图跑到下面去了”。原因前面说过scene-items()的返回顺序和zValue有关默认是从上到下。而新建的图元如果zValue相同Qt会用插入顺序决定显示层级。解决方案有两种写入XML时遍历前先把所有图元按zValue升序排序这样加载时先插入低层图元再插入高层图元场景的显示顺序自然就对了。读取XML时对每个图元调用setZValue(zValue)显式恢复层级。如果两个图元的zValue相同还得保证它们之间的相对顺序这种场景就要在XML里额外保存一个插入序号比如order属性。我最终的做法是两者都用写入时排序读取时设zValue之外再维护一个从0递增的zOrder序号彻底杜绝顺序错乱。这个方法在有大量图元、多个图元重叠时非常重要。4.5 打包发布后程序启动极慢或闪退发布时我遇到过一个问题程序在开发机上运行良好打包后换到客户机器上点击启动没什么反应过了几秒才闪退。后来排查发现是缺少OpenGL驱动导致的因为Qt的部分图形渲染后端依赖OpenGL。解决方案有两个方向代码层面QApplication::setAttribute(Qt::AA_UseSoftwareOpenGL)强制使用软件渲染。对图形量不大的应用性能几乎没有影响但兼容性大幅提升。部署层面确保目标机器有较新的显卡驱动或者把Qt的opengl32sw.dll软件OpenGL实现一起打包。我建议在发布版本的main函数里就设置软件OpenGL省事且稳定。如果是麒麟这类国产系统上部署这块尤其重要很多环境默认没有可用的硬件OpenGL。5. 项目扩展与个人经验做完这个基础版后续我给它加了几个实用功能顺手说一下可以怎么扩展图元属性面板通过选中图元发射信号在右侧面板显示当前图元的颜色、线宽、坐标修改后实时刷新。因为基类是QGraphicsObject属性系统天然支持再配合自定义属性面板非常方便。撤销重做Qt的QUndoStack是一个被低估的框架把“添加图元”“删除图元”“移动图元”“编辑属性”各封装成一个QUndoCommand可以快速实现完整的撤销栈。这个功能建议从一开始就设计进去项目后期再补会比较痛苦。XML Schema校验如果要和外部系统对接建议写一套XSD文件加载XML时先做Schema校验避免非法文件导致程序崩溃。Qt没有现成的XSD校验类可以借助第三方库或者集成libxml的校验功能。最后分享两个我实际踩出来的经验一是保存文件时一定要做原子写入。先写到临时文件写完再QFile::rename覆盖原文件这样即使程序在保存过程中崩溃原文件也不会损坏。这个习惯帮我避免过不止一次“保存到一半断电导致整个文件报废”的悲剧。二是提交代码前把XML文件的缩进格式规范好。setAutoFormatting(true)配合固定缩进Git diff看起来会干净很多Review代码时只需关注真正的改动。如果两个开发者的Qt版本缩进设置不一样git blame会变成一场灾难。这个项目本身不算难但麻雀虽小五脏俱全图形编辑、序列化、跨平台、打包部署都有了。如果你正准备做一个类似的工具建议先把XML结构设计想清楚再开始写图形代码——数据结构先定后面所有流程都会顺很多。本文还有配套的精品资源点击获取
返回列表