news 2026/10/19 5:12:40

C# WinForm绘图:GDI+对象模型、重绘机制与命中测试实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# WinForm绘图:GDI+对象模型、重绘机制与命中测试实战解析

简介:这套C# WinForm绘图程序源码是一个可直接运行的桌面图形编辑器,面向学习NET窗体开发、GDI+绘图或需要完成相关课设的读者。程序覆盖线条、矩形、圆、多边形、椭圆等基本图形的绘制与填充,包含换色、移动、画笔粗细调节等编辑能力,涉及GDI+常用绘图接口、画笔与画刷对象、鼠标事件响应等核心知识点。资源共70个文件,压缩包约738KB,主体为C#源码工程(.cs、.csproj、.sln),另含编译生成的程序、动态库、调试符号和29张运行截图,整体目录清晰,方便直接打开项目动手调试。已有931人学习。内容还演示了如何通过鼠标按下、移动与抬起事件实现图形拖动和选中,利用实心画刷填充图形、设置画笔宽度调整线宽,采用双缓冲减少重绘闪烁,并介绍了图形数据保存与加载的序列化思路;同时包含颜色选择器、按钮切换形状、滑块调节画笔粗细等交互设计,适合跟随源码从零搭建一个可用的WinForm绘图程序。

1. 一个绘图 Demo 的真相:画上去的图形,程序根本不记得

先说结论:C# WinForm 里用 GDI+ 画图,本质上是一锤子买卖。你调用一次DrawEllipse,像素确实上了屏幕,但程序内存里没有留下任何关于"这里有一个椭圆"的记录。窗口被遮挡再恢复,这些像素就人间蒸发。这个绘图 Demo 的价值恰恰不在于那几个绘制 API 的调用,而在于它怎么把"画"变成"数据"——用ClassObject把每一笔图形记下来,在OnPaint里统一重绘,再用鼠标事件去改这份数据。搞懂"数据模型 + 重绘 + 命中测试"这个三角关系,你才算真正看懂了这类程序;这也是理解几乎所有矢量绘图引擎(从画图板到设计工具)的起点。

2. GDI+ 绘制机制:为什么每次重绘都要把所有图形重画一遍

2.1 立即模式与一次性胶片:Graphics 到底在干什么

Graphics类是System.Drawing对 GDI+ 的托管封装,它的核心特征是"立即模式":每次绘制调用发生时,GDI+ 当场把图元光栅化到当前设备上下文上,调用结束,这次操作就彻底结束了,没有任何"图形对象"被留存在引擎内部。你可以把Graphics理解成一台一次性胶片投影仪——给你指令,它当场把画面烧到幕布上,然后忘掉所有指令。

示例项目的Form1.cs里,重绘逻辑大致长这样:

