WinForms 学习笔记(四):GDI+ 绘制——Graphics、OnPaint 与自定义控件

写在前面

承接篇3。本篇讲 WinForms 自定义绘制的核心:GDI+、GraphicsOnPaint、双缓冲、自定义控件。

WinForms 的"外观"分两类:控件自带外观(Button 长啥样系统定)和自绘外观(你在 OnPaint 里画)。后者就是 GDI+ 的地盘——画文字、线条、矩形、图片,做仪表盘、图表、自定义按钮。理解 GDI+,你才能做 WinForms 里任何"非标准"的视觉。


一、GDI+ 是什么

GDI+ 是 Windows 的 2D 图形库(Win32 GDI 的升级),System.Drawing 命名空间封装它。核心是 Graphics 对象——你的"画布",所有绘制都通过它。

1
2
3
4
5
6
7
GDI+ 三件套:
  Graphics   画布(绑定到某控件/位图的 HDC)
  Pen        画线/边框(颜色、粗细、虚线)
  Brush      填充(纯色/渐变/纹理)
  + Font/Color/Image 等辅助

  绘制 = Graphics.用 Pen/Brush 画形状 + 用 Font 写字

二、OnPaint:绘制入口

控件每次需要重绘时(首次显示、被遮挡后露出、手动 Invalidate()),触发 Paint 事件,你重写 OnPaint 或挂 Paint 事件来画:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class MyPanel : Panel
{
    protected override void OnPaint(PaintEventArgs e)
    {
        base.OnPaint(e);
        var g = e.Graphics;        // 这次绘制的 Graphics
        g.Clear(Color.White);
        g.DrawString("Hello", Font, Brushes.Black, 10, 10);
        g.DrawRectangle(Pens.Red, 50, 50, 100, 60);
    }
}

PaintEventArgs 给你 GraphicsClipRectangle(需要重绘的区域,优化用)。

别缓存 Graphics——每次 Paint 的 Graphics 是新的,用完即弃。CreateGraphics() 能拿到一个,但别用它做持续绘制(不随重绘刷新,会被擦掉),只在响应鼠标实时画(橡皮线)时用。


三、Graphics 常用方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var g = e.Graphics;

// 文字
g.DrawString("文本", font, brush, x, y);
g.DrawString("文本", font, brush, rect, format);   // 带布局

// 线
g.DrawLine(pen, x1, y1, x2, y2);
g.DrawLines(pen, points);              // 折线
g.DrawCurve(pen, points);              // 平滑曲线

// 形状(描边用 Pen,填充用 Brush)
g.DrawRectangle(pen, x, y, w, h);
g.FillRectangle(brush, x, y, w, h);
g.DrawEllipse(pen, x, y, w, h);        // 圆/椭圆
g.FillEllipse(brush, x, y, w, h);
g.DrawPolygon(pen, points);
g.DrawArc(pen, x, y, w, h, startAngle, sweepAngle);   // 弧/饼图

// 图片
g.DrawImage(image, x, y);
g.DrawImage(image, destRect, srcRect, GraphicsUnit.Pixel);   // 缩放/裁剪

// 变换
g.TranslateTransform(dx, dy);          // 平移
g.ScaleTransform(sx, sy);              // 缩放
g.RotateTransform(angle);              // 旋转

四、Pen/Brush/Font:GDI+ 对象必须 Dispose

Pen、Brush、Font、Bitmap 都是非托管资源(包装 GDI 句柄),不用了必须 Dispose,否则句柄泄漏。

1
2
3
4
5
6
7
// 正确:using 包裹
using var pen = new Pen(Color.Red, 2);
using var brush = new SolidBrush(Color.Blue);
using var font = new Font("Arial", 12);
e.Graphics.DrawRectangle(pen, 10, 10, 100, 60);
e.Graphics.FillRectangle(brush, 10, 10, 100, 60);
// using 结束自动 Dispose
1
2
3
4
哪些要 Dispose:
  ✓ Pen / Brush / Font / Bitmap / Graphics(自己 CreateGraphics 创建的)
  ✗ Brushes.Red / Pens.Black 等系统内置(共享,别 Dispose)
  ✗ Paint 事件里的 e.Graphics(系统管,别 Dispose)

经验:用了 new 创建的 GDI+ 对象,都要 Dispose。系统内置(Brushes.Xxx/Pens.Xxx/SystemFonts.Xxx)是共享单例,别动。


五、双缓冲:解决闪烁

WinForms 默认直接画到屏幕,复杂绘制会闪烁(先擦白再画,肉眼可见)。解决:双缓冲——先画到内存位图,完成后一次性贴到屏幕。

