GDI+是微软在Windows操作系统中提供的图形编程接口,用于处理二维图形、图像渲染和文本绘制。我们知道绘图涉及操作硬件,GDI+的核心实现其实都在GdiPlus.dll
动态链接库内,它是一个Win32的非托管Native DLL。在Winform中,操作GdiPlus.dll
的接口都被封装在了System.Drawing
命名空间内,它是一个.NET下的托管封装,其底层会通过互操作(P/Invoke)调用Native的GDI+函数。
这篇笔记我们介绍如何在Winform中使用GDI+绘制图像。
实际上Windows操作系统中除了GDI+还有很多绘图接口。
GDI:GDI仅支持基础的绘图功能,它是屏幕和打印机绘图的抽象层,GDI是最Windows中最基础的绘图接口,Win32API中的基础控件就是基于GDI绘制的。早期GDI是纯CPU绘制的,但Windows7版本后对其底层架构进行了改进,部分函数如BitBlts
、AlphaBlend
等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+包含以下几个核心类,我们绘图其实就是调用这些核心类的功能实现的,这些类都位于System.Drawing
命名空间下。
Graphics:Graphics
是GDI+图形绘制的核心,它提供了一系列方法用于绘制线条、矩形、圆形、图像等。
Pen:该类表示用于绘制线条的对象,它可以定义线条的颜色、宽度、样式等属性。
Brush:该类用于填充图形,我们可以使用单一颜色或纹理等填充。
Font:该类用于定义绘制文本时的字体样式和大小。
Color:该类表示颜色,它可以创建常见的颜色(如红色、绿色、蓝色等),也可以使用ARGB参数自定义颜色。
Image:该类封装了图像,它可以加载不同格式的图片,具体格式包括BMP、JPEG、JPG2000、PNG、GIF、TIFF、ICO、WMF、EMF。
GDI+使用的是屏幕坐标系,原点和坐标轴方向与大多数GUI框架类似。
原点:GDI+的坐标系原点位于图形区域的左上角。原点的坐标默认是(0, 0)
。
坐标轴:X轴是水平轴,向右为正方向;Y轴是垂直轴,向下为正方向。
下面例子代码展示了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
事件的回调函数,每次用完Graphics
和Pen
后,我们应该调用其Dispose()
方法释放资源,这里我们就直接使用using
语法自动释放了。代码中,我们调用了graphics.DrawEllipse()
方法,它用于绘制椭圆,我们绘制的椭圆大小就是当前窗体客户区的大小。
为什么不直接把绘图逻辑写在btnDraw_Click()
方法中呢?这是因为有很多情况都会触发窗体重绘,比如窗体初始化或是窗体被遮挡后遮挡物移开等,如果不把绘制代码统一放在Paint
事件的回调函数中,重绘时我们的自定义绘制代码就不会触发了,这会造成非常诡异的现象,例如窗体被遮挡后自绘内容消失等情况。因此我们的实现方式是在btnDraw_Click()
先设置控制绘制的成员变量,然后调用Invalidate()
方法,这个方法定义在Control
基类上,它会触发当前控件的重绘Paint
事件。
总而言之,在Winform中使用GDI+绘图时,最佳实践就是将所有自绘逻辑写在Paint
事件的回调函数中,绘图依赖的状态我们统一将其定义为窗体类的成员变量,绘制逻辑被自动或手动触发时,我们的自绘代码依赖这些状态变量绘制。
前面的例子中我们学习了Paint
事件,这个事件会在控件需要被重新绘制时由Windows操作系统自动触发,此外如果需要手动触发Paint
事件,我们也可以调用Invalidate()
方法,这个方法定义在Control
基类上。总而言之,控件会在以下条件下触发重绘:
Paint
事件。Paint
事件会被触发。Visible
或Enabled
属性发生变化,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.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
GDI+中,Pen
是用于绘制线框的对象,Pen
的构造函数有多个重载,我们可以传入画笔的颜色和线的宽度。下面例子代码创建了黑色的宽度为1的线框画笔。
Pen pen = new Pen(Color.Black, 1f);
画笔可以添加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));
}
除了端点,线也可以有虚线风格,下面是一个绘制虚线的例子。
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));
}
几何中线本身没有宽度的概念,当绘制宽度大于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
样式用于设置折线上点的样式,下面是一个例子。
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
表示圆角。
画刷类似PhotoShop的油漆桶工具,它可以绘制填充颜色的图形。
最简单的画刷是SolidBrush
实颜色画刷,下面例子例子代码绘制一个黑色的圆形。
using (Graphics graphics = this.CreateGraphics())
using (Brush brush = new SolidBrush(Color.Black))
{
graphics.FillEllipse(brush, 10, 10, 100, 100);
}
线性渐变画刷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);
}
纹理画刷是基于图像构造的,它默认使用图像平铺填满整个空间,我们也可以指定WarpMode
修改平铺的行为。下面例子我们使用默认的平铺方式填充了一个矩形。
using (Graphics graphics = this.CreateGraphics())
using (Brush brush = new TextureBrush(Properties.Resources.Icon))
{
graphics.FillRectangle(brush, 10, 10, 300, 30);
}
GDI+内置了多种形状的绘制函数,例如线段、曲线段、椭圆、矩形等,对于闭合图形通常还有DrawXXX()
和FillXXX()
两种函数分别用于绘制线框和填充图形。
直线通过两个端点绘制,下面例子绘制了直线。
graphics.DrawLine(pen, new Point(10, 10), new Point(300, 10));
如果要绘制多个直线,我们可以使用DrawLines()
方法。
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;
贝塞尔曲线是另一种曲线绘制方式,它通过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+中还内置了许多绘制函数,例如圆弧线、椭圆、多边形、矩形、扇形等,其用法具体参考文档即可,这里就不展开介绍了。
路径是基于多个线条和子路径构建的,和之前我们画线不同,路径还可以是闭合的,它是可被画刷填充的。下面例子中,我们使用路径绘制了一个(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+绘图默认是没有开启双缓冲的,我们可以设置DoubleBuffered
为true
开启双缓冲。