使用GDI+

GDI+是微软在Windows操作系统中提供的图形编程接口,用于处理二维图形、图像渲染和文本绘制。我们知道绘图涉及操作硬件,GDI+的核心实现其实都在GdiPlus.dll动态链接库内,它是一个Win32的非托管Native DLL。在Winform中,操作GdiPlus.dll的接口都被封装在了System.Drawing命名空间内,它是一个.NET下的托管封装,其底层会通过互操作(P/Invoke)调用Native的GDI+函数。

这篇笔记我们介绍如何在Winform中使用GDI+绘制图像。

Windows下的几种绘图API

实际上Windows操作系统中除了GDI+还有很多绘图接口。

GDI:GDI仅支持基础的绘图功能,它是屏幕和打印机绘图的抽象层,GDI是最Windows中最基础的绘图接口,Win32API中的基础控件就是基于GDI绘制的。早期GDI是纯CPU绘制的,但Windows7版本后对其底层架构进行了改进,部分函数如BitBltsAlphaBlend等API支持了GPU加速绘制。

GDI+:GDI+是作为GDI的接替者而出现的,GDI+除了基本的绘图功能还支持抗锯齿、渐变填充、透明通道、复杂路径、多种图像格式支持等高级特性,是Winform自绘制控件使用的绘图接口,不过GDI+仍是纯CPU绘制的,不支持硬件加速。

Direct2D:Direct2D基于DirectX,是支持GPU硬件加速的绘图API,它具有高性能和支持高级绘图特性的特点,可直接调用GPU进行抗锯齿、Alpha混合等操作,集成于DirectX10及以上版本中,常用于游戏等高帧率应用。

Direct3D:Direct3D虽然主要用于3D图形渲染,但实际上例如WPF等框架仍是基于Direct3D实现的,Direct3D提供了更底层的控制,允许GUI框架实现复杂的渲染效果(如硬件加速的矢量图形、位图效果、动画等)。如果GUI框架的渲染引擎需要支持多种高级功能(如分辨率无关的UI、矢量图形、复杂的变换和特效等),这些功能在Direct3D中也更容易实现。

Winform本身是Win32API的封装,对于非自绘控件(来自Win32API的控件)实际上底层仍可能是Win32调用GDI绘制的,而对于自绘控件(通常是我们自定义的)则通常是调用GDI+绘制的,这是因为.NET平台提供了GDI+的封装而没有提供GDI的直接封装,如果你一定要用GDI那就必须通过P/Invoke调用gdi32.dll实现,当然我们实际上几乎不可能这么做。总而言之Winform内的控件底层可能是GDI绘制的也可能是GDI+绘制的,但我们在Winform中可以直接使用的绘图接口就是GDI+,这是GDI和GDI+二者的关系。

至于Direct2D和Direct3D,它们是支持硬件加速的绘图接口,这些接口绘图是可以通过GPU实现的,它们固然更现代且在当下的计算机中有更好的性能表现,但这并不意味着GDI+就淘汰了,Windows操作系统中GDI+仍有着广泛的使用,传统的Win32控件仍大部分基于GDI/GDI+绘制,甚至对于简单的2D游戏(例如贪吃蛇、扫雷等)GDI+的性能也是足够的。

GDI+中的基本概念

GDI+包含以下几个核心类,我们绘图其实就是调用这些核心类的功能实现的,这些类都位于System.Drawing命名空间下。

GraphicsGraphics是GDI+图形绘制的核心,它提供了一系列方法用于绘制线条、矩形、圆形、图像等。

Pen:该类表示用于绘制线条的对象,它可以定义线条的颜色、宽度、样式等属性。

Brush:该类用于填充图形,我们可以使用单一颜色或纹理等填充。

Font:该类用于定义绘制文本时的字体样式和大小。

Color:该类表示颜色,它可以创建常见的颜色(如红色、绿色、蓝色等),也可以使用ARGB参数自定义颜色。

Image:该类封装了图像,它可以加载不同格式的图片,具体格式包括BMP、JPEG、JPG2000、PNG、GIF、TIFF、ICO、WMF、EMF。

GDI+使用的是屏幕坐标系,原点和坐标轴方向与大多数GUI框架类似。