1
2
3
4
5
6
7
8
public class MyPanel : Panel
{
    public MyPanel()
    {
        DoubleBuffered = true;   // 开双缓冲(最简单)
        // 或 SetStyle(ControlStyles.OptimizedDoubleBuffer, true);
    }
}
1
2
3
为什么闪烁:
  无双缓冲:擦白 → 画 A → 画 B → ... 每步都上屏,肉眼见过程 → 闪烁
  双缓冲:  在内存位图画完整帧 → 一次贴屏 → 看不到过程

自定义控件绘制(尤其动画/重绘频繁),默认开双缓冲


六、自定义控件:重写 OnPaint

做个"圆形按钮"演示自绘:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class RoundButton : Control
{
    public Color FillColor { get; set; } = Color.SteelBlue;

    public RoundButton()
    {
        DoubleBuffered = true;
        ResizeRedraw = true;   // 尺寸变就重绘
        Size = new Size(80, 80);
    }

    protected override void OnPaint(PaintEventArgs e)
    {
        var g = e.Graphics;
        g.SmoothingMode = SmoothingMode.AntiAlias;   // 抗锯齿

        var rect = new Rectangle(0, 0, Width - 1, Height - 1);

        using var brush = new SolidBrush(FillColor);
        g.FillEllipse(brush, rect);                   // 填充圆

        using var pen = new Pen(Color.DarkBlue, 2);
        g.DrawEllipse(pen, rect);                     // 描边

        // 居中文字
        var sf = new StringFormat { Alignment = StringAlignment.Center,
                                    LineAlignment = StringAlignment.Center };
        using var font = new Font("Arial", 10, FontStyle.Bold);
        g.DrawString(Text, font, Brushes.White, rect, sf);
    }
}

要点:

  • 继承 Control(或 Panel/Button),重写 OnPaint
  • SmoothingMode.AntiAlias 开抗锯齿,边缘平滑。
  • ResizeRedraw = true 让尺寸变化时重绘。
  • 所有 new 的 GDI+ 对象 Dispose。

七、触发重绘:Invalidate

OnPaint 只在系统认为需要重绘时调用。你改了数据想让画面更新,要主动触发:

1
2
3
4
5
// 改了某属性后让控件重绘
this.Invalidate();              // 整个区域
this.Invalidate(dirtyRect);     // 只重绘某区域(性能优)

// 不要直接调 OnPaint / CreateGraphics 画

Invalidate异步的(标记脏,等下次 WM_PAINT 消息处理)。要立即刷新用 Update()(立即处理重绘),但通常不必。


八、DPI 感知

高 DPI 屏幕(4K 笔记本),WinForms 默认会模糊或错位。.NET 8 + WinForms 改进:app.manifest 设 DPI 感知。

1
2
3
4
5
6
<!-- app.manifest -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
  <windowsSettings>
    <dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
  </windowsSettings>
</application>

PerMonitorV2 是现代标准(每个显示器独立 DPI)。.NET 8 下 WinForms 对 PerMonitorV2 支持大幅改善。绘制时用 DeviceDpi 缩放坐标/字号,别写死像素。


九、GDI+ vs 其他

1
2
3
4
5
6
GDI+(WinForms)   CPU 绘制,简单,性能一般,无硬件加速
Direct2D(UWP/Win2D) GPU 加速,快,但 API 复杂
DirectX(WPF)     GPU 保留模式,矢量,动画友好

  → WinForms 想要 GPU 加速的复杂图形:嵌 Win2C 或直接上 WPF
  → 简单仪表/图表/自绘控件:GDI+ 够用

十、踩坑速记

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
坑 1:GDI+ 对象不 Dispose → 句柄泄漏,久了崩或画错
  → new 的 Pen/Brush/Font/Bitmap 全 Dispose(using)

坑 2:闪烁
  → DoubleBuffered = true

坑 3:CreateGraphics 画的图被擦掉
  → 持续内容画在 OnPaint;CreateGraphics 只用于实时交互(橡皮线)

坑 4:高 DPI 模糊/错位
  → app.manifest 开 PerMonitorV2 + 绘制按 DeviceDpi 缩放

坑 5:OnPaint 里每次 new Font
  → 字体复用(字段缓存),别每帧 new

十一、小结

1
2
3
4
5
6
7
GDI+ 绘制核心:
  ✓ Graphics 是画布;OnPaint 是绘制入口;Invalidate 触发重绘
  ✓ Pen/Brush/Font/Bitmap 是非托管,new 了必 Dispose
  ✓ 系统内置 Brushes.Xxx/Pens.Xxx 共享,别 Dispose
  ✓ 双缓冲(DoubleBuffered)防闪烁
  ✓ 自定义控件 = 继承 Control + 重写 OnPaint
  ✓ 高 DPI 用 PerMonitorV2 + 按 DeviceDpi 缩放

下一篇:数据绑定——BindingSourceBindingList<T>DataGridViewINotifyPropertyChanged,WinForms 比 WPF 弱但仍能用的绑定体系。