
简介这是一份面向C#桌面开发初学者与WinForm绘图爱好者的完整绘图程序源码基于.NET Framework构建解决在窗体上实现基本图形绘制与交互编辑的问题。程序支持线条、矩形、圆、椭圆、多边形等图形的绘制并提供填充、换色、移动及画笔粗细调整等功能适合作为图形编程入门练习或课程设计参考。压缩包共70个文件约738KB以29个png界面截图、10个cs源码文件为主另含pdb调试文件、dll依赖库、resx资源文件、config配置及sln解决方案等结构完整可直接编译运行。资源围绕System.Drawing命名空间展开涵盖Graphics类的DrawLine、DrawRectangle、DrawEllipse、DrawPolygon等方法以及SolidBrush填充、Pen属性设置、鼠标事件驱动的图形移动与双缓冲性能优化等知识点。目前已有930人学习下载读者可借此掌握图形绘制API、事件处理与用户交互的完整实现思路快速搭建可扩展的绘图工具原型。1. 从零写一个 C# WinForm 绘图程序为什么 GDI 依然是桌面画板的首选很多人第一次接到「用 C# 做一个绘图程序」的需求时脑子里蹦出来的方案是 WPF、是 SkiaSharp、甚至是嵌一个 WebView 跑 Canvas。但如果你真的在工控上位机、教学演示、内部小工具这些场景里干过几年就会发现一个反直觉的结论WinForm 加 GDI 依然是这类需求里最省事、最稳、最不容易翻车的组合。原因很朴素——它不需要额外的渲染管线不需要处理 DPI 缩放的一堆玄学控件事件和绘图 API 是同一套消息循环里的东西鼠标按下到图形出现几乎没有延迟。这个标题要落地的东西其实很具体一个能画线条、矩形、圆、椭圆、多边形的画板支持填充、换色、拖动移动、调整画笔粗细。听起来像课程设计但真做起来坐标变换、双缓冲、命中检测、状态机这几块每一块都能让你卡半天。这篇文章面向的是想把它真正做出来的人——不管你是刚学完 C# 基础想找个练手项目还是要在上位机里塞一个标注/绘制模块的工程师下面这套路径都能直接抄。我下面讲的方案核心就三件事用Graphics对象负责画用ListShape负责存用鼠标事件驱动一个「当前工具 当前状态」的状态机。把这三件事理顺剩下的都是体力活。2. 图形数据怎么建模抽象基类、枚举与命中检测2.1 为什么先定 Shape 抽象类而不是直接画新手最容易犯的错是在MouseMove里直接graphics.DrawLine(...)。这样画出来的东西窗口一被遮挡、一最小化就全没了——因为 WinForm 的绘制是「按需重绘」系统只保证Paint事件里画的东西能留下来。所以正确的做法是鼠标事件只负责改数据Paint事件只负责根据数据重画。这就是所谓的「数据驱动绘制」。数据模型我一般这么定一个抽象基类Shape里面放公共属性画笔颜色、线宽、是否填充、填充色再放两个抽象方法Draw(Graphics g)和Contains(Point p)。前者负责画自己后者负责判断某个点是不是落在自己身上——这是后面「移动图形」的关键。// 图形基类所有图形都继承它 public abstract class Shape { public Color PenColor { get; set; } Color.Black; // 线条颜色 public float PenWidth { get; set; } 2f; // 画笔粗细 public bool Filled { get; set; } false; // 是否填充 public Color FillColor { get; set; } Color.LightGray; // 填充色 // 每个图形自己决定怎么画 public abstract void Draw(Graphics g); // 每个图形自己决定点是否落在身上用于选中和拖动 public abstract bool Contains(Point p); // 拖动时整体平移子类各自实现 public abstract void Move(int dx, int dy); }这段代码的逻辑很直白把「画」和「命中判断」这两个行为下放到子类基类只统一管理样式属性。参数上PenWidth用float是因为 GDI 的Pen构造函数接受float用int后面还得转Filled和FillColor分开是因为「描边不填充」和「描边加填充」是两种常见需求用一个布尔量控制最省心。2.2 五种基本图形各自的字段与实现差异线条、矩形、圆/椭圆、多边形它们的字段差别其实挺大硬塞进一个类会很难看。我一般拆成四个子类圆和椭圆可以合并因为 GDI 里圆就是宽高相等的椭圆。图形关键字段绘制 API命中检测思路线条 Line起点、终点DrawLine点到线段距离小于阈值矩形 Rect左上角、宽高DrawRectangle/FillRectangle点在矩形范围内椭圆 Ellipse外接矩形DrawEllipse/FillEllipse归一化椭圆方程 ≤ 1多边形 Polygon顶点数组DrawPolygon/FillPolygon射线法判断点在多边形内以矩形为例实现起来最短public class RectShape : Shape { public Rectangle Rect; // 位置和大小 public override void Draw(Graphics g) { using (var pen new Pen(PenColor, PenWidth)) { if (Filled) using (var brush new SolidBrush(FillColor)) g.FillRectangle(brush, Rect); // 先填充 g.DrawRectangle(pen, Rect); // 再描边保证边框不被盖住 } } public override bool Contains(Point p) Rect.Contains(p); public override void Move(int dx, int dy) { Rect new Rectangle(Rect.X dx, Rect.Y dy, Rect.Width, Rect.Height); } }这里有个细节值得说先填充再描边。如果反过来填充色会把边框盖掉一半视觉上线条会变细。using包住Pen和Brush是必须的GDI 的这些对象是非托管资源不释放的话画得多了内存会涨——这是 WinForm 绘图最经典的血泪经验之一。椭圆的命中检测稍微绕一点不能直接用外接矩形判断否则四个角会被误判。正确做法是把点坐标归一化到椭圆方程里public override bool Contains(Point p) { float cx Rect.X Rect.Width / 2f; // 椭圆中心 x float cy Rect.Y Rect.Height / 2f; // 椭圆中心 y float rx Rect.Width / 2f; // 半长轴 float ry Rect.Height / 2f; // 半短轴 if (rx 0 || ry 0) return false; float dx (p.X - cx) / rx; float dy (p.Y - cy) / ry; return dx * dx dy * dy 1f; // 归一化椭圆方程 }多边形的命中检测用射线法从待测点向右发一条水平射线数它和多边形边的交点个数奇数就在内部。这个方法对凹多边形也成立比凸包判断通用得多。2.3 用 List 还是数组存图形热搜里有人问「C# 中数组和集合分别是怎么定义的使用上有什么区别」放到这个场景里答案很明确用ListShape不要用数组。因为绘图程序里图形数量是动态的用户随时加、随时删、随时撤销数组定长这一点就直接出局了。ListShape支持Add、RemoveAt、Insert配合索引还能实现「选中第 N 个图形」这种操作。数组唯一的优势是遍历快一点点但图形数量通常就几十上百个这点差异完全感知不到。private ListShape shapes new ListShape(); // 所有已绘制图形 private Shape selected null; // 当前选中的图形shapes是绘制顺序也是 Z 序——后加的盖在前面上。selected单独拎出来是为了拖动时只改它一个不用遍历整个列表。3. 画布与交互双缓冲、鼠标状态机与工具切换3.1 双缓冲不闪屏的唯一正解如果你直接重写OnPaint然后疯狂Invalidate会看到画面疯狂闪烁。这不是代码写错了是 WinForm 默认的绘制机制导致的每次重绘先刷背景色再画内容中间那一瞬间就是白屏。解决办法是开启双缓冲让所有绘制先在一张内存位图上完成再一次性贴到屏幕。最省事的做法是在窗体构造函数里设置public MainForm() { InitializeComponent(); // 开启双缓冲消除重绘闪烁 this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.UpdateStyles(); }三个标志位各有分工AllPaintingInWmPaint让绘制只在WM_PAINT里发生忽略背景擦除消息UserPaint表示自己负责绘制OptimizedDoubleBuffer才是真正的双缓冲开关。三个一起开闪烁基本就没了。如果你把画布放在Panel上而不是窗体上记得对Panel也做同样设置或者干脆继承一个DoubleBufferedPanel。3.2 鼠标事件驱动的状态机绘图程序的交互本质是一个状态机当前选的是哪个工具画线/画矩形/画圆/画多边形/选择鼠标按下时处于什么阶段空闲/正在拖拽/正在画。我一般用两个枚举加几个字段来管public enum ToolType { Line, Rect, Ellipse, Polygon, Select } private ToolType currentTool ToolType.Line; private bool isDrawing false; // 是否正在拖拽绘制 private Point startPoint; // 按下时的起点 private Shape tempShape null; // 正在绘制的临时图形 private ListPoint polygonPoints null; // 多边形顶点收集MouseDown的逻辑分两条路如果是Select工具就从后往前遍历shapes后画的在上面优先选中找到第一个Contains为真的图形设为selected如果是绘制工具就记录起点、置isDrawing true。private void Canvas_MouseDown(object sender, MouseEventArgs e) { if (e.Button ! MouseButtons.Left) return; if (currentTool ToolType.Select) { // 从后往前找优先选中上层图形 selected null; for (int i shapes.Count - 1; i 0; i--) { if (shapes[i].Contains(e.Location)) { selected shapes[i]; break; } } lastMouse e.Location; // 记录拖动基准点 return; } startPoint e.Location; isDrawing true; // 多边形是逐点点击单独处理 if (currentTool ToolType.Polygon) { if (polygonPoints null) polygonPoints new ListPoint(); polygonPoints.Add(e.Location); } }MouseMove负责两件事绘制工具下更新tempShape并Invalidate触发重绘预览选择工具下如果selected不为空就计算位移量并调用Move。private void Canvas_MouseMove(object sender, MouseEventArgs e) { if (currentTool ToolType.Select selected ! null e.Button MouseButtons.Left) { int dx e.X - lastMouse.X; int dy e.Y - lastMouse.Y; selected.Move(dx, dy); // 平移选中图形 lastMouse e.Location; Invalidate(); // 请求重绘 return; } if (!isDrawing) return; // 根据当前工具构造临时图形用于预览 tempShape BuildShape(startPoint, e.Location); Invalidate(); }MouseUp收尾把tempShape加进shapes清空临时状态。多边形例外它要等用户双击或右键才结束。3.3 画笔粗细和颜色的实时切换换色和调粗细本质是改「下一个图形用什么样式」。我一般维护两个字段currentPenColor和currentPenWidth在BuildShape里赋给新图形。颜色用ColorDialog粗细用一个NumericUpDown或者TrackBar。private Color currentPenColor Color.Black; private float currentPenWidth 2f; private Shape BuildShape(Point p1, Point p2) { Shape s; switch (currentTool) { case ToolType.Line: s new LineShape { Start p1, End p2 }; break; case ToolType.Rect: s new RectShape { Rect NormalizeRect(p1, p2) }; break; case ToolType.Ellipse: s new EllipseShape { Rect NormalizeRect(p1, p2) }; break; default: return null; } s.PenColor currentPenColor; // 应用当前颜色 s.PenWidth currentPenWidth; // 应用当前粗细 s.Filled chkFill.Checked; // 是否填充 s.FillColor currentFillColor; return s; }NormalizeRect是个小工具方法因为用户可能从右下往左上拖p2.X - p1.X会是负数Rectangle不接受负宽高得统一成左上角加正宽高private Rectangle NormalizeRect(Point p1, Point p2) { return new Rectangle( Math.Min(p1.X, p2.X), Math.Min(p1.Y, p2.Y), Math.Abs(p1.X - p2.X), Math.Abs(p1.Y - p2.Y)); }这个坑我见过太多人踩不归一化的话从右下往左上拖图形直接消失调试半天以为是绘制代码错了其实是矩形宽高为负。3.4 Paint 事件里的绘制顺序OnPaint里要做三件事顺序不能乱先画所有已完成的图形再画正在拖拽的临时图形最后画选中高亮。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.SmoothingMode SmoothingMode.AntiAlias; // 抗锯齿线条更顺滑 foreach (var s in shapes) s.Draw(e.Graphics); // 已完成的图形 tempShape?.Draw(e.Graphics); // 拖拽预览 if (selected ! null) // 选中虚线框 { using (var pen new Pen(Color.Blue, 1) { DashStyle DashStyle.Dash }) { var b GetBounds(selected); e.Graphics.DrawRectangle(pen, b); } } }SmoothingMode.AntiAlias一定要开否则斜线和圆边缘会有明显锯齿观感差一个档次。代价是绘制稍慢但几十个图形完全无感。GetBounds是给每个图形算外接矩形的方法选中框和高亮都靠它。4. 避坑与排查绘图程序最容易翻车的五个地方4.1 图形画完就消失最小化后全没了现象鼠标拖拽时能看到图形一松手或者窗口被遮挡再恢复图形不见了。原因在MouseMove或MouseUp里直接拿了CreateGraphics()画图。这个Graphics对象是临时的只对当前这一帧有效系统重绘时不会重放你的绘制。解决所有绘制逻辑只放在OnPaint里鼠标事件只改数据然后调Invalidate()。这是数据驱动绘制的铁律没有例外。4.2 拖动图形时越拖越飘位置对不上现象选中一个图形拖动图形移动的距离和鼠标移动的距离不一致或者拖几下就跑到别处去了。原因MouseMove里用了startPoint当基准而不是上一次的鼠标位置。每次移动都从最初按下的点算位移累积误差就出来了。解决单独维护一个lastMouse字段每次MouseMove结束后更新它位移量永远用「当前点减 lastMouse」。上面 3.2 的代码就是这么写的。4.3 填充色把边框盖住线条看起来变细了现象开启填充后图形边框比不填充时细或者颜色发暗。原因先DrawRectangle再FillRectangle填充色覆盖了边框的内半边。解决调整顺序先填充后描边。如果还是觉得边框被压可以把填充矩形向内缩一个线宽或者干脆用FillRectangle画一个稍小的矩形。4.4 画了很多图形后内存持续上涨现象程序跑一段时间任务管理器里内存一直涨画得越多涨得越快。原因Pen、Brush、Font这些 GDI 对象没有释放。它们包装的是非托管句柄GC 管不到必须手动Dispose。解决所有Pen和Brush都用using包起来或者放进字段复用而不是每次new。上面所有Draw方法都用了using照抄就行。4.5 多边形点完后不知道怎么结束现象多边形工具点一个加一个点但永远画不完也没法确认。原因多边形和线条、矩形不一样它不是「按下-拖拽-松开」的一次性操作而是「多次点击 一次结束信号」。解决约定双击或右键结束。在MouseDown里判断e.Clicks 2或者e.Button MouseButtons.Right把polygonPoints构造成PolygonShape加进列表然后清空临时点集。记得在MouseMove里画一条从最后一个点到当前鼠标的「橡皮筋线」让用户知道下一个点会连到哪。5. 进阶技巧撤销重做、序列化保存与命中检测优化做到上面这些一个能用的画板就成型了。但如果你想让它在实际项目里站得住还有三块值得补。撤销重做。最省事的实现是命令模式但更简单粗暴的做法是「快照栈」每次图形列表发生变化新增、移动、删除之前把整个shapes深拷贝一份压进StackListShape。撤销就是弹出栈顶替换当前列表。缺点是内存占用大但图形数量不多时完全够用。深拷贝记得给每个Shape写Clone方法或者用序列化绕一圈。保存与加载。把shapes序列化成 JSON 存文件加载时反序列化回来。这里有个坑Shape是抽象类System.Text.Json默认不知道怎么还原子类。解决办法是用[JsonDerivedType]特性标注每个子类或者自己写一个带type字段的转换器。存的时候把PenColor转成 ARGB 整数读回来再转回Color比直接存Color对象稳。命中检测优化。图形少的时候从后往前遍历没问题图形上百个之后每次MouseMove都全量遍历会有点卡。可以给每个图形缓存一个外接矩形先用矩形做粗筛只有点落进外接矩形才做精确的Contains判断。线条和椭圆的精确判断相对贵这一步能省不少。// 粗筛 精判减少 Contains 调用次数 private Shape HitTest(Point p) { for (int i shapes.Count - 1; i 0; i--) { if (!GetBounds(shapes[i]).Contains(p)) continue; // 外接矩形粗筛 if (shapes[i].Contains(p)) return shapes[i]; // 精确判断 } return null; }最后说个我自己的习惯每加一个新图形类型先写Draw和Contains两个方法再写一个最小的测试用例手动点一遍。我吃过太多次亏——Contains写错图形能画但选不中用户以为程序坏了其实是命中检测的边界条件没考虑。尤其是椭圆和多边形边界上的点算不算「在里面」得自己定个规则并保持一致。这套东西不难难的是每个细节都想到希望帮到你。本文还有配套的精品资源点击获取