原点:GDI+的坐标系原点位于图形区域的左上角。原点的坐标默认是(0, 0)

坐标轴:X轴是水平轴,向右为正方向;Y轴是垂直轴,向下为正方向。

GDI+绘图基础

绘制图形

下面例子代码展示了Winform中使用GDI+绘图的基本用法。代码中,我们点击按钮时将把成员变量_drawEllipse设置为true,同时调用Invalidate()方法,这将触发Paint事件让窗体重绘,Paint事件触发时会回调MainForm_Paint()方法,我们绘制圆形的逻辑就在其中。

using System.Drawing;
using System.Windows.Forms;

namespace DemoWinform
{
    public partial class MainForm : Form
    {
        private bool _drawEllipse = false;

        public MainForm()
        {
            InitializeComponent();
        }

        private void MainForm_Paint(object sender, PaintEventArgs e)
        {
            if (_drawEllipse)
            {
                using (Graphics graphics = this.CreateGraphics())
                using (Pen pen = new Pen(Color.Black))
                {
                    graphics.DrawEllipse(pen, ClientRectangle);
                }
            }
        }

        private void btnDraw_Click(object sender, System.EventArgs e)
        {
            _drawEllipse = true;
            this.Invalidate();
        }
    }
}

运行效果如下图所示。

绘制代码中,MainForm_Paint是窗体Paint事件的回调函数,每次用完GraphicsPen后,我们应该调用其Dispose()方法释放资源,这里我们就直接使用using语法自动释放了。代码中,我们调用了graphics.DrawEllipse()方法,它用于绘制椭圆,我们绘制的椭圆大小就是当前窗体客户区的大小。

为什么不直接把绘图逻辑写在btnDraw_Click()方法中呢?这是因为有很多情况都会触发窗体重绘,比如窗体初始化或是窗体被遮挡后遮挡物移开等,如果不把绘制代码统一放在Paint事件的回调函数中,重绘时我们的自定义绘制代码就不会触发了,这会造成非常诡异的现象,例如窗体被遮挡后自绘内容消失等情况。因此我们的实现方式是在btnDraw_Click()先设置控制绘制的成员变量,然后调用Invalidate()方法,这个方法定义在Control基类上,它会触发当前控件的重绘Paint事件。

总而言之,在Winform中使用GDI+绘图时,最佳实践就是将所有自绘逻辑写在Paint事件的回调函数中,绘图依赖的状态我们统一将其定义为窗体类的成员变量,绘制逻辑被自动或手动触发时,我们的自绘代码依赖这些状态变量绘制。

Paint事件和重绘

前面的例子中我们学习了Paint事件,这个事件会在控件需要被重新绘制时由Windows操作系统自动触发,此外如果需要手动触发Paint事件,我们也可以调用Invalidate()方法,这个方法定义在Control基类上。总而言之,控件会在以下条件下触发重绘:

  • 当控件首次显示时或者它需要重新绘制时(比如它从屏幕外被移到屏幕内)会触发Paint事件。
  • 控件的大小或位置发生变化时,控件也需要重新绘制,此时Paint事件会被触发。
  • 控件的VisibleEnabled属性发生变化,Paint事件也会被触发。
  • 如果控件的父容器发生改变(如大小变化或重新布局),该控件可能会被重新绘制,从而触发Paint事件。
  • 调用控件的Invalidate()方法时,控件的区域会被标记为需要重绘,随之Paint事件会在下一个合适的时机被触发。

关于Invalidate()方法,它默认只会重绘当前控件,但如果我们需要重绘该控件和所有子控件,可以额外传入一个参数即Invalidate(true),这会确保Paint事件在窗体和其所有子控件上触发。

此外,我们还提到Invalidate()方法的逻辑是标记区域为需要重绘,它并不是立即重绘的,这是出于性能考虑而做出的优化。因为绘图是非常耗时的操作,如果Windows操作系统总是优先绘图,那么鼠标键盘等类型的操作可能被“阻塞”,导致图形界面变得卡顿,Windows操作系统默认会延迟重绘,多个重绘事件可能被合并,这样能起到优化图形界面响应速度的目的。

