
用 C# WinForms 手撸一个轻量级矢量图绘制系统这件事听起来像“重复造轮子”但在实际项目中经常绕不开。我做工控和桌面工具的时候遇到太多情况是图表库太重、License 太贵、数据结构绑死、交互方式改不动。最后干脆自己做了一个轻量级的画布系统能画线、画矩形、画圆、画任意多边形支持选中、拖拽、缩放、平移、撤销重做还能序列化保存。这篇文章把我整个实现过程、关键代码思路和踩过的坑都整理出来希望能给正在纠结“要不要自己画”的你一点参考。这套系统用的是 C# WinForms GDI运行在 .NET Framework 4.7.2 和 .NET 6 上都验证过。它不依赖任何第三方绘图库核心代码量大概 3000 行左右属于真正的“轻量级”。如果你是做上位机、流程编辑器、标注工具、简单 CAD 或者内部小工具开发的这篇文章的内容应该能直接帮你省下不少时间。1. 为什么要自己画而不是拉一个现成控件很多人的第一反应是矢量图绘制不是有现成框架吗比如各种开源画布、图表库甚至 WPF 里也有类似能力。但我在实际选型时发现问题往往不是“能不能画”而是“画了之后能不能按我的逻辑去用”。1.1 现成框架和自研绘制的分界线在哪里先说结论如果你的需求只是展示数据比如折线图、柱状图、饼图那没必要自己画直接用现成图表库是最高效的。但如果你的需求是让用户“在画布上自由创建和编辑图形”比如拖一个矩形出来、点选图形、按住某个点修改形状这时候现成库的适配成本往往高得离谱。我曾经试过在某个开源图表控件里做可拖拽节点结果发现它内部有一套自己的坐标系、命中测试和事件模型我为了改一个“框选后多选”的功能花了快一周时间读源码最后还是绕不过它的架构假设。自研绘制的分界线我认为是看你对这三件事有没有强控制需求数据模型图形存什么字段、怎么组织层级必须完全由业务决定。交互模型鼠标在画布上的行为比如画线时是点击连线还是拖拽连线选中后显示什么手柄。坐标语义图形坐标到底是像素、毫米还是领域业务单位比如温度、电压、物理尺寸。这三件事任何一个需要深度定制自己就是更优解。我做的这个系统核心原因就是为了控制矢量图形数据模型。每个图形本质上是“一组坐标 一个样式 一个标识”这个简单的模型不管是导出到 JSON、写入数据库还是联动其他控件都非常顺手。1.2 画布设计的三个底层决策动手之前我先定了三个决策后面所有代码都围绕它们展开。第一个决策逻辑坐标就是数据坐标。我不会让图形对象去关心屏幕尺寸图元只保存自己的逻辑坐标和几何属性。屏幕上的显示效果完全由视图变换View Transform决定。这样缩放平移时图形数据完全不动变的是“观察角度”。这一点对后续序列化太重要了保存文件时我只需要写图元数据不需要写任何显示状态。第二个决策用GraphicsPath作为所有图元绘制的统一载体。不管是直线、矩形、椭圆还是贝塞尔曲线最终都转成一个GraphicsPath绘制的时候只有一个接口DrawPath。好处是命中测试、边界计算、缩放绘制全都统一了不需要为每种图元写一套特殊逻辑。第三个决策视图层只依赖一个变换矩阵。鼠标坐标转换成逻辑坐标、绘图时逻辑坐标转换成屏幕坐标都通过同一个Matrix。这个矩阵是Graphics对象里的Transform属性在绘制和命中测试时保持一致。这三个决策看起来简单但它们把“数据”、“显示”、“交互”三个层彻底解耦了。后面所有功能包括撤销重做、缩放平移、框选多选都是在这些决策的基础上自然生长出来的。2. 图元模型先把数据层钉死再动手画我一直有一个习惯做图形编辑器永远先写数据层而不是先写绘制代码。因为绘制是“面向屏幕”的数据是“面向业务”的如果先写绘制很容易把屏幕坐标存储到图元里后面改坐标系语义的时候痛不欲生。2.1 接口设计一个接口把所有图元统一起来我定义的图元接口大致是这样public interface IShape { Guid Id { get; } string Type { get; } bool Selected { get; set; } bool Visible { get; set; } bool HitTest(PointF point, float tolerance); bool IntersectsWith(RectangleF rect); RectangleF GetBounds(); void ApplyTransform(Matrix matrix); void Draw(Graphics g, PaintStyle style); IShape Clone(); }每一项都是经过实际使用倒推出来的需求Id每次新建图元就生成一个 GUID用来做撤销、删除、序列化时唯一标识。没有 ID 的图形编辑器后面做同步和合并时非常痛苦。HitTest命中测试判断一个鼠标点是否“碰到”这个图形。tolerance是容差单位是逻辑坐标。IntersectsWith用来做框选判断一个矩形区域内有哪些图形。ApplyTransform应用变换矩阵。这一步很关键物体旋转、镜像或者进行高级编辑时直接对几何数据做矩阵运算。Draw绘制自身接收一个PaintStyle来统一控制颜色、线宽、透明度。Clone深拷贝用于复制粘贴和撤销快照。为什么不用抽象基类而用接口因为我后来确实遇到了一种混合图形——组Group它把多个图形绑成一个整体移动的时候整体移动。组本身也是一个IShape但它内部管理的是一个ListIShape。如果用继承体系这种组合逻辑会非常别扭而接口配合组合模式就干净很多。2.2 最小可用图元集合我第一版只实现了四个图元因为它们是矢量绘图的基础图元数据存储绘制方式LineShape起点StartPoint、终点EndPoint绘制一条直线RectShape左上角Location、宽高Size绘制矩形可带圆角半径EllipseShape左上角Location、宽高Size绘制椭圆内切于矩形PolylineShape顶点数组PointF[]依次连线可闭合每个图元内部都有一个GraphicsPath在构造函数或者数据变化时调用RebuildPath()重新生成。比如RectShapepublic class RectShape : IShape { public PointF Location { get; set; } public SizeF Size { get; set; } public float CornerRadius { get; set; } private GraphicsPath _path new GraphicsPath(); public RectangleF GetBounds() { return new RectangleF(Location, Size); } public void RebuildPath() { _path.Reset(); if (CornerRadius 0f) { _path.AddRectangle(GetBounds()); } else { _path.AddArc(Location.X, Location.Y, CornerRadius * 2, CornerRadius * 2, 180, 90); _path.AddArc(Location.X Size.Width - CornerRadius * 2, Location.Y, CornerRadius * 2, CornerRadius * 2, 270, 90); _path.AddArc(Location.X Size.Width - CornerRadius * 2, Location.Y Size.Height - CornerRadius * 2, CornerRadius * 2, CornerRadius * 2, 0, 90); _path.AddArc(Location.X, Location.Y Size.Height - CornerRadius * 2, CornerRadius * 2, CornerRadius * 2, 90, 90); } _path.CloseFigure(); } public void Draw(Graphics g, PaintStyle style) { using (var pen new Pen(style.BorderColor, style.BorderWidth)) { if (style.FillColor ! Color.Empty) { using (var brush new SolidBrush(style.FillColor)) g.FillPath(brush, _path); } g.DrawPath(pen, _path); } } }这里每组数据变化都要记得调用RebuildPath()。这个细节很容易漏漏掉之后画出来的图形还是旧位置但属性面板上数据已经变了排查半天发现是路径缓存没有刷新。我还为LineShape做了一点优化直线不需要GraphicsPath直接用DrawLine反而更快。但为了保持接口统一我最后还是给它包了一层GraphicsPath。实测 5000 条直线用DrawPath并不会成为瓶颈后面性能测试部分我会给数据。2.3 为什么所有图元都转成 GraphicsPath这是我在做第二版时顿悟的。原来LineShape用DrawLineRectShape用DrawRectangleEllipseShape用DrawEllipse每种图元都有自己的绘制分支命中测试也各写各的。有一次要增加“虚线风格”需求需要给每种图元改 Pen 的 DashStyle结果每个绘制分支都要动。后来我把所有图元统一到GraphicsPath绘制的代码就变成public void DrawShapes(Graphics g, IEnumerableIShape shapes, PaintStyle style) { foreach (var shape in shapes) { if (shape.Selected) DrawSelectionAdorner(g, shape); shape.Draw(g, style); } }GraphicsPath带来的第二收益是边界计算。GraphicsPath.GetBounds()能直接返回路径的包围盒配合矩阵还能把变换后的边界算出来。做框选、视图缩放、自动缩放适配时我只需要遍历图元调用GetBounds()不用关心具体是哪种图元。第三收益是命中测试的统一。GDI 的GraphicsPath自带IsVisible(PointF)方法做“点在图形内部”的判断非常方便。但要注意IsVisible只判断内部区域对直线这种没有面积的图元无效所以直线的命中测试我仍然是自己在做距离计算这个细节在交互章节展开。3. GDI 画布渲染坐标系与渲染质量的取舍WinForms 的 GDI 经常被人觉得“老”、“性能差”但实际上只要用对方法做轻量级矢量绘图完全够用。这一节是整篇文章里最容易踩坑的部分我按“坐标系”、“渲染开关”、“双缓冲”三个层面讲清楚。3.1 三套坐标系的换算关系做图形绘制脑子里必须时刻清楚三套坐标系逻辑坐标数据坐标图元存储的坐标比如矩形Location是(100, 200)这个值不随缩放变化。视图坐标控件坐标MouseEventArgs.Location给的就是这个坐标单位是像素以控件左上角为原点。屏幕坐标全局坐标Control.PointToScreen得到的只在处理跨控件拖拽时会用到比如拖一个图形从画布到工具箱。大部分时间我在处理前两套坐标的换算。换算关系由一个视图矩阵_viewMatrix决定private Matrix _viewMatrix new Matrix(); public PointF ScreenToLogical(PointF screenPoint) { var pts new[] { screenPoint }; _viewMatrix.Invert(); _viewMatrix.TransformPoints(pts); _viewMatrix.Invert(); // 转回来保持后续操作可用 return pts[0]; }这里注意Matrix.Invert()会修改矩阵本身所以用完一定要再转回来。这个问题我踩过坑有一次在缩放后框选坐标一直反向排查半天才发现是Invert后没还原导致后面绘制时所有图形都偏移了。后来我改成用Matrix.Clone().Invert()的方式更加安全public PointF ScreenToLogical(PointF screenPoint) { using (var inverse _viewMatrix.Clone()) { inverse.Invert(); var pts new[] { screenPoint }; inverse.TransformPoints(pts); return pts[0]; } }每次鼠标交互都需要做这个换算所以这个方法性能很重要。实测换算一次大概几微秒处理个几千次完全不是问题。3.2 抗锯齿和像素偏移看着简单实际有讲究在Paint事件里我会设置几个渲染参数protected override void OnPaint(PaintEventArgs e) { var g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.PixelOffsetMode System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; g.CompositingQuality System.Drawing.Drawing2D.CompositingQuality.HighQuality; }SmoothingMode.AntiAlias是矢量绘图的基本盘没有它斜线会呈锯齿状圆形边缘也会发毛。代价是绘制效率略有下降但现代机器上差别不大。真正容易被忽略的是PixelOffsetMode。我初次实现的时候用了默认值结果在平移画布时会看到一条条不平滑的线条边缘闪动。原因是 GDI 在对齐像素时可能把一条 1px 宽的线绘制在两个像素中间大家各着色一半视觉上就是发虚。设置成HighQuality或者Half后线的边缘会稳定对齐到像素网格上观感好很多。还有一个细节Pen 的宽度。默认情况下Pen的宽度是逻辑单位还是像素取决于你是否在Graphics.Transform上设置了缩放矩阵。如果设置了缩放矩阵那么pen.Width 2f表示逻辑宽度 2实际绘制宽度会随缩放变化。比如整体放大 10 倍线宽也会变成 20 像素这在矢量图语义下是正确的——因为矢量图放大后细节应该更粗。但如果你希望线宽始终是屏幕像素 2px比如标注框、选中手柄就需要在绘制时把线宽除以缩放系数。我的做法是传入一个PaintStyle里面存着逻辑线宽绘制时统一换算float scale GetViewScale(); pen.Width style.BorderWidth / scale;这个除法是我用过最实用的 GDI 小技巧做标注类工具时几乎必用。3.3 DoubleBuffered一个属性解决闪烁问题WinForms 画布在图形多、重绘频率高时如果没有开启双缓冲会看到明显的闪烁和“重绘水波”。解决方式其实很简单在构造函数里SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); UpdateStyles();OptimizedDoubleBuffer是 WinForms 内置的双缓冲机制它会让系统先在内存位图里完成绘制再一次性地 BitBlt 到屏幕上肉眼基本看不到中间状态。还有ResizeRedraw这个必须加。没有它控件大小变化时 GDI 可能只重绘被暴露出来的区域否则你会看到缩放窗口后画布内容边缘出现残留块。加上之后每次 Resize 都会完整重绘。提到双缓冲有的教程会建议自己创建BufferedGraphicsContext来实现手动双缓冲实际上没必要。WinForms 自带的这组控制标志就是最优解。我自己唯一会额外做的是当图元数量特别多时把背景网格画到一张独立的Bitmap缓存上重绘时先贴网格缓存再画图元。这一步后面性能章节详细讲。4. 鼠标交互的完整链路按下、移动、抬起、命中图形编辑器的核心体验全在鼠标交互上。这一部分我用了状态机的思想来组织避免OnMouseDown里塞一大堆分支导致代码爆炸。4.1 交互状态机的四个分支我把鼠标交互分成四种状态Idle空闲没有任何操作在进行。Drawing正在画一个新图形比如按下左键拖出矩形抬起时完成。Dragging正在拖动一个已选中的图形。Resizing正在拖拽某个选中图形的锚点手柄改变形状。状态切换的规则如下protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); var logicalPoint ScreenToLogical(e.Location); if (e.Button MouseButtons.Left) { if (_currentTool Tool.Select) // 选择模式 { var hit HitTest(logicalPoint); if (hit ! null) { if (!hit.Selected) // 点击未选中的图形 { ClearSelection(); hit.Selected true; _currentShape hit; _state InteractionState.Dragging; _dragStart logicalPoint; } else { // 点击已选中的图形直接进入拖拽或锚点调整 var handle GetHandleAtPoint(logicalPoint); if (handle ! null) { _state InteractionState.Resizing; _activeHandle handle; } else { _state InteractionState.Dragging; } } } else { ClearSelection(); // 空白处按下如果按住 Shift 则进入框选模式 if (Control.ModifierKeys Keys.Shift) { _rubberBandStart logicalPoint; _state InteractionState.RubberBand; } } } else // 绘图模式 { _currentShape CreateShapeAt(_currentTool, logicalPoint); AddShape(_currentShape); _state InteractionState.Drawing; } } }这段逻辑我建议认真读两遍。你会发现两个关键设计第一绘制的图元在按下时就立刻加入图元列表。很多人习惯在鼠标抬起时才添加但这样画的过程中图形不会显示在画布上只能通过特殊方式临时绘制。我选择按下即添加抬起时只是更新尺寸这样画到一半图形就可见交互反馈更自然。对应地撤销操作也要处理“半成品”的情况——撤销时正在绘制的图形要被移除。第二框选RubberBand模式我用Shift 鼠标拖动触发避免和普通空白区的拖动逻辑冲突。这个设计是根据实际使用场景来的绘图工具里空白区拖动通常用于平移画布但平移用鼠标右键更顺手所以左键空白区就留给框选了。4.2 命中的数学点到点、点到线段、点到图形命中测试是图形编辑器的核心计算直接决定用户操作舒不舒服。我的策略是按图形面积从小到大排优先级锚点手柄Handle优先判断鼠标是否点中了锚点如果点中直接进入缩放模式线Line/Polyline判断点到线段的最小距离是否小于容差面Rect/Ellipse判断点是否在图形内部或者离边界足够近。距离计算直接决定精度这里给出线段距离的函数public static float DistancePointToSegment(PointF p, PointF a, PointF b) { float dx b.X - a.X; float dy b.Y - a.Y; if (dx 0 dy 0) return Distance(p, a); float t ((p.X - a.X) * dx (p.Y - a.Y) * dy) / (dx * dx dy * dy); t Math.Max(0f, Math.Min(1f, t)); float closestX a.X t * dx; float closestY a.Y t * dy; return Distance(p, new PointF(closestX, closestY)); } private static float Distance(PointF a, PointF b) { float dx a.X - b.X; float dy a.Y - b.Y; return (float)Math.Sqrt(dx * dx dy * dy); }t的计算是理解这段代码的关键它是垂足在线段上的位置比例。如果t小于 0垂足在 A 点外侧最近点就是 A如果大于 1最近点就是 B只有t在 0 到 1 之间垂足才真正落在线段上。这个算法是计算点到线段距离最经典的方式也是后续所有直线命中测试的基石。容差tolerance应该设置为多少我一开始写死 3 像素的屏幕距离。后来发现一个问题放到最大时容差在逻辑坐标里非常小很难点中缩到最小时容差又特别大点哪里都像点中了。正确做法是设置一个“屏幕像素容差”再换算成逻辑坐标private float GetToleranceInLogical(float screenTolerancePx 6f) { return screenTolerancePx / GetViewScale(); }这个细节让命中的手感在所有缩放级别下保持了一致属于那种代码只写一行但体验差别巨大的优化。4.3 图形编辑手柄锚点和旋转选中一个图形时我会在它的边界框四个角和四条边中点画出 8 个白色小方块手柄这些就是Handle。拖拽手柄时根据手柄的位置参数调整图形的几何数据。例如矩形图元的右下角手柄是控制宽高的。当用户拉右下角时private void ResizeRectByHandle(RectShape rect, PointF handlePos, HandleType type) { var bounds rect.GetBounds(); PointF newLocation bounds.Location; SizeF newSize bounds.Size; // 根据手柄类型计算新的 Location 和 Size switch (type) { case HandleType.BottomRight: newSize.Width handlePos.X - bounds.Left; newSize.Height handlePos.Y - bounds.Top; break; case HandleType.TopLeft: newLocation handlePos; newSize.Width bounds.Right - handlePos.X; newSize.Height bounds.Bottom - handlePos.Y; break; // 其他手柄类似 } // 防止尺寸变负值 if (newSize.Width 10) newSize.Width 10; if (newSize.Height 10) newSize.Height 10; rect.Size newSize; rect.Location newLocation; rect.RebuildPath(); Invalidate(); }这里有一个容易忽略的坑当拖拽左上角手柄时矩形的位置会变化而右下角保持不变。如果直接在原Location上累加偏移会出现尺寸越拖越大的问题。正确做法是根据手柄的“对角锚点”反推新的Location和Size。上面代码里的bounds.Right - handlePos.X就是这个意思新位置变了宽度也同步调整保证右下角锚点真正固定住。5. 视图变换缩放和平移的矩阵实现画布没有缩放和平移就像绳子没有编制。但很多同学一讲到缩放就头疼主要是因为对Matrix的使用不够熟悉。这一节我把缩放、平移和鼠标坐标联动讲透。5.1 用 Matrix 统一管理视图视图变换的所有东西都围绕字段_viewMatrix展开。初始它为单位矩阵表示逻辑坐标和视图坐标一一对应。执行缩放和平移时直接做矩阵运算public void ZoomAt(PointF screenCenter, float zoomFactor) { _viewMatrix.Translate(screenCenter.X, screenCenter.Y); _viewMatrix.Scale(zoomFactor, zoomFactor); _viewMatrix.Translate(-screenCenter.X, -screenCenter.Y); Invalidate(); }这里要解释为什么ZoomAt要“平移到中心点 - 缩放 - 平移回来”。矩阵变换的乘法是有顺序约束的后调用的变换作用在先调用的变换之前的坐标系里。Translate(screenCenter)把画布坐标系的原点挪到鼠标中心然后Scale以这个新原点为基准缩放最后Translate(-screenCenter)把坐标系移回原位置。结果就是缩放发生在以鼠标位置为中心的“局部坐标系”里屏幕上鼠标指针下的那个图形点会保持在原地不动。同样的思想也用于平移public void PanByDelta(float dx, float dy) { _viewMatrix.Translate(dx, dy); Invalidate(); }搞定矩阵之后绘制方法要记得把矩阵应用到Graphics上protected override void OnPaint(PaintEventArgs e) { var g e.Graphics; g.SmoothingMode SmoothingMode.AntiAlias; g.PixelOffsetMode PixelOffsetMode.HighQuality; g.Transform _viewMatrix; // 之后的 GDI 绘制都基于逻辑坐标GDI 自动完成坐标变换 foreach (var shape in _shapes) { if (shape.Visible) shape.Draw(g, _paintStyle); } }设置g.Transform _viewMatrix后所有绘制调用都使用逻辑坐标GDI 会在渲染内部把坐标变换成屏幕坐标我们完全不需要手动逐点换算。提示如果这里引入了Pen.Width的逻辑单位问题前面提到的线宽除法style.BorderWidth / scale必须同步处理。5.2 缩放时保证鼠标位置不漂移用鼠标滚轮缩放时如果只是简单执行Scale你会遇到一个很别扭的问题放大后鼠标指针下的内容点“跑掉”了你需要重新移动鼠标去对准。这就是因为缩放中心是控件原点(0,0)而不是鼠标位置。正确做法是把鼠标位置作为缩放中心protected override void OnMouseWheel(MouseEventArgs e) { float oldScale GetViewScale(); float newScale oldScale * (e.Delta 0 ? 1.1f : 1 / 1.1f); newScale Math.Max(0.05f, Math.Min(20f, newScale)); if (Math.Abs(newScale - oldScale) 1e-6) return; ZoomAt(Matrix.TransformPoint(e.Location), newScale); // 注意这里使用视图坐标 }关键在于ZoomAt(screenCenter, zoomFactor)这个函数保证鼠标位置在缩放前后对应的逻辑坐标保持不变。数学推导是缩放前鼠标位置对应逻辑点 P缩放后由于以鼠标位置为原点进行缩放P 对应的屏幕位置仍然是鼠标位置于是视觉上图形没有漂移。这里还有一个细节滚轮缩放的比例系数。1.1 倍是比较舒服的手感每滚一格放大 10%。有同学喜欢用1.2或者1.5但实测在日常使用中 1.1 更易控制不容易一下飞出去或者缩不回来。5.3 视图变换下的命中测试修正视图变换之后命中测试一定不能忽略矩阵对坐标的影响。我推荐统一用“先转换鼠标坐标再做逻辑空间命中测试”的方案而不是直接拿屏幕坐标跟逻辑坐标比较。过程如下鼠标按下时调用ScreenToLogical(e.Location)得到逻辑坐标P。把图元的GetBounds()也放在逻辑空间里做命中判断。结果操作完成后图形坐标仍然保持逻辑坐标不需要反向换算。这样视图变换只影响绘制不影响图元数据命中测试数据面和交互面是完全一致的。如果某个操作需要把逻辑坐标转换回去比如把拖拽后的图形坐标显示在状态栏再调用LogicalToScreen即可也就是_viewMatrix.TransformPoints。提示Matrix.TransformPoints会修改传入数组的内容使用时记得拷贝或直接传入临时数组。6. 撤销重做与序列化让工具真正可以交给别人用能画、能缩放、能选中这只是一个“能玩的 Demo”。真正把它变成一个“能用的工具”必须具备两个能力撤销重做和持久化。这两块也最能体现工程的复杂度差异。6.1 命令模式让每个操作变成对象我采用的是简化版命令模式在文档里引入一系列待处理的命令对象。每次用户操作生成一个命令放到执行栈里撤销时弹出栈顶命令执行反向操作重做时重新压栈。命令的定义非常简单public interface ICommand { void Execute(); void Undo(); string Name { get; } }以“添加图形”命令为例public class AddShapeCommand : ICommand { private ListIShape _shapeList; private IShape _shapeAdded; public AddShapeCommand(ListIShape shapeList, IShape shape) { _shapeList shapeList; _shapeAdded shape; } public void Execute() { if (!_shapeList.Contains(_shapeAdded)) _shapeList.Add(_shapeAdded); } public void Undo() { _shapeList.Remove(_shapeAdded); } public string Name 添加图形; }看起来很朴素但它带来两个好处。第一个好处所有修改操作都集中在命令对象中逻辑清晰。比如“移动图形”命令Execute记录原始位置Undo恢复原位置以后不管移动几个像素还是旋转多少度命令用法一致。第二个好处历史记录天然支持。编辑器右下角的状态栏可以显示“已添加图形”、“移动图形”等操作记录对工业类工具很有用用户需要知道刚才做了什么。撤销栈的大小我限制在 100 步超过就把最早的命令丢弃。100 步对矢量绘图完全够用因为操作比较重跟文本编辑器里一秒钟几十次输入完全不同。6.2 JSON 序列化的契约设计矢量图形数据的持久化我选择用 JSON。原因很简单轻量、可读、跨语言后续如果需要对接 Web 前端或者 Python 脚本JSON 几乎是零成本。序列化时我利用System.Text.Json的多态序列化能力需要一点点额外配置。我的做法是给每个图元一个类型标签反序列化时根据标签创建对应类型的实例[JsonDerivedType(typeof(LineShape), line)] [JsonDerivedType(typeof(RectShape), rect)] [JsonDerivedType(typeof(EllipseShape), ellipse)] [JsonDerivedType(typeof(PolylineShape), polyline)] public interface IShape { ... }这是一个在 .NET 8 中可用的写法.NET 6 可以用JsonConverter实现同样的效果。序列化出来的 JSON 示例长这样{ Shapes: [ { $type: line, Id: …, Start: {X:100,Y:200}, End: {X:300,Y:150} }, { $type: rect, Id: …, Location: {X:10,Y:20}, Size: {Width:100,Height:50} } ] }反序列化的时候注意 GUID 和浮点数的精度问题。JSON 的反序列化默认是float和double都可以但这里有一个我踩过的大坑JSON 里浮点数默认可能被反序列化成double而PointF的属性是float如果类型不匹配会抛异常。我用JsonSerializerOptions.PropertyNameCaseInsensitive true配合强类型忽略了这个风险但如果你手写反序列化一定要显式指定类型。6.3 保存位图导出与矢量复用的平衡既然是矢量图系统很多人的第一反应是“导出成 PNG 位图”。我的做法是同时支持两种导出保存为 JSON保留所有矢量数据和样式供后续编辑。导出为 PNG渲染到一张大位图上输出给文档、报告或网页使用。导出 PNG 时有一个渲染精度问题如果当前视图缩放了直接Control.DrawToBitmap导出的图会带着缩放痕迹但不是矢量渲染的细节。我更推荐的做法是设置一个临时的Graphics对象手动设置Transform new Matrix()不依赖屏幕画布把图元直接渲染到目标位图上public Bitmap ExportToBitmap(int width, int height, string format PNG) { var bmp new Bitmap(width, height); using (var g Graphics.FromImage(bmp)) { g.Clear(Color.White); g.SmoothingMode SmoothingMode.AntiAlias; g.PixelOffsetMode PixelOffsetMode.HighQuality; // 按照当前视图矩阵或归一化矩阵绘制 g.Transform _viewMatrix; foreach (var shape in _shapes) { shape.Draw(g, _paintStyle); } } // 按 format 保存 return bmp; }真正的矢量导出SVG我也尝试过第一版就用GraphicsPath直接输出 SVG path 的d属性其实是用PathToSvg这类工具转换。但坐标转换和样式映射的工作量不小如果不是硬性要求可以先不做。轻量级工具的定位决定了JSON PNG 已经能满足绝大多数场景。7. 实测数据与踩坑记录最后一部分我来说说这套系统在真实使用中的表现以及我修过的几个最折磨人的 Bug。这些经验可能比前面的代码更值钱。7.1 5000 个图元的实际表现我做了一个压力测试向画布中添加 5000 个图形其中一半是矩形、一半是折线每条折线平均 20 个顶点然后测试刷新、缩放和命中测试的耗时。在 Intel i5-8250U8G 内存集显的机器上全量重绘一次耗时约 12ms也就是刷新率可以跑到 80fps 以上。如果开启抗锯齿和高质量像素偏移耗时增加到 28ms大概 35fps依然流畅可用。但是有一个性能陷阱必须提命中测试的高频调用。在鼠标移动时为了让选中状态实时更新我每帧都要对可见图元做一次HitTest。5000 个图元时遍历全部图元做命中测试的耗时大约是 2ms可以接受。但如果是框选遍历每个图元的边界框判断相交5000 次IntersectsWith调用会到 15ms同时伴随高频率拖拽时会有明显卡顿感。优化手段有两个空间索引我做了最简单的网格索引把画布分成 64x64 的格子每个格子记录包含哪些图元。命中测试时先算鼠标在哪个格子只需要测试这个格子里的图元。实测 5000 图元时命中测试从 2ms 降到 0.3ms。脏区域重绘在拖拽图形时只Invalidate图形原来的边界框和新位置的边界框合并区域而不是整个画布。GDIClip能帮上忙但要注意Clip与视图变换矩阵的交互否则容易擦除内容。如果只是做轻量级内部工具5000 个图元属于中等偏高的负载不索引也基本可用。但如果你想做流畅的工业标注工具或者电子海图空间索引是你绕不开的升级点。7.2 踩过的坑刷新策略与 DPI坑一滚动条事件和画布内容不同步。WinForms 里有 ScrollableControl 的自动滚动机制但我在实现平移时最初用的是AutoScrollPosition结果鼠标滚轮和平移逻辑经常互相打架。后来我放弃自动滚动用OnMouseMove判断中键拖拽来改变矩阵避免和系统滚动条冲突。如果业务上确实需要滚动条建议只做“手动设置AutoScrollMinSize作为全图边界”的思路滚动条移动只改变矩阵平移量不依赖AutoScrollPosition的回调顺序。坑二DPI 缩放导致坐标漂移。在 125% 或 150% 缩放的显示器上WinForms 默认不会自动处理 DPI导致MouseEventArgs.Location和Graphics实际渲染的像素不一致。我的解决方式是程序入口Application.SetHighDpiMode(HighDpiMode.PerMonitorV2).NET Core/.NET 5或者配置文件里声明dpiAwareness PerMonitorV2。在高 DPI 下所有坐标必须以“物理像素”为基准否则图形位置看起来就会错位。如果你要兼容老版本 .NET Framework可以在app.manifest里加dpiAwaretrue/pm/dpiAware。坑三GraphicsPath在添加闭合曲线时的方向问题。AddArc之后默认路径是逆时针的但如果连续添加多个AddArc路径的起点和终点可能不闭合需要手动调用CloseFigure()否则填充色会漏出来。我已经在上面的RectShape.RebuildPath里展示了CloseFigure()的使用这个小细节在自定义形状时几乎必踩。坑四撤销重做和选中状态的联动。撤销时如果一个图形被选中了但在撤销后它已经被移除那么Invalidate时绘制选中框会抛ObjectDisposedException或者空引用。我的做法是每次撤销/重做之后强制ClearSelection()把选中状态清空。这类问题无法在命令内部完全规避因为选中状态是编辑器层而非命令层的职责。7.3 这套系统还能朝哪个方向长如果只是到这一步这个系统已经能应付大多数内部工具场景了。但如果你有时间我建议按这几个方向继续扩展每一个都有明显收益分组Group把多个图元绑定为一个整体移动、选择、撤销都按组进行。前面接口设计已经为这个留好了口子IShape的组合实现并不难。对齐吸附Snap在拖拽时检测到网格、图元边界、中心点等关键点把鼠标位置吸附到最近的对齐点上。工业标注工具里这个功能几乎是刚需。样式属性面板把每个图元当前的线宽、颜色、填充色、虚线模式暴露出来让用户可以在属性面板里实时修改修改逻辑继续走命令模式支持撤销。多选和批量操作框选已经能返回图元集合了批量移动、批量删除、批量改样式都不难加。导入导出兼容层如果后续要接 DWG 或 SVG 格式只需在数据层写转换器把IShape转换成对方的数据结构整套架构不需要变动。我个人偏向先做对齐吸附因为这个功能使用频率最高而且实现它需要把矩阵运算和鼠标交互的逻辑再理一遍能顺手把代码里所有直接使用屏幕坐标的地方清理干净。做完了这一步再去接 SVG 导出之类的功能就会顺畅很多。提示任意项目不要试图在初版就支持所有格式和功能。轻量级系统的核心价值就是“快速解决当前问题、能扛住一定规模、逻辑足够清晰”。后续有没有扩展空间比当前功能有多少更重要。如果你也在做类似的工具我的建议是先花一个小时把鼠标交互状态机画在纸面上再动手写代码。等你在纸上把所有分支都列清楚了代码反而成了体力活。这个习惯帮我避免了很多次写到一半推翻重来的局面。