简介:这是一份基于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 还是直接重写 OnPaint
WinForm 里做自绘,有两种常见选择:放一个Panel并订阅它的Paint事件,或者直接在主窗体重写OnPaint。如果绘图区域只是界面的一部分,用Panel更干净;如果整个窗口就是画布,重写OnPaint更省事。无论哪种,都必须开启双缓冲,否则拖动图形时画面会闪成一片。
public class CanvasPanel : Panel { public List<Shape> Shapes = new List<Shape>(); // 所有图形 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. 进阶技巧:用序列化保存画布,以及一个我常用的调试习惯
做到这里,画、填、换色、移动、调粗细都能跑了。但真实项目里,用户画完总想保存,下次打开还能接着改。最省事的做法是把List<Shape>序列化成 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.Deserialize<List<Shape>>(File.ReadAllText("canvas.json")); Shapes = loaded ?? new List<Shape>(); Invalidate();JsonDerivedType是 .NET 7 之后的多态序列化方案,低版本得自己写Type字段加工厂方法。Color和Point在System.Text.Json里默认能序列化,但Color会存成 ARGB 整数,可读性差,介意的话加个自定义转换器。
调试绘图程序时,我有个习惯:在OnPaint开头画一个半透明的网格,间距 20 像素,标出坐标。图形位置对不对、移动有没有偏移,一眼就能看出来。上线前把网格关掉即可。这个习惯帮我省下了大量「图形为什么偏了两像素」的排查时间。
最后说一句血泪经验:绘图程序的复杂度不在「画」,而在「状态同步」。图形列表、选中状态、鼠标模式、重绘时机,这四样只要有一个没对齐,就会出现各种玄学 bug。我一般会先保证「任何状态变化都走同一个Invalidate入口」,再谈性能优化。希望帮到你。
本文还有配套的精品资源,点击获取