ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Winform自绘表盘控件:GDI+原理与坐标旋转实践

Winform自绘表盘控件:GDI+原理与坐标旋转实践 简介面向Windows窗体Winform开发者的表盘控件示例工程内含ComTest演示项目与自定义表盘控件完整源码。资源包共58个文件体积约497KB主要包含12个C#源文件、项目配置文件、可执行程序、调试符号及界面资源文件并有解决方案与工程设置便于直接运行和二次修改。通过AGauge控件实例可学习利用绘图对象绘制表盘背景、指针与刻度涵盖数字标签、颜色定制、动态更新等关键实现并掌握双缓冲减少闪烁的技巧以及刻度线样式调整方法。工程同时附带解决方案、界面设计器代码与资源文件结构清晰适合需要为桌面程序添加仪表盘界面的中级开发者参考。目前已有843人学习下载可作为快速集成速度表、温度计、电量指示器等可视化界面的实用模板提升桌面应用的数据展示效果。1. 自绘Winform表盘控件不是找现成轮子而是GDI的基本功一个订单管理系统里要放一个“设备实时负载表盘”第一反应是在NuGet上搜“Winform 表盘控件”。等你把几个热门包拉下来就会发现要么样式改不动要么依赖了一堆无关的Winform界面美化库要么版本还停在.NET Framework 4.0。自己拿GDI画半小时能出第一个能转的圆盘后续每个像素都在你控制内。Winform表盘控件的本质不是控件而是角度换算、坐标旋转和OnPaint里那几十行绘图代码的组合。这篇文章写给想把表盘牢牢握在手里的C#开发者也写给面试时被问“自定义控件怎么画”的新手照着抄能跑跑完能改。2. 表盘控件的数学基础角度换算、刻度定位与坐标旋转附最小绘制代码2.1 从指针长度到像素点三角函数与角度偏移表盘上最核心的一步是把“当前值”变成一个屏幕坐标点。比如范围是0到100当前值50指针应该指向垂直向上。Winform的坐标系和数学书本不完全一样原点在左上角X轴向右Y轴向下。这就导致角度为正时旋转方向是顺时针反而刚好符合表盘直觉。我们定一个约定12点方向为0度顺时针增加。private PointF CalcPoint(PointF center, float radius, float angleInDegree) { // 由于我们约定0度指向12点而数学里0度指向3点需要减去90度 float radians (angleInDegree - 90f) * (float)Math.PI / 180f; float x center.X radius * (float)Math.Cos(radians); float y center.Y radius * (float)Math.Sin(radians); return new PointF(x, y); }这段代码里的角度减90度是第一个坑如果不剪0度会指向3点钟方向整个表盘顺时针偏了四分之一圈。参数上center是圆心radius是你想画的刻度长度或指针定位点返回值可以直接传给DrawLine或DrawEllipse。如果你要算起点到终点的连线比如指针从圆心伸出80个像素那么终点就是CalcPoint(center, 80, angle)起点保留center。要画针尾再向反方向算一个短一点的点角度加180即可。2.2 在临时Bitmap上画第一个表盘刻度、数字与指针的最简实现网上很多教程直接重写OnPaint来画但我在项目里更习惯先在临时Bitmap上画再一次性贴到屏幕。这既能避免闪烁也让绘制逻辑跟控件解耦。下面这段是最小的可运行表盘放在一个Control子类里即可。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); int side Math.Min(Width, Height); using (Bitmap bmp new Bitmap(side, side)) { using (Graphics g Graphics.FromImage(bmp)) { g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; SizeF size new SizeF(side - 1, side - 1); // 背景圆盘 using (SolidBrush backBrush new SolidBrush(Color.FromArgb(245, 245, 245))) g.FillEllipse(backBrush, 0, 0, size.Width, size.Height); // 画12个整点刻度 PointF center new PointF(side / 2f, side / 2f); for (int i 0; i 12; i) { float angle i * 30f; float r1 side / 2f - 12f; float r2 side / 2f - 2f; PointF p1 CalcPoint(center, r1, angle); PointF p2 CalcPoint(center, r2, angle); using (Pen majorPen new Pen(Color.Black, 2f)) g.DrawLine(majorPen, p1, p2); } // 指针指向75% float valueAngle 75f / 100f * 360f; PointF pointerEnd CalcPoint(center, side / 2f - 25f, valueAngle); using (Pen pointerPen new Pen(Color.Firebrick, 3f)) g.DrawLine(pointerPen, center, pointerEnd); } e.Graphics.DrawImageUnscaled(bmp, 0, 0); } }逻辑说明所有刻度坐标都从同一个CalcPoint函数算出来区别只是半径长短。整点刻度每30度一个所以循环12次。valueAngle算的是当前值对应的偏转角度这里直接写死75和MinValue/MaxValue还没有关联只是一个能看见效果的静态盘。指针我直接画了一条以圆心为起点的线这样最简单。参数方面r1和r2决定刻度长短Pen的宽度和颜色可以按界面风格调DrawImageUnscaled避免因为画布缩放产生额外缩放模糊。2.3 为什么用“先画图再贴”而不是直接在Panel上画直接重写Panel.OnPaint更直观但在实际Winform项目中控件经常需要在后台线程预热绘制数据或者在一帧里画多个刻度盘。先在Bitmap上画好处有三个第一一帧内多次Graphics.DrawXxx操作不必立刻刷屏幕减少OnPaint频次第二Bitmap本身可以被缓存刻度、背景这种非动态元素画一次存起来指针按新位置覆盖绘图第三后续做高分屏缩放时可以用更大尺寸的Bitmap来换清晰度。当然这不是银弹如果每次OnPaint都new Bitmap(width, height)内存频繁分配GC压力不小。我一般会在控件里维护一个_backBuffer仅在尺寸变化时重建平时清空后复用。这种写法的代价是要自己处理SizeChanged事件比直接画麻烦一点但性能提升明显这也是后面性能验证章节要再说的地方。3. 用C#规范实现可复用的表盘控件属性、事件与设计器支持3.1 继承UserControl还是Control控件定位与OnPaint表盘控件不需要承载任何子控件所以不要继承UserControl那会带来多余的设计器画布和布局开销。直接继承Control自己处理OnPaint更纯粹。但是直接继承Control有一个副作用默认没有背景擦除逻辑需要手动处理双缓冲。在构造函数里设置几个关键参数public class GaugeControl : Control { private int _minValue 0; private int _maxValue 100; private double _value 0; public GaugeControl() { // 双缓冲在Control子类里默认不开需要手动开 DoubleBuffered true; ResizeRedraw true; SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint, true); } }这里SetStyle加上UserPaint后系统的WM_ERASEBKGND消息会被忽略避免先灰底再重绘造成的闪烁。ResizeRedraw让尺寸变化时自动重绘省得在外面订阅SizeChanged。DoubleBuffered和OptimizedDoubleBuffer一起用是Winform里最标准的抗闪烁组合但后面我们会看到它们跟临时Bitmap还有一层关系。3.2 把表盘参数做成公开属性最小值、最大值、当前值、指针样式光有硬编码的表盘没法复用需要把关键数值暴露成属性。这里每个属性都要在setter里做两步校验参数、触发重绘。示例如下public int MinValue { get _minValue; set { if (value _maxValue) throw new ArgumentOutOfRangeException(nameof(MinValue)); _minValue value; Invalidate(); } } public int MaxValue { get _maxValue; set { if (value _minValue) throw new ArgumentOutOfRangeException(nameof(MaxValue)); _maxValue value; Invalidate(); } } public double Value { get _value; set { // 允许超出范围多用于动画过冲但默认就夹在区间内 double clamped Math.Max(_minValue, Math.Min(_maxValue, value)); if (Math.Abs(clamped - _value) 0.001) return; _value clamped; Invalidate(); ValueChanged?.Invoke(this, EventArgs.Empty); } }要注意MinValue和MaxValue的边界校验最小不能大于等于最大否则后面的角度计算公式会出现除零。Value的setter里先做clamp这样外面的业务代码给了一个越界数据不会导致指针飞出表盘这比把异常抛给调用方友好得多。每次setter调用Invalidate()Winform会合并同一消息循环里的多次重绘所以不必担心连续设置几十次值会闪烁。3.3 让属性在设计器里可编辑DefaultValue与Browsable在项目里使用这个控件时如果属性没有打特性标签设计器属性面板会乱糟糟的数字不好改还会出现“未标记可浏览”的灰条。加上特性是最省事的设计器支持方式[Browsable(true)] [Category(GaugeData)] [DefaultValue(0)] [Description(表盘当前值)] public double Value { // ... } [Browsable(true)] [Category(GaugeVisual)] [DefaultValue(typeof(Color), Firebrick)] public Color PointerColor { get; set; } Color.Firebrick;Browsable(false)的地方要留给那种不想让使用者乱调的中间状态属性比如DesignMode。DefaultValue不是必须的但设置后属性面板能显示为粗体提示与默认值不同而且序列化时能少写很多resx噪音。Category分组让“数据”和“视觉”分开比默认的Misc好用很多。这里有个小细节不能只给属性加Category却不加Browsable否则还是不会显示。3.4 让使用方能在指针区域叠加内容暴露绘制事件有些业务场景不满足于纯指针比如温控表上要画一个红色扇区代表超温报警区。给控件内置所有扇区逻辑会越写越臃肿。我常用的做法是暴露一个PaintOverlay事件让使用方拿到Graphics和当前的绘图矩形在表盘上层再画一层。public event EventHandlerPaintOverlayEventArgs PaintOverlay; protected override void OnPaint(PaintEventArgs e) { // 先把表盘画完…… PaintOverlay?.Invoke(this, new PaintOverlayEventArgs(e.Graphics, ClientRectangle, Value)); }事件的参数类需要自己定义把当前值和有效绘图区域传出去。这样做的好处是控件保持瘦身业务画逻辑放在外层订阅者那里换一个盘面不用改控件源码。缺点是如果使用方在事件里做了耗时操作会直接影响重绘性能所以我会在说明里写一句“不要在PaintOverlay里写循环和数据库访问”。这个事件本身就是很好的扩展点也能解决后面多表盘联动里“每个表盘画不同装饰”的诉求。4. 让指针动起来定时器、多线程更新与状态栏/进度条联动4.1 平滑旋转用定时器驱动Value变化的两种策略表盘静态没有意义常见场景是每一秒或每几百毫秒刷新一次当前值。最简单的做法是定时器直接赋值但两次赋值之间指针是瞬跳的视觉上很生硬。我一般用两种策略一是线性插值二是缓动插值。下面代码是线性插值的最小实现适合数值稳步增长比如任务进度。private Timer _animTimer; private double _startValue; private double _targetValue; private DateTime _startTime; public void AnimateTo(double target) { if (_animTimer null) { _animTimer new Timer { Interval 30 }; _animTimer.Tick OnAnimTick; } _startValue Value; _targetValue target; _startTime DateTime.UtcNow; _animTimer.Start(); } private void OnAnimTick(object sender, EventArgs e) { double durationMs 600; double elapsed (DateTime.UtcNow - _startTime).TotalMilliseconds; double ratio Math.Min(elapsed / durationMs, 1.0); // 线性插值从起点到终点均匀变化 Value _startValue (_targetValue - _startValue) * ratio; if (ratio 1.0) _animTimer.Stop(); }参数说明Timer.Interval 30意味着每秒约33帧对指针旋转足够。durationMs是动画持续时间我常用600毫秒到800毫秒短了像抽搐长了让人等。这种写法里Value的setter会触发Invalidate所以动画过程中每个Tick都会重绘。如果你要更自然的刹车效果把ratio替换成缓动函数即可比如1 - (1 - ratio) * (1 - ratio)就是加速开场、减速收尾。4.2 数据源更新线程与UI控件的消息泵问题很多单位表盘的数据来自后台线程比如一个串口读数或TCP报文10毫秒来一批。这时候最忌讳直接在后台线程里给_value赋值甚至调用Invalidate因为Winform控件不是线程安全的跨线程访问轻则数据错乱重则直接抛InvalidOperationException。// 后台线程收到的新值 private void OnSensorDataReceived(double reading) { if (IsHandleCreated InvokeRequired) { BeginInvoke((Action)(() SetValueSafe(reading))); } else { SetValueSafe(reading); } } private void SetValueSafe(double reading) { // 在这里才真正更新表盘 Value reading; // 同时更新状态栏文本和进度条 toolStripStatusLabel1.Text $当前负载{reading:F1}%; progressBar1.Value (int)Math.Round(reading); }这里BeginInvoke是异步的不会因为UI线程忙而卡住后台采集线程。InvokeRequired是标准防御性判断确保在控件句柄创建前不强行调用。要注意的是如果后台线程以很高频率调用BeginInvokeUI消息队列会积压反而导致界面越来越卡。解决手段我常用两种在外围减少上抛频率比如每100毫秒才推一次或者在SetValueSafe里做增量判断值波动小于0.1就直接丢弃。表盘不是示波器肉眼跟不上的更新纯属浪费CPU。4.3 把表盘值同步到状态栏与进度条Invoke与进度条溢出的坑如果你把上面的代码复制到具体项目里会发现progressBar1.Value (int)Math.Round(reading);在reading超过100时直接抛ArgumentOutOfRangeException。进度条默认Range是0到100而你的传感器量程可能到150。所以需要先校准进度条范围或在赋值前做阀值修剪。// 在窗体构造函数里校准 progressBar1.Minimum 0; progressBar1.Maximum 180; progressBar1.Step 1; // 更新时使用Math.Max/Math.Min夹住 int percent (int)Math.Round(Math.Max(0, Math.Min(180, reading))); progressBar1.Value percent;状态栏更新也有一个常见坑toolStripStatusLabel1.Text的赋值如果不走BeginInvoke会出现状态栏刷新脱节。原因是在UI线程里连续多次设置Text可能只产生一次重绘看到的还是旧值。我在实际开发里不会每帧都写状态栏而是每秒钟写一次或者只在数值变化超过某个阈值时写。这个细节对任务进度、设备负载这类低频文本特别管用。5. 表盘控件避坑与排查闪烁、资源泄漏、DPI缩放、设计器崩5.1 画面闪烁与残影双缓冲和Invalidate区域控制现象表盘指针转动时有明显闪动有时旧指针的位置会留下一块残影像墨水没擦干净一样。原因有两个一是没有做双缓冲OnPaint绘制时先擦背景再画新内容中间露出白色底色二是残影其实是上一次指针的像素没有覆盖完全特别在绘制圆弧指针时旧线条边缘有抗锯齿残留。解决措施第一重保障是前面提到的SetStyle双缓冲这能解决整圆刷新的闪烁。但如果你只用Invalidate()重绘区域是整个控件表盘背景和所有刻度都会重画浪费GPU像素。优化方式是只重绘指针扫过的扇形区域private Rectangle GetPointerDirtyRect() { // 计算指针起点到运动范围外接矩形再放大2像素边缘 // 这里简化为整个表盘实际可按角度算出外接矩形 return ClientRectangle; }老式做法是存一份上一次的指针Bitmap在OnPaint里先恢复背景再画新指针。我建议不要这样做维护两份绘图的复杂度太高还要处理背景被其他窗口覆盖的问题。直接用双缓冲加全量重绘通常足够只有当指针区域特别大、表盘刻度特别细时才考虑按区域Invalidate。5.2 GDI资源泄漏Pen/Brush要Dispose但Font.SystemFont例外现象把表盘控件放在一个仪表页面里页面反复打开关闭任务管理器内内存和GDI对象数持续上涨最后画面变花甚至整个程序崩溃。原因很简单每次OnPaint里new Pen、new SolidBrush如果不释放它们会一直占着GDI句柄。解决所有新建对象都在using里使用。但还有一个例外——系统字体对象不能Dispose。看下面的反面例子// 错误做法直接Dispose系统字体 using (Font font SystemFonts.DefaultFont) { g.DrawString(...); // 此处分Dispose会把系统字体句柄也释放掉 }正确做法是直接拿引用不要释放private void DrawScaleText(Graphics g, string text, PointF pos) { using (StringFormat format new StringFormat()) { format.Alignment StringAlignment.Center; // 注意SystemFonts.DefaultFont不能Dispose g.DrawString(text, SystemFonts.DefaultFont, Brushes.Black, pos, format); } }这属于GDI新手最容易踩的坑跟表盘控制没有直接关系但凡是自绘控件都会碰到。我在Winform界面美化的项目里已经记不清多少次因为这事半夜加班查内存泄漏了写下来帮你省掉这段血泪经验。5.3 设计器里控件显示异常/崩溃现象把控件拖到窗体上时设计器显示“创建控件时出错”但程序编译运行却一切正常。或者在设计器里能看到控件但表盘变成全灰指针不显示。原因设计器在加载窗体时会实例化所有控件并且会触发OnPaint。这时候你的业务依赖项比如数据库上下文、外部服务、还在初始化中的其他控件还没有准备好一旦代码里访问这些东西就抛异常。解决在绘制代码里判断DesignMode跳过业务依赖分支。要特别注意这个属性能从Component基类拿到但如果在构造函数里查询它可能不准确因为设计器可能在完全初始化前就创建了实例。安全做法是在OnPaint里判断protected override void OnPaint(PaintEventArgs e) { // 设计模式下只画一个空心圆保证看到轮廓即可 if (DesignMode) { e.Graphics.DrawEllipse(Pens.Gray, 0, 0, Width - 1, Height - 1); return; } base.OnPaint(e); // 正常绘制流程…… }另一个常见崩溃源是属性setter里抛异常。设计器一旦拖动属性面板的滚动条就会访问所有属性此时如果MinValue为0而MaxValue为0就会触发范围校验异常。我通常给MinValue和MaxValue设定安全的默认值并在setter里只做Invalidate把校验移交给绘制函数用Math.Max/Min来兜底不以异常作为常规流程。5.4 高分屏DPI缩放后指针错位现象程序放到一台4K或150%缩放的Windows设备上表盘变模糊指针末端和刻度对不上点击窗口的时候指针还偏几个像素。这个问题的根源是Winform在默认PerMonitorV1的DPI缩放下Graphics会把物理坐标换算成逻辑坐标而像素级别的GDI画圆、画线在这套坐标体系里会出现舍入误差。常见处理手段有几种最简单的是在app.manifest里启用PerMonitorV2application xmlnsurn:schemas-microsoft-com:asm.v3 windowsSettings dpiAwareness xmlnshttp://schemas.microsoft.com/SMI/2016/WindowsSettingsPerMonitorV2/dpiAwareness /windowsSettings /application但这会让整个窗体的布局重新适应新的DPI缩放规则不是每个老项目都能承受。如果只想让表盘控件自己抗DPI我建议用float坐标避免所有int运算。CalcPoint里已经全部是float但new Bitmap(side, side)里的side是int取整这就会造成缩放失真。可以先把side算成float再在绘制完成后用Graphics.ScaleTransform配合缩放比例修正float scale Math.Max(Width / 200F, Height / 200F); g.ScaleTransform(scale, scale);这种写法把表盘所有坐标都基于一个统一逻辑尺寸200缩放由ScaleTransform整体处理不会出现一半刻度清晰一半模糊的问题。当然这会牺牲一点绘制效率但对单个表盘来说完全可接受。5.5 指针旋转中心对不上的数学修正现象表盘指针沿某个端点旋转但旋转轴不在圆心转起来像画圈。原因是Graphics.RotateTransform总是围绕原点旋转如果原点在控件左上角直接旋转指针线条会让它像卫星一样绕左上角转。解决手法是先把原点平移到圆心旋转完再平移回去GraphicsState state g.Save(); g.TranslateTransform(center.X, center.Y); g.RotateTransform(pointerAngle); // 此时(0,0)就在圆心画出指针 g.DrawLine(new Pen(Color.Black, 2f), 0, 0, pointerLength, 0); g.Restore(state);这里有一个容易忽略的细节RotateTransform里用的角度是以3点钟方向为0度、顺时针增加。而表盘指针通常希望以12点为0度所以在RotateTransform之前把角度减90度就行了。你还要注意DrawLine的方向是从圆心向右画还是向上画我习惯保持指针初始指向3点方向然后旋转到目标角度这样符合RotateTransform的基准。如果一开始把指针画成向上0, -length再旋转那反而要把90度加回来这个正负号是自绘表盘里最容易翻车的地方。6. 进阶多表盘联动、渐变弧线、绘制折叠箭头与性能验证技巧6.1 用渐变笔刷画开机自检弧光为了让表盘在设备启动时更好看可以在指针后面画一条从起始角度到当前角度的渐变弧线模仿进度圈。关键是用PathGradientBrush或LinearGradientBrush围绕圆环做颜色过渡using (GraphicsPath path new GraphicsPath()) { Rectangle arcRect new Rectangle(10, 10, Width - 20, Height - 20); path.AddArc(arcRect, -90f, (float)(Value / MaxValue * 360f)); using (PathGradientBrush pgb new PathGradientBrush(path)) { pgb.CenterColor Color.FromArgb(200, Color.Orange); pgb.SurroundColors new Color[] { Color.FromArgb(30, Color.Firebrick) }; e.Graphics.FillPath(pgb, path); } }这段代码要放在表盘背景和指针之间画。AddArc的起始角度依然要减90度和指针角度保持同一套约定。PathGradientBrush的CenterColor和SurroundColors数组长度必须和路径的点数匹配如果报参数错误八成是AddArc生成的路径点数和你传入的颜色数量不一致。这种渐变更适合慢速变化的范围指示不适合快速转动的指针因为每次重绘都要重建路径和笔刷性能上会有一点压力。6.2 多表盘控件组合成设备面板事件对外抛值一台设备上可能有负载、温度、电压三个表盘它们之间要联动一个文本摘要区。我通常把多个表盘放在一个TableLayoutPanel里然后统一订阅ValueChanged事件gaugeLoad.ValueChanged OnGaugeChanged; gaugeTemp.ValueChanged OnGaugeChanged; gaugeVolt.ValueChanged OnGaugeChanged; private void OnGaugeChanged(object sender, EventArgs e) { // 来源是哪个表盘可以用sender判断 string summary $负载{gaugeLoad.Value:F1}% 温度{gaugeTemp.Value:F1}℃ 电压{gaugeVolt.Value:F1}V; summaryLabel.Text summary; }这里要注意事件触发频率如果动画每一帧都改Value事件也会每帧触发文本摘要每秒刷新几十次毫无必要。我一般会在控件的ValueChanged后面加一个节流比如在窗体里记录最后更新时间200毫秒以内不重复刷新文本。表盘本身继续动画但摘要文本保持刷新频率在5Hz左右这样看起来既不卡也不费CPU。6.3 顺带解决“菜单折叠箭头怎么绘制”的通用GDI画法搜过表盘控件的朋友大概率也搜过“Winform 菜单折叠的箭头是怎么绘制的”。其实折叠箭头就是表盘指针的一种退化形态不需要圆盘只需要在指定方向画一个三角形或两条折线。下面是画右向箭头和一个向下箭头的通用函数private static void DrawArrow(Graphics g, PointF center, float size, float angleInDegree) { GraphicsState state g.Save(); g.TranslateTransform(center.X, center.Y); g.RotateTransform(angleInDegree); // 以原点为顶点画一个等腰三角形 PointF[] arrow new PointF[] { new PointF(size, 0), new PointF(0, -size / 2f), new PointF(0, size / 2f) }; using (SolidBrush brush new SolidBrush(Color.FromArgb(60, 60, 60))) { g.FillPolygon(brush, arrow); } g.Restore(state); }调用时给angleInDegree传0就是向右箭头传90就是向下箭头。这个函数放在任何Winform项目里都能用不需要依赖任何菜单库。理解了旋转加平移的做法之后你还能扩展出播放视频控件里的播放/暂停图标切换、ListView里的自定义排序箭头等本质都是这一套。6.4 性能验证打开计时器统计FPS和CPU占用自绘控件最容易膨胀的是OnPaint里的绘制命令数。指针动画加上渐变弧、文字、刻度一圈下来每个Tick重绘几百次在低配工控机上可能会占到20%以上CPU。我会挂一个临时的绘制计时器验证优化是否有效private int _frameCount; private DateTime _frameLastTime DateTime.UtcNow; protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); _frameCount; if ((DateTime.UtcNow - _frameLastTime).TotalSeconds 1) { double fps _frameCount / (DateTime.UtcNow - _frameLastTime).TotalSeconds; Text $FPS: {fps:F0}; _frameCount 0; _frameLastTime DateTime.UtcNow; } }这套统计逻辑放在生产环境里会干扰界面我一般只在优化时用优化完成就注释掉。判断性能好坏的经验值动画运行中每秒30帧以上、CPU占据单个核的20%以下都算健康。如果FPS掉到个位数优先查是否有渐变笔刷重建、是否有太多次CreateGraphics临时绘制、以及是否用了非必要的SmoothingMode.HighQuality。把抗锯齿级别从HighQuality降到AntiAlias通常能吃掉一半耗时。这个从Winform表盘控件展开的小项目让我最后养成了一个习惯任何自绘控件先写一个能刷FPS的调试开关再谈美化。你照着这个方案做出来的表盘不会有工程里那种黑匣子式的玄学问题所有性能指标都有数字可看。希望帮到你。本文还有配套的精品资源点击获取
返回列表