上位机界面里圆形仪表盘(压力表、温度表、转速表)是出现频率最高的自绘控件,也是新手最容易画砸的控件:刷新一次闪一下、指针边缘全是锯齿、高分屏上糊成一团、一屏放 20 个表 CPU 就飙上去。这些问题都不是 GDI+ 的能力问题,而是细节没做对。这篇以"270° 圆形仪表"为例,把七个关键细节逐个拆开讲,最后给完整可用的控件代码。

细节一:闪烁的根源与双缓冲四件套

闪烁的本质:控件重绘时先被系统刷成背景色,再画内容,两次画面切换被人眼看见。解决办法是双缓冲——所有绘制先在内存位图上完成,一次性贴到屏幕。WinForm 里要四个设置一起上才彻底:

SetStyle(ControlStyles.AllPaintingInWmPaint   // 不擦背景
       | ControlStyles.UserPaint              // 自绘
       | ControlStyles.OptimizedDoubleBuffer  // 双缓冲
       | ControlStyles.ResizeRedraw,          // 尺寸变化时重绘
       true);
UpdateStyles();

只设 OptimizedDoubleBuffer 不设 AllPaintingInWmPaint,某些情况下仍会看到背景闪——两个必须成对出现。

细节二:静态元素离屏缓存,别每帧都画表盘

仪表的表盘、刻度、文字在值不变时完全一样,每帧重画是纯浪费。把静态部分画到一张离屏 Bitmap 上,值变化时只重画指针层:

private Bitmap _base;   // 离屏缓存:表盘+刻度+文字

protected override void OnPaint(PaintEventArgs e)
{
    if (_base == null || _base.Size != Size) BuildBase();  // 尺寸变才重建
    e.Graphics.DrawImageUnscaled(_base, 0, 0);              // 贴底图
    DrawNeedle(e.Graphics);                                  // 只画指针
}
private void BuildBase()
{
    _base?.Dispose();
    _base = new Bitmap(Width, Height);
    using var g = Graphics.FromImage(_base);
    g.SmoothingMode = SmoothingMode.AntiAlias;
    DrawDial(g);   // 表盘、刻度、量程文字,一次画完
}

▲ 一屏 20 个仪表、每秒刷新 5 次的场景,这个缓存把每帧绘制开销从"全量重画"降到"一张位图 + 一条指针",CPU 占用能降八成。

细节三:抗锯齿开对地方,直线还要像素对齐

圆弧、指针这类斜线元素必须开 SmoothingMode.AntiAlias,否则锯齿明显。但反过来,水平/垂直的细线(1px)开了抗锯齿反而会发虚——因为抗锯齿把边缘像素做了半透明混合。经验法则:

  • 表盘圆弧、指针:AntiAlias;
  • 1px 横竖网格线:关掉抗锯齿,且坐标取整数 +0.5f 偏移(GDI+ 的坐标是像素左上角,画 1px 线要落在像素中心才实);
  • 文字:TextRenderingHint.AntiAliasGridFit,小字号下比纯 AntiAlias 更清晰。

细节四:几何全部按比例算,别写死像素

仪表控件必须能随窗口缩放。所有几何量从 Math.Min(Width, Height) 推导:表盘半径 = 短边×0.45,刻度长 = 半径×0.12,字号 = 半径×0.16 并向下取整。写死像素的控件一缩放就露馅——要么留白过大,要么文字溢出表盘。

细节五:高 DPI 下先声明感知,再按缩放因子画

WinForm 默认是 DPI 不感知的,150% 缩放的屏幕上系统帮你位图拉伸——结果就是"糊"。正确做法:app.manifest 里声明 DPIAware,控件内用 DeviceDpi / 96f 拿缩放因子,线宽、字号统一乘这个因子。我们的做法是把缩放因子收进一个 DpiScale 属性,BuildBase 时统一使用,OnDpiChanged 事件里重建缓存。

细节六:值更新用 Invalidate 而不是 Refresh,且别在采集线程里调

Invalidate vs Refresh

Invalidate 只标记"下次重画",多个更新会合并成一次 OnPaint;Refresh 是立即重画,高频调用会把界面线程打满。值更新永远用 Invalidate。

线程封送

采集线程给控件赋值必须 BeginInvoke 封送。更稳的做法:控件内部自己封送,对外暴露线程安全的 Value 属性,调用方不用关心线程。

刷新节流

采集 100ms 一次、人眼 200ms 才分辨——控件内置 200ms 节流定时器,取最新值重画,界面线程压力减半且观感无差别。

平滑过渡

指针跳变观感生硬。用 150ms 的线性插值动画把旧值过渡到新值,每 30ms 一步——代价很小,质感提升明显。

细节七:资源对象及时 Dispose,GDI 句柄是会漏的

Pen、Brush、Font、Bitmap 都是非托管资源,OnPaint 里每次 new 不释放,跑几个小时就报"GDI+ 一般性错误"或句柄耗尽。规矩:方法内创建的对象用 using 包住;跨帧复用的对象(离屏缓存、常用画笔)作为字段,在控件 Dispose 里统一释放。可以用任务管理器"查看→选择列→GDI 对象"盯着验证——正常的控件跑一天,GDI 对象数应该是平的。

离屏缓存的重建时机要收全。只在尺寸变化时重建不够:字体改了、配色改了、DPI 变了、父容器主题切换了,都要触发 BuildBase。我们的做法是所有影响外观的属性 setter 里统一调 InvalidateBase(),把重建入口收敛到一处。

完整控件骨架

public class GaugeControl : Control
{
    private double _value, _shown;        // 目标值 / 显示值(动画)
    private Bitmap _base;
    public double Min { get; set; } = 0;
    public double Max { get; set; } = 100;

    public double Value   // 线程安全入口
    {
        set
        {
            if (InvokeRequired) { BeginInvoke(new Action(() => Value = value)); return; }
            _value = Math.Clamp(value, Min, Max);
            Invalidate();
        }
    }
    protected override void OnPaint(PaintEventArgs e)
    {
        if (_base == null) BuildBase();
        e.Graphics.DrawImageUnscaled(_base, 0, 0);
        // 指针角度:270°量程,从135°起
        float ratio = (float)((_shown - Min) / (Max - Min));
        float angle = 135f + ratio * 270f;
        using var pen = new Pen(Color.OrangeRed, 2f);
        // ...以中心为原点旋转绘制指针
    }
    protected override void Dispose(bool disposing)
    {
        if (disposing) _base?.Dispose();
        base.Dispose(disposing);
    }
}

七个细节按重要度排序:双缓冲 → 离屏缓存 → 资源释放是及格线,抗锯齿、比例几何、DPI、节流是优秀线。把这些做进控件基类里,后面所有仪表类控件(压力表、温度表、流量计)都直接继承,界面的一致性和性能就都有了。