ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C# WinForm绘图程序开发:GDI+双缓冲与图形交互实战

C# WinForm绘图程序开发:GDI+双缓冲与图形交互实战 简介这是一份基于C# WinForm的图形绘制程序源码面向正在学习.NET桌面开发、希望掌握GDI绘图与鼠标交互的初学者和进阶开发者。程序支持绘制线条、矩形、圆、椭圆、多边形等基本图形并具备填充图形、更换颜色、移动图形、调整画笔粗细等编辑能力是理解Graphics类、Pen与Brush对象以及MouseDown、MouseMove、MouseUp事件处理的完整实践案例。压缩包共70个文件约738KB包含10个cs源码文件、2个csproj工程文件、2个sln解决方案以及png界面截图、dll依赖、pdb调试符号、resx资源与config配置等工程结构完整可直接编译运行。目前已有930人学习下载。通过阅读源码读者可掌握DrawLine、DrawRectangle、DrawEllipse、DrawPolygon与Fill系列方法的用法理解双缓冲重绘优化思路并借鉴图形保存加载与用户交互控件的组织方式快速搭建自己的绘图工具。1. 从零写一个 C# WinForm 绘图程序为什么 GDI 双缓冲是绕不开的第一道坎很多人第一次接到「用 C# WinForm 做一个绘图程序」的需求时脑子里想的是工具栏、颜色选择器、图形列表这些界面活。但真正动手写下去翻车点几乎都集中在同一个地方画出来的东西一拖动就闪、一缩放就糊、一填充就盖住别的图形。这个标题要解决的核心问题其实不是「怎么把圆画出来」——Graphics.DrawEllipse一行就够了——而是怎么让线条、矩形、圆、多边形、椭圆这些基本图形在可以填充、可以换颜色、可以移动、可以调画笔粗细的前提下依然保持画面不闪、状态不乱、坐标不飘。这套东西适合谁适合已经会写 WinForm 窗体、用过Button和TextBox但没系统做过自绘界面的 C# 开发者。上位机、工业控制面板、简易画板、图形验证码生成器这类场景底层都是同一套 GDI 绘制逻辑。下面我按「先立住模型再动手画最后处理交互和坑」的顺序把整个方案拆开讲清楚代码可以直接抄进项目里跑。2. 图形对象模型怎么设计别把坐标和画笔塞进一个类2.1 为什么需要一个统一的图形基类如果每画一种图形就写一个DrawLine、DrawRect、DrawCircle方法很快就会发现代码在重复换颜色要改所有方法调画笔粗细要改所有方法移动图形更是无从下手。常见做法是抽一个抽象基类把「画自己」这件事交给每个子类实现把「颜色、线宽、是否填充、填充色」这些公共状态提到基类。// 图形基类所有基本图形都继承它 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 IsFilled { get; set; } false; // 是否填充 // 每个子类自己决定怎么画 public abstract void Draw(Graphics g); // 命中测试判断某个点是否落在图形上移动和选中都靠它 public abstract bool Contains(Point p); // 整体平移移动图形时调用 public abstract void Move(int dx, int dy); }这段代码的关键在于把「状态」和「行为」分开状态放在基类行为交给子类。Contains和Move是后面实现「移动图形」的基础没有它们你只能靠重画整个画布来模拟移动效率低且容易出错。PenWidth用float而不是int是因为 GDI 的Pen构造函数接受float用int会在后续做缩放时被迫频繁转换。2.2 线条、矩形、圆、椭圆、多边形各自的字段每种图形需要的字段不一样。线条要两个端点矩形要左上角和宽高圆和椭圆可以共用「外接矩形」的表示法多边形要一个点数组。这里有个容易踩的坑圆和椭圆如果各写一个类代码几乎一样不如让圆继承椭圆只是宽高相等。public class LineShape : Shape { public Point Start, End; public override void Draw(Graphics g) { using (var pen new Pen(StrokeColor, PenWidth)) g.DrawLine(pen, Start, End); } public override bool Contains(Point p) { // 简化处理判断点到线段的距离是否小于阈值 return DistanceToSegment(p, Start, End) Math.Max(PenWidth, 4); } public override void Move(int dx, int dy) { Start.Offset(dx, dy); End.Offset(dx, dy); } } public class RectShape : Shape { public Rectangle Rect; public override void Draw(Graphics g) { using (var pen new Pen(StrokeColor, PenWidth)) { if (IsFilled) g.FillRectangle(new SolidBrush(FillColor), Rect); g.DrawRectangle(pen, Rect); } } public override bool Contains(Point p) Rect.Contains(p); public override void Move(int dx, int dy) { Rect.Offset(dx, dy); } } public class EllipseShape : Shape { public Rectangle Bounds; // 外接矩形圆就是宽高相等的特例 public override void Draw(Graphics g) { using (var pen new Pen(StrokeColor, PenWidth)) { if (IsFilled) g.FillEllipse(new SolidBrush(FillColor), Bounds); g.DrawEllipse(pen, Bounds); } } public override bool Contains(Point p) { // 椭圆命中测试归一化到单位圆判断 double cx Bounds.X Bounds.Width / 2.0; double cy Bounds.Y Bounds.Height / 2.0; double rx Bounds.Width / 2.0, ry Bounds.Height / 2.0; if (rx 0 || ry 0) return false; double dx (p.X - cx) / rx, dy (p.Y - cy) / ry; return dx * dx dy * dy 1.0; } public override void Move(int dx, int dy) { Bounds.Offset(dx, dy); } } public class PolygonShape : Shape { public Point[] Points; public override void Draw(Graphics g) { using (var pen new Pen(StrokeColor, PenWidth)) { if (IsFilled) g.FillPolygon(new SolidBrush(FillColor), Points); g.DrawPolygon(pen, Points); } } public override bool Contains(Point p) { // 射线法判断点是否在多边形内部 bool inside false; for (int i 0, j Points.Length - 1; i Points.Length; j i) { if ((Points[i].Y p.Y) ! (Points[j].Y p.Y) p.X (Points[j].X - Points[i].X) * (p.Y - Points[i].Y) / (double)(Points[j].Y - Points[i].Y) Points[i].X) inside !inside; } return inside; } public override void Move(int dx, int dy) { for (int i 0; i Points.Length; i) Points[i] new Point(Points[i].X dx, Points[i].Y dy); } }Contains的实现质量直接决定「移动图形」的手感。线条用点到线段距离椭圆用归一化单位圆多边形用射线法这三套是业界最常用的做法。注意FillRectangle和DrawRectangle的顺序先填充再描边否则描边会被填充盖掉一半视觉上线条变细这是新手最常见的翻车点之一。3. 在 WinForm 里把图形画出来双缓冲和重绘机制3.1 用 Panel 还是直接重写 OnPaintWinForm 里做自绘有两种常见选择放一个Panel并订阅它的Paint事件或者直接在主窗体重写OnPaint。如果绘图区域只是界面的一部分用Panel更干净如果整个窗口就是画布重写OnPaint更省事。无论哪种都必须开启双缓冲否则拖动图形时画面会闪成一片。public class CanvasPanel : Panel { public ListShape Shapes new ListShape(); // 所有图形 public Shape Selected; // 当前选中的图形 public CanvasPanel() { // 关键开启双缓冲消除闪烁 this.DoubleBuffered true; this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); this.ResizeRedraw true; // 窗口大小变化时重绘 } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); e.Graphics.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 抗锯齿 foreach (var shape in Shapes) shape.Draw(e.Graphics); // 给选中图形画一个虚线框提示当前选中 if (Selected ! null) { using (var pen new Pen(Color.Blue, 1) { DashStyle System.Drawing.Drawing2D.DashStyle.Dash }) { var b GetBounds(Selected); e.Graphics.DrawRectangle(pen, b); } } } }DoubleBuffered true加上OptimizedDoubleBuffer样式是 WinForm 自绘不闪的标准组合。SmoothingMode.AntiAlias让线条和圆边缘更平滑代价是绘制速度略降图形数量在几百个以内基本无感。ResizeRedraw true保证窗口拉伸时图形跟着重绘否则会出现残影。3.2 重绘的触发时机和 Invalidate 的正确用法图形状态一变就要通知系统重绘。很多人习惯直接调Refresh()它内部会强制立即重绘频繁调用会拖慢交互。更合理的做法是调Invalidate()把重绘请求排进消息队列系统在空闲时统一处理。// 移动图形时改完坐标再请求重绘 private void MoveSelected(int dx, int dy) { if (Selected null) return; Selected.Move(dx, dy); Invalidate(); // 不要用 Refresh避免频繁强制重绘 } // 换颜色时 private void ChangeColor(Color c) { if (Selected null) return; Selected.StrokeColor c; Invalidate(); } // 调画笔粗细时 private void ChangePenWidth(float w) { if (Selected null) return; Selected.PenWidth w; Invalidate(); }Invalidate()不带参数会重绘整个画布图形多的时候可以传矩形区域只重绘局部但要注意移动图形时旧位置和新位置都要包含进去否则会留下残影。我一般图省事直接全刷几百个图形以内完全够用。4. 鼠标交互画、选、拖三件事怎么串起来4.1 用状态机管理「正在画」和「正在拖」鼠标按下、移动、抬起这三个事件里程序要区分两种模式一种是正在画新图形一种是正在拖动已有图形。用一个枚举状态字段来管理比用一堆布尔标志清晰得多。private enum EditMode { None, Drawing, Dragging } private EditMode mode EditMode.None; private Point startPoint; // 画图起点或拖动起点 private Shape currentDrawing; // 正在画的图形 private void CanvasPanel_MouseDown(object sender, MouseEventArgs e) { if (e.Button ! MouseButtons.Left) return; // 先做命中测试看是否点中了已有图形 Selected Shapes.LastOrDefault(s s.Contains(e.Location)); if (Selected ! null) { mode EditMode.Dragging; startPoint e.Location; } else { mode EditMode.Drawing; startPoint e.Location; currentDrawing CreateShapeByTool(currentTool, startPoint); Shapes.Add(currentDrawing); } Invalidate(); } private void CanvasPanel_MouseMove(object sender, MouseEventArgs e) { if (mode EditMode.Dragging Selected ! null) { int dx e.X - startPoint.X, dy e.Y - startPoint.Y; Selected.Move(dx, dy); startPoint e.Location; Invalidate(); } else if (mode EditMode.Drawing currentDrawing ! null) { UpdateShape(currentDrawing, startPoint, e.Location); // 根据当前鼠标位置更新尺寸 Invalidate(); } } private void CanvasPanel_MouseUp(object sender, MouseEventArgs e) { mode EditMode.None; currentDrawing null; }Shapes.LastOrDefault从后往前找保证点中重叠图形时选到最上面那个符合直觉。拖动时每次移动都更新startPoint用增量位移而不是绝对坐标避免图形「跳」到鼠标位置。4.2 不同图形的尺寸更新逻辑画矩形和椭圆时鼠标从左上拖到右下宽高就是差值但如果从右下往左上拖差值会变成负数Rectangle构造会出问题。所以要用Math.Min和Math.Abs归一化。private void UpdateShape(Shape s, Point start, Point current) { int x Math.Min(start.X, current.X); int y Math.Min(start.Y, current.Y); int w Math.Abs(current.X - start.X); int h Math.Abs(current.Y - start.Y); if (s is RectShape r) r.Rect new Rectangle(x, y, w, h); else if (s is EllipseShape el) el.Bounds new Rectangle(x, y, w, h); else if (s is LineShape l) { l.Start start; l.End current; } else if (s is PolygonShape p) { // 多边形简化处理用起点和当前点构造一个三角形示例 p.Points new[] { start, new Point(current.X, start.Y), current }; } }多边形如果要支持任意边数得改成「点击落点、双击闭合」的交互这里为了聚焦主线先用三角形演示。实际项目里我会把多边形单独做成一个「逐点添加」模式和拖拽画图分开。5. 避坑与排查绘图程序最容易翻车的 5 个地方5.1 现象拖动图形时画面疯狂闪烁原因没有开启双缓冲每次Invalidate都先擦背景再画图形中间那一帧被看到。解决DoubleBuffered true并设置AllPaintingInWmPaint | UserPaint | OptimizedDoubleBuffer三个样式缺一不可。只设DoubleBuffered有时在Panel上不生效必须配合SetStyle。5.2 现象填充后描边变细甚至消失原因先DrawRectangle再FillRectangle填充色盖住了描边的一半。解决永远先填充后描边。另外FillRectangle的矩形和DrawRectangle的矩形边界定义差一个像素描边会向外扩视觉上更自然。5.3 现象移动图形后旧位置留下残影原因Invalidate的区域没包含旧位置或者用了Refresh但没触发背景擦除。解决移动前记录旧边界Invalidate时传入新旧边界的并集或者干脆全刷。用Region做局部刷新时记得把PenWidth也算进边界粗线条的溢出范围比矩形本身大。5.4 现象画笔粗细调大后线条端点出现缺口原因GDI 默认的LineCap是平头粗线拐角处会露缝。解决设置pen.StartCap pen.EndCap LineCap.Round或者用LineJoin.Round处理折线。多边形描边尤其明显圆角连接能显著改善观感。5.5 现象命中测试选不中细线或空心图形原因Contains用矩形边界判断细线或空心圆的内部区域被误判为命中。解决线条用点到线段距离空心图形判断是否靠近轮廓而不是在内部。阈值取Math.Max(PenWidth, 4)给鼠标一点容错空间否则用户会觉得「点不中」。6. 进阶技巧用序列化保存画布以及一个我常用的调试习惯做到这里画、填、换色、移动、调粗细都能跑了。但真实项目里用户画完总想保存下次打开还能接着改。最省事的做法是把ListShape序列化成 JSON每个子类加一个Type字段做多态还原。// 用 System.Text.Json 保存需要给每个子类加 [JsonDerivedType] 特性 [JsonDerivedType(typeof(LineShape), line)] [JsonDerivedType(typeof(RectShape), rect)] [JsonDerivedType(typeof(EllipseShape), ellipse)] [JsonDerivedType(typeof(PolygonShape), polygon)] public abstract class Shape { /* 同上 */ } // 保存 string json JsonSerializer.Serialize(Shapes); File.WriteAllText(canvas.json, json); // 加载 var loaded JsonSerializer.DeserializeListShape(File.ReadAllText(canvas.json)); Shapes loaded ?? new ListShape(); Invalidate();JsonDerivedType是 .NET 7 之后的多态序列化方案低版本得自己写Type字段加工厂方法。Color和Point在System.Text.Json里默认能序列化但Color会存成 ARGB 整数可读性差介意的话加个自定义转换器。调试绘图程序时我有个习惯在OnPaint开头画一个半透明的网格间距 20 像素标出坐标。图形位置对不对、移动有没有偏移一眼就能看出来。上线前把网格关掉即可。这个习惯帮我省下了大量「图形为什么偏了两像素」的排查时间。最后说一句血泪经验绘图程序的复杂度不在「画」而在「状态同步」。图形列表、选中状态、鼠标模式、重绘时机这四样只要有一个没对齐就会出现各种玄学 bug。我一般会先保证「任何状态变化都走同一个Invalidate入口」再谈性能优化。希望帮到你。本文还有配套的精品资源点击获取
返回列表