资讯详情

C# Winform 绘图程序实战:GDI+ 实现线条、矩形、圆与多边形

📅 2026/10/8 23:25:43 | 华诺云谱 👁 阅读
C# Winform 绘图程序实战:GDI+ 实现线条、矩形、圆与多边形
简介这是一份面向C#桌面开发初学者与WinForm图形编程练习者的绘图程序源码包基于.NET Framework构建解决在窗体上实现基本图形绘制与交互编辑的问题。程序支持线条、矩形、圆、椭圆、多边形等图形的绘制并提供填充、换色、移动及画笔粗细调整等功能核心依托System.Drawing命名空间中的Graphics、Pen、Brush类配合鼠标事件完成图形定位与拖拽同时涉及双缓冲优化与图形序列化保存等进阶思路。资源包共70个文件约738KB以29个png界面截图、10个cs源码文件为主另含csproj项目文件、resx资源文件、config配置、dll与exe可执行文件等结构完整可直接编译运行。目前已有931人学习下载适合作为课程设计、图形编辑器入门或WinForm事件与绘图API综合练习的参考案例帮助读者快速理解绘图程序的模块划分与交互实现方式。1. 从零写一个 C# Winform 绘图程序线条、矩形、圆、多边形到底怎么落地很多人第一次接到「用 C# 写个画图工具」的需求脑子里第一反应是拖个 PictureBox然后在 MouseMove 里画线。真动手才发现一移动窗口图形就没了、画多了卡成幻灯片、填充和描边颜色分不开、撤销根本没做。这个标题讲的就是把这些坑一次性填平——用 GDI 在 Winform 上做一个支持线条、矩形、圆、椭圆、多边形能填充、能换色、能拖动、能调画笔粗细的绘图程序。它适合两类人一是刚学完 C# 基础、想找个能写进简历的 winform 项目案例练手的二是做 c# 上位机、需要在自己软件里嵌一个简易标注或绘图模块的工程师。核心不在画得多花哨而在把「图形数据怎么存、界面怎么刷、鼠标怎么交互」这三件事理顺。2. 绘图程序的底层账本图形数据模型与 GDI 绘制管线2.1 为什么不能直接往 PictureBox 上画新手最容易踩的坑是把图形直接画在控件的 Graphics 上。你画完看着挺好一切换窗口、一最小化再还原图形全没了。原因是 Winform 的绘制是「按需重绘」——系统认为这块区域脏了就发一个 Paint 消息让你重新画一遍。你没有把「画了什么」记下来重绘时自然一片空白。所以绘图程序的第一原则图形数据必须独立于绘制动作存在。你画的每一条线、每一个矩形都要变成一个对象存进一个集合里Paint 事件触发时遍历这个集合重画。这就是所谓的「数据驱动绘制」也是后面能实现移动、撤销、保存的基础。GDI 的绘制管线大致是这样你拿到一个Graphics对象来自 Paint 事件的e.Graphics或者Graphics.FromImage然后调用它的DrawLine、DrawRectangle、FillEllipse等方法配合Pen画笔管线条颜色和粗细和Brush画刷管填充。Graphics是「画布」Pen/Brush是「工具」图形对象是「账本」。三者分清代码就不会乱。2.2 用抽象基类统一管理所有图形线条、矩形、圆、椭圆、多边形它们的共同点是都有一组坐标、一个描边颜色、一个描边粗细、一个填充颜色可选。差异在于「怎么画自己」。这天然适合用一个抽象基类 多态来解决。// 所有图形的基类负责存数据 定义绘制契约 public abstract class Shape { public Color StrokeColor { get; set; } Color.Black; // 描边颜色 public Color FillColor { get; set; } Color.Transparent; // 填充色透明表示不填充 public float PenWidth { get; set; } 1f; // 画笔粗细 public bool IsSelected { get; set; } false; // 是否被选中用于高亮 // 每个子类自己实现怎么画 public abstract void Draw(Graphics g); // 每个子类自己实现命中检测判断某个点是否落在图形上用于选中/移动 public abstract bool HitTest(Point p); // 移动把图形整体平移 dx, dy public abstract void Move(int dx, int dy); }这段代码是整个程序的骨架。Draw负责画HitTest负责判断鼠标点没点中它Move负责平移。三个方法一确定后面所有交互逻辑都围绕它们展开。参数说明StrokeColor默认黑色FillColor默认TransparentGDI 里透明色就是不填充PenWidth默认 1 像素。IsSelected用来在选中时画虚线框或控制点先留个字段。有了基类矩形和椭圆就很好写public class RectShape : Shape { public Rectangle Rect { get; set; } // 矩形的位置和大小 public override void Draw(Graphics g) { // 抗锯齿否则斜线和圆边缘会有锯齿 g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; using (var pen new Pen(StrokeColor, PenWidth)) using (var brush new SolidBrush(FillColor)) { if (FillColor ! Color.Transparent) g.FillRectangle(brush, Rect); // 先填充 g.DrawRectangle(pen, Rect); // 再描边保证边框压在填充上 } } public override bool HitTest(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); } }逻辑说明先Fill再Draw是固定顺序反过来的话描边会被填充盖掉一半边框看起来变细。using包住Pen和Brush是必须的GDI 对象是非托管资源不释放画几千个图形后内存和 GDI 句柄会爆。HitTest用Rectangle.Contains是最省事的做法矩形和椭圆都可以用外接矩形判断精度够用。椭圆、圆、多边形同理。圆其实就是宽高相等的椭圆多边形用一个Point[]数组存顶点DrawPolygon和FillPolygon搞定。这里不逐个贴思路完全一致。2.3 双缓冲解决画多了闪烁的问题图形一多你会发现画面疯狂闪烁。原因是每次重绘都先擦背景再画图形两步之间有个空档被看到了。解决办法是开双缓冲——先把所有东西画到内存里的一张位图上再一次性贴到屏幕。最省事的做法是给承载绘制的控件开双缓冲// 自定义一个双缓冲面板替代原生 Panel public class CanvasPanel : Panel { public CanvasPanel() { // 开启双缓冲三件套 this.DoubleBuffered true; this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.ResizeRedraw true; // 窗口大小变化时强制重绘 } }参数说明DoubleBuffered true打开双缓冲AllPaintingInWmPaint表示忽略系统擦背景的消息全部在 Paint 里画UserPaint表示自己负责绘制OptimizedDoubleBuffer进一步优化。ResizeRedraw保证窗口拉伸时图形跟着重画不然会出现残影。这三行是 Winform 绘图防闪烁的标准配置记住就行。3. 鼠标交互三件套画、选、拖怎么串起来3.1 用状态机管理鼠标行为绘图程序的鼠标逻辑本质是一个状态机。鼠标左键按下时你在「画新图形」按下后拖动你在「确定图形大小」松开图形定型。如果当前是「选择模式」按下时你在「命中检测」拖动时你在「移动图形」。状态不清晰代码就会写成一团 if-else。我一般用一个枚举把模式定死public enum ToolMode { DrawLine, DrawRect, DrawEllipse, DrawPolygon, // 各种绘制模式 Select // 选择/移动模式 }然后在画布控件里维护CurrentMode、IsDrawing、StartPoint、CurrentPoint这几个字段。鼠标事件里根据模式分流。3.2 绘制流程从 MouseDown 到 MouseUp以画矩形为例完整流程是这样的private void Canvas_MouseDown(object sender, MouseEventArgs e) { if (e.Button ! MouseButtons.Left) return; StartPoint e.Location; IsDrawing true; if (CurrentMode ToolMode.Select) { // 选择模式从后往前找后画的图形在上层优先选中 SelectedShape null; for (int i shapes.Count - 1; i 0; i--) { if (shapes[i].HitTest(e.Location)) { SelectedShape shapes[i]; SelectedShape.IsSelected true; LastMousePoint e.Location; // 记录拖动起点 break; } } canvas.Invalidate(); // 触发重绘显示选中高亮 } } private void Canvas_MouseMove(object sender, MouseEventArgs e) { CurrentPoint e.Location; if (IsDrawing CurrentMode ! ToolMode.Select) { canvas.Invalidate(); // 拖动过程中不断重绘预览 } else if (CurrentMode ToolMode.Select SelectedShape ! null e.Button MouseButtons.Left) { // 移动算出这次相对上次移动了多少 int dx e.Location.X - LastMousePoint.X; int dy e.Location.Y - LastMousePoint.Y; SelectedShape.Move(dx, dy); LastMousePoint e.Location; canvas.Invalidate(); } } private void Canvas_MouseUp(object sender, MouseEventArgs e) { if (!IsDrawing) return; IsDrawing false; if (CurrentMode ToolMode.Select) return; // 根据当前模式创建对应图形加入集合 Shape shape CreateShapeByMode(CurrentMode, StartPoint, e.Location); if (shape ! null) { shape.StrokeColor CurrentStrokeColor; shape.FillColor CurrentFillColor; shape.PenWidth CurrentPenWidth; shapes.Add(shape); } canvas.Invalidate(); }逻辑说明MouseDown记录起点选择模式下做命中检测从后往前遍历因为后画的在上层符合直觉。MouseMove分两条路——绘制模式下只更新预览点并重绘选择模式下算位移增量并调用Move。MouseUp才真正把图形定型加入集合。注意移动时用的是「增量」而不是「绝对坐标」这样图形不会因为鼠标跳变而瞬移。参数说明LastMousePoint是移动时的关键每次移动后要更新它否则位移会累加出错。CreateShapeByMode是个工厂方法根据模式返回对应的Shape子类实例把StartPoint和当前点转成矩形或点数组。3.3 预览图形怎么画拖动过程中要实时看到「正在画的图形」做法是在 Paint 里额外画一个临时图形private void Canvas_Paint(object sender, PaintEventArgs e) { e.Graphics.SmoothingMode SmoothingMode.AntiAlias; // 先画所有已定型的图形 foreach (var s in shapes) s.Draw(e.Graphics); // 再画正在拖动的预览图形 if (IsDrawing CurrentMode ! ToolMode.Select) { var preview CreateShapeByMode(CurrentMode, StartPoint, CurrentPoint); if (preview ! null) { preview.StrokeColor CurrentStrokeColor; preview.FillColor CurrentFillColor; preview.PenWidth CurrentPenWidth; preview.Draw(e.Graphics); } } // 最后画选中高亮 if (SelectedShape ! null SelectedShape.IsSelected) { using (var pen new Pen(Color.Blue, 1) { DashStyle DashStyle.Dash }) { // 这里根据图形类型画虚线外框示意 e.Graphics.DrawRectangle(pen, GetShapeBounds(SelectedShape)); } } }逻辑说明绘制顺序决定层级——先画的在底层后画的在上层。预览图形画在已定型图形之上选中高亮画在最上面。DashStyle.Dash画虚线框表示选中这是最直观的反馈。GetShapeBounds需要你根据图形类型返回外接矩形矩形和椭圆直接返回多边形要遍历顶点算 min/max。4. 颜色、填充、画笔粗细属性面板怎么和画布联动4.1 用 ColorDialog 和 NumericUpDown 做属性输入换颜色用ColorDialog调画笔粗细用NumericUpDown填充开关用CheckBox。这些控件本身没难度关键是「当前属性」要能被新画的图形读取。private Color CurrentStrokeColor Color.Black; private Color CurrentFillColor Color.Transparent; private float CurrentPenWidth 1f; // 描边颜色按钮 private void btnStrokeColor_Click(object sender, EventArgs e) { using (var dlg new ColorDialog()) { if (dlg.ShowDialog() DialogResult.OK) { CurrentStrokeColor dlg.Color; btnStrokeColor.BackColor dlg.Color; // 按钮背景同步显示 } } } // 填充开关 private void chkFill_CheckedChanged(object sender, EventArgs e) { if (chkFill.Checked) CurrentFillColor btnFillColor.BackColor; // 用填充色按钮当前的颜色 else CurrentFillColor Color.Transparent; } // 画笔粗细 private void numPenWidth_ValueChanged(object sender, EventArgs e) { CurrentPenWidth (float)numPenWidth.Value; }逻辑说明CurrentStrokeColor等字段是「当前工具状态」新图形创建时从这些字段取值。ColorDialog用using包住用完即释放。填充开关联动填充色勾选时用填充色按钮的颜色取消时设为透明。NumericUpDown的Value是decimal要显式转float。参数说明NumericUpDown建议设Minimum 1、Maximum 20、DecimalPlaces 0画笔太粗或为 0 都不合理。填充色按钮默认给个浅色避免用户没选就勾了填充导致看不见。4.2 修改已有图形的属性光能设置新图形还不够选中一个图形后改颜色、改粗细要能作用到它身上。做法是在属性控件的事件里判断SelectedShape是否为空private void ApplyToSelected(ActionShape action) { if (SelectedShape ! null) { action(SelectedShape); canvas.Invalidate(); } }调用时比如ApplyToSelected(s s.PenWidth CurrentPenWidth);。这样一套属性面板同时服务「新图形默认值」和「选中图形修改」两个场景代码不重复。这是我在实际项目里最常用的写法比写两套逻辑省心得多。5. 避坑与排查绘图程序最容易翻车的 5 个地方5.1 图形画完一闪就没了现象画完松开鼠标图形出现一瞬间然后消失或者最小化还原后全空。 原因图形只画在了 Graphics 上没有存进集合重绘时没有数据可画。 解决所有图形必须加入shapes集合Paint 里遍历集合重画。这是最根本的一条没有例外。5.2 画几百个图形后卡顿甚至崩溃现象图形数量上去后拖动预览明显掉帧任务管理器里 GDI 对象数飙升。 原因Pen、Brush没释放每次 Paint 都 new 一堆非托管对象。 解决所有Pen/Brush用using包住或者做成静态缓存复用。另外预览图形不要每次 new可以复用一个临时对象。GDI 对象每个进程有上限默认 10000超了就崩。5.3 移动图形时越移越偏现象拖动图形松手后再拖图形位置跳变或者移动距离和鼠标不一致。 原因用了绝对坐标而不是增量或者LastMousePoint没在每次移动后更新。 解决移动一律用「当前点 - 上次点」的增量算完立刻更新LastMousePoint。别用StartPoint去算那是画图用的。5.4 多边形填充后边框变细或看不见现象多边形填充色一开描边像是被吃掉了一半。 原因先Draw后Fill填充盖住了描边内侧。 解决永远先Fill再Draw。如果还嫌细把PenWidth加 1或者用PenAlignment.Inset让描边往里收。5.5 选中高亮框位置对不上现象选中一个椭圆虚线框画成了矩形或者位置偏移。 原因GetShapeBounds对不同图形返回的外接矩形算错了多边形没遍历顶点。 解决矩形/椭圆直接返回其Rect多边形遍历所有顶点取 min/max 构造外接矩形线条取两端点。统一走一个方法别在每个分支里各写各的。6. 进阶技巧把绘图程序做成能存能读能撤销的完整工具前面五章把「画」这件事讲透了但一个真正能用的绘图程序还得解决「存」和「悔」。这两点决定了它是玩具还是工具。先说保存。最直接的做法是序列化shapes集合。用System.Text.Json把图形列表转成 JSON 存文件读的时候反序列化回来。难点在于多态——基类Shape反序列化时不知道具体是哪个子类。常见做法是加一个Type字段做区分或者用自定义的JsonConverter。我一般图省事在每个图形里存一个字符串类型标识读的时候 switch 一下手动构造// 保存给每个图形打上类型标签 var dtos shapes.Select(s new ShapeDto { Type s.GetType().Name, // 用类名当类型标识 StrokeColor s.StrokeColor.ToArgb(), FillColor s.FillColor.ToArgb(), PenWidth s.PenWidth, Data GetShapeData(s) // 各图形自己的坐标数据 }).ToList(); File.WriteAllText(drawing.json, JsonSerializer.Serialize(dtos));参数说明ToArgb()把 Color 转成 int避免序列化 Color 结构体的兼容问题。GetShapeData根据图形类型返回坐标数组或矩形。读回来时按Type字段 switchnew 出对应子类再填数据。这套方案不优雅但极其稳跨版本也不容易坏。再说撤销。撤销的本质是「历史快照」。最简单的实现是每次操作前把整个shapes集合深拷贝一份压进栈撤销时弹出恢复。图形不多时完全够用代码十行搞定private StackListShape undoStack new StackListShape(); private void PushUndo() { // 深拷贝当前状态图形需要实现 Clone undoStack.Push(shapes.Select(s s.Clone()).ToList()); } private void Undo() { if (undoStack.Count 0) return; shapes undoStack.Pop(); SelectedShape null; canvas.Invalidate(); }逻辑说明PushUndo在每次「会改变图形集合」的操作前调用——画新图形、移动、删除、改属性都要调。Clone需要你在Shape基类里定义抽象方法各子类实现深拷贝。栈的深度可以设个上限比如 50 步防止内存无限涨。最后说一个我踩过的坑别在 Paint 里做任何耗时计算。我早期版本在 Paint 里遍历图形算外接矩形、算命中检测图形一多直接卡死。正确做法是把这些计算挪到鼠标事件里Paint 只负责「照着数据画」。这个习惯养成后你的绘图程序能扛住几千个图形不卡。这套东西我从头写过三遍每一遍都在「数据与绘制分离」这条原则上栽过跟头也每次都靠它爬出来。如果你正准备动手先把Shape基类和shapes集合立起来剩下的都是水到渠成。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