简介:一份基于C# WinForm实现的图形绘制程序源码包,面向学习WinForm绘图与GDI+的开发者,覆盖线条、矩形、圆、多边形、椭圆等基本图形绘制,并支持填充、换色、移动和画笔粗细调整,可满足课程设计或入门练习需求。资源共70个文件,压缩包738KB,以.cs源码与.csproj/sln工程文件为核心,辅以png界面截图、dll/exe可运行程序、config配置文件及resources资源文件等,结构较完整,便于直接打开调试。已有931人学习下载。除完整项目代码外,还包含Form1.cs等窗体逻辑与Designer设计文件,可对照理解鼠标事件处理、Graphics对象绘图、Pen/Brush属性设置等关键实现,适合需要快速上手或参考整体工程布局的开发者。
1. 这套 WinForm 绘图程序的真正价值不在画图方法,而在交互状态机
拿到这份 C# WinForm 绘图程序资源时,大多数人第一眼会去找 DrawLine、DrawRectangle 这些绘制 API,看完觉得"就这?"——但等你真去复现一遍就会发现,画几个基本图形只是半小时的事,真正卡住你的是另一件事:画完之后怎么让图形能被选中、被拖动、被改变颜色和线宽而不出错。这套资源的完整代码恰好把这套交互逻辑串起来了,线条、矩形、圆、多边形、椭圆五类图形统一管理,支持填充、颜色切换、移动和画笔粗细调整,适合已经学过 C# 基础语法和 WinForm 控件使用、想通过一个完整项目把 GDI+ 绘图和鼠标事件链打通的人。拿到代码后从运行到改造,大约一个下午就能把每一个交互细节读透。
2. 从压缩包到跑起来:先摸清工程骨架再谈绘图
2.1 目录结构里那些容易被忽略的文件
解压后你会看到一个标准 WinForm 解决方案的完整骨架。解决方案文件(.sln)和项目文件(.csproj)定义了工程的编译范围;Form1.cs是主窗体的逻辑代码,所有绘图和交互都在这里;Form1.Designer.cs是窗体设计器生成的布局代码,工具栏按钮、颜色选择器等控件都在这里初始化;Program.cs是入口,负责启动主窗体。值得注意的是,资源里还有一个独立的Class1.cs文件——这个文件在原项目里承担了核心数据结构的定义,也就是"图形对象"类。这是整个程序能实现"移动、修改"的关键:每个图形在内存里都被封装成一个对象,记录自己的类型、坐标、颜色、线宽和填充状态,而不是画完就丢掉的临时像素。
我一般会建议拿到资源后先不要急着按 F5,花十分钟把每个.cs文件打开扫一遍,重点看Form1.cs里的字段声明——你会发现窗体类里声明了一个图形对象的集合(List 类型),这个集合就是整个程序的"内存画布"。后续所有绘制、重绘、移动操作,本质都是往这个集合里增删改数据。
2.2 启动流程:从 Main 到窗体的完整链路
WinForm 应用的启动路径非常固定,理解这条链路能帮你快速定位"程序到底在哪一行开始执行"。Program.cs里的入口函数负责创建一个窗体实例并启动消息循环:
// Program.cs:标准 WinFrom 入口 static class Program { [STAThread] static void Main() { // 启用 Windows 视觉样式,否则控件外观偏旧 Application.EnableVisualStyles(); // 设置文本渲染模式为默认兼容模式 Application.SetCompatibleTextRenderingDefault(false); // 创建主窗体实例并启动消息循环 Application.Run(new Form1()); } }这段代码的重点在于Application.Run(new Form1()):它不仅是显示窗口,更关键的是启动了一个消息循环——鼠标点击、键盘输入、重绘请求都会变成消息进入队列,由窗体逐个处理。绘图程序的所有交互都依赖这个循环,如果你自己写小程序时把这行写成了new Form1().Show(),后续鼠标事件大概率不响应,因为根本没有进入消息循环。
Form1构造函数里则会完成两件关键初始化:调用InitializeComponent()加载设计器里摆放的按钮、颜色选择器等控件;然后把画布控件的DoubleBuffered属性设为 true 以减少重绘闪烁。这个属性你在设计器里找不到,但代码里直接赋值是有效的,后面避坑章节会展开说。
2.3 画布选型:为什么用 PictureBox 而不是直接画在窗体上
资源里的绘图区用的是 PictureBox 控件,而不是直接在窗体上画。这个选择很实用:PictureBox 自带Paint事件,而且可以独立控制大小和背景色,不会和工具栏按钮区域互相干扰。窗体本身如果被按钮占据了大半空间,绘图区就很难管理,把画布独立出来之后,鼠标坐标计算也清晰了——你只需要做一次PictureBox的坐标系转换,不用去算按钮区域的偏移量。
实际使用中,PictureBox 的Image属性可以直接持有位图,后期做保存功能时只需pictureBox.Image.Save(...)一行代码。如果当初选的是 Panel,虽然也能绘制,但位图保存和双缓冲都得自己额外处理。从扩展性角度看,资源选 PictureBox 是一个值得保留的设计。
3. 绘制核心:Graphics 状态机与五类图形的统一实现
3.1 Graphics 对象的生命周期:谁创建、谁释放
GDI+ 绘图的核心是Graphics类。它封装了画布、画笔、画刷和坐标系转换等底层能力。在 WinForm 里获取Graphics对象有三种途径:在Paint事件参数里拿(e.Graphics)、调用控件CreateGraphics()方法拿、从Bitmap图像上拿。这套资源里用的是第一种和第三种组合——Paint事件里做全部重绘,保存位图时用第三种。
Graphics对象是典型的非托管资源,用完后必须释放。但在Paint事件里拿到的e.Graphics由事件系统托管释放,你不需要(也不应该)手动调用 Dispose;而CreateGraphics()拿到的必须手动释放,否则 GDI 句柄会泄漏,程序跑久了绘图会越来越卡甚至报错。这套程序里另一个重要细节是 Pen 和 Brush 的创建:它们也都是非托管资源,资源代码里用using语句包裹,这是正确做法。
3.2 五类图形的数据建模:一个对象管所有形状
这套资源的设计亮点在于用一个图形对象类统一存储五类图形的信息。参考它的设计思路,我会这样定义一个图形类:
public enum ShapeType { 线条, 矩形, 圆, 椭圆, 多边形 } public class ShapeObject { public ShapeType Type { get; set; } // 图形类型 public Point StartPoint { get; set; } // 起点(拖拽起点) public Point EndPoint { get; set; } // 终点(当前鼠标位置) public Color LineColor { get; set; } = Color.Black; // 线条颜色 public int PenWidth { get; set; } = 2; // 画笔粗细 public bool IsFilled { get; set; } // 是否填充 public Color FillColor { get; set; } = Color.Transparent; // 填充色 // 由起点和终点计算出规范化矩形区域(避免反向拖拽时矩形坐标异常) public Rectangle GetRectangle() { int x = Math.Min(StartPoint.X, EndPoint.X); int y = Math.Min(StartPoint.Y, EndPoint.Y); int width = Math.Abs(EndPoint.X - StartPoint.X); int height = Math.Abs(EndPoint.Y - StartPoint.Y); return new Rectangle(x, y, width, height); } }这里最关键的思路是"规范化矩形":用户在画矩形时可能从右下角往左上角拖,如果不做Math.Min和Math.Abs处理,矩形宽度和高度会出现负数,画出来就是错乱的。GetRectangle()方法确保任何时候拿到的都是一个坐标正向的矩形。
3.3 统一绘制入口:一个 switch 分派五种画法
有了数据对象之后,绘制逻辑就集中在一个方法里:根据ShapeType决定调用哪个 Graphics 方法。在Paint事件里遍历图形集合,逐个调用即可:
// Form1_Paint:负责把所有图形画到画布上 private void Form1_Paint(object sender, PaintEventArgs e) { Graphics g = e.Graphics; // 对集合里的每个图形对象依次绘制 foreach (ShapeObject shape in shapeList) { // 用 using 确保 Pen 和 Brush 及时释放 using Pen pen = new Pen(shape.LineColor, shape.PenWidth); using Brush brush = new SolidBrush(shape.FillColor); switch (shape.Type) { case ShapeType.线条: g.DrawLine(pen, shape.StartPoint, shape.EndPoint); break; case ShapeType.矩形: Rectangle rect = shape.GetRectangle(); // 填充和描边分开处理:先填充再画边框 if (shape.IsFilled) g.FillRectangle(brush, rect); g.DrawRectangle(pen, rect); break; case ShapeType.圆: case ShapeType.椭圆: Rectangle ellipseRect = shape.GetRectangle(); if (shape.IsFilled) g.FillEllipse(brush, ellipseRect); g.DrawEllipse(pen, ellipseRect); break; case ShapeType.多边形: // 由起点、终点和固定高度推导出正多边形顶点 Point[] points = GetPolygonPoints(shape); if (shape.IsFilled) g.FillPolygon(brush, points); g.DrawPolygon(pen, points); break; } } }这段代码的几个细节值得注意。第一,圆和椭圆在这里是共用的绘制逻辑,圆只是起点和终点宽度相等时的特例,所以资源把它们归为一类处理。第二,先填充再描边:如果顺序反过来,填充色会盖住边框线,视觉效果差很多。第三,using声明会在方法结束时自动释放 Pen 和 Brush,避免 GDI 句柄泄漏——这是 C# 8 之后的语法,如果你用的 .NET Framework 版本低于 4.8,需要改成using (Pen pen = new Pen(...)) { ... }的传统写法。
GetPolygonPoints方法是多边形的顶点生成器。常见做法是根据起点作为中心点,用三角函数计算正多边形顶点坐标。你可以在资源代码里找到具体实现,我会通过测试来验证结果(确保它确实生成了正多边形,而不是随机点)。
3.4 填充与颜色映射:Brush 和 Pen 的参数对应关系
用户界面上"填充图形"和"换颜色"这两个按钮,本质上是修改ShapeObject的IsFilled、FillColor、LineColor属性。绘制的趋势是:画一个图形时,从工具栏读取当前选中的形状类型、颜色、线宽,构造ShapeObject;点击填充按钮时,修改选中图形的IsFilled和FillColor。Pen只有粗细和颜色两维可变;SolidBrush只有颜色一维可变。如果你后续想加渐变填充,LinearGradientBrush构造函数需要矩形坐标、起始色和结束色三个参数,这些参数在GetRectangle()方法里现成可用的,扩展成本很低。
4. 交互与移动:鼠标事件链与命中测试
4.1 鼠标三事件的分工:按下、拖动、抬起
绘制图形的交互逻辑是典型的"按下-拖动-抬起"三事件链。这套资源里三件事分别处理:MouseDown记录起点并创建新图形、MouseMove更新终点并触发重绘、MouseUp结束绘制。核心代码结构如下:
// 鼠标按下:判断是"画新图形"还是"选中移动已有图形" private void canvas_MouseDown(object sender, MouseEventArgs e) { if (currentMode == DrawMode.Draw) { // 新建一个图形对象,起点终点都设为按下位置 currentShape = new ShapeObject { Type = currentShapeType, StartPoint = e.Location, EndPoint = e.Location, LineColor = currentColor, PenWidth = currentPenWidth, IsFilled = chkFill.Checked, FillColor = fillColor }; isDrawing = true; } else if (currentMode == DrawMode.Move) { // 遍历图形集合,找到第一个包含鼠标点的图形 selectedShape = HitTest(e.Location); if (selectedShape != null) { // 记录拖动起点与图形起点之间的偏移量 dragOffset = new Size(e.Location.X - selectedShape.StartPoint.X, e.Location.Y - selectedShape.StartPoint.Y); } } } // 鼠标移动:实时更新终点,产生"正在画"的效果 private void canvas_MouseMove(object sender, MouseEventArgs e) { if (isDrawing && currentShape != null) { currentShape.EndPoint = e.Location; canvas.Invalidate(); // 触发重绘 } else if (selectedShape != null) { // 用当前位置减去偏移量,得出图形新位置 selectedShape.StartPoint = new Point(e.X - dragOffset.Width, e.Y - dragOffset.Height); selectedShape.EndPoint = new Point(e.X - dragOffset.Width, e.Y - dragOffset.Height); canvas.Invalidate(); } } // 鼠标抬起:结束绘制或移动 private void canvas_MouseUp(object sender, MouseEventArgs e) { if (isDrawing && currentShape != null) { // 把画好的图形正式加入集合 shapeList.Add(currentShape); isDrawing = false; } selectedShape = null; }这套逻辑里Invalidate()是最值得理解的调用。它不会立刻重绘,而是向消息队列投递一个重绘请求,等当前消息处理完再触发Paint事件。这样即使鼠标快速移动产生大量MouseMove事件,实际重绘次数也会被合并,性能上比每次都手动调用Refresh()好得多。
4.2 命中测试:判断点是否落在图形内部
移动图形的前提是"选中"图形,这就要求写一个命中测试方法。矩形和椭圆比较简单,直接用GetRectangle()拿区域,然后判断鼠标点是否在区域内;线条需要算点到线段的最短距离;多边形需要做点在多边形内部的判定。这套资源里有一个实用的实现思路:把命中测试放在一个独立方法里,按图形类型分别处理:
// 命中测试:返回第一个包含鼠标点的图形对象 private ShapeObject HitTest(Point point) { // 倒序遍历:后画的图形在上层,优先命中 for (int i = shapeList.Count - 1; i >= 0; i--) { ShapeObject shape = shapeList[i]; // 椭圆和矩形用区域判定,线条用距离判定 if (shape.Type == ShapeType.线条 || shape.Type == ShapeType.多边形) { // 简化为计算点到图形中心点距离,小于阈值则命中 Rectangle rect = shape.GetRectangle(); if (Distance(point, rect.Center()) < Math.Max(10, shape.PenWidth)) return shape; } else { Rectangle rect = shape.GetRectangle(); // 扩大命中区域:边界外扩 5 像素,方便用户选中细线条 rect.Inflate(5, 5); if (rect.Contains(point)) return shape; } } return null; }倒序遍历是一个关键技巧。因为后画的图形在视觉上覆盖先画的图形,命中测试也必须从后往前找,否则会出现"点了上层的圆却选到了被覆盖的矩形"这种反直觉问题。rect.Inflate(5, 5)是为了给用户留出容错空间——如果图形特别小,精确点击很难,向外扩几个像素能明显改善体验。
4.3 工具栏与画布解耦:按钮如何指挥画布
WinForm 里工具栏按钮和画布是两回事,中间需要一个"指挥"机制。这套资源的做法是使用枚举变量来记录当前工具状态:currentMode区分是画图模式还是移动模式,currentShapeType记录选中了哪种图形。点击工具栏按钮时只改这些变量,真正的操作发生在鼠标事件里读取它们。
主窗体上放置的各类按钮分别赋值不同的currentShapeType和currentMode。这种"状态变量 + 事件读取"的设计是典型的小型绘图工具架构,它避免了每个按钮都要单独维护一套事件逻辑的复杂度。
5. 避坑与调试:闪烁、坐标漂移与虚线问题
5.1 重绘闪烁严重,拖动图形时画面"残影"明显
现象:鼠标拖动图形时,画面像是被"撕开"了,旧位置的图形没擦干净,新位置又画了一半,整个画布闪烁严重。
原因:WinForm 控件的默认重绘方式是先擦除背景再绘制前景,擦除和绘制之间有短暂的时间差,肉眼看到的就是闪烁。绘图程序里鼠标移动会触发大量Invalidate()调用,闪烁被放大到难以忍受的程度。
解决:在Form1构造函数里对画布控件设置双缓冲。双缓冲的核心原理是在内存中先画好一帧完整图像,再一次性拷贝到屏幕。你可以在代码里直接加一行canvas.DoubleBuffered = true;,这个属性在 PictureBox 上可以直接设置。如果某些控件类型不支持直接设置,可以写一个继承控件类并修改DoubleBuffered的派生类来处理。
5.2 移动图形时,图形"跳"了一下
现象:鼠标按住图形拖动,图形不是跟手移动,而是跳到一个偏离的位置,之后才跟随鼠标。
原因:这是移动逻辑的典型 bug——记录鼠标偏移量时出了问题。MouseDown里记录的是图形起点到鼠标的偏移,如果在MouseMove里直接用鼠标位置作为新起点,就会导致图形"瞬移"到鼠标位置。
解决:确保按下时记录偏移量,移动时用当前位置减去偏移量,代码即可正常运行。没有偏移量计算的直接赋值会导致跳变——这个 bug 在绘制逻辑里非常隐蔽。
5.3 多个图形颜色同时变化,"串色"了
现象:改了一个图形的颜色,结果画布上多个图形颜色都变了,甚至新画的图形也用了上一个图形的线宽。
原因:多个图形对象引用了同一个 Pen 或 Brush 实例。Pen是引用类型,如果创建时从同一个变量赋值,多个图形会共享同一个 Pen 对象。修改一个图形的颜色时,其实修改的是共享的 Pen 对象,所有用这个 Pen 的图形一起变了。
解决:每次绘制时都用new Pen(...)创建独立实例,或者用pen.Clone()拷贝一份。资源代码里在Paint事件里用using创建新 Pen 的做法天然规避了这个问题——每个图形绘制都生成独立的 Pen 和 Brush。
5.4 窗口最小化后再恢复,画布内容消失
现象:绘图中间把窗口最小化,恢复后发现画布一片空白,之前画的图形全没了。
原因:Paint事件触发的时机比你想的更频繁,窗口被遮住、最小化、拖出屏幕再拉回来,系统都会触发Paint事件要求窗体重新绘制。如果绘制逻辑只处理了"当前正在画的图形",没有遍历所有已保存的图形集合,重绘时自然只有空白。
解决:把所有图形持久化在shapeList集合中,Paint事件里遍历整个集合。这套资源的Form1_Paint方法做的就是这回事。如果没有这个集合,每次Paint事件都要重新画一遍,数据就丢失了。
6. 扩展:保存画布、撤销与右键删除——十分钟补齐实用功能
绘图程序的基础版本能画、能改、能移动之后,下一个刚需就是保存和撤销。保存功能的实现比想象中简单:PictureBox 的DrawToBitmap方法可以把当前画布内容导出成位图。
// 保存画布为 PNG:借用 DrawToBitmap 实现 private void btnSave_Click(object sender, EventArgs e) { using (SaveFileDialog dlg = new SaveFileDialog()) { dlg.Filter = "PNG 图片|*.png|位图|*.bmp"; if (dlg.ShowDialog() == DialogResult.OK) { // 创建与画布等大的位图 Bitmap bmp = new Bitmap(canvas.Width, canvas.Height); // 把画布内容绘制到位图上 canvas.DrawToBitmap(bmp, new Rectangle(0, 0, canvas.Width, canvas.Height)); bmp.Save(dlg.FileName); } } }撤销功能的实现思路是维护一个操作历史栈,每次"完成一个图形"时入栈。撤销时从栈顶弹出图形对象,从shapeList里移除,同时调用Invalidate()重绘。需要注意的点是撤销栈不要无限增长,一般限制在 20 步以内就够了。
右键删除功能是移动模式的自然延伸:在MouseDown里判断e.Button == MouseButtons.Right,如果能命中图形就把它从集合里移除。这三项扩展工作量不大,但补完之后工具就从"能 demo"进化到了"能日常用"。我从类似项目里拿到的经验是:基础绘图交互可能踩的坑,九成都在鼠标事件链和重绘机制上——从那以后我每次在 WinForm 里写带交互的画布程序,都会先强制走一遍三件事:确认Paint事件里画的是数据集合、确认Invalidate()是唯一触发重绘的入口、确认 Pen 和 Brush 没有跨图形共享。希望帮到你。
本文还有配套的精品资源,点击获取