如果我们不想这种延迟发生,可以调用Update()方法,它会强制控件立即重绘。此外,我们还可以使用Refresh()方法,这个方法等于Invalidate(true) + Update两个操作。实际开发中,我们一般不需要立即重绘,只有在渲染动画或是开发游戏程序时可能需要这样操作。

此外,对于重绘还有一个裁剪区域的问题。前面例子其实并不完美,如果我们尝试拖动窗体的大小可能会看到非常奇怪的现象,如下图所示。

出现这种现象是因为Windows默认只会重绘新暴露的区域,它会假设现存的矩形区域不需要重画,我们的自绘逻辑显然需要重画整个椭圆,但有一些绘制区域处于Windows默认的剪切区域之外,好在我们可以设置一个样式来要求Windows在窗体改变大小时重画整个窗体。

public MainForm()
{
    InitializeComponent();
    SetStyle(ControlStyles.ResizeRedraw, true);
}

Color 颜色

之前例子中我们绘制圆形时使用过颜色,Color是结构体,它封装了颜色信息。Color.Black是预定义颜色,实际上Color提供了144中预定义颜色。

Color color = Color.Black;

除了预定义颜色,我们其实可以使用任意的ARGB颜色,下面是两个例子,注意参数的顺序是ARGB或RGB。

Color color = Color.FromArgb(90, 0, 0, 255);  // A:90 R:0 G:0 B:255
Color color = Color.FromArgb(0, 0, 255);      // A:255 R:0 G:0 B:255

Pen 画笔

GDI+中,Pen是用于绘制线框的对象,Pen的构造函数有多个重载,我们可以传入画笔的颜色和线的宽度。下面例子代码创建了黑色的宽度为1的线框画笔。

Pen pen = new Pen(Color.Black, 1f);

StartCap/EndCap 端点样式

画笔可以添加Cap(帽子),所谓的Cap就是线的起始端点或结束端点的特定样式,这些Cap定义在System.Drawing.Drawing2D命名空间下,下面例子中,我们在线的结束点加了个箭头。

using (Graphics graphics = this.CreateGraphics())
using (Pen pen = new Pen(Color.Black, 5f))
{
    pen.EndCap = LineCap.ArrowAnchor;
    graphics.DrawLine(pen, new Point(10, 100), new Point(100, 100));
}

DashStyle 虚线样式

除了端点,线也可以有虚线风格,下面是一个绘制虚线的例子。

using (Graphics graphics = this.CreateGraphics())
using (Pen pen = new Pen(Color.Black, 5f))
{
    pen.DashStyle = DashStyle.Dash;
    graphics.DrawLine(pen, new Point(10, 100), new Point(100, 100));
}

Alignment 对齐样式

几何中线本身没有宽度的概念,当绘制宽度大于1的线时,Alignment属性用于控制画笔的对齐模式。PenAlignment.Center为中间对齐,PenAlignment.Inset表示线在闭合线框图形的内部绘制,它仅在闭合图形下生效。具体效果有什么区别我们可以观察下面例子。

using (Graphics graphics = this.CreateGraphics())
using (Pen penBlack = new Pen(Color.Black, 10f))
using (Pen penRed = new Pen(Color.Red, 1f))
{
    penBlack.Alignment = PenAlignment.Center;
    graphics.DrawEllipse(penBlack, 10, 10, 100, 100);
    graphics.DrawEllipse(penRed, 10, 10, 100, 100);
    penBlack.Alignment = PenAlignment.Inset;
    graphics.DrawEllipse(penBlack, 140, 10, 100, 100);
    graphics.DrawEllipse(penRed, 140, 10, 100, 100);
}

下图中,第一个圆是中间对齐,第二个圆是闭合内部对齐。

LineJoin 连结点

LineJoin样式用于设置折线上点的样式,下面是一个例子。

using (Graphics graphics = this.CreateGraphics())
using (Pen pen = new Pen(Color.Black, 10f))
{
    pen.LineJoin = LineJoin.Miter;
    graphics.DrawRectangle(pen, 10, 10, 100, 100);
    pen.LineJoin = LineJoin.Bevel;
    graphics.DrawRectangle(pen, 130, 10, 100, 100);
    pen.LineJoin = LineJoin.Round;
    graphics.DrawRectangle(pen, 250, 10, 100, 100);
}

