我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Qt图形编辑器开发:QPainter绘制与QXmlStreamWriter持久化实战

Qt图形编辑器开发:QPainter绘制与QXmlStreamWriter持久化实战 简介一份面向Qt中高级开发者的绘图与XML序列化完整工程包覆盖QPainter自定义绘制、图形数据持久化及可视化编辑器场景适合快速上手与项目移植。工程中不仅涵盖点、线、曲线、矩形、椭圆、多边形等基本图元的绘制演示还使用QPainterPath构建复杂形状再利用QXmlStreamWriter自动格式化输出图形坐标、尺寸、颜色等属性并讲解QGraphicsScene/View与QGraphicsItem子类在可交互图形场景中的组织方式。压缩包共382个文件容量约2.88MB主要包含cpp/h源文件、pro/qrc工程配置、html帮助文档、png示意图及可执行程序并附带debug/release构建产物源码、文档与运行文件齐全。已有1454人学习浏览该资源其中整合的Qt PropertyBrowser属性面板组件可辅助搭建带属性编辑器的图形工具便于调整图元参数适合对照实践与二次开发。1. 从“图形画完就没了”说起一个能把画布落盘的Qt项目很多人在Qt里画个矩形、画个椭圆都挺顺可真到「把图形存下来、下次打开还能接着改」这一步就卡住了——直接截一张图片矢量信息全丢手拼XML字符串又栽在转义字符和路径编码上。这个项目补的正是这块短板用QPainter在画布上绘制矩形、椭圆、线段等常见图形再用QXmlStreamWriter把这些图形的类型、坐标、填充颜色、边框样式序列化保存为XML文件同时也实现了从XML读回、在画布上恢复并继续编辑的完整回路。它本质是一个小型图形编辑器的地基适合正在做绘图类桌面工具、或者想给白板/标注类软件加持久化能力的开发者用它做起点比自己从零搭要省掉大半天的功夫。2. 技术选型与架构QPainter绘图 QXmlStreamWriter落盘模块怎么切2.1 技术栈为什么是Qt Widgets而不是QML为什么XML不手拼字符串这个项目基于Qt Widgets框架。图形绘图部分依赖QPainter它负责把矩形、椭圆、线段画到QWidget画布上数据持久化部分用的是QXmlStreamWriter和QXmlStreamReader这两个类是Qt自带的流式XML读写器。选择Widgets而不是QML原因是QPainter和事件系统mousePressEvent、mouseMoveEvent在Widgets里更直接画布的坐标计算也一目了然适合做这种偏工具型的小应用QML虽然动画表现力强但在这类“精准坐标 文件序列化”场景里反而绕路。XML选型这里多说一句。现在很多新项目图省事会直接上JSON但JSON是键值配对图形的嵌套关系、属性分组都要自己在代码里“画”出来XML有根节点、子节点、属性三位一体的天然树形结构一个shape节点带上type、x、y、w、h属性和屏幕上的图形对象是一一对应的读回来时不需要额外的映射表。再加上Qt对QXmlStreamWriter做了完善的转义处理、、这些特殊字符会自动编码成amp;、lt;比手拼字符串省心得多——手拼字符串最大的坑是用户画了一个带的文本标签时生成的XML直接报废。2.2 模块划分模型、视图、序列化三层的边界整个项目代码按职责分成三层拿到源码后先按这个思路去读结构会非常清晰。第一层是模型层ShapeBase抽象基类加上RectShape、EllipseShape、LineShape等派生类。每个图形类负责两件事一是draw(QPainter)把自己画出来二是toXml(QXmlStreamWriter)把自己写进XML流。两层逻辑收在一个类里好处是新加一个图形时只需要写一个文件不用同时改绘制和序列化两处代码。第二层是视图层ShapeWidget继承自QWidget。它维护一个QListQSharedPointerShapeBase的容器所有已画好的图形都存在这个list里。鼠标按下、拖拽、释放三个事件完成图形的交互式创建paintEvent里遍历容器逐个调用draw()。这里有一个容易犯迷糊的点paintEvent里的画布坐标和鼠标事件里的坐标是同一套逻辑坐标只要不主动做缩放平移两者天然对齐。第三层是序列化层xmlhelper.cpp里的两个顶层函数负责把整个shape链表灌进XML文件、或者从XML文件恢复链表。它不关心具体图形内部的细节统一走toXml()/fromXml()的多态接口后续加图形也不用回头改辅助函数。三层边界切完之后哪怕把QPainter换成QGraphicsView也只是改视图层模型和序列化都不动。3. 核心模块拆解从ShapeBase到XML文件的完整链路3.1 图形模型一个抽象基类管理公共属性ShapeBase是整个程序的骨架。它把坐标、颜色、边框宽度这些所有图形共有的属性收拢在一起同时规定每个子类必须实现绘制和序列化两个接口。下面是基类头文件的核心部分// shapebase.h class ShapeBase { public: // 图形类型枚举XML里的 type 属性与之对应 enum class Type { Rect, Ellipse, Line }; ShapeBase(Type type, const QRectF rect); virtual ~ShapeBase() default; // 纯虚函数子类各自实现绘制与写XML virtual void draw(QPainter painter) const 0; virtual void toXml(QXmlStreamWriter writer) const 0; // 根据XML的type属性反序列化出具体图形对象 static QSharedPointerShapeBase fromXml(QXmlStreamReader reader); Type type() const { return m_type; } QRectF rect() const { return m_rect; } void setRect(const QRectF rect) { m_rect rect; } protected: Type m_type; QRectF m_rect; // 图形的外接矩形 QColor m_fillColor; // 填充颜色 QColor m_borderColor; // 边框颜色 int m_borderWidth 2; // 边框粗细 };这段代码做的事情很简单把图形的公共属性集中在基类里派生类只负责“与众不同”的部分。m_rect用的是QRectF而不是四个float好处是后续做拖拽缩放时QRectF自带的normalized()方法能自动修正反向拖拽产生的负宽度少写一段容易出错的边界判断。fromXml()做成静态工厂函数内部根据type枚举逐一分发构造。以矩形为例看一个具体子类的实现// rectshape.cpp void RectShape::draw(QPainter painter) const { painter.save(); QPen pen(m_borderColor, m_borderWidth); painter.setPen(pen); painter.setBrush(m_fillColor); painter.drawRect(m_rect); painter.restore(); } void RectShape::toXml(QXmlStreamWriter writer) const { writer.writeStartElement(shape); writer.writeAttribute(type, rect); writer.writeAttribute(x, QString::number(m_rect.x())); writer.writeAttribute(y, QString::number(m_rect.y())); writer.writeAttribute(w, QString::number(m_rect.width())); writer.writeAttribute(h, QString::number(m_rect.height())); writer.writeAttribute(fill, m_fillColor.name()); writer.writeAttribute(border, m_borderColor.name()); writer.writeAttribute(borderWidth, QString::number(m_borderWidth)); writer.writeEndElement(); }painter.save()和painter.restore()包裹整个绘制过程确保每个图形不会把画笔状态污染给下一个图形——如果你见过相邻两个图形颜色串了多半是少了这两行。toXml()里每个writeAttribute()都对应XML中的typerect这种键值对。颜色用QColor::name()输出的是#rrggbb十六进制格式XML属性值里#是合法字符不需要转义但它不能作为文件名开头使用这个细节在保存文件时要注意。3.2 交互绘图鼠标拖拽创建图形的完整流程ShapeWidget是交互核心。下面这段是鼠标三个事件加上paintEvent的组合实现“按下定起点、拖动出大小、松开落定”的标准画布交互// shapewidget.cpp void ShapeWidget::mousePressEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) return; m_pressed true; // Qt 6 用 position()Qt 5 用 pos()下方避坑章会细说 m_startPoint event-position().toPoint(); m_currentRect QRectF(m_startPoint, m_startPoint); } void ShapeWidget::mouseMoveEvent(QMouseEvent *event) { if (!m_pressed) return; // normalized() 自动处理向右下方拖拽变成负宽高的问题 m_currentRect QRectF(m_startPoint, event-position().toPoint()).normalized(); update(); } void ShapeWidget::mouseReleaseEvent(QMouseEvent *event) { if (!m_pressed) return; m_pressed false; if (m_currentRect.width() 3 || m_currentRect.height() 3) return; // 小于3像素视为误触不生成图形 QSharedPointerShapeBase shape; switch (m_shapeType) { case ShapeBase::Type::Rect: shape.reset(new RectShape(m_currentRect)); break; case ShapeBase::Type::Ellipse: shape.reset(new EllipseShape(m_currentRect)); break; case ShapeBase::Type::Line: shape.reset(new LineShape(m_currentRect)); break; } m_shapes.append(shape); update(); } void ShapeWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); for (const auto shape : m_shapes) shape-draw(painter); if (m_pressed) m_currentShapePreview-draw(painter); // 拖拽过程中的预览帧 }交互链路里三个点值得记一下。一是m_pressed布尔标志防止鼠标移出窗口后还继续画图二是normalized()它把用户从右下往左上拖拽时产生的QRectF(m_startPoint, 终点)自动纠正成宽度高度都为正数的矩形否则矩形会消失或者填充方向错乱三是paintEvent里只做“遍历绘制”不创建任何对象所有状态变更都发生在事件函数里绘制结束后调用update()触发下一次重绘。如果直接在paintEvent里改数据会造成递归调用和无意义的重复绘制。3.3 序列化一张画布如何变成一份XML文件保存函数要以整个shape链表为单位它挂在xmlhelper.cpp里// xmlhelper.cpp bool saveShapesToXml(const QString filePath, const QListQSharedPointerShapeBase shapes) { QFile file(filePath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) return false; QXmlStreamWriter writer(file); writer.setAutoFormatting(true); writer.writeStartDocument(); writer.writeStartElement(canvas); writer.writeAttribute(width, QString::number(kCanvasWidth)); writer.writeAttribute(height, QString::number(kCanvasHeight)); for (const auto shape : shapes) shape-toXml(writer); writer.writeEndElement(); writer.writeEndDocument(); file.close(); return true; }这里的关键在setAutoFormatting(true)它会自动输出换行和缩进生成的XML文件直接用记事本打开就能人工核对手工输入的数据。画布尺寸写在根节点canvas的属性里加载时可以据此重建窗口大小。每个shape对象通过虚函数toXml()写出自己的子节点保存逻辑对图形类型完全无感知。加载函数则是反方向走一遍。QXmlStreamReader按事件流逐个读取节点遇到shape开标签时读取属性并调用工厂函数构造图形QListQSharedPointerShapeBase loadShapesFromXml(const QString filePath) { QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; QXmlStreamReader reader(file); QListQSharedPointerShapeBase shapes; while (!reader.atEnd()) { reader.readNext(); if (reader.isStartElement() reader.name() QLatin1String(shape)) { QXmlStreamAttributes attrs reader.attributes(); QString typeStr attrs.value(type).toString(); QRectF rect(attrs.value(x).toDouble(), attrs.value(y).toDouble(), attrs.value(w).toDouble(), attrs.value(h).toDouble()); // 根据type分发构造不同子类再反过来把属性赋回对象 auto shape ShapeBase::fromXml(reader, rect); if (shape) shapes.append(shape); } } if (reader.hasError()) qWarning() XML解析错误: reader.errorString(); return shapes; }这段代码体现了流式解析的一个好处边读边构造不需要把整个XML文档树加载进内存。2万个图形的文件解析也是线性时间。从XML里读回来只有两种情况会失败一是文件本身不是合法XMLreader.hasError()会捕获这类问题并把错误字符串打印出来二是某个必需属性缺失attrs.value(x).toDouble()会返回0.0而不是报错这类静默错误需要结合后面的坐标合法性校验来兜底。4. 部署与运行从.pro文件到完整画图流程4.1 工程结构与构建配置拿到源码之后先看目录整个工程的类划分一目了然shape_editor/ ├── shape_editor.pro ├── main.cpp ├── shapebase.h ├── rectshape.h / rectshape.cpp ├── ellipseshape.h / ellipseshape.cpp ├── lineshape.h / lineshape.cpp ├── shapewidget.h / shapewidget.cpp └── xmlhelper.h / xmlhelper.cpp这是典型的Qt Widgets工程布局用qmake管理构建。.pro文件内容如下QT core gui widgets TARGET shape_editor TEMPLATE app CONFIG c17 SOURCES \ main.cpp \ shapewidget.cpp \ rectshape.cpp \ ellipseshape.cpp \ lineshape.cpp \ xmlhelper.cpp HEADERS \ shapebase.h \ shapewidget.h \ rectshape.h \ ellipseshape.h \ lineshape.h \ xmlhelper.hmain.cpp里做的是最基础的QApplication初始化和主窗口装配窗口对象就是ShapeWidget固定为1200×800的尺寸。没有复杂的依赖只要有Qt 5.15或Qt 6.x的开发环境直接打开.pro文件就能用Qt Creator构建运行。4.2 从画图到落盘的完整工作流跑起来之后按下面这条链路走一遍就能确认程序的核心功能正常第一步在画布上按住鼠标左键拖出一个矩形拖拽过程中能看到预览边框松开后图形以填充色固定显示。第二步切换右侧的工具栏下拉框选择椭圆或者线段重复一次拖拽观察不同图形类型共存于画布上的效果。第三步点击工具栏上的“保存XML”按钮在文件对话框里输入canvas_test.xml保存到任意路径。第四步打开这个文件你会看到类似下面的内容?xml version1.0 encodingUTF-8? canvas width1200 height800 shape typerect x88 y104 w320 h240 fill#ffd700 border#333333 borderWidth2/ /canvas第五步点击“加载XML”选择刚才保存的文件画布会恢复出原来的图形。这里要特别说明加载是“恢复”而不是“追加”——程序会先清空当前所有图形容器再加载文件里的数据这个逻辑和大多数编辑器保持一致。沿这条链路走完你已经验证了项目的核心价值图形能画、能存、能读、能改。如果加载后图形位置有偏差优先检查坐标系和canvas宽高属性是否匹配。5. 避坑手册绘图与XML互转的五个常见翻车点5.1 带和的文本属性直接撑爆XML现象程序画了个带文本标注的图形text属性里写的是QA保存后XML文件双击打开报错“格式错误”。原因手拼XML字符串的旧习惯把直接拼进去了解析器读到会尝试识别实体引用遇到a这种非法实体直接报错。这项目虽然用了QXmlStreamWriter自动转义但如果有人图省事在读取端用了QString::replace()手工拼XML就会复现这个经典故障。解决写入端永远走writeAttribute()它是安全的读取端拿到的是已经反转义的值不需要手工再处理一遍。如果确实有继承来的旧代码在拼字符串把QString里所有替换成amp;、替换成lt;、替换成gt;顺序不能反否则会把amp;的再转一次。5.2 float精度丢失坐标对不上原来画的位置现象保存一个x89.678的图形加载回来变成x89.67图形整体偏移了几个像素。多次保存、加载循环几次后图形明显偏离原始位置。原因QString::number(double)默认保留6位有效数字QRectF内部是double精度但写成XML时被截断了。图形数量多、坐标值大时截断误差逐次累积几次来回就肉眼可见。解决保存坐标时用QString::number(value, f, 2)把精度固定到小数点后两位对屏幕坐标系来说0.01像素的误差完全无感。载入侧不需要改动还是toDouble()。如果做的是CAD类工具对精度要求到微米级那就要用g, 12以上的精度并且考虑存原始double的十六进制位模式。5.3 小方块变成一条线normalized()没调用现象从右下往左上拖拽画矩形生成的图形宽度为负paintEvent里画出来是极窄的竖线或者干脆消失。原因QRectF允许负宽度QPainter会把负宽度的矩形绘制成一条线或者一个空区域。mouseRelease里直接用了QRectF(startPoint, endPoint)而没有纠正顺序。解决在mouseMoveEvent里构造矩形时调用.normalized()这个方法会返回一个坐标顺序正确的矩形宽度和高度恒为正。同样从XML加载时如果有人手工改过坐标使x右边界也要在构造QRectF后调用normalized再做一次兜底。5.4 repaint()在拖拽时疯狂闪屏现象拖动鼠标画图时画布闪烁得厉害快速拖动时还会偶发花屏残影。原因代码里在mouseMoveEvent中调用了repaint()而不是update()。repaint()会立即同步触发paintEvent阻塞事件循环直到绘制完成update()则会把重绘请求压到事件循环里合并处理。在拖拽这种高频场景下repaint的同步重绘会产生大量冗余绘制。解决一律使用update()。Qt已经帮我们做了脏矩形合并连续多个update在同一个事件循环里只会触发一次实际绘制。如果确实需要同步刷新比如操作前后紧连着一个耗时计算才考虑repaint但绝大多数交互场景没这个需求。5.5 Qt 5和Qt 6的坐标获取差异现象工程在Qt 5.15下编译通过换到Qt 6.4直接报错error: position is not a member of QMouseEvent。原因Qt 6把QMouseEvent的坐标接口从pos()改成了position()返回QPointFpos()被标记为deprecated。两代接口混用是切换套件时最常见的编译故障。解决用一个独立的兼容封装比如QPoint localPos event-position().toPoint();Qt 6编译通过如果是Qt 5工程要升级把event-pos()替换成event-position().toPoint()即可。这类问题在阅读老代码时尤其常见别直接复制粘贴到新套件里。6. 二次开发思路给图形系统加一个自定义多边形先不考虑改代码想一想“加一种新图形”到底要动几个文件。顺着现有代码的结构数新建PolygonShape类.h和.cpp、继承ShapeBase实现draw和toXml、在ShapeWidget的switch分支里加一个case、在工具栏下拉框里加选项、最后在ShapeBase::fromXml里补一个分发分支。总共五处改动模块之间环环相扣但没有重叠这已经是一个扩展性不错的基座。但如果你期望的是长期维护、让其他人也能低成本往里面加图形我会建议做两步收敛第一步把ShapeBase::fromXml里的枚举分发改成注册表机制——每个子类注册自己的type字符串和对应的工厂函数实现一个QMapQString, std::functionQSharedPointerShapeBase()新图形只需在静态初始化时向注册表登记自己fromXml自动找到对应的构造器不再要求修改基类代码。第二步把paintEvent里的“预览图形”改成统一走虚函数draw让预览用的虚线样式也由子类自行决定而不是在ShapeWidget里硬编码。从序列化角度看当前字段还给后续扩展留了口子。比如多边形需要一组顶点坐标而不是一个QRectF可以在toXml里写成子节点points每次writeStartElement一个点加载时循环读取即可。现有代码不会因为你加了PolygonShape而出现任何回归——这就是虚函数分层的价值。只不过要记住每个新字段都必须同时落实save侧和load侧否则就会出现“保存成功但加载后属性丢失”的静默问题。我吃过这类的亏从那以后每次给序列化层加字段都会强制走一遍完整闭环画一个、存一遍、读回来、对比属性四条链路全绿才收工。希望帮到你。本文还有配套的精品资源点击获取
返回列表