
简介面向Qt Quick开发者的自定义绘图示例工程以QQuickPaintedItem为核心系统演示了如何在QML中绘制圆形、矩形、多边形及带刻度的直尺并通过可拖拽的控制点实时编辑路径形状适用于需要构建图形编辑器或自绘组件的Qt项目。工程基于QPainter与QPainterPath实现底层绘图涵盖路径moveTo/lineTo拼接、closeSubpath封闭图形、主次刻度绘制等典型操作直接回应了自定义绘图组件中“绘制—交互—刷新”的核心问题。包体共27个文件包含8个cpp源文件、7个h头文件、3个qml界面文件、5张png效果图以及qrc资源、pro/pri工程配置等整体大小仅42KB结构清晰便于对照学习与二次开发。目前已有854人学习下载代码针对圆形、矩形、多边形、直尺分别封装了独立绘图模块并给出控制点与鼠标事件结合来修改路径的实现思路通过阅读源码可掌握paint()重写、QPainterPath动态构建及item.update()重绘机制为后续扩展旋转、缩放等复杂交互功能打下扎实基础。 搞 QML 图形工具的人应该都有过这种困扰画一个静态的圆形、矩形代码写起来很简单但“画完之后还能继续改”就是另一回事了。我这段时间维护的一个 QML 工程核心需求就是把圆形、矩形、多边形和直尺这四类图形的绘制做出来并且在图形生成之后通过控制点直接改变图形的几何路径。说白了这就是一个轻量级的标注/测量编辑器核心体验就是“画得出来也改得动”。这个实现思路适用面挺广比如白板应用里的便签和批注、简易 CAD 工具的图形装配、数据可视化里的标绘组件、地图路径绘制里的轨迹点调整都能用同一种方案去套。整套东西不依赖第三方库纯 QML Canvas 完成逻辑压缩在几个文件里哪怕你只是刚接触 QML 不久跟着思路走一遍也能做出自己的可编辑绘图组件。1. 项目思路与整体架构1.1 这个项目到底在做什么先说清楚最终效果界面上有一块空白画布用户可以选择绘制圆形、矩形、多边形或者直尺。画完之后图形边界上会出现若干个小方块这些小方块就是控制点。拖动控制点图形的形状跟着变化——圆形改半径、矩形改长宽、多边形改某个顶点位置、直尺改长度和角度。这个需求看起来基础但真正操作过就知道难点不在“画”而在“改”。所谓改本质上是把用户拖拽的屏幕坐标映射回图形的几何数据里然后让画布重绘。整个过程涉及三块内容几何数据模型、Canvas 绘制、控制点交互。这三块相互独立又彼此关联我个人建议一开始就把它们拆开设计不要在绘制函数里写交互逻辑也不要在 MouseArea 的 onPositionChanged 里临时算图形数据否则后期加一个新图形会非常痛苦。1.2 为什么用 Canvas 而不是 ShapeQML 里画图其实有两条常见路线一是 Qt Quick 提供的 Shape 组件ShapePath二是 Canvas 2D 绘图。很多初学者会默认选 Shape因为它是声明式写法和 QML 风格一致。但我在这个项目里选择了 Canvas原因很实在Shape 每次改图形数据都要重新创建 ShapePath 的属性对象频繁拖拽控制点时这种重建存在一定属性绑定开销并且当控制点数量不固定比如多边形增删顶点时Shape 需要维护多个动态的 ShapePath 子组件代码会变得绕。Canvas 这边就简单得多。它本质上是一个过程式绘图环境我在 id 为 paintArea 的 Canvas 组件里用 onPaint 遍历一个形状数组循环生成每个图形的 Path最后统一 stroke 和 fill。拖拽控制点只是修改数组里某个坐标值然后调用paintArea.requestPaint()画布会在下一个帧周期自动重绘路径变化的效果是实时出来的。不需要考虑组件 diff也不需要管哪段 Path 该更新全部重绘在这个规模下性能完全够用。1.3 数据模型先行在这个工程里我把每个图形都定义成一个 JS 对象统一放进根节点的 shapes 数组里。对象结构大致是type: 图形类型标识比如 circle、rect、polygon、rulerpoints: 核心控制点的坐标数组统一用[{x, y}, {x, y}, …]表示顺序就是绘图时连线的顺序style: 线条颜色、宽度、填充色等绘制参数这里有两个设计要点要强调。第一所有图形都抽象成“控制点数组”。圆形虽然有圆心和半径两个概念但我也用两个点来表示一个点是圆心一个点是圆周上的半径点两点之间的距离就是半径。矩形则用对角两个点加外扩的控制点表示。这样统一之后控制点生成逻辑、拖拽逻辑、光标反馈逻辑都可以通用每个图形只需要提供“从 points 生成绘制路径”的函数即可。第二shapes 数组是唯一数据源。也就是说Canvas 绘制、控制点显示、属性面板显示都只认这个数组里的数据。控制点的位置更新时只改数组再由数组变化触发布局刷新和重绘。这种“单向数据流”的思维在 QML 这种声明式语言里非常关键可以有效避免画布上显示的内容和实际数据不一致。2. 四种图形的绘制实现2.1 圆形“半径点”比“半径数值”好用圆形绘制逻辑很多人第一反应是保存 radius 数值拖动时用滑块或者输入框改。但在画布场景里自然的交互是用鼠标拉出一个半径点这样用户能直观看到半径大小和方向。我的实现是这样的// 圆形绘制函数points 中有两个点 // points[0] 是圆心points[1] 是圆周上的点 function drawCircle(ctx, points, style) { var cx points[0].x; var cy points[0].y; var px points[1].x; var py points[1].y; var radius Math.sqrt(Math.pow(px - cx, 2) Math.pow(py - cy, 2)); ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2, false); ctx.strokeStyle style.color; ctx.lineWidth style.width; ctx.stroke(); // 画一条从圆心到半径点的辅助虚线方便用户理解圆心位置 ctx.beginPath(); ctx.setLineDash([4, 4]); ctx.moveTo(cx, cy); ctx.lineTo(px, py); ctx.strokeStyle #888888; ctx.stroke(); ctx.setLineDash([]); }拖动圆周控制点的时候只要更新 points[1] 为鼠标当前位置然后重新计算半径和重绘。这个方案的好处在于控制点本身也遵循“点坐标”的通用规则后续我可以把这第二个点改成椭圆的一个短轴控制点扩展性很好。2.2 矩形用两组对角点加中点控制点矩形最简单的方式是存两个对角点绘制时按顺序连成闭环。但为了让用户能调整某一条边而不是只能拉对角我在矩形的控制点方案里加了一些细节四个顶点控制整体大小分别对应 points[0] 到 points[3]四条边中点单独调整某条边的位置用 Canvas 画矩形时我会根据当前控制点顺序重新组装绘制路径。比如顶点顺序不定时先计算包围盒或者中心点然后按“左上、右上、右下、左下”的顺序生成一条闭合路径function drawRect(ctx, points, style) { if (points.length 5) { // 至少需要 4 个顶点 1 个边中点才能构成可绘制网格 return; } // 计算外接包围盒 var minX Math.min(points[0].x, points[2].x); var minY Math.min(points[0].y, points[2].y); var maxX Math.max(points[0].x, points[2].x); var maxY Math.max(points[0].y, points[2].y); ctx.beginPath(); ctx.rect(minX, minY, maxX - minX, maxY - minY); ctx.strokeStyle style.color; ctx.lineWidth style.width; ctx.stroke(); }第二种方式也就是存固定数量的点坐标在拖拽逻辑上更直接。每个控制点对应一个数据索引拖动索引为 1 的点时只需要将 points[1] 设为新坐标同时让相邻的两个角点跟随变化保证矩形边不会扭曲。实际项目里我用的是“四顶点固定存储 边中点作为计算点”的策略用户看到的控制点多但存储在 points 数组里的只有四个顶点。2.3 多边形顶点集合就是全部多边形是所有图形里最灵活、也最容易写乱的一个。它的 points 数量不固定拖动任何一个顶点都只改变那一个点的坐标。绘制函数反而是最简单的function drawPolygon(ctx, points, style) { if (points.length 3) { return; } var startPoint points[0]; ctx.beginPath(); ctx.moveTo(startPoint.x, startPoint.y); for (var i 1; i points.length; i) { ctx.lineTo(points[i].x, points[i].y); } ctx.closePath(); ctx.fillStyle style.fillColor || transparent; ctx.fill(); ctx.strokeStyle style.color; ctx.lineWidth style.width; ctx.stroke(); }值得注意的一点是新增顶点的交互。我是在鼠标双击编辑状态时顺着最近一条边的方向插入一个新点。相比起“在两个顶点间中点位置加入一个点”沿边方向插入会更符合用户预期因为这样不会出现新点突然弹到边外的情况。当然这是细节层面的事如果只是做基础版本也可以先不做插入功能。2.4 直尺看起来简单处理刻度要小心直尺和前面三种图形有一点本质差异它是一个纯辅助工具不需要填充也没有面积其核心价值是“测量距离”和“辅助对齐标线”。绘制数据也是两个端点但绘制函数需要额外计算刻度。刻度绘制的关键是要沿着两个端点的方向向量走不能简单地按横坐标分刻度线否则直尺旋转之后刻度线就不在尺子上。我的做法是这样function drawRuler(ctx, points, style) { var p1 points[0]; var p2 points[1]; // 方向向量 var dx p2.x - p1.x; var dy p2.y - p1.y; var length Math.sqrt(dx * dx dy * dy); if (length 1) { return; } var ux dx / length; // 单位方向向量 var uy dy / length; // 垂直于尺身的方向 var nx -uy; var ny ux; ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.strokeStyle style.color; ctx.lineWidth style.width; ctx.stroke(); // 每 10 像素画一个刻度 var tickCount Math.floor(length / 10); for (var i 0; i tickCount; i) { var t i * 10; var baseX p1.x ux * t; var baseY p1.y uy * t; // 长刻度每 50 像素短刻度 10 像素 var tickLen (i % 5 0) ? 12 : 6; ctx.beginPath(); ctx.moveTo(baseX, baseY); ctx.lineTo(baseX nx * tickLen, baseY ny * tickLen); ctx.strokeStyle #666666; ctx.lineWidth 1; ctx.stroke(); } }我之前踩过一个坑刻度线写成 x 方向垂直直尺水平时看着对一旦旋转 30 度刻度线整个歪掉测量值也没法读。所以刻度线一定要基于“法线方向”来画也就是垂直于尺身方向的向量 (nx, ny)。在代码里就是上面那两行var nx -uy; var ny ux;。3. 控制点系统交互的核心图形绘制只是基础真正让这个工程有实用价值的是控制点拖拽系统。它由三部分组成控制点的UI组件、图形数据更新方法、命中检测。3.1 控制点组件和命中优先级我把控制点做成一个独立的 QML 组件 ConrolPoint.qml本质是一个带 MouseArea 的小矩形Rectangle { id: controlPoint width: 14 height: 14 radius: 4 color: mouseArea.containsMouse ? #FF9800 : #33CCFF border.color: #FFFFFF border.width: 2 opacity: 0.95 property int shapeIndex: -1 property int pointIndex: -1 property bool selected: false signal pointMoved(int shapeIndex, int pointIndex, real newX, real newY) MouseArea { id: mouseArea anchors.fill: parent hoverEnabled: true drag.threshold: 0 cursorShape: Qt.SizeAllCursor // 绑定控制点位置到 shape points 数据 Connections { target: root function onShapesChanged() { var pts root.shapes[controlPoint.shapeIndex].points; controlPoint.x pts[controlPoint.pointIndex].x - controlPoint.width / 2; controlPoint.y pts[controlPoint.pointIndex].y - controlPoint.height / 2; } } onPositionChanged: function(mouse) { var newX mouse.x controlPoint.x; var newY mouse.y controlPoint.y; controlPoint.pointMoved(controlPoint.shapeIndex, controlPoint.pointIndex, newX, newY); } } }这里有个关键点控制点的 MouseArea 必须设置drag.threshold: 0。默认情况下 QML 的鼠标拖拽会有一个小阈值鼠标按下后要移动几个像素才触发 drag 事件这在画精度要求较高的标绘时不友好表现为拖灰点时图形不动。把 threshold 设为 0鼠标一按下去就能立刻响应位置变化。关于命中优先级也就是多个控制点重叠时谁响应我直接依赖 QML 的 z 序。控制点的父级是一个浮动在画布上的 overlay 层这个层 z 值比 Canvas 高很多可以保证它在 Canvas 上面。控制点互相之间的重叠鼠标点击在最上面的一个这跟控件顺序有关注意子项顺序即可。3.2 拖拽更新逻辑分类型处理控制点发出 pointMoved 信号后到底怎么更新哪类控制点我选择在根组件里写一个 updateShapePoint 函数集中处理。比如圆形控制点function onPointMoved(shapeIndex, pointIndex, newX, newY) { var shape shapes[shapeIndex]; var points shape.points; points[pointIndex].x newX; points[pointIndex].y newY; if (shape.type circle) { // 如果是圆心控制点半径点保持方向不变一般用户更习惯拖半径点 // 如果拖的是圆心那整个圆平移所以所有点一起平移 if (pointIndex 0) { var dx points[1].x - points[0].x; var dy points[1].y - points[0].y; points[0].x newX; points[0].y newY; points[1].x newX dx; points[1].y newY dy; } } else if (shape.type rect) { // 矩形如果拖动边中点同时要改动两个相邻顶点 // 这里需要维护矩形顶点顺序关系数据量小直接判断索引 } // 刷新控制点视觉位置 refreshControlPoints(); // 重绘画布 canvas.requestPaint(); }这个函数看着简单但实际上有几个雷第一不要在 onPositionChanged 里频繁创建新对象。如果你写var newPoint {x: newX, y: newY}; points[pointIndex] newPoint;每次鼠标移动都会触发 GC 压力拖久了会感觉越来越卡。直接points[pointIndex].x newX这种就地修改效率会好很多。第二圆心的拖拽逻辑一定要单独处理。很多新手会把圆形的两个控制点都当成“独立点”直接赋值结果拖圆心的时候圆的其他部分不动整个图就散了。我上面的处理是拖圆心时保持圆心到半径点的相对位移把整个圆平移过去。第三像矩形这种“多个控制点联动”的需求不要把所有状态写在 updateShapePoint 里。我是维护了一个映射表把矩形控制点的索引分为顶点类和边中点类边中点被拖动时自动计算相邻两个顶点的坐标变化。这样区分之后逻辑清晰也方便把同样的模式套用到六边形等其他规则图形上。3.3 控制点的显示与隐藏策略一般来说未选中的图形不显示控制点选中的图形才显示不然画面上全是小方块干扰视觉。我是在 overlay 层动态维护一个 ListModel 或者直接 Repeater 绑定选中哪一个 shape 就把该 shape 对应的控制点生成出来。由于刚才把控制点设计成独立组件这里只需要告诉它 shapeIndex 和 pointIndex 就能自行定位。还有一个小经验控制点最好比鼠标点击热区大一点。视觉上是 14x14 的小方块但 MouseArea 的响应区域可以扩到 20x20 甚至 24x24在密集标绘时抓取容易很多。代价是视觉上可能有一个“差一点就点上”的错觉所以我一般是视觉方块 14x14外层再包一个透明的 MouseArea 中心对齐把 hittest 范围扩大。4. 实操过程与关键代码4.1 工程目录规划我建议把代码拆成这几个文件结构清晰后续扩展也方便. ├── qmlproject │ ├── main.qml │ ├── ShapeCanvas.qml │ ├── ControlPoint.qml │ └── ShapeModel.jsShapeModel.js 里放形状的 CRC 函数创建、读取、更新坐标ShapeCanvas.qml 里放 Canvas 和 overlay 层。main.qml 只负责组装左侧工具栏右侧画布。工具栏这里我简化处理用三个按钮来选择当前绘制模式Circle、Rect、Polygon、Ruler。选择之后在 Canvas 上点击或者拖拽分别生成不同图形。因为重点在绘制与控制点工具栏代码不做展开。4.2 生成图形的核心函数以圆形为例鼠标按下释放两个事件配合生成一个圆形MouseArea { id: canvasMouse anchors.fill: parent acceptedButtons: Qt.LeftButton property string currentMode: circle onPressed: function(mouse) { if (!root.isDrawing) { return; } var startX mouse.x; var startY mouse.y; // 记录绘制起点 } onReleased: function(mouse) { var endX mouse.x; var endY mouse.y; if (root.currentMode circle) { var shape { type: circle, points: [ {x: startX, y: startY}, {x: endX, y: endY} ], style: { color: #2196F3, width: 2 } }; root.shapes.push(shape); } // 创建控制点 root.refreshControlPoints(); canvas.requestPaint(); } }注意 MouseArea 坐标默认是相对其父项如果里面还有 Canvas需要把 mouse.x 做坐标系转换。如果 Canvas 和 MouseArea 是同级的锚定关系坐标可以直接用。4.3 控制点拖拽的完整链路完整的控制点拖拽链路是这样的显示控制点root.refreshControlPoints()先清空 overlay 层然后遍历当前选中 shapes实例化 ControlPoint。用户拖拽ControlPoint 里 MouseArea 的 onPositionChanged 发出pointMoved(index, pointIndex, newX, newY)。根组件接收到 signal调用updateShapePoint按照类型修改 points 里对应坐标。根组件再调用refreshControlPoints()更新所有控制点的 x/y 位置因为圆心移动会导致半径点跟着动。调用canvas.requestPaint()重绘画布。这个顺序很重要别记反了。如果你先重绘再刷新控制点位置会出现视觉延迟圆形已经变大控制点还停留在旧位置。实际拖拽时帧率表现取决于 Canvas 重绘的效率。我一开始测试的时候拖动圆形的圆周点发现会掉帧最后定位到问题出在每次 onPaint 都重新填充整个画布背景导致不必要的开销。优化方案是把背景色用 CSS 方式画在画布外层的 Rectangle 上Canvas 内部只处理图形路径不要每帧都ctx.clearRect然后 fillRect。Canvas { id: canvas anchors.fill: parent // 鼠标拖拽期间减少 onPaint 的复杂计算 property bool isDragging: false onPaint: { var ctx getContext(2d); ctx.clearRect(0, 0, width, height); for (var i 0; i root.shapes.length; i) { var shape root.shapes[i]; switch(shape.type) { case circle: drawCircle(ctx, shape.points, shape.style); break; case rect: drawRect(ctx, shape.points, shape.style); break; case polygon: drawPolygon(ctx, shape.points, shape.style); break; case ruler: drawRuler(ctx, shape.points, shape.style); break; } } } }4.4 直尺在实操中的特殊细节直尺虽然是辅助工具但它在 pan平移和缩放这块比较特殊。如果你的工程后面要支持画布缩放直尺的刻度必须在绘制时按缩放比例更新。一个 10 像素的刻度在缩放 2 倍时应该显示 5 像素否则刻度线粗细和间距就不对。更稳的方案是直尺刻度的步长不固定为像素而是根据缩放级别选择“5、10、20、50”这样的台阶类似地图缩放时标注间距自动适配。这一点基本版本可以不做但你要知道扩展点在哪里。另外直尺上通常要标注当前长度数值。QML 里可以用 Text 组件叠加在尺子中间位置。要注意文字方向尺子旋转到竖直方向时文字不要跟着旋转 90 度否则读数会歪着头看。我处理的方式是文字始终水平显示把它放在尺子中点的上方偏移 15 像素的位置。这样代码量稍微多一点但实际查看测量结果时舒适度会明显提升。5. 常见问题与排查经验5.1 拖拽控制点时 Canvas 重绘太频繁出现卡顿排查思路先看 onPaint 里做了什么。如果里面有访问文件、动态创建对象、或者每帧都在new Path()但没有释放都很容易出问题。另一个常见原因是 QML Canvas 默认渲染策略可能是按需渲染鼠标快速移动时事件太多requestPaint 不断排队渲染压力大。我的经验处理方式给 Canvas 设置renderStrategy: Canvas.Threaded让渲染放到独立线程UI 交互会更顺畅。在 onPositionChanged 里不要每次操作都refreshControlPoints()。如果这个图形有 8 个控制点每个控制点位置刷一遍很浪费。可以设置一个 dirty 标记在一次事件循环里合并刷新或者只更新当前拖拽的控制点自身位置其他点延后 1 帧更新。最关键还是从根本上控制 draw 复杂度只绘制当前 viewport 内的图形画布外的形状直接跳过。5.2 控制点“咬不住”按下时图形不动移动几像素后突然跳这个现象十有八九是drag.threshold没设为 0。我前面已经提过这里再强调一次因为真的比较容易踩。QML 的 Drag 和 MouseArea 默认会判断移动阈值只有超过阈值后才会把事件当作拖拽。如果是做精确标绘必须设置drag.threshold: 0。另外还要注意如果 MouseArea 内部有多个子元素并且 event.accepted 被某些控件截获也会出现类似问题。最好确保控制点组件的 MouseArea 是叶子节点不要在它上面再叠其他可点击组件。5.3 QML 和 C 交互模型怎么设计虽然本次工程是纯 QML 实现但很多实际项目里shapes 数据最后要发给 C 后端保存或者参与计算。我建议不要在 ControlPoint 里直接访问 C 指针而是把坐标变更信号统一从 QML 层抛给一个 QObject 单例或控制器。具体做法是在 QML 里定义一个signal shapeDataChanged(int shapeIndex, var pointsArray)。C 侧通过QQuickView的 rootContext 注册一个 manager 对象槽函数接收QVariantList或者QByteArray格式的 JSON。控制点拖拽时调用这个信号C 侧做存储或转发。这样做的优势是控制点的拖拽逻辑与后端解耦。如果你直接让 MouseArea 在 onPositionChanged 里去manager.xxx()调 C 槽每次鼠标移动都走一遍 QObject 消息循环效率很差而且代码一多非常难调试。5.4 控制点组件事件报错之后的恢复问题有一个比较隐蔽的坑如果某个 ControlPoint 内部出现 JavaScript 异常比如 points 数组越界、属性调用 undefinedMouseArea 的事件循环可能会被中断视觉上控制点变成了“按住了弹不起来”的状态整个画布好像被锁住。这种时候不要慌优先在 onPaint 和 onPositionChanged 里做防御性判断比如检查 points 数组长度对不对、points[i] 是否存在。一旦代码里加上了这些判断绝大多数异常都不会发生。如果真的出现异常导致控制点状态卡死可以在根组件上绑定一个Connections监听shapesChanged信号强制重刷 overlay 层里所有 ControlPoint 的状态。也就是把所有控制点销毁重建QML 组件复用性和回复能力就会强很多。6. 后续可以怎么扩展一开始我只写了四类图形但通过这套架构扩展能力其实远超这四个图形。比如画任意贝塞尔曲线只需要新增一种 shape 类型points 里存锚点和两个控制点绘制函数用ctx.bezierCurveTo即可。控制点组件不需要任何修改因为它的本质只是拖拽点坐标并触发重绘。再比如做路径规划标绘把多边形的 points 换成路线的航点数组控制点拖动就变成了调整路线轨迹这和“移动一个多边形顶点”在交互层面完全同构。如果要在工程里加入分层绘制也就是热词里常出现的“分层图绘制”也可以基于现有的 shapes 数组扩展。给每个 shape 增加一个 layerId 字段绘制时先按 layerId 分组按层顺序绘制控制点同样按层过滤显示。这样整个系统的复杂度增加不多但可维护性提升明显。根据我个人做完这个项目的体会最值得注意的一点就是不要把“图形绘制”这件事当成简单的 Canvas 绘图而应该把它理解成“数据模型 视图映射 交互映射”三者的组合。只要这个底子打对了后续增加再多的图形也只是多加一个分支的事。本文还有配套的精品资源点击获取