LineJoin.Miter即斜接,它会形成一个尖角,LineJoin.Bevel会生成一个斜面,而LineJoin.Round表示圆角。

Brush 画刷

画刷类似PhotoShop的油漆桶工具,它可以绘制填充颜色的图形。

SolidBrush 实颜色画刷

最简单的画刷是SolidBrush实颜色画刷,下面例子例子代码绘制一个黑色的圆形。

using (Graphics graphics = this.CreateGraphics())
using (Brush brush = new SolidBrush(Color.Black))
{
    graphics.FillEllipse(brush, 10, 10, 100, 100);
}

LinearGradientBrush 线性渐变画刷

线性渐变画刷LinearGradientBrush可以指定两个颜色,画刷在绘制过程中自动计算渐变颜色并填充。线性渐变画刷包含多个重载,下面例子中我们编写了一个从红色过度到绿色的线性渐变矩形。

using (Graphics graphics = this.CreateGraphics())
using (Brush brush = new LinearGradientBrush(new Rectangle(10, 10, 300, 30), Color.Red, Color.Green, LinearGradientMode.Horizontal))
{
    graphics.FillRectangle(brush, 10, 10, 300, 30);
}

TextureBrush 纹理画刷

纹理画刷是基于图像构造的,它默认使用图像平铺填满整个空间,我们也可以指定WarpMode修改平铺的行为。下面例子我们使用默认的平铺方式填充了一个矩形。

using (Graphics graphics = this.CreateGraphics())
using (Brush brush = new TextureBrush(Properties.Resources.Icon))
{
    graphics.FillRectangle(brush, 10, 10, 300, 30);
}

绘制形状

GDI+内置了多种形状的绘制函数,例如线段、曲线段、椭圆、矩形等,对于闭合图形通常还有DrawXXX()FillXXX()两种函数分别用于绘制线框和填充图形。

Line 直线

直线通过两个端点绘制,下面例子绘制了直线。

graphics.DrawLine(pen, new Point(10, 10), new Point(300, 10));

如果要绘制多个直线,我们可以使用DrawLines()方法。

Curve 曲线

GDI+中Curve曲线基于多个点指定,Curve支持tension张力参数,tension为0时绘制的其实就是折线,张力的增加意味着控制点弯曲度的增加。

using (Graphics graphics = this.CreateGraphics())
using (Pen pen = new Pen(Color.Black, 1f))
{
    graphics.DrawCurve(pen, new Point[] {
        new Point(50, 50),
        new Point(100, 50),
        new Point(50, 100),
        new Point(100, 100)
    }, 0.1f);
}

上图中我们可以看到,虽然我们确实绘制的是曲线,但精确的绘制这种“曲线”锯齿是很明显的,这是因为曲线虽然是连续的数学对象,但屏幕上的像素是离散的,GDI+中我们可以开启反锯齿(Anti-Alias)来平滑曲线的绘制结果。

graphics.SmoothingMode = SmoothingMode.AntiAlias;

Bezier 贝塞尔曲线

贝塞尔曲线是另一种曲线绘制方式,它通过4个点指定,分别是起始点、2个控制点和终止点。

using (Graphics graphics = this.CreateGraphics())
using (Pen pen = new Pen(Color.Black, 1f))
{
    graphics.DrawBezier(pen,
        new Point(50, 50),
        new Point(100, 50),
        new Point(50, 100),
        new Point(100, 100)
    );
}

起始点和终止点决定了贝塞尔曲线的开始和结束位置,控制点决定了贝塞尔曲线的变形方向和强度,控制点离得越远,贝塞尔曲线被“拉弯”的强度越强。

其它线形状

直线、曲线和贝塞尔曲线是GDI+中最基础的几种线形绘制图元,Winform的GDI+中还内置了许多绘制函数,例如圆弧线、椭圆、多边形、矩形、扇形等,其用法具体参考文档即可,这里就不展开介绍了。

Path 路径

路径是基于多个线条和子路径构建的,和之前我们画线不同,路径还可以是闭合的,它是可被画刷填充的。下面例子中,我们使用路径绘制了一个(10, 10, 300, 100)的圆角矩形,并对其使用黑色描边后填充为天蓝色。

