简介:这是一份基于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# 做图形界面为什么不用 WPF?WPF 有更强大的渲染能力和动画支持,但很多工控项目、上位机项目至今仍是 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 时要记录当前鼠标位置_lastPoint,MouseMove 时计算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.8,WinForm 项目。新建项目后,界面布局按“左边工具栏 + 中间画布”来做。左侧放几个 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列表快照压入栈,方便 Ctrl+Z。
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,但在 WinForm+GDI+ 的体系下,双缓冲和局部刷新已经是性价比最高的优化。
从功能扩展角度,这套架构后续加直线、三角形、文字标注都很容易:继承Shape基类,实现Draw和HitTest就可以了。如果再配合序列化保存,就能做出一个非常完整的轻量级图形编辑器。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 图形闪烁 | 未开启双缓冲 | 设置 DoubleBuffered = true |
| 拖动飞走 | 坐标使用混乱 | 用坐标差值,不直接用鼠标点 |
| 图形变形 | Bounds 宽高为负 | 设置最小尺寸限制 |
| 点不中图形边缘 | 命中测试区域太小 | 适当扩大 3~5 像素判断范围 |
| 绘制时看不见图形 | 颜色与背景相同 | 检查 Pen 颜色、宽度设置 |
| 图形重叠无法选中 | 命中顺序不对 | 倒序遍历图形列表 |
我在实际项目里用这套方案跑了两个版本,一个用在设备布局展示,一个用在简单的报表流程图编辑器,整体非常稳定。最后再分享一个小细节:绘图过程中如果希望鼠标形状跟着场景变化,可以在进入图形区域时把Cursor改成SizeAll,进入控制点时改成SizeNWSE,这种交互细节虽然不显眼,但用户体感会立刻不一样。画图功能看起来简单,真正把它做顺手,关键还是数据结构清晰、状态机严谨、重绘策略得当。
本文还有配套的精品资源,点击获取