简介这是一份面向C#初学者与Winform桌面开发练习者的绘图程序源码基于.NET Framework构建可用于学习图层管理、图形绘制与图像保存等典型桌面绘图场景。压缩包共46个文件约529KB以cs源码、resx资源、config配置、sln解决方案及exe可执行文件为主另含少量zbak备份与cache缓存文件结构完整可直接编译运行。程序支持新建图层并单独显示或隐藏可在不同图层上绘制线条、矩形、圆形等基础图形配合颜色选择器精确取色并提供可调大小的橡皮擦用于局部修正作品还能保存到磁盘以便后续继续编辑。已有44人学习下载适合作为课程设计、毕业设计或Winform绘图入门的参考案例帮助读者快速理解多图层绘制、图形编辑与文件保存的实现思路。1. 图层化绘图板从“画完就丢”到“可编辑可回退”的 WinForms 实战用 C# WinForms 做绘图程序很多人第一版都是直接在窗体上Graphics.DrawLine画完窗口一刷新就没了想擦掉一条线只能整屏重绘。这个标题要解决的核心问题是把“画布”升级成“图层 对象”的结构新建图层、单独显示某个图层、在图层上画线条矩形圆、选颜色、橡皮擦、保存文件。它适合两类人一类是刚学完 C# 基础、想找一个能写进简历的 WinForms 项目案例的入门者另一类是做上位机或工业控制界面、需要在界面上叠加标注和图形的工程师。图层这个概念在 ArcMap、PS、甚至前端 canvas 绘图引擎里都通用WinForms 里没有现成的图层控件得自己用数据结构搭出来。下面按“先想清楚结构、再动手写、最后避坑”的顺序讲。2. 图层与图形对象的数据结构怎么定先想清楚再写代码2.1 为什么不能直接往窗体上画直接在Form的Paint事件里画线图形只存在于当前这一帧的 GDI 绘制指令里窗口被遮挡再恢复、最小化再还原系统会重新触发Paint你之前画的东西全没了。这是 WinForms 绘图新手最常见的翻车点。要持久化必须把“画了什么”存成数据每次Paint时根据数据重绘。再进一步标题要求“新建图层、单独显示某个图层”意味着数据要分两层组织图层Layer是容器图形Shape是内容。图层控制可见性和顺序图形控制自己的几何和样式。这样“单独显示某个图层”就变成把其他图层的Visible设为false重绘时跳过即可不需要删数据。2.2 三个核心类的字段设计我一般会定义三个类Shape基类、Layer、Document。Shape存几何和样式Layer存一组 Shape 加可见性Document存一组 Layer 加当前选中图层。// 图形基类所有可绘制对象的公共字段 public abstract class Shape { public Color StrokeColor { get; set; } Color.Black; // 线条/边框颜色 public float StrokeWidth { get; set; } 2f; // 线宽 public bool IsFilled { get; set; } false; // 是否填充 public Color FillColor { get; set; } Color.LightBlue; public abstract void Draw(Graphics g); // 子类各自实现 public abstract bool HitTest(Point p); // 橡皮擦命中判断 } // 线条 public class LineShape : Shape { public Point Start { get; set; } public Point End { get; set; } public override void Draw(Graphics g) { using (var pen new Pen(StrokeColor, StrokeWidth)) g.DrawLine(pen, Start, End); } public override bool HitTest(Point p) { // 点到线段距离小于阈值即算命中阈值取线宽3 return DistanceToSegment(p, Start, End) StrokeWidth 3; } } // 矩形 public class RectShape : Shape { public Rectangle Rect { get; set; } public override void Draw(Graphics g) { using (var pen new Pen(StrokeColor, StrokeWidth)) { if (IsFilled) using (var brush new SolidBrush(FillColor)) g.FillRectangle(brush, Rect); g.DrawRectangle(pen, Rect); } } public override bool HitTest(Point p) Rect.Contains(p); } // 圆用椭圆矩形表示 public class EllipseShape : Shape { public Rectangle Rect { get; set; } public override void Draw(Graphics g) { using (var pen new Pen(StrokeColor, StrokeWidth)) { if (IsFilled) using (var brush new SolidBrush(FillColor)) g.FillEllipse(brush, Rect); g.DrawEllipse(pen, Rect); } } public override bool HitTest(Point p) { // 椭圆命中归一化到单位圆判断 float cx Rect.X Rect.Width / 2f; float cy Rect.Y Rect.Height / 2f; 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 1.0f; } }Shape用抽象基类而不是接口是因为颜色、线宽这些字段有默认实现子类不用重复写。HitTest是橡皮擦的关键橡皮擦不是“擦像素”而是“删对象”所以每个图形必须能判断某个点是否落在自己身上。线条用点到线段距离矩形用Contains椭圆用归一化方程这三个是最小可用集合。2.3 图层与文档的容器设计public class Layer { public string Name { get; set; } 图层; public bool Visible { get; set; } true; // 单独显示靠这个开关 public ListShape Shapes { get; set; } new ListShape(); } public class Document { public ListLayer Layers { get; set; } new ListLayer(); public int ActiveLayerIndex { get; set; } 0; public Layer ActiveLayer Layers[ActiveLayerIndex]; }Document里用ListLayer而不是数组因为图层要频繁增删List的Add/RemoveAt更顺手。ActiveLayerIndex记录当前在哪个图层上画新建图形时ActiveLayer.Shapes.Add(shape)。绘制顺序就是Layers的顺序后面的图层盖在前面的上面和 PS 的图层排列器逻辑一致。提示Shape里不要存Graphics对象或Pen对象GDI 的Pen/Brush是稀缺资源存下来容易忘记释放。每次Draw时用using临时创建画完即释放这是避免 GDI 句柄泄漏的基本习惯。3. 画布重绘与鼠标交互把绘制循环和工具切换跑通3.1 用双缓冲 Panel 承载画布不要直接在Form上画用一个Panel或自定义Control当画布开启双缓冲减少闪烁。public class CanvasPanel : Panel { public Document Doc { get; set; } new Document(); public CanvasPanel() { // 双缓冲减少重绘闪烁WinForms 绘图必开 this.DoubleBuffered true; this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.BackColor Color.White; } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 按图层顺序绘制不可见图层直接跳过 foreach (var layer in Doc.Layers) { if (!layer.Visible) continue; foreach (var shape in layer.Shapes) shape.Draw(e.Graphics); } } }DoubleBuffered true配合SetStyle的三个标志位是 WinForms 自绘控件的标准配置。SmoothingMode.AntiAlias让线条和圆边缘平滑代价是略微降低绘制速度图形数量在几千以内感觉不到。OnPaint里只做重绘不做任何数据修改这是绘制循环的铁律。3.2 鼠标事件驱动图形创建工具切换用一个枚举ToolModeLine、Rect、Ellipse、Eraser。鼠标按下记起点移动时画预览抬起时把图形加入当前图层。public enum ToolMode { Line, Rect, Ellipse, Eraser } // 在 CanvasPanel 中 private Point _startPoint; private bool _isDrawing false; private Shape _previewShape null; public ToolMode CurrentTool { get; set; } ToolMode.Line; public Color CurrentColor { get; set; } Color.Black; protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); if (e.Button ! MouseButtons.Left) return; _startPoint e.Location; _isDrawing true; if (CurrentTool ToolMode.Eraser) { // 橡皮擦从当前图层删除命中的图形倒序删避免索引错位 var shapes Doc.ActiveLayer.Shapes; for (int i shapes.Count - 1; i 0; i--) { if (shapes[i].HitTest(e.Location)) { shapes.RemoveAt(i); break; // 一次只擦一个想连续擦可去掉 break } } Invalidate(); _isDrawing false; return; } _previewShape CreateShape(CurrentTool, _startPoint, _startPoint); _previewShape.StrokeColor CurrentColor; } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (!_isDrawing || _previewShape null) return; UpdateShape(_previewShape, _startPoint, e.Location); Invalidate(); // 触发重绘预览图形跟着鼠标走 } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); if (!_isDrawing || _previewShape null) return; UpdateShape(_previewShape, _startPoint, e.Location); Doc.ActiveLayer.Shapes.Add(_previewShape); // 落笔加入图层 _previewShape null; _isDrawing false; Invalidate(); }CreateShape和UpdateShape是两个辅助方法根据工具类型创建对应图形并更新终点坐标。橡皮擦用倒序删除是因为RemoveAt会让后面元素索引前移正序删会漏掉元素这是血泪经验。Invalidate()触发OnPaint预览图形因为还没加入Shapes列表需要在OnPaint里额外画一次_previewShape否则看不到拖拽预览。3.3 单独显示某个图层的实现“单独显示某个图层”有两种理解一是只让一个图层可见其他全隐藏二是切换某个图层的可见性。我一般做成图层面板里每行一个CheckBox控制Visible再加一个“独显”按钮。// 独显只保留指定图层可见 private void SoloLayer(int index) { for (int i 0; i Doc.Layers.Count; i) Doc.Layers[i].Visible (i index); canvasPanel.Invalidate(); } // 切换可见性 private void ToggleLayerVisible(int index) { Doc.Layers[index].Visible !Doc.Layers[index].Visible; canvasPanel.Invalidate(); }图层面板可以用ListView或CheckedListBoxCheckedListBox更省事勾选状态直接映射Visible。注意CheckedListBox的ItemCheck事件在勾选状态真正改变之前触发要用e.NewValue判断不要直接读GetItemChecked否则拿到的是旧值。注意Invalidate()只是标记重绘不是立即重绘。连续快速调用多次Invalidate只会合并成一次OnPaint这是好事但如果你在OnPaint里又调Invalidate会造成无限重绘死循环界面卡死。4. 颜色选择、橡皮擦与保存把交互闭环补完4.1 颜色选择用 ColorDialog颜色选择不需要自己写调色板WinForms 自带ColorDialog。private void btnColor_Click(object sender, EventArgs e) { using (var dlg new ColorDialog()) { dlg.FullOpen true; // 展开自定义颜色区 dlg.Color canvasPanel.CurrentColor; if (dlg.ShowDialog() DialogResult.OK) { canvasPanel.CurrentColor dlg.Color; btnColor.BackColor dlg.Color; // 按钮背景同步显示当前色 } } }FullOpen true让对话框直接展开调色板少点一次。using包住ColorDialog是因为它实现了IDisposable虽然很多人不写也没事但养成习惯没坏处。选完颜色后把按钮背景色改掉用户一眼能看到当前用什么颜色。4.2 橡皮擦的两种策略与选择橡皮擦有两种做法对象擦除和像素擦除。对象擦除是删掉整个图形像素擦除是把经过的像素涂成背景色。标题里“橡皮擦”配合“图层”和“保存”我建议用对象擦除因为保存成矢量数据后还能再编辑像素擦除保存的就只是一张位图图层结构没意义了。对象擦除的命中判断已经在HitTest里实现。如果想让橡皮擦连续擦把OnMouseDown里的break去掉改成在OnMouseMove里也做命中删除但要注意性能每次移动都遍历所有图形图形上千时会卡。优化办法是给每个图形算一个包围盒先用包围盒粗筛再做精确HitTest。// 包围盒粗筛减少精确判断次数 public abstract Rectangle Bounds { get; } // LineShape 的 Bounds public override Rectangle Bounds { get { int x Math.Min(Start.X, End.X) - (int)StrokeWidth; int y Math.Min(Start.Y, End.Y) - (int)StrokeWidth; int w Math.Abs(Start.X - End.X) (int)StrokeWidth * 2; int h Math.Abs(Start.Y - End.Y) (int)StrokeWidth * 2; return new Rectangle(x, y, w, h); } }4.3 保存与加载JSON 序列化最省事保存格式我推荐 JSON用System.Text.Json或Newtonsoft.Json。二进制格式虽然小但调试时打不开JSON 出问题能直接看文本后悔药好吃。using System.Text.Json; // 保存 public void SaveDocument(string path) { var options new JsonSerializerOptions { WriteIndented true, IncludeFields false }; string json JsonSerializer.Serialize(Doc, options); File.WriteAllText(path, json); } // 加载 public void LoadDocument(string path) { string json File.ReadAllText(path); Doc JsonSerializer.DeserializeDocument(json); canvasPanel.Doc Doc; canvasPanel.Invalidate(); }System.Text.Json序列化多态类型Shape基类引用指向LineShape等子类默认会丢类型信息反序列化时不知道要还原成哪个子类。解决办法是加[JsonDerivedType]特性标注子类或者用Newtonsoft.Json的TypeNameHandling.Auto。这是最容易翻车的地方保存时看着正常加载时所有图形变成基类或者直接报错。// .NET 7 用 JsonDerivedType 标注多态 [JsonDerivedType(typeof(LineShape), line)] [JsonDerivedType(typeof(RectShape), rect)] [JsonDerivedType(typeof(EllipseShape), ellipse)] public abstract class Shape { ... }如果项目还在用 .NET Framework 4.x没有System.Text.Json的多态支持直接上Newtonsoft.Json在序列化设置里加TypeNameHandling.Auto一行搞定。提示保存路径用SaveFileDialog让用户选默认扩展名.jsonFilter写绘图文件 (*.json)|*.json。加载时先判断文件是否存在、内容是否为空再反序列化反序列化用try-catch包住损坏的文件不要让程序直接崩。5. 避坑与排查图层绘图程序最容易翻车的 5 个点5.1 现象窗口最小化再还原画的东西全没了原因图形只画在OnPaint里没有存成数据或者存了但OnPaint没遍历数据重绘。解决确认所有图形都在Layer.Shapes里OnPaint遍历Doc.Layers逐个Draw。如果用了Graphics.FromImage画到后台位图要确认位图没被重新new覆盖。5.2 现象橡皮擦擦不掉或者一擦擦掉好几个原因HitTest阈值太小鼠标点不准或者倒序删除写成了正序索引错位。解决线条命中阈值设为StrokeWidth 3以上给用户留容错删除循环必须for (int i count - 1; i 0; i--)。如果一次擦掉多个检查是不是在MouseMove里也做了删除且没加节流。5.3 现象图形多了以后拖动鼠标明显卡顿原因每次MouseMove都Invalidate全量重绘图形上千时 GDI 绘制开销大。解决预览图形单独画不要每次重绘所有图层或者用两个位图做双缓冲静态图层画到后台位图只重绘预览层。另一个常见原因是Pen/Brush没释放GDI 句柄耗尽后绘制会异常缓慢甚至失败用任务管理器看 GDI 对象数是否持续上涨。5.4 现象保存的 JSON 加载时报“无法创建抽象类 Shape 的实例”原因多态序列化没配置反序列化时不知道具体子类。解决用[JsonDerivedType]标注或换Newtonsoft.Json开TypeNameHandling.Auto。注意开了TypeNameHandling.Auto后加载外部不可信 JSON 有安全风险本地工具自己用没问题如果要分享文件给别人加载前做类型白名单校验。5.5 现象图层顺序和显示对不上新图层画的东西被盖住原因Layers列表顺序和绘制顺序理解反了。OnPaint里foreach是从索引 0 到末尾索引大的后画盖在上面。如果新建图层用Insert(0, layer)新图层就在最底下。解决新建图层用Add图层面板显示时倒序显示最上面的图层显示在列表最上面或者统一约定“列表第一项是最底层”面板里加个“上移/下移”按钮调整顺序。6. 进阶技巧用撤销栈和图层缩略图把工具变成能用的东西基础功能跑通后决定这个绘图程序“能不能真用”的往往是两个进阶点撤销/重做和图层缩略图。没有撤销用户画错一笔只能重来体验直接崩。撤销栈的实现思路是命令模式每次修改Document的操作加图形、删图形、改图层可见性都记一条命令撤销时反向执行。// 极简命令栈只存快照适合图形数量不大的场景 public class UndoStack { private Stackstring _undo new Stackstring(); private Stackstring _redo new Stackstring(); private const int MaxDepth 50; // 最多存 50 步防止内存涨太快 public void Push(string snapshotJson) { _undo.Push(snapshotJson); _redo.Clear(); // 新操作后重做栈失效 if (_undo.Count MaxDepth) { // Stack 不能从底部删实际项目用 List 或 LinkedList 更好 } } public string Undo(string current) { if (_undo.Count 0) return current; _redo.Push(current); return _undo.Pop(); } public string Redo(string current) { if (_redo.Count 0) return current; _undo.Push(current); return _redo.Pop(); } }快照方案简单粗暴每次操作前把整个Document序列化成 JSON 压栈撤销时反序列化回去。图形几百个时完全够用上千个时内存会涨可以改成只存增量命令。MaxDepth限制步数防止用户画一整天后内存爆掉。注意Stack没法从底部删除真要限制深度得用List模拟栈或者定期把栈底元素丢掉重建。图层缩略图是另一个提升体验的点。每个图层生成一张小位图画在面板上用户一眼能看到哪个图层有什么内容。实现方式是给每个Layer加一个RenderThumbnail(int width, int height)方法内部new Bitmap后按比例缩放绘制所有图形。public Bitmap RenderThumbnail(int w, int h) { var bmp new Bitmap(w, h); using (var g Graphics.FromImage(bmp)) { g.Clear(Color.White); g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 计算所有图形的总包围盒做等比缩放 // 简化处理假设画布尺寸固定直接按比例缩放 float scaleX (float)w / 800; // 假设原始画布宽 800 float scaleY (float)h / 600; g.ScaleTransform(scaleX, scaleY); foreach (var shape in Shapes) shape.Draw(g); } return bmp; }缩略图不要每次重绘都生成图形多的时候开销不小。我一般只在图层内容变化时生成一次缓存起来面板刷新时直接用缓存的Bitmap。Bitmap用完要Dispose否则又是 GDI 句柄泄漏。最后说一个我自己的习惯这个项目写到后面最容易失控的不是绘图逻辑而是状态管理。工具模式、当前颜色、当前图层、撤销栈、缩略图缓存这些状态散落在各个事件处理里改一个功能就碰坏另一个。我的做法是尽早抽一个EditorState类把所有可变状态集中进去事件处理只调EditorState的方法不直接改字段。这样加撤销、加图层、加保存都只动一个地方。绘图程序不难难的是让它一直能改。希望帮到你。本文还有配套的精品资源点击获取