using (Graphics graphics = this.CreateGraphics())
using (Pen pen = new Pen(Color.Black, 1f))
using (Brush brush = new SolidBrush(Color.SkyBlue))
using (GraphicsPath path = new GraphicsPath())
{
    // 绘制4个圆角
    Rectangle arcRectangle = new Rectangle(10, 10, 20, 20);
    path.AddArc(arcRectangle, 180, 90);
    arcRectangle.X = 310 - 20;
    path.AddArc(arcRectangle, 270, 90);
    arcRectangle.Y = 110 - 20;
    path.AddArc(arcRectangle, 0, 90);
    arcRectangle.X = 10;
    path.AddArc(arcRectangle, 90, 90);
    // 闭合路径
    path.CloseFigure();

    graphics.FillPath(brush, path);
    graphics.DrawPath(pen, path);
}

构建路径时,我们其实主要就是绘制了4个圆弧线,它们使用AddArc()方法被添加到了路径上,圆弧线之间我们不必手动连接,路径会自动用直线连接终止端点和下一个起始端点,但最后我们需要调用path.CloseFigure()将路径闭合,当然如果我们手动将路径闭合了,那就不必调用这个方法了。

绘制图片

GDI+中我们可以绘制两种图像,位图元文件,位图是指JPEG、PNG、BMP等类型的光栅图像,我们需要使用Bitmap类处理光栅图像;元文件是指Windows元文件.wmf、增强型Windows元文件.emf这类的矢量格式,这类图像需要使用MetaFile类处理。

下面例子代码中,我们绘制了一个资源中的Bitmap光栅图像。

using (Graphics graphics = this.CreateGraphics())
{
    graphics.DrawImage(Properties.Resources.DemoImage, 10, 10);
}

绘制文本

绘制文本需要使用Font字体对象,其中包含了字体和字号等信息,绘制时需要指定绘制的文字、字体、画刷和位置,下面是一个例子。

using (Graphics graphics = CreateGraphics())
using (Brush solidBrush = new SolidBrush(Color.Black))
using (Font font = new Font("隶书", 20))
{
    graphics.DrawString("Hello, Winform!", font, solidBrush, 100, 100);
}

坐标系变换

如果我们需要对绘制的图像进行一些位置或旋转操作,直接计算新的坐标可能不太方便,GDI+支持直接对绘图的坐标系进行平移、旋转和缩放,下面例子我们对坐标系设置X、Y轴上都放大2倍。

using (Graphics graphics = this.CreateGraphics())
using (Pen pen = new Pen(Color.Black))
{
    // 将坐标系移至(10, 10)
    graphics.TranslateTransform(10, 10);

    // 绘制一些图形
    graphics.DrawRectangle(pen, 0, 0, 100, 50);
    graphics.DrawEllipse(pen, 0, 0, 100, 50);

    // 设置坐标系X、Y均缩放为2倍
    graphics.ScaleTransform(2, 2);

    // 再次绘制一些图形
    graphics.DrawRectangle(pen, 0, 0, 100, 50);
    graphics.DrawEllipse(pen, 0, 0, 100, 50);
}

效果如下图所示。

开启双缓冲

绘图是一个耗时操作,GDI+是使用CPU绘图的,实际上这一过程就可以简单理解为CPU在内存区域内计算每个像素点的颜色数据,然后定时将内存绘制区域拷贝到显存中。默认情况下,如果我们频繁的重绘,拷贝到显存时我们可能还没画完,图像可能是不完整的,这就会看到界面的自绘制内容闪烁、撕裂的情况,而双缓冲技术可以解决这一问题。开启双缓冲后,GDI+会先在内存中的另一块区域绘制图像,绘制完成后再直接切换当前显示的区域到已绘制好的区域内存,拷贝到显存的永远都是绘制完整的图像,这样我们的屏幕上就只会显示完整的帧,不会看到闪烁或撕裂的情况了。

Winform中GDI+绘图默认是没有开启双缓冲的,我们可以设置DoubleBufferedtrue开启双缓冲。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。
Copyright © 2017-2024 Gacfox All Rights Reserved.
Build with NextJS | Sitemap