
简介这是一份基于C#的图形绘制与编辑项目GraphExample2适合学习Windows Forms窗体编程、GDI绘图和鼠标交互处理的开发者参考。项目实现了矩形、圆形、菱形等多种图形的绘制并支持通过鼠标任意拖动、缩放和旋转图形日常演示或入门级图形编辑器都可直接复用。压缩包共50个文件以C#源码.cs为主另含解决方案与工程配置.sln/.csproj、可直接运行的exe、资源文件.resx/.resources及程序数据库.pdb等整体大小仅100KB轻量易用。已有4753人学习下载是一个实用的C#画图教学示例。通过阅读代码可以掌握PictureBox绘图表面初始化、Shape类层次设计、Paint事件绘制、鼠标事件实现拖动、旋转变换及序列化保存图形等关键知识点项目还附带对象与集合类结构便于二次扩展是理解C#图形交互和界面编程的良好范例。 前阵子做一个 C# 上位机项目客户要求在一个工控界面上画设备布局图矩形代表机柜、圆形代表阀门、菱形代表传感器还得支持用鼠标拖来拖去调整位置最关键的是模块尺寸不能写死得能放大缩小。折腾了几天把 WinForm 下的 GDI 绘图方案彻底摸了一遍踩了不少坑。这篇文章就把这套画图功能的核心实现思路完整整理出来如何用 C# 画出矩形、圆形、菱形等基本图形怎么写命中测试怎么实现拖动和放大缩小。适合正在做类似小工具、简单图形编辑器或者想入门 C# 图形编程的开发者参考。1. 功能定位与整体设计思路1.1 先理清楚需求这到底是个什么工具先说清楚我们要做的不是 PhotoShop也不是 CAD而是一个轻量级的图形编辑小工具。它的核心能力就三条一是能通过鼠标拖拽绘制出矩形、圆形、菱形这类基础图形二是能点击选中某个图形然后用鼠标拖动它改变位置三是能通过图形边缘的控制点拉大拉小。搞清楚这个定位非常重要因为很多人一开始就往复杂里想结果写出来一堆代码却跑不通。以我实际项目为例这类画图功能通常出现在设备监控界面、简易拓扑图编辑器、教学演示工具、甚至一些标注工具里。形状不需要多复杂但要求交互自然鼠标一拖图形跟着走点一下选中能出现控制点拉角落图形按比例或自由缩放。理解了需求边界后面设计就不会跑偏。1.2 为什么用 GDI选型背后的真实考量有朋友会问C# 做图形界面为什么不用 WPFWPF 有更强大的渲染能力和动画支持但很多工控项目、上位机项目至今仍是 WinForm 技术栈维护成本最低的方案就是直接用 GDI。GDI 是 WinForm 自带的一套 2D 绘图 API画矩形、椭圆、多边形都是现成方法对我们这种简单图形编辑器来说完全够用而且理解起来直观。还有一个更实际的原因GDI 的模式是“一帧一帧重绘”你让系统画什么它就画什么非常适合做交互式编辑器。相比之下WPF 虽然靠 XAML 声明 UI但在这种“鼠标按下、拖动、实时更新形状位置”的场景里学习曲线反而更陡。至于第三方图形控件功能强是强但引入依赖后布署麻烦而且很多开源库对自定义交互的支持也没有想象中好。所以我的结论是轻量图形编辑GDI 就是最稳的起点。1.3 三个关键设计原则数据、交互、绘制分离一开始写这种程序最容易犯的错就是把绘制逻辑、鼠标逻辑全塞在 Form 的 Paint 事件里结果代码几百行挤成一坨。我的建议是分三层数据层定义图形的抽象基类和具体图形类保存位置、大小、颜色、选中状态。交互层处理鼠标按下、移动、抬起这些事件只更新数据不直接画图。绘制层统一在 Paint 事件里遍历图形集合按顺序把每个图形画出来。这个设计的好处是职责清晰。拖动一个图形时交互层做的只是修改它的 Bounds 属性然后调用 Invalidate 通知界面重绘。绘制层不关心图形是被新建了、拖动了还是缩放了它只负责把当前所有图形的最新状态画到屏幕上。这样后面加新图形类型、加撤销功能都会非常轻松。2. 图形数据模型与绘制原理2.1 用矩形边界统一描述所有图形画矩形、圆、菱形第一反应可能是分别记左上角坐标、圆心坐标、四个顶点坐标。但这样做交互逻辑会非常痛苦拖动时要分别算不同坐标缩放时更是灾难。所以我统一用一个矩形边界Rectangle来描述所有图形也就是图形外接矩形左上角坐标加宽高。外接矩形的好处在于画图时只需要两个点就能生成矩形拖动时只需要平移这个矩形缩放时只需要调整矩形宽高。圆形画出来是椭圆但它的外接矩形就是矩形菱形的外接矩形也是矩形。这样一套数据结构通吃所有图形绘制、命中测试、拖动、缩放代码都能复用。这个抽象是我认为整个项目里最重要的一步。2.2 三种图形的绘制细节与像素对齐GDI 里绘制图形很简单但有几个细节新手容易忽略。矩形用DrawRectangle圆用DrawEllipse菱形没有现成方法需要构造四个顶点后用DrawPolygon。代码如下public abstract class Shape { public Rectangle Bounds { get; set; } public bool IsSelected { get; set; } public Color BorderColor { get; set; } Color.Black; public float BorderWidth { get; set; } 2f; public abstract void Draw(Graphics g); public abstract bool HitTest(Point p); public abstract Point[] GetHandlePoints(); } public class RectangleShape : Shape { public override void Draw(Graphics g) { using (Pen pen new Pen(BorderColor, BorderWidth)) { g.DrawRectangle(pen, Bounds); } } } public class CircleShape : Shape { public override void Draw(Graphics g) { using (Pen pen new Pen(BorderColor, BorderWidth)) { g.DrawEllipse(pen, Bounds); } } } public class DiamondShape : Shape { public Point[] GetPoints() { return new Point[] { new Point(Bounds.Left Bounds.Width / 2, Bounds.Top), new Point(Bounds.Right, Bounds.Top Bounds.Height / 2), new Point(Bounds.Left Bounds.Width / 2, Bounds.Bottom), new Point(Bounds.Left, Bounds.Top Bounds.Height / 2) }; } public override void Draw(Graphics g) { using (Pen pen new Pen(BorderColor, BorderWidth)) { g.DrawPolygon(pen, GetPoints()); } } }这里有个像素对齐的问题GDI 的Pen默认是中心对齐的也就是说画矩形时线条会向边框内外各扩展一半线宽导致 1 像素宽的线条看起来发虚。如果需要清晰锐利的边缘可以设置Pen.Alignment PenAlignment.Inset或者干脆把坐标做偏移处理。在简单编辑器里如果对美观要求不高默认中心对齐也能接受但至少得知道有这回事。2.3 命中测试判断鼠标点中谁拖动一个图形前得先知道鼠标点到了谁身上。这就是命中测试HitTest。不同图形的判断方式不同矩形直接判断点是否在Rectangle内用Contains即可。圆形如果当前形状是椭圆需要把坐标归一化到圆心和半径的比例上计算(dx / rx)^2 (dy / ry)^2 1。菱形最稳妥的方式是构建GraphicsPath用Region.IsVisible(point)判断。命中测试还有一个关键点如果有多个图形重叠应该命中哪一层我的做法是倒序遍历图形列表因为列表中后面的图形绘制时画在更上层所以先判断后画的图形匹配到就直接返回。实现如下public Shape HitTest(Point p) { for (int i Shapes.Count - 1; i 0; i--) { if (Shapes[i].HitTest(p)) return Shapes[i]; } return null; }这里要注意的是命中测试区域要适当放大一点不然鼠标点到图形边缘那条细线上时经常判断不中。实际项目中我会在每个图形外面加 3 到 5 像素的冗余范围操作手感会好很多。2.4 控制点缩放功能的基石要实现放大缩小就得在选中图形时画几个控制点让用户能抓住这些点拖拽。我采用的是 8 个控制点方案四角和四边中点。这些控制点其实也是一个一个的小矩形存储当前索引鼠标按下时判断点是否落在某个控制点范围内。控制点坐标根据Bounds动态计算GetHandlePoints方法就是干这个的。例如左上角控制点坐标为(Bounds.Left, Bounds.Top)上边中点为(Bounds.Left Bounds.Width / 2, Bounds.Top)以此类推。存储控制点时我会单独用一个Point[]数组索引从 0 到 7后面缩放逻辑只要知道“用户拖的是哪个索引”就能确定如何调整矩形。这是实现缩放的根基一定设计好。3. 拖动与缩放交互从鼠标事件到最终效果3.1 鼠标状态机设计画图工具的交互本质上是一个状态机。鼠标按下、移动、抬起分别对应状态变化。我定义了三类操作模式绘制模式用户选中“矩形工具”在画布上按下鼠标拖出一个形状松开后图形固定。移动模式鼠标点击到某个图形内部按下并拖动图形跟着光标平移。缩放模式鼠标点击到某个控制点按下并拖动图形边界实时变化。用一个枚举保存当前模式MouseDown 时确定进入哪种状态MouseMove 时根据状态执行对应逻辑MouseUp 时统一结束操作并重置状态。这是整个交互层的主干逻辑代码结构是否清晰就看这一步分得明不明白。3.2 拖动逻辑实现拖动逻辑的核心是“鼠标移动了多少距离图形就平移多少距离”。所以 MouseDown 时要记录当前鼠标位置_lastPointMouseMove 时计算dx、dy然后把Bounds的 X、Y 分别加上偏移量更新_lastPoint为当前坐标最后调用Invalidate()触发重绘。private Point _lastPoint; private Shape _selectedShape; private DragMode _mode DragMode.None; private int _resizeHandleIndex -1; protected override void OnMouseDown(MouseEventArgs e) { if (_currentTool ToolType.Select) { _selectedShape HitTest(e.Location); if (_selectedShape ! null) { int index HitTestHandle(e.Location); if (index 0) { _mode DragMode.Resize; _resizeHandleIndex index; } else { _mode DragMode.Move; } _lastPoint e.Location; } } base.OnMouseDown(e); } protected override void OnMouseMove(MouseEventArgs e) { if (_mode DragMode.Move _selectedShape ! null) { int dx e.X - _lastPoint.X; int dy e.Y - _lastPoint.Y; _selectedShape.Bounds new Rectangle( _selectedShape.Bounds.X dx, _selectedShape.Bounds.Y dy, _selectedShape.Bounds.Width, _selectedShape.Bounds.Height); _lastPoint e.Location; Invalidate(); } base.OnMouseMove(e); }这里最容易犯的错误是直接用 MouseMove 的全局坐标计算而没有减去上一次的坐标。如果每次都把图形 Bounds 直接设置成鼠标当前位置图形会瞬间“瞬移”到鼠标旁边看起来像飞走了一样。必须用差值方式这一点切记。3.3 缩放逻辑实现缩放比拖动复杂一些。因为控制点有 8 个拖不同的控制点效果不同。比如拖左上角控制点应该能同时改变图形的 X、Y、Width、Height拖上边中点只改 Y 和 Height。实现时需要根据控制点索引决定改哪几个字段同时保证宽高不小于最小值。拖右下角控制点索引 4的示例private void ResizeShape(Shape shape, int handleIndex, Point current) { Rectangle b shape.Bounds; int minSize 10; switch (handleIndex) { case 4: // 右下角 b.Width current.X - b.Left; b.Height current.Y - b.Top; break; case 1: // 上边中点 b.Height b.Bottom - current.Y; b.Y current.Y; break; // 其他控制点按对应关系处理 } if (b.Width minSize) b.Width minSize; if (b.Height minSize) b.Height minSize; shape.Bounds b; }这里最需要注意的是“反方向拖动”问题。用户可能从左往右画矩形也可能从右往左画。因此鼠标拖出来创建图形时必须对起点和终点做归一化处理保证Bounds的宽高始终为正数。另外缩放时如果往左拖过了头宽高会变成负数GDI 绘制时会出现异常或图形翻转所以最小尺寸限制一定不能省。3.4 双缓冲与局部刷新WinForm 绘图最大的痛点就是闪烁。默认的OnPaint如果不做处理每次鼠标移动都会先擦掉背景再重绘显示效果就是一闪一闪的。解决办法就是双缓冲WinForm 里的DoubleBuffered属性可以直接启用this.DoubleBuffered true; // 或者针对 PictureBox typeof(PictureBox).GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(pictureBox1, true);除了双缓冲还要尽量减少重绘区域。拖动过程中每次Invalidate()时如果不带参数是把整个画布都重绘一遍图形一多就会卡。更好的方案是调用Invalidate(region)只刷新图形旧位置和新位置合并出来的矩形区域。这个方法在图形数量超过几十个时能明显改善流畅度。4. 完整实操搭建一个可运行的画图小工具4.1 项目搭建与界面布局我实际操作的环境是 Visual Studio 2022 .NET Framework 4.8WinForm 项目。新建项目后界面布局按“左边工具栏 中间画布”来做。左侧放几个 Button 或者 RadioButton用来切换工具选择、矩形、圆、菱形。中间放一个PictureBox作为画布。画布的大小我设置成 800×600。初始时背景用白色便于看清图形。双击窗体进入代码后在构造函数里把PictureBox的MouseDown、MouseMove、MouseUp、Paint事件都接上。事件是交互的核心入口后面所有逻辑都挂在这四个事件上。4.2 核心代码图形类的完整实现图形类我在第 2 节已经贴了核心代码但实际项目里还需要补充几个方法。比如控制点绘制逻辑可以在基类里统一实现遍历GetHandlePoints()返回的点在每个点中心画一个小方块public void DrawHandles(Graphics g) { if (!IsSelected) return; foreach (Point p in GetHandlePoints()) { g.FillRectangle(Brushes.White, p.X - 4, p.Y - 4, 8, 8); g.DrawRectangle(Pens.Blue, p.X - 4, p.Y - 4, 8, 8); } }这里我选择白底蓝边的方块视觉上比较清晰。控制点太大会挡住图形太小又不好点中8×8 像素是我反复测试后觉得比较合适的手感。注意控制点绘制顺序要放在图形绘制之后这样控制点才不会因为后绘制图形而盖住。4.3 核心代码鼠标交互完整流程完整交互流程是鼠标按下时如果当前是绘制工具就创建一个新图形加入列表如果当前是选择工具就做命中测试。鼠标移动时根据状态执行绘制、移动或缩放。鼠标抬起时把状态重置。这一段我把核心逻辑补充完整private Point _startPoint; private Shape _drawingShape; protected override void OnMouseDown(MouseEventArgs e) { if (_currentTool ! ToolType.Select) { _startPoint e.Location; _drawingShape CreateShapeFromTool(_currentTool, _startPoint, e.Location); Shapes.Add(_drawingShape); } else { // 命中测试逻辑见3.2 } Invalidate(); base.OnMouseDown(e); } protected override void OnMouseMove(MouseEventArgs e) { if (_mode DragMode.Draw _drawingShape ! null) { Rectangle rect NormalizeRect(_startPoint, e.Location); _drawingShape.Bounds rect; Invalidate(); } else if (_mode DragMode.Move || _mode DragMode.Resize) { // 见3.2、3.3 } base.OnMouseMove(e); } private Rectangle NormalizeRect(Point a, Point b) { return new Rectangle( Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)); }CreateShapeFromTool根据工具栏状态创建对应的图形对象这里会设置初始Bounds虽然它会在 MouseMove 里被修正但一定要先创建出来否则拖动过程中没有对象可画。我这个版本为了保证代码可读省略了撤销功能实际项目建议在操作前把Bounds和Shapes列表快照压入栈方便 CtrlZ。4.4 实测运行效果这套代码跑起来后交互逻辑基本符合预期。选择矩形工具在画布上按下鼠标并拖动屏幕上会实时出现一个矩形松开后图形固定切到选择工具鼠标移动到矩形内部按下并拖动矩形会跟着光标移动点击矩形边缘上的控制点拖拽时矩形尺寸实时变化。一个值得注意的现象是如果图形数量只有十几个整体帧率完全没问题但一旦图形数量超过两三百不做局部刷新优化时会出现轻微卡顿。所以我的建议是虽然本文的例子很简单但只要你计划支持超出屏幕容量的图形数量局部刷新和双缓冲最好一开始就加进去不然后面优化要动的地方会很多。5. 常见问题与排查技巧实录5.1 图形闪烁和拖影严重这个问题在我最开始写的时候几乎必然出现。原因是没有开启双缓冲或者重绘区域过大。排查步骤是先设置窗体和画布的DoubleBuffered true如果仍然闪烁就把Invalidate()换成指定区域的Invalidate(rect)。拖影则通常是因为背景没有在Paint事件里重新填充或者旧图形位置没有刷新导致残影残留。记得在OnPaint开头用白色或指定背景色清空整个画布再循环绘制图形。5.2 拖动时图形“飞走”或突然跳到鼠标位置这是典型的坐标使用错误。分两种可能一是用鼠标的屏幕坐标而不是客户区坐标导致坐标偏移二是在MouseMove里直接把图形Bounds设置成了鼠标坐标而不是累计差值。解决办法很简单确保所有坐标都来自MouseEventArgs.Location它已经是相对于控件的客户区坐标移动逻辑用dx、dy累计偏移。5.3 缩放到极小值后图形变形或反向用户把控制点拖过头时Bounds的宽或高会变成负数结果就是图形反向显示看起来像被翻转了。解决方法是设置最小宽高在每次ResizeShape最后都统一检查if (b.Width minSize) b.Width minSize; if (b.Height minSize) b.Height minSize;还要注意控制点拖到图形另外一侧时比如拖右下角控制点越过左上角这种“反向”情况需要特殊逻辑。最简单的处理是限制控制点只能在原有方向的象限内活动复杂一点的可以动态交换左右边界。对于基础版本限制最小尺寸已经足够应对大多数误操作。5.4 性能卡顿和扩展方向如果图形达到几百上千个每次重绘全部图形就不太行了。性能优化有两个方向一是局部刷新用Invalidate(rect)合并旧新位置二是对图形做区域管理比如按网格拆分图形索引只重绘视野范围内的图形。还有一个更彻底的方案是用 WPF 的DrawingVisual但在 WinFormGDI 的体系下双缓冲和局部刷新已经是性价比最高的优化。从功能扩展角度这套架构后续加直线、三角形、文字标注都很容易继承Shape基类实现Draw和HitTest就可以了。如果再配合序列化保存就能做出一个非常完整的轻量级图形编辑器。5.5 常见问题速查表现象可能原因解决办法图形闪烁未开启双缓冲设置 DoubleBuffered true拖动飞走坐标使用混乱用坐标差值不直接用鼠标点图形变形Bounds 宽高为负设置最小尺寸限制点不中图形边缘命中测试区域太小适当扩大 3~5 像素判断范围绘制时看不见图形颜色与背景相同检查 Pen 颜色、宽度设置图形重叠无法选中命中顺序不对倒序遍历图形列表我在实际项目里用这套方案跑了两个版本一个用在设备布局展示一个用在简单的报表流程图编辑器整体非常稳定。最后再分享一个小细节绘图过程中如果希望鼠标形状跟着场景变化可以在进入图形区域时把Cursor改成SizeAll进入控制点时改成SizeNWSE这种交互细节虽然不显眼但用户体感会立刻不一样。画图功能看起来简单真正把它做顺手关键还是数据结构清晰、状态机严谨、重绘策略得当。本文还有配套的精品资源点击获取