protected override void OnPaint(PaintEventArgs e) { Graphics g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; // 抗锯齿,线条边缘更平滑 foreach (ClassObject obj in shapeList) { using (Pen pen = new Pen(obj.PenColor, obj.PenWidth)) { // 先填充后描边:填充用 Brush,描边用 Pen,是两套独立 API if (obj.IsFilled) { using (SolidBrush brush = new SolidBrush(obj.FillColor)) { g.FillEllipse(brush, obj.Bounds); } } g.DrawEllipse(pen, obj.Bounds); } // using 块结束,Pen 和 Brush 自动 Dispose } }

这段代码揭示了三个机制。第一,填充和描边是两套 API:FillXxx吃Brush,DrawXxx吃Pen,一个画"面",一个画"轮廓"。要做带填充的椭圆必须两个都调,且顺序有讲究——先填充后描边,否则描边会被填充色压掉一半。第二,OnPaint是被邀请方不是主动方:程序里没有任何一行代码"决定"此刻要画椭圆,画不画完全取决于 Windows 有没有发来WM_PAINT消息。第三,也正因为 GDI+ 不留状态,每次重绘都只能从头把shapeList完整走一遍,这是一个 O(n) 操作,n 是图形数量。理解了这一层,你就明白为什么这个程序要专门维护一个图形列表——那不是为了结构好看,是 GDI+ 的立即模式逼出来的。

提示:OnPaint里临时创建的Pen/Brush用完必须释放。GDI+ 对象是内核资源,不是托管内存,GC 不会替你及时回收,这是后面避坑章的第一大坑。

2.2 Pen 与 Brush:颜色、粗细、填充背后是同一套资源生命周期

Pen不是"颜色加粗细"这么简单。它内部包装的是一个 GDI+ Pen 对象,除了Color和Width,还拖着DashStyle(虚线样式)、LineJoin(折线连接方式)、StartCap/EndCap(线帽)一票属性。在 WinForm 里调整画笔粗细,常见做法是把NumericUpDown的值绑定到当前绘制参数上:

private void nudPenWidth_ValueChanged(object sender, EventArgs e) { currentPenWidth = (float)nudPenWidth.Value; // 先写进字段,不直接改任何 Pen Invalidate(); // 通知窗口"数据变了,该重绘" }

参数传递的完整链路是:UI 控件 → 字段/属性 →OnPaint里创建 Pen。为什么不直接在ValueChanged里改某个 Pen 对象?因为 Pen 本来就是重绘时现建现用的临时资源,把状态存在字段里、绘制时再读,才是 GDI+ 的正确姿势。currentPenWidth能稳定地存活于各个事件之间,靠的正是它不依赖任何 Graphics 实例——ClassObject能在 MouseDown、MouseMove、MouseUp 之间稳定存续,也是同一个道理。

这里还有一个用户几乎不会注意但实现者必须知道的细节:WinForm 里画"圆"其实是个骗局。GDI+ 没有圆形 API,只有DrawEllipse,圆就是宽高相等的椭圆。更底层的事实是,椭圆是外接矩形的内切图形——DrawEllipse(Pen, Rectangle)的语义是"画这个矩形内切的最大椭圆",矩形四边恰好是椭圆的外切边界。这个设计直接决定了第 3 章命中测试的做法:判断椭圆是否被点中,用的还是它的外接矩形。

2.3 重绘链路:WM_PAINT、Invalidate 与双缓冲

public Form1() { InitializeComponent(); // 开启内置双缓冲,等价于手写 SetStyle 三件套 this.DoubleBuffered = true; }

为什么必须开双缓冲?WinForm 控件的默认绘制流程是先处理WM_ERASEBKGND擦背景,再处理WM_PAINT画前景。用户拖动图形时每秒触发几十次重绘,每次先擦后画,人眼就会看到整个画布在背景色和图形之间反复横跳。双缓冲的本质是把绘制目标从屏幕换成一块内存位图,画完一次性拷贝上屏,用户看到的永远是完整的帧。

这条链路里最容易被误解的是Invalidate()。它不执行任何绘制,只是把控件标记为"脏",然后往消息队列投递一条WM_PAINT。真正绘制发生在下一次消息循环取到这条消息时。这意味着你连续调用十次Invalidate(),消息会被合并,实际只重绘一次——这是性能优化的基础,但也是"图形移动出现拖影"这类 bug 的根源:如果某次修改数据后忘了调Invalidate,画面就停留在旧状态,看起来像图形长出残影。示例项目里,几乎每一个事件处理函数的结尾都是Invalidate(),这行代码才是"数据变更生效"的最后一步。

3. 图形对象模型:把绘制指令变成可查询的几何数据

3.1 ClassObject:每张图形的"参数档案"

从项目文件结构看,和图形相关的核心文件只有一个ClassObject.cs,它同时被Form1.cs引用。我一般会这么理解这个类的设计:它就是画布上每一笔图形的"参数档案",把绘制一个图形所需要的全部信息收拢成一个可序列化的对象。

图形数据表示描边 API填充 API
线条StartPoint、EndPointDrawLine(Pen, Point, Point)无
矩形Bounds 矩形DrawRectangle(Pen, Rectangle)FillRectangle(Brush, Rectangle)
椭圆/圆Bounds 矩形DrawEllipse(Pen, Rectangle)FillEllipse(Brush, Rectangle)
多边形顶点数组DrawPolygon(Pen, Point[])FillPolygon(Brush, Point[])

常见的字段设计是一组枚举加一组坐标加两组颜色:

public enum ShapeType { Line, Rectangle, Circle, Ellipse, Polygon } public class ClassObject { public ShapeType Type { get; set; } public Point StartPoint { get; set; } // 拖拽起点 public Point EndPoint { get; set; } // 拖拽终点 public List<Point> PolygonPoints { get; set; } // 多边形专用顶点集 public Color PenColor { get; set; } public Color FillColor { get; set; } public float PenWidth { get; set; } public bool IsFilled { get; set; } // 所有图形统一用外接矩形描述占据范围 public Rectangle Bounds { get { int x = Math.Min(StartPoint.X, EndPoint.X); int y = Math.Min(StartPoint.Y, EndPoint.Y); int w = Math.Abs(EndPoint.X - StartPoint.X); int h = Math.Abs(EndPoint.Y - StartPoint.Y); return new Rectangle(x, y, w, h); } } }

注意Bounds里的规整化。用户可能从右下角往左上角拖,此时EndPoint.X - StartPoint.X是负数,Rectangle不认负宽高。Math.Min和Math.Abs这四行代码,保证任何拖拽方向得到的都是一个"左上角 + 正宽高"的合法矩形。这个属性是整个绘制程序的几何基石:矩形、椭圆、圆的绘制和命中测试全部建立在它之上,多边形的外接矩形也可以用它近似。

还有一个容易忽略的动机:为什么矩形、椭圆、圆能共享同一个绘制入口?因为它们共享同一个Bounds。绘制阶段只需要用Type把调用分发到对应的DrawXxx方法,数据结构和绘制逻辑一一对应,新增一种图形类型的成本被压到了最低。

3.2 命中测试:移动、选中背后是一道计算几何题

移动图形的前提是选中图形。怎么判断鼠标点中了哪一个图形?这是绘图程序里最容易写糊的部分,也是ClassObject被设计成纯几何数据后才有资格做的事。命中测试的常见实现是放在ClassObject内部,让图形自己回答"这个点算不算点中了我":

public bool HitTest(Point p, float tolerance) { switch (Type) { case ShapeType.Line: // 线条只看外接矩形会误判,必须算点到线段距离 return DistanceToSegment(p, StartPoint, EndPoint) <= tolerance; case ShapeType.Rectangle: case ShapeType.Ellipse: case ShapeType.Circle: Rectangle r = Bounds; r.Inflate((int)tolerance, (int)tolerance); // 放大容差 return r.Contains(p); case ShapeType.Polygon: using (GraphicsPath path = new GraphicsPath()) { path.AddPolygon(PolygonPoints.ToArray()); // 奇偶填充规则:内部命中;边界附近另加容差判断 return path.IsVisible(p) || IsPointOnPolygonEdge(p, tolerance); } } return false; }

矩形、椭圆用外接矩形判定是"够用"的方案:椭圆内切于矩形,矩形包含了椭圆,所以矩形命中后即使点在椭圆和矩形之间的空隙,也只是轻微误判,容差tolerance能把这种误差吸收掉。线条则绝对不能看外接矩形——一条斜线在屏幕上只有一两像素宽,但它的外接矩形可能覆盖小半个屏幕,点进矩形不等于点中线条,必须算点到线段的最短距离。

多边形的GraphicsPath.IsVisible(p)走的是奇偶填充规则:从点 p 发一条任意方向的射线,统计它与多边形边界的交点数,奇数为内、偶数为外。这个算法 GDI+ 已经内置,不需要你手工实现,但要知道它管辖的是"内部"判定,落在边线上的点会被判为不可见,所以要额外做一条"点到任意边的距离是否小于容差"的边界判定,两者取或。

3.3 保存与加载:存参数而不是存像素

用户画完图想保存,最直觉的方案是截屏存位图。但位图保存有两个致命短板:不能二次编辑,分辨率焊死。这个示例项目如果要实现保存功能,正确姿势是把List<ClassObject>直接序列化成 XML 或 JSON:

public void SaveShapes(string filePath) { XmlSerializer serializer = new XmlSerializer(typeof(List<ClassObject>)); using (StreamWriter writer = new StreamWriter(filePath)) { serializer.Serialize(writer, shapeList); } } public void LoadShapes(string filePath) { XmlSerializer serializer = new XmlSerializer(typeof(List<ClassObject>)); using (StreamReader reader = new StreamReader(filePath)) { shapeList = (List<ClassObject>)serializer.Deserialize(reader); } Invalidate(); // 加载后必须重绘,数据才上屏 }

XmlSerializer的机制约束值得记一笔:它要求类型有公共的无参构造函数,所有要序列化的字段必须是公共读写属性。这也是为什么ClassObject的属性全是简写的{ get; set; }——不是编码习惯,是序列化框架的硬性契约;构造函数不写,是因为框架要自己Activator.CreateInstance再逐属性赋值。保存的是"图形参数",加载后图形仍然可移动、可改色、可调粗细;位图导出的Graphics.FromImage(bmp)则更适合做最终交付。两种方案并存是这个领域最常见的工程形态,一个管编辑,一个管输出。

4. 鼠标交互状态机:一次拖拽背后的事件调度

4.1 MouseDown 的一分为二:绘制新图形还是选中旧图形

绘图程序的鼠标交互可以抽象成一个三态状态机:空闲态、绘制态、移动态。示例项目里用枚举就能实现:

private enum EditMode { None, Drawing, Moving } private EditMode mode = EditMode.None; private void canvas_MouseDown(object sender, MouseEventArgs e) { // 倒序遍历:列表尾部最后绘制,处于最上层,应该优先被命中 for (int i = shapeList.Count - 1; i >= 0; i--) { if (shapeList[i].HitTest(e.Location, 5)) { selectedIndex = i; lastPoint = e.Location; mode = EditMode.Moving; return; } } // 没有命中任何图形,进入绘制态:创建一个新的 ClassObject currentShape = new ClassObject { Type = currentType, StartPoint = e.Location, EndPoint = e.Location, PenColor = currentPenColor, FillColor = currentFillColor, PenWidth = currentPenWidth, IsFilled = currentIsFilled }; shapeList.Add(currentShape); mode = EditMode.Drawing; }

两个关键点。第一,命中测试必须倒序遍历:shapeList从头画到尾,末尾的图形压在最上面,用户点击时应该优先命中"看起来在最上层"的那个,这符合直觉。第二,MouseDown同时承担"创建图形"和"选中图形"两个职责,靠HitTest的结果分流。这两条路径共用后续的MouseMove和MouseUp,区别只在mode字段——状态机把两个完全不同的交互意图统一到了一套事件处理里,这是示例项目最简洁也最值得模仿的设计。

各事件在三种状态下的行为可以用一张表捋清楚:

事件空闲态 None绘制态 Drawing移动态 Moving
MouseDown 命中进入移动态结束当前绘制,重新进入移动态选中新图形
MouseDown 未命中进入绘制态结束当前绘制,开始新图形取消移动,开始新图形
MouseMove更新鼠标样式更新 EndPoint,Invalidate增量平移图形,Invalidate
MouseUp—回到空闲态,锁定图形回到空闲态,锁定位置

这张表的含义是:任何时刻鼠标按下,程序都只需要回答一个问题——"点中的是已有图形还是空白区域"。答案决定状态,状态决定后续 MouseMove 的行为,MouseUp 则把状态归零。

4.2 MouseMove 与 Invalidate:橡皮筋预览和增量平移

private void canvas_MouseMove(object sender, MouseEventArgs e) { if (mode == EditMode.Drawing) { // 拖拽过程中反复更新终点坐标,形成"橡皮筋"预览效果 currentShape.EndPoint = e.Location; Invalidate(); } else if (mode == EditMode.Moving) { int dx = e.X - lastPoint.X; // 本次位移增量 int dy = e.Y - lastPoint.Y; shapeList[selectedIndex].Translate(dx, dy); lastPoint = e.Location; Invalidate(); } }

"橡皮筋"效果的本质:每次 MouseMove 只改一个数据点,然后触发全量重绘。因为一次重绘成本是 O(n),而数据只改了一处,这里存在明显的"更新一块数据 vs 重绘整个画面"的不对称。图形量少(几十个)时完全无感,量级到几百上千时,就要考虑只重绘脏区域,即调用Invalidate(rect)只让受影响的那一小块矩形区域重绘,消息循环会把它裁剪进PaintEventArgs.ClipRectangle。

移动操作用的是Translate(dx, dy),dx、dy 是当前事件相对上一次事件的位移增量,而不是相对 MouseDown 的总位移。这是有意为之:如果存总位移,鼠标快速拖动时事件采样稀疏,图形移动会一顿一顿;增量式天然平滑,代价是lastPoint必须每次更新,漏更一次位移就会翻倍。Translate方法本身是个纯几何操作,要把多边形的全部顶点一起平移:

public void Translate(int dx, int dy) { StartPoint = new Point(StartPoint.X + dx, StartPoint.Y + dy); EndPoint = new Point(EndPoint.X + dx, EndPoint.Y + dy); if (PolygonPoints != null) { for (int i = 0; i < PolygonPoints.Count; i++) { PolygonPoints[i] = new Point(PolygonPoints[i].X + dx, PolygonPoints[i].Y + dy); } } }

注意这里Point是值类型结构体,PolygonPoints[i]取出来修改坐标后必须写回去,直接改字段编译器都不让过。这个细节看着小,却是新手把多边形移动到"散架"的高发区。

4.3 颜色与画笔粗细:属性变更的统一落点

画之前设置属性和画之后修改属性,是两条不同的代码路径。前者在MouseDown创建ClassObject时把 UI 值拷贝进去;后者要修改已经存在列表里的图形:

private void btnLineColor_Click(object sender, EventArgs e) { using (ColorDialog dialog = new ColorDialog()) { if (dialog.ShowDialog() == DialogResult.OK) { if (mode == EditMode.None && selectedIndex >= 0) { // 已选中图形:改的是历史对象,改完必须重绘 shapeList[selectedIndex].PenColor = dialog.Color; Invalidate(); } else { // 未选中:设置的是"下一次绘制"的默认值 currentPenColor = dialog.Color; } } } }

ColorDialog是 WinForm 内置的通用对话框,调ShowDialog()会启动一个模态消息循环,期间主窗体照常处理绘制消息但不响应输入,这是 WinForm 消息循环的机制使然,不是卡死。这段代码还体现了一个容易被忽略的分支设计:颜色修改要区分"作用于已选中图形"和"作用于后续新图形",两者共用同一个 UI 入口,靠selectedIndex判断落点。填充色走SolidBrush,线色走Pen,在ClassObject里分别是FillColor和PenColor两个字段,UI 上对应两个颜色按钮。

这套"UI 收集参数 → 写进 ClassObject 属性 → Invalidate 重绘"的模式,在示例项目里出现了至少三次:线色、填充色、画笔粗细。当所有交互最终都归结为"改几个属性加一次重绘",程序的复杂度就被压在一个极小的闭环里。这也是ClassObject必须设计成纯数据类的原因——纯数据才经得起反复读写和序列化。

5. 避坑:绘图程序最容易翻车的五个现场

下面五个坑是绘制类程序的血泪经验,每一条都按"现象 → 原因 → 解决"拆开。前四个坑几乎每个新手都会踩一遍,第五个坑则是老手也会在图形量变大后撞上的。

5.1 图形画完一最小化就消失

现象:用鼠标画好一个矩形,窗口最小化再恢复,画布回到空白,刚才的笔迹全部蒸发。 原因:绘制代码写在了MouseUp事件里,直接拿Graphics.FromHwnd或CreateGraphics往屏幕上画。屏幕像素一旦被系统重绘就覆盖,程序里没有任何对象记录这个矩形——这是第 2 章"立即模式"的必然结果。 解决:所有最终画面一律在OnPaint里绘制,绘图操作只负责往shapeList添加一个ClassObject,然后调Invalidate()。牢记一条铁律:任何地方都不要直接调用Graphics去画最终画面,Graphics只存在于OnPaint的PaintEventArgs里。

5.2 拖动时整个画布疯狂闪烁

现象:选中图形一拖动,整个画布像日光灯一样闪,画面破碎。 原因:WinForm 默认每帧先擦背景再画前景,擦和画之间屏幕暴露了白色背景。双缓冲未开启或只开了DoubleBuffered但控件是自定义的,属性没作用上去。 解决:主窗体直接this.DoubleBuffered = true;。如果是自定义控件,要在构造函数里手写SetStyle(ControlStyles.UserPaint | ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer, true);。其中AllPaintingInWmPaint的作用是抑制WM_ERASEBKGND擦背景消息,OptimizedDoubleBuffer才真正启用内存位图。两个标志缺一个,闪烁问题都治不彻底。

5.3 GDI 对象只增不减,程序越来越卡

现象:程序运行几分钟后,拖动画笔开始迟滞,内存看着没涨但操作越来越慢,最终报出"内存不足"。任务管理器里看 GDI 对象数,已经几千还在涨。 原因:OnPaint里每次重绘new Pen、new SolidBrush,但Pen漏了Dispose。GDI+ 对象是操作系统内核资源,进程不主动释放就一直占用;GC 只管托管内存,管不了内核句柄。 解决:所有Pen/Brush一律using包裹(见 2.1 的代码),或者try/finally兜底。注意Pens.Red、Brushes.Blue这类静态预定义对象是进程级共享的,不要Dispose它们,Dispose之后其他控件再画就异常了。排查时可以在任务管理器加 GDI 对象列,观察重绘前后数值是否回落。

5.4 从右下往左上拖,矩形直接"翻车"

现象:从左往右拖矩形一切正常,从右下角往左上角拖,矩形画不出来,或者画出一个镜像错位的形状。 原因:EndPoint - StartPoint的差值出现负数,Rectangle的宽高为负。GDI+ 对负宽高矩形的处理是未定义的,常见表现是图形翻转、错位甚至干脆不画。 解决:所有矩形、椭圆的坐标来源统一走Bounds属性,用Math.Min/Math.Max/Math.Abs规整化(见 3.1 代码)。只要Bounds实现正确,矩形、椭圆、圆三种图形的拖拽方向问题一次全解决;千万别在MouseUp里再写一份坐标修正逻辑,两份逻辑早晚不一致。

5.5 线条怎么都选不中:命中测试的"玄学"

现象:矩形、椭圆一点就选中,但画一条斜线,鼠标明晃晃压在线上却选不中,必须精确到像素才能命中。 原因:线条的命中测试用了Bounds.Contains(p)。斜线的外接矩形面积远大于线条本身的像素面积,鼠标点在了矩形内、线条外,自然判不中。反过来另一种极端是容差设成 1 像素,鼠标偏移两个像素就脱靶,高分屏下简直没法用。 解决:线条单独用点到线段距离判定,容差取 3 到 5 像素:

private float DistanceToSegment(Point p, Point a, Point b) { float dx = b.X - a.X, dy = b.Y - a.Y; float lenSq = dx * dx + dy * dy; // 把 p 投影到线段所在直线上,t 被钳制在 [0,1] 保证落在线段内 float t = lenSq == 0 ? 0 : ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / lenSq; t = Math.Max(0, Math.Min(1, t)); float projX = a.X + t * dx, projY = a.Y + t * dy; float vx = p.X - projX, vy = p.Y - projY; return (float)Math.Sqrt(vx * vx + vy * vy); }

这段代码的机制是投影:把点 p 投影到线段 ab 的延长线上得到最近点,再把投影点拉回线段范围内,最后算两点距离。容差取太小在触摸屏上会让用户怀疑人生,取太大则会误选相邻图形,3 到 5 像素是经过大量实践验证的甜点区间。

6. 设计取舍:为什么是"对象列表"而不是"一张大位图"

画到一半时我纠结过一个问题:既然双缓冲已经有一块内存位图了,为什么不把所有图形直接画进那块位图,每次操作只改位图?答案是:位图保存的是"结果",对象列表保存的是"原因"。用户要移动一个椭圆,位图方案只能搬动一块矩形像素,搬完图形的边缘和背景糊在一起;对象列表方案只需要改EndPoint两个坐标,重绘后边缘依然锐利。这个取舍可以用一张对比表看得很清楚:

维度对象列表方案内存位图方案
可编辑性高:改参数即可重绘低:只能整块替换像素
命中测试几何计算,快且准需要像素分析,成本极高
保存格式XML/JSON,参数可逆图片,像素不可逆
重绘成本O(n) 全量重绘局部 blit,速度快
数据主权列表是真相,位图是缓存位图是唯一真相

更本质的分歧在存储语义:像素适合显示,参数适合编辑。示例项目把List<ClassObject>当作唯一数据源,双缓冲位图只是它的渲染缓存——数据变更后重绘缓存、再整块上屏,这和数据库与查询缓存之间的关系如出一辙。如果将来图形量涨到几千个,全量重绘的 O(n) 成本会成为瓶颈,三种进阶手段依次是:用Invalidate(Rectangle)做局部失效、给图形建空间索引加速命中测试、把不再编辑的底层图形一次性"冻结"进缓存位图只让顶层图形走全量重绘。这套对象模型能走到多远,取决于你在缓存和一致性之间怎么平衡。

那以后我每次写绘制类程序,都强制自己把三件事拆开:数据模型(ClassObject)、绘制逻辑(OnPaint)、交互逻辑(鼠标事件)。画不出来先查重绘,选不中先查命中测试,图形消失先查数据有没有进列表——百分之九十的问题都能归到这三件事的接口上。这份示例项目把这三件事压缩在一个主窗体和两个类文件里,拿它当骨架去逐个机制对照,比从零搭框架要省掉大半弯路。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 5:12:00

open-code-review:开源代码审查工具,规则引擎+AI双通道内网闭环

1. 为什么我会动手写 open-code-review&#xff1a;现有 Code Review 流程的三个硬伤先说结论&#xff1a;代码审查这个环节&#xff0c;在小团队里基本是“名存实亡”的。我在带过几个 3 到 8 人的研发小组之后&#xff0c;对这一点体会特别深。不是大家不愿意 review&#xf…

作者头像 李华
网站建设 2026/10/19 5:11:46

开源代码审查机器人:用自动化将代码评审从形式主义拉回正轨

坦白说&#xff0c;代码审查这事我纠结了很久。团队里PR越积越多&#xff0c;review意见没人看&#xff0c;合并代码全靠自觉&#xff0c;出了事故才回头翻提交记录。后来我干脆写了个开源工具&#xff0c;把代码审查流程从“等人点评”变成了“机器先查一遍&#xff0c;人再重…

作者头像 李华
网站建设 2026/10/19 5:11:21

Matrix+Element+FluffyChat:自建去中心化沟通协作系统完整部署指南

1. 为什么说沟通协作软件不能只看“能聊天”做沟通协作系统选型的时候&#xff0c;很多团队第一反应是先看聊天界面好不好看、文件传输快不快&#xff0c;或者有没有群组视频会议。实际踩过几次坑之后就会发现&#xff0c;这些功能只是浮在水面上的冰山一角。真正决定一套沟通协…

作者头像 李华
网站建设 2026/10/19 5:09:51

基于VMD分解与ISSA优化LSTM的时间序列预测模型实现

做时间序列预测的同行应该都有这种共鸣&#xff1a;不管拿到的是风电功率、电价曲线、设备振动信号还是城市交通流量&#xff0c;最头疼的往往不是模型本身&#xff0c;而是数据本身太不“听话”。非平稳、强噪声、多尺度变化、偶发尖峰&#xff0c;这些问题叠加在一起&#xf…

作者头像 李华
网站建设 2026/10/19 5:09:20

CA证书申请与Nginx部署全指南:从CSR生成到自动续期

很多年前我第一次向某免费证书签发机构申请CA证书时&#xff0c;以为就是把一个文件下载下来、传到服务器上改改配置那么简单。结果那一次我对着浏览器里的“不安全”警告折腾了整整一个下午才找到原因——证书链没合并完整。后来陆陆续续帮同事、帮客户部署过不少证书&#xf…

作者头像 李华
网站建设 2026/10/19 5:09:18

开放代码审查实践:从流程设计到自动化落地

在团队里一提 code review&#xff0c;一般都会出现两种极端&#xff1a;一种是把评审当走过场&#xff0c;看一眼标题点个 approve&#xff0c;等到上线出问题才发现代码根本没人真正看过&#xff1b;另一种是评审卡成瓶颈&#xff0c;一个 MR 挂了三天没人理&#xff0c;催一…

作者头像 李华