简介:这是一份面向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对象负责画,用List<Shape>负责存,用鼠标事件驱动一个「当前工具 + 当前状态」的状态机。把这三件事理顺,剩下的都是体力活。
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# 中数组和集合分别是怎么定义的,使用上有什么区别」,放到这个场景里答案很明确:用List<Shape>,不要用数组。因为绘图程序里图形数量是动态的,用户随时加、随时删、随时撤销,数组定长这一点就直接出局了。List<Shape>支持Add、RemoveAt、Insert,配合索引还能实现「选中第 N 个图形」这种操作。数组唯一的优势是遍历快一点点,但图形数量通常就几十上百个,这点差异完全感知不到。
private List<Shape> shapes = new List<Shape>(); // 所有已绘制图形 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 List<Point> 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 List<Point>(); 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深拷贝一份压进Stack<List<Shape>>。撤销就是弹出栈顶替换当前列表。缺点是内存占用大,但图形数量不多时完全够用。深拷贝记得给每个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写错,图形能画但选不中,用户以为程序坏了,其实是命中检测的边界条件没考虑。尤其是椭圆和多边形,边界上的点算不算「在里面」,得自己定个规则并保持一致。这套东西不难,难的是每个细节都想到,希望帮到你。
本文还有配套的精品资源,点击获取