简介基于C# Windows Forms的图形绘制与交互示例项目面向C#初学者及图形编程学习者演示了如何在窗体中创建矩形、圆形、菱形等基本图形并实现拖动、缩放与旋转等交互操作。包里共50个文件以21个.cs源码文件为核心涵盖Shape基类与各图形派生类、图形集合管理及序列化存储逻辑另有.sln工程文件、空白配置、可执行exe与调试符号等压缩包仅100KB结构精简便于剖析。目前已有4757人学习下载。通过研读该项目读者可掌握Windows Forms中PictureBox绘图表面的初始化、Graphic对象绘制、鼠标事件驱动的图形拖拽以及RotateTransform实现旋转的核心思路同时学习使用XmlSerializer保存图形数据对后续自研简易图形编辑器或CAD类课设具参考价值。1. C# 画图功能拆解从零把矩形、圆形、菱形串成一套可拖动体系很多刚开始接触 C# 的人都会想做一个迷你画图程序按住左键拖一下屏幕上多一个矩形换个工具再拖一下又变成圆形或菱形。画的时候很爽真正麻烦的是画完之后还能随意拖动、放大缩小。这个项目的核心就是用 C# 自绘控件把“画、拖、缩”这三件事串起来。它没有用任何第三方图形库而是把图形抽象成一个 ShapeBase 基类由矩形、椭圆、菱形三个子类分别实现绘制和点选逻辑最后通过一个画布控件统一接收鼠标事件。适合想理解 GDI 自绘和鼠标交互状态机的人也适合把这份代码当基础框架去扩展成流程图编辑器或者几何练习工具。2. 技术选型与工程结构为什么看似简单的画板需要拆成五个类2.1 从目录结构看项目分层拿到手的时候可以先看整个解决方案的文件分布。我见过的这类画图示例大部分都把全部逻辑塞在 Form1.cs 里几千行堆在一起改动一个颜色选择器都要上下翻半天。而这个项目的结构明显不一样核心文件这样划分文件职责关键点Program.cs程序入口只负责启动主窗体MainForm.cs主窗体管工具切换、颜色选择、清空画布CanvasControl.cs自绘画布重写 OnPaint接收鼠标事件ShapeBase.cs图形基类定义位置、尺寸、绘制接口RectangleShape.cs矩形图形FillRectangle DrawRectangleEllipseShape.cs椭圆/圆形FillEllipse DrawEllipseDiamondShape.cs菱形图形计算四个顶点并画多边形ShapeFactory.cs图形工厂通过枚举创建对应的 ShapeBase 实例ShapeType.cs图形类型枚举Rectangle / Ellipse / Diamond这个分层的核心思路是把“画布”和“图形”彻底分开。MainForm 更像一个遥控器它只负责记录当前选中了哪个工具、当前填充色和边框色是什么然后把这些参数交给 CanvasControl。CanvasControl 自己维护一个 List 在 OnPaint 里遍历列表逐张绘制在鼠标事件里修改列表元素的属性最后由 Invalidate 触发重绘。这种结构的最大好处是添加新图形时不一定要改动画布控件。你只需要新增一个继承 ShapeBase 的类在工厂里加一个 case再在枚举里加一个值。画布的遍历、命中、拖动逻辑完全不变。换句话说核心扩展点是 ShapeBase 和 ShapeFactory而不是一堆 switch 分支堆在鼠标事件里。2.2 为什么选择 WinForms GDI而不是 WPF 或 Direct2D直接给结论对这种小工具WinForms 加 GDI 的学习成本和写代码速度都比 WPF 更合适。Graphics 对象上开箱即用的 FillRectangle、FillEllipse、FillPolygon 正好覆盖矩形、圆形、菱形三种图形的填充和描边。如果换成 WPF你需要为每个图形构造 Geometry 对象还要考虑 DrawingVisual 的缓存代码量至少多一倍。Direct2D 性能确实好但它的资源生命周期和绘制链路对新手不友好写一个简单的形状编辑器属于大炮打蚊子。另一个原因是鼠标事件模型。WinForms 控件的 OnMouseDown、OnMouseMove、OnMouseUp 是天然的交互状态机入口拖动一个图形只需要在这三个事件里维护一个“当前抓住谁”的字段。WPF 虽然也有 MouseDown/MouseMove/MouseUp但它的事件路由机制会经过隧道和冒泡两层初学者很容易在某个子元素上丢失消息。用 WinForms 的 Control 作为画布事件边界清晰调试时定位问题也快。当然GDI 的痛点也很明显它默认不开启双缓冲如果直接在 OnPaint 里绘制大量图形窗口刷新时会出现明显闪烁。这一点不是选型错了而是实现细节上要主动处理后面避坑章节会讲到常用方案。2.3 运行环境与依赖清单拿到源码之后怎么跑起来这个项目不依赖任何第三方 NuGet 包运行条件非常低。只要本机安装了 .NET Framework 4.6.1 以上版本或者安装了 .NET 6 SDK直接用 Visual Studio 打开解决方案按 F5 就能跑。如果命令行环境更顺手也可以这样启动dotnet run前提是项目文件已经是 SDK 风格。旧式 .csproj 想迁移也很简单我一般会改成下面这样的配置Project SdkMicrosoft.NET.Sdk PropertyGroup OutputTypeWinExe/OutputType TargetFrameworknet6.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms Nullabledisable/Nullable /PropertyGroup /Project这里最关键的是 TargetFramework 用了 net6.0-windows以及 UseWindowsForms 设置为 true。前者告诉编译系统这是 Windows 专用项目后者把 WinForms 运行时引用进来。如果在 SDK 项目里漏掉 UseWindowsForms编译时会直接报错说找不到 System.Windows.Forms 命名空间。迁移过程中代码文件本身基本不用改MainForm 的构造函数里继续调用 InitializeComponent 就行。另一点值得养成习惯拿到源码后先跑通旧版本再动手改。命令行编译生成的 exe 在 bin 目录下双击能运行说明环境没问题如果连一次干净的编译都过不去后面所有改动都容易混进环境问题。3. 核心模块拆解形状基类、工厂与三种图形的完整绘制链路3.1 ShapeBase把所有图形的公共行为抽象到基类先看最核心的 ShapeBase。这个类定义了图形的位置、尺寸、填充色、边框色和选中状态并预留了 Draw、ContainsPoint、Resize 三个关键方法。public abstract class ShapeBase { // 图形外接矩形的左上角锚点 public PointF Location { get; set; } // 图形外接矩形的宽高所有图形都通过这个矩形参与布局 public SizeF Size { get; set; } public Color FillColor { get; set; } Color.Transparent; public Color BorderColor { get; set; } Color.Black; public bool IsSelected { get; set; } // 子类实现具体的绘制逻辑 protected abstract void DrawCore(Graphics g); public void Draw(Graphics g) { DrawCore(g); if (IsSelected) { DrawSelectionBorder(g); } } // 默认用外接矩形做命中测试子类可覆盖 public virtual bool ContainsPoint(PointF point) { return new RectangleF(Location, Size).Contains(point); } // 限定最小尺寸防止图形被缩放到看不见 public virtual void Resize(float newWidth, float newHeight) { Size new SizeF( Math.Max(5, newWidth), Math.Max(5, newHeight)); } private void DrawSelectionBorder(Graphics g) { using (Pen pen new Pen(Color.DodgerBlue, 1f)) { pen.DashStyle System.Drawing.Drawing2D.DashStyle.Dash; g.DrawRectangle( pen, Location.X - 3, Location.Y - 3, Size.Width 6, Size.Height 6); } } }这里的 Location 和 Size 并不是图形的绝对形状坐标而是外接矩形的参数。为什么要用外接矩形原因很实际矩形、椭圆、菱形在 Math 层面的几何表达差别很大但在一张画布上它们都需要占据一块矩形区域来响应鼠标点击、参与排版和缩放。Draw 方法做了两件事先调用子类的 DrawCore 画实际内容再根据 IsSelected 画一圈虚线选中框选中框向外扩 3 像素可以避免虚线边框和图形边缘完全重叠。3.2 ShapeFactory把创建图形的逻辑从鼠标事件中剥出去如果一个画布控件的 OnMouseDown 里直接 new RectangleShape那只要新增图形类型就得改鼠标事件。工厂的作用是把这段创建逻辑单独收口。public static class ShapeFactory { public static ShapeBase Create(ShapeType type, PointF location, SizeF size) { switch (type) { case ShapeType.Rectangle: return new RectangleShape { Location location, Size size }; case ShapeType.Ellipse: return new EllipseShape { Location location, Size size }; case ShapeType.Diamond: return new DiamondShape { Location location, Size size }; default: throw new ArgumentOutOfRangeException(nameof(type)); } } }这段代码的逻辑很简单参数 type 决定创建哪个子类实例location 决定初始锚点size 决定外接矩形尺寸。三个子类共享同一个基类型所以画布控件后续处理它们不需要感知具体类型。新增一种图形时只需要在 ShapeType 枚举里加一个值再在这个工厂里补一个 case 即可。注意工厂方法返回的是 ShapeBase而不是具体的 RectangleShape这给上层代码留出了多态操作空间。3.3 三种图形的绘制实现与坐标计算差异矩形和椭圆可以直接用 GDI 提供的方法绘制。菱形则要手动算四个顶点再交给 DrawPolygon。我见过不少写法是把菱形的顶点坐标写死在常量里那样的图形一拖动就错位。正确做法是每次绘制时根据 Location 和 Size 临时计算顶点。public class RectangleShape : ShapeBase { protected override void DrawCore(Graphics g) { using (SolidBrush brush new SolidBrush(FillColor)) using (Pen pen new Pen(BorderColor, 2f)) { g.FillRectangle( brush, Location.X, Location.Y, Size.Width, Size.Height); g.DrawRectangle( pen, Location.X, Location.Y, Size.Width, Size.Height); } } } public class EllipseShape : ShapeBase { protected override void DrawCore(Graphics g) { using (SolidBrush brush new SolidBrush(FillColor)) using (Pen pen new Pen(BorderColor, 2f)) { g.FillEllipse( brush, Location.X, Location.Y, Size.Width, Size.Height); g.DrawEllipse( pen, Location.X, Location.Y, Size.Width, Size.Height); } } } public class DiamondShape : ShapeBase { protected override void DrawCore(Graphics g) { float cx Location.X Size.Width / 2f; float cy Location.Y Size.Height / 2f; PointF[] points { new PointF(cx, Location.Y), new PointF(Location.X Size.Width, cy), new PointF(cx, Location.Y Size.Height), new PointF(Location.X, cy) }; using (SolidBrush brush new SolidBrush(FillColor)) using (Pen pen new Pen(BorderColor, 2f)) { g.FillPolygon(brush, points); g.DrawPolygon(pen, points); } } }菱形的四个顶点分别是外接矩形的上边中点、右边中点、下边中点、左边中点。这里 cx 和 cy 是根据当前位置实时算出来的所以图形被拖动到任意位置都不会画歪。Pen 的宽度参数可以放在基类里统一管理我这里直接用 2f方便调试时观察边框。FillColor 如果设置为 Color.TransparentSolidBrush 填充时看起来就是透明的内部不会覆盖背景色。4. 拖动与缩放鼠标事件链路的完整状态机实现4.1 命中测试如果点击落在图形上拖动的前提是能判断鼠标按到哪个图形。矩形直接判断点是否落在外接矩形里圆形用椭圆方程来判断菱形则用中心点到目标点的归一化距离判断。三个子类分别重写 ContainsPoint。public class RectangleShape : ShapeBase { public override bool ContainsPoint(PointF point) { return new RectangleF(Location, Size).Contains(point); } } public class EllipseShape : ShapeBase { public override bool ContainsPoint(PointF point) { float rx Size.Width / 2f; float ry Size.Height / 2f; float cx Location.X rx; float cy Location.Y ry; float dx point.X - cx; float dy point.Y - cy; return (dx * dx) / (rx * rx) (dy * dy) / (ry * ry) 1f; } } public class DiamondShape : ShapeBase { public override bool ContainsPoint(PointF point) { float cx Location.X Size.Width / 2f; float cy Location.Y Size.Height / 2f; float dx Math.Abs(point.X - cx); float dy Math.Abs(point.Y - cy); float rx Size.Width / 2f; float ry Size.Height / 2f; return (dx / rx) (dy / ry) 1f; } }椭圆命中测试用的是标准椭圆方程把当前点变换到圆心坐标系再算归一化距离。菱形命中测试用的是菱形两条对角线构成的斜坐标系dx/rx dy/ry 小于等于 1 说明点在菱形内部。注意这里没有用 PointInPolygon 的通用算法因为菱形顶点已知直接用数学表达式判断更快也不容易出错。4.2 拖动状态机按下选中、移动更新、弹起释放画布控件里维护两个关键字段一个是 grabbedShape记录当前被按住的图形一个是 grabOffset记录鼠标点相对于图形锚点的偏移量。protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); for (int i shapes.Count - 1; i 0; i--) { if (shapes[i].ContainsPoint(e.Location)) { grabbedShape shapes[i]; grabOffset new PointF( e.X - grabbedShape.Location.X, e.Y - grabbedShape.Location.Y); shapes.RemoveAt(i); shapes.Add(grabbedShape); SelectShape(grabbedShape); break; } } } protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (grabbedShape ! null) { grabbedShape.Location new PointF( e.X - grabOffset.X, e.Y - grabOffset.Y); Invalidate(); } else { Cursor HitTest(e.Location) ? Cursors.SizeAll : Cursors.Default; } } protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp(e); grabbedShape null; Cursor Cursors.Default; }OnMouseDown 里从后往前遍历列表是为了让视觉上最上层的图形优先获得命中事件。命中的图形会被临时移出列表再添加到末尾这样画的时候后画的图形永远盖住前面的图形。grabOffset 记录的是按下瞬间鼠标位置与图形左上角的差值。OnMouseMove 里更新 Location 时减去这个差值图形就不会出现“鼠标拖到哪儿图形左上角就跳到鼠标位置”的突兀移动。HitTest 方法遍历所有图形并返回是否有任意图形命中这里主要是为了切换光标。不做这个处理的话鼠标悬浮在图形上方时还是默认箭头交互反馈会差很多。实际项目中我还会加一个 hover 效果给鼠标下面的图形做轻微高亮思路就是同一套 HitTest。4.3 缩放选中后拖动右下角手柄并限制最小尺寸缩放比拖动更考验边界处理。常见做法是让被选中的图形绘制一个右下角缩放手柄鼠标在手柄区域内按下时进入缩放模式。private const int ResizeHandleSize 6; private ResizeMode resizeMode ResizeMode.None; private PointF resizeStart; private ResizeMode GetResizeMode(PointF p) { if (selectedShape null) return ResizeMode.None; RectangleF handle GetResizeHandleRect(selectedShape); if (handle.Contains(p)) return ResizeMode.Resize; return ResizeMode.None; } private RectangleF GetResizeHandleRect(ShapeBase shape) { return new RectangleF( shape.Location.X shape.Size.Width - ResizeHandleSize / 2f, shape.Location.Y shape.Size.Height - ResizeHandleSize / 2f, ResizeHandleSize, ResizeHandleSize); }在 OnMouseMove 的缩放分支里根据鼠标移动的增量更新 Sizeif (resizeMode ResizeMode.Resize selectedShape ! null) { float dx e.X - resizeStart.X; float dy e.Y - resizeStart.Y; float newWidth selectedShape.Size.Width dx; float newHeight selectedShape.Size.Height dy; // 按住 Shift 时锁定等比缩放 if (ModifierKeys.HasFlag(Keys.Shift)) { float ratio Math.Max( newWidth / selectedShape.Size.Width, newHeight / selectedShape.Size.Height); newWidth selectedShape.Size.Width * ratio; newHeight selectedShape.Size.Height * ratio; } selectedShape.Resize(newWidth, newHeight); Invalidate(); return; }缩放比例的计算方法是取宽高变化量中较大的一个作为基准值乘以原始宽高得到新尺寸。这样做的好处是Shift 缩放时图形不会因为宽高变化幅度不同而变形。Resize 里有 Math.Max(5, newWidth) 的保护避免鼠标把图形缩到宽度为 0 甚至负数图形直接消失或引发绘制异常。在真实的拖动场景里快速移动鼠标会产生很多中间事件这些事件都依次更新 Size视觉上不会有断续感。5. 避坑指南画布闪烁、图形跑飞、坐标错位这些坑的排查记录中间加了几个交互模块之后很多问题才会浮现出来。下面这 5 条是我在实际调试时踩过的比较典型的坑每一条都按现象、原因、解决的顺序整理方便你照着排查。5.1 画面疯狂闪烁重绘区域像心电图现象从画一个图形开始就会闪烁鼠标拖动的过程中画面像心电图。实际上不只是拖动窗口尺寸变化时也一样。原因Control 的默认绘制过程是先用背景色擦除客户区再触发 Paint 事件绘制内容。每次 Invalidate 都走一遍“擦除-重绘”如果图形数量多或者 OnPaint 里做了大量创建和销毁闪烁就很明显。很多人以为把图形画到 Form 上就不会闪其实 WinForms 自绘控件不开启双缓冲就是会闪这是机制决定的。解决在 CanvasControl 构造函数里设置控件样式SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);第一行防止系统先擦除背景第二行让控件自己负责绘制第三行启用内置双缓冲。设置之后所有绘制先进入内存画布再一次性提交到屏幕。如果你用的是 .NET Framework 4.6 以上也可以直接把控件的 DoubleBuffered 属性设为 true两种方式效果一样。5.2 快速拖动时图形瞬间“跑飞”到画布外现象快速拖动图形时图形会突然跳到鼠标所在位置的某个偏移处甚至飞出去找不到。慢速拖动正常快速就出问题。原因错误地使用了“每帧把图形中心改成鼠标坐标”的方法而没有记录按下时的偏移量。准确原因通常是 MouseMove 的 e.Location 是控件坐标但鼠标一旦移动到画布边缘外WinForms 不会继续派发 MouseMove 事件最后一个事件里图形就停在那里等鼠标再回到画布时又突然跳动。更深一层是抓取偏移量没有用按下时的坐标减去图形锚点导致每次事件都累加错误增量。解决按下时计算 grabOffset e.Location - grabbedShape.Location移动时设置 grabbedShape.Location e.Location - grabOffset。同时拖动过程中改用 Control.MousePosition 和 PointToClient 来获取坐标这样鼠标离开画布边界也不怕。标准写法是protected override void OnMouseMove(MouseEventArgs e) { Point client PointToClient(MousePosition); if (grabbedShape ! null) { grabbedShape.Location new PointF( client.X - grabOffset.X, client.Y - grabOffset.Y); Invalidate(); } }PointToClient 把屏幕坐标转换成客户区坐标和 e.Location 保持一致可以避免快速拖出边界后的跳变。5.3 绘制结果整体偏移几个像素现象画出的图形整体偏移 2 到 3 个像素图形越大越明显。尤其在切换窗体 DPI 或把画布放在 Panel 里时偏移量会变成一个动态数据。原因绘制用的是画布控件的客户区坐标但某些代码把 MousePosition 转成了屏幕坐标或者在 MainForm 的 Paint 事件里绘制 shapes而鼠标事件却在 CanvasControl 里两边坐标基准不一致。另一个常见原因是 DPI 缩放WinForms 在高 DPI 屏幕上如果没有设置自动缩放坐标会被放大图形就会落在偏下的位置。解决全部绘制和命中测试统一以画布控件为坐标基准。鼠标按下时如果拿到的是屏幕坐标先用 PointToClient 转换再存储。高 DPI 下不要手动乘以缩放比例而是让 WinForms 自动缩放。所有图形的 Location 只接受客户区坐标禁止把 Cursor.Position 直接赋值给图形。排查时在鼠标事件和 OnPaint 里各打印一组坐标偏移量是不是常量一眼就能看出来。5.4 圆形只有边缘能被选中空心部分点不中现象画好的圆形只能从边框附近选中大部分内部区域点上去没有反应矩形和菱形反而正常。原因EllipseShape 的 ContainsPoint 如果用的是外接矩形判断会把矩形四角也包含进来这导致鼠标点了图形外面的角上也能选中看起来“选得过多”反过来如果用边框像素判断又“选得过少”。最常见的翻车是把椭圆命中写成了判断 |dx| rx |dy| ry那只是一个矩形盒子点在圆边缘内部的角落区域时会被漏掉。解决使用椭圆方程归一化判断float dx point.X - cx; float dy point.Y - cy; return dx * dx / (rx * rx) dy * dy / (ry * ry) 1f;这个公式把椭圆内域完整覆盖四个角不会被误判边缘区域也能正常选中。菱形同理不使用矩形判断用对角线公式。写完多画几个大圆在点内部和外部角落分别测试命中结果需要符合直觉。5.5 新增三角形工具后图形点不中、缩放时灵时不灵现象新增一个三角形工具后画出来的图形可以显示但是几乎点不中拖动缩放更是时灵时不灵。原因TriangleShape 的 ContainsPoint 没有重写用的还是基类外接矩形判断。三角形的边界形状和外接矩形差距很大点击矩形右下角的空白区域时也被判断为命中反过来点三角形中心偏外的位置却可能判定为未命中。解决为每个新形状重写 ContainsPoint用点与多边形的包含关系判断。最简单的方式是构建 GraphicsPath调用 IsVisibleusing (GraphicsPath path GetPath()) { return path.IsVisible(point); }多边形和任意曲线形状都建议用 GraphicsPath它内部采用几何填充规则比自己写射线法更可靠。这个教训说明给形状类新增子类不能只处理绘制命中测试永远要一起实现。以上 5 条如果按照出现频率排序双缓冲和坐标偏移几乎每个自绘控件项目都会遇到。排查时不要急着怀疑框架先在关键位置把坐标值和状态值打印出来问题通常藏在事件时序里。6. 二次开发把形状引擎扩出可插拔的新图形插槽6.1 新增一个三角形或者六边形顺着当前代码的扩展点增加新图形只需要三步。首先在 ShapeType 枚举里加一个 Triangle。然后在 Shapes 目录下新建 TriangleShape 类继承 ShapeBase重写 DrawCore 和 ContainsPoint。最后在 ShapeFactory 的 switch 里补一个 case。整个过程不需要改动画布控件的任何鼠标逻辑因为所有调用都发生在多态层面。6.2 给图形增加序列化如果想把画布内容存下来最简单的方式是给每个图形加 SaveToXml 和 LoadFromXml 方法遍历 shapes 列表保存类型名、Location、Size、颜色四个字段。加载时根据类型名反查 ShapeType再调用 ShapeFactory.Create 重新生成图形。重点是把保存逻辑放在 ShapeBase 子类内部别把序列化代码堆在 MainForm 里。6.3 验证方法用手工用例和简单断言把交互链路盯住改动之前最好准备一组固定数据在固定位置画一个 100x50 的矩形、一个半径 40 的圆、一个 80x80 的菱形。验证拖动后 Location 与鼠标偏移一致验证缩放到最小尺寸后不会小于 5 像素。也可以用单元测试对 ContainsPoint 做断言比如圆心内部返回 true菱形顶点向内偏移一个像素返回 true。这些用例很小但对拦截回归非常有效。做完这几处扩展你会发现自己再面对类似白板、脑图编辑器需求时不需要从零开始搭框架只需要把这个画布控件当作一个小小的形状引擎。当初我把这套结构改成“圆形矩形箭头连线”的简单流程图工具只用了大概一晚上时间。从那以后我每次给画布控件加新功能都强制把“命中测试-光标状态-按下落点-移动更新-弹起释放”这条链路完整走一遍哪怕只是加一个小手柄也不跳过鼠标事件边界检查。这个习惯帮我堵住了不少缩放跑飞的意外。希望帮到你。本文还有配套的精品资源点击获取