章30
第
使用 GDI+绘图
在本书中,有 8 章内容介绍用户交互和.NET Framework,第 28 章主要介绍了 Windows
窗体、如何显示对话框或 SDI、MDI 窗口,以及如何把各种控件放在这些窗口上,如按钮、
文本框和列表框。第 29 章介绍在 Windows 窗体中使用许多 Windows 窗体控件处理各种数
据源中的数据。
这些标准控件的功能非常强大,使用它们就可以获得许多应用程序的完整用户界面。
但是,有时还需要在用户界面上有更大的灵活性。例如,要在窗口的确定位置以给定的字
体绘制文本,或者显示图像,但不使用图像框控件,只使用形状和图形。这些都不能使用
第 28 章中的控件来完成。要显示这种类型的输出,应用程序必须直接告诉操作系统需要在
其窗口的什么地方显示什么内容。
本章主要介绍如何绘制以下内容:
● 直线、简单图形
● .BMP 图像和其他图像文件
● 文本
在这个过程中,还需要使用各种帮助对象,包括钢笔(用于定义直线的特性)、画笔(用
于定义区域的填充方式)和字体(用于定义文本字符的图形)。我们还将介绍设备如何解释和
显示不同的颜色。
下面首先讨论 GDI+技术。GDI+由.NET 基类集组成,这些基类可用于在屏幕上完成定
制绘图,能把合适的指令发送到图形设备的驱动程序上,确保在监视器屏幕上显示正确的
输出(或打印到硬拷贝中)。
30.1 理解绘图规则
本节讨论一些基本规则,只有理解了它们,才能开始在屏幕上绘图。首先概述 GDI,
GDI+技术就建立在 GDI 上,然后说明它与 GDI+的关系。接着介绍几个简单的例子。
第Ⅴ部分 显 示
30.1.1 GDI 和 GDI+
一般来说,Windows 的一个优点 (实际上是现代操作系统的优点) 是它可以让开发人员
不考虑特定设备的细节。例如,不需要理解硬盘设备驱动程序,只需在相关的.NET 类中调
用合适的方法(在没有.NET 的日子里,使用等价的 Windows API 函数),就可以编程读写磁
盘上的文件。这个规则也适用于绘图。计算机在屏幕上绘图时,把指令发送给视频卡。问
题是市面上有几百种不同的视频卡,大多数有不同的指令集和功能。如果把这个考虑在内,
在应用程序中为每个视频卡驱动程序编写在屏幕上绘图的特定代码,这样的应用程序就根
本不可能编写出来。这就是为什么在 Windows 最早期的版本中就有 Windows Graphical
Device Interface (GDI)的原因。
GDI+提供了一个抽象层,隐藏了不同视频卡之间的区别,这样就可以调用 Windows
API 函数完成指定的任务了,GDI 会在内部指出在运行特定的代码时,如何让客户机的视
频卡完成要绘制的图形。GDI 还可以完成其他任务。大多数计算机都有多个显示设备—— 例
如,监视器和打印机。GDI 成功地使应用程序所使用的打印机看起来与屏幕一样。如果要
打印某些东西,而不是显示它们,只需告诉系统输出的设备是打印机,再用相同的方式调
用相同的 Windows API 函数即可。
可以看出,DC(设备环境)是一个功能非常强大的对象,在 GDI 下,所有的绘图工作都
必须通过设备环境来完成。DC 甚至可用于不涉及在屏幕或其他硬件设备上绘图的其他操
作,例如在内存中修改图像。
GDI 给开发人员提供了一个相当高级的 API,但它仍是一个基于旧的 Windows API
并且有 C 语言风格函数的 API,所以使用起来不是很方便。GDI+在很大程度上是 GDI
和应用程序之间的一层,提供了更直观、基于继承性的对象模型。尽管 GDI+基本上是
GDI 的一个包装器,但 Microsoft 已经能通过 GDI+提供新功能了,它还有一些性能方面
的改进。
.NET 基类库的 GDI+部分非常大,本章不解释其特性。这是一个深思熟虑的决定,因
为只要解释其中的几个类、方法和属性,就会把本章变成一个仅列出 GDI+类和方法的参
考指南。而理解绘图的基本规则更重要;这样您应可以自己研究这些类。当然,关于 GDI+
中类和方法的完整列表,可以参阅 SDK 文档说明。
注意:
有 VB6 背景的开发人员会发现,自己并不熟悉绘图过程涉及的概念,因为 VB6 的重
点是处理绘图的控件。有 C++/MFC 背景的开发人员则比较熟悉这个领域,因为 MFC 要求
开发人员使用 GDI 更多地控制绘图过程。但是,即使您具备很好的 GDI 背景知识,也会
发现本章有许多新东西。
1. GDI +命名空间
表 30-1 列出了 GDI+基类的主要命名空间。
994
第 30 章 使用 GDI+绘图
命 名 空 间
System.Drawing
System.Drawing.Drawing2D
System.Drawing.Imaging
System.Drawing.Printing
System.Drawing.Design
表 30-1
说
明
包含与基本绘图功能有关的大多数类、结构、枚举和委托
为大多数高级 2D 和矢量绘图操作提供了支持,包括消除锯齿、几
何转换和图形路径
帮助处理图像(位图、GIF 文件等)的各种类
把打印机或打印预览窗口作为输出设备时使用的类
一些预定义的对话框、属性表和其他用户界面元素,与在设计期
间扩展用户界面相关
System.Drawing.Text
对字体和字体系列执行更高级操作的类
本章使用的几乎所有的类、结构等都包含在 System.Drawing 命名空间中。
2. 设备环境和 Graphics 对象
在 GDI 中,识别输出设备的方式是使用设备环境(DC)对象。该对象存储特定设备的信
息,并能把 GDI API 函数调用转换为要发送给该设备的指令。还可以查询设备环境对象,
确定对应的设备有什么功能(例如,打印机是彩色的,还是黑白的)。这样才能据此调整输
出结果。如果要求设备完成它不能完成的任务,设备环境对象就会检测到,并采取相应的
措施(这取决于具体的情形,例如可能产生一个错误,或修改请求,获得与该设备的能力最
相近的匹配)。
但是,设备环境对象不仅可以处理硬件设备,还可以用作 Windows 的一个桥梁,因此
能考虑到 Windows 绘图的要求或限制。例如,如果 Windows 知道只有一小部分应用程序
窗口需要重新绘制,设备环境就可以捕获和撤销在该区域外绘图的工作。因为设备环境与
Windows 的关系非常密切,通过设备环境来工作就可以用其他方式简化代码。
例如,硬件设备需要知道在什么地方绘制对象,通常它们需要相对于屏幕(或输出设备)
左上角的坐标。但应用程序常常要使用自己的坐标系统在自己窗口的客户区域(用于绘图的
窗口)上绘图。而因为窗口可以放在屏幕上的任何位置,用户可以随时移动它,所以在两个
坐标之间转换就是一个比较困难的任务。设备环境总是知道窗口在什么地方,并能自动进
行这种转换。
在 GDI+中,设备环境包装在.NET 基类 System. Drawing.Graphics 中。大多数绘图工作
都是调用 Graphics 实例的方法完成的。实际上,因为 Graphics 类负责处理大多数绘图操作,
所以 GDI+中很少有操作不涉及到 Graphics 实例。理解如何处理这个对象是理解如何使用
GDI+在显示设备上绘图的关键。
30.1.2 绘制图形
下面用一个小示例 DisplayAtStartup 来说明如何在应用程序的主窗口中绘图。本章的示
例都在 Visual Studio 2005 中创建为 C# Windows 应用程序。对于这种类型的项目,代码向
995
第Ⅴ部分 显 示
导会提供一个类 Form1,它派生自 System.Windows.Form,表示应用程序的主窗口。还会
生成一个类 Program(在 Program.cs 文件中),表示应用程序的主起点。除非特别声明,否则
在所有的示例中,新代码或修改过的代码都添加到向导生成的代码中(可以从 Wrox 网站
www.wrox.com 上下载示例代码)。
注意:
在.NET 的用法中,当说到显示各种控件的应用程序时,“窗口”大都用术语“窗体”
来代替,表示一个矩形对象,它占据了屏幕上的一块区域,代表应用程序。在本章中,我
们使用术语“窗口”,因为在手工绘图时,它更有意义。当谈到用于实例化窗体/窗口的.NET
类时,使用术语“窗体”。最后,“绘图”或“绘制”可以互换使用,描述在屏幕或其他显
示设备上显示一些项的过程。
第一个示例创建一个窗体,并在启动窗体时在构造函数中绘制它。这并不是在屏幕上
绘图的最佳方式,这个示例并不能在启动后按照需要重新绘制窗体。但利用这个示例,我
们不必做太多的工作,就可以说明绘图的许多问题。
对于这个示例,启动 Visual Studio 2005,创建一个 Windows 应用程序,首先把窗体的
背景色设置为白色。把这行代码放在 InitializeComponent()方法的后面,这样 Visual Studio
2005 就会识别该命令,并改变窗体的设计视图的外观。在 Visual Studio Solution Explorer
中单击 Show All Files 按钮,再展开 Form1.cs 文件旁边的加号,就可以看到 Form1.Designer.cs
文件,在这个文件中,包含了 InitializeComponent()方法。也可以使用设计视图设置背景色,
这会自动添加相同的代码:
private void InitializeComponent()
{
//
// Form1
//
this.components = new System.ComponentModel.Container();
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.Text = "Form1";
this.BackColor = System.Drawing.Color.White;
接着,给 Form1 构造函数添加代码。使用窗体的 CreateGraphics()方法创建一个 Graphics
对象,这个对象包含绘图时需要使用的 Windows 设备环境。创建的设备环境与显示设备
相关,也与这个窗口相关。
public Form1()
{
InitializeComponent();
Graphics dc = this.CreateGraphics();
this.Show();
Pen bluePen = new Pen(Color.Blue, 3);
dc.DrawRectangle(bluePen, 0,0,50,50);
Pen redPen = new Pen(Color.Red, 2);
996
第 30 章 使用 GDI+绘图
dc.DrawEllipse(redPen, 0, 50, 80, 60);
}
然后调用 Show()方法显示窗口。之所以让窗口立即显示,是因为在窗口显示出来之前,
我们不能做任何工作—— 没有可供绘图的地方。
最后,显示一个矩形,其坐标是(0,0),宽度和高度是 50,再绘制一个椭圆,其坐标是
(0, 50),宽度是 80,高度是 50。注意坐标(x,y)表示从窗口的客户区域左上角开始向右的 x
个像素,向下的 y 个像素—— 这些是显示出来的图形的左上角坐标。
我们使用的 DrawRectangle()和 DrawEllipse()重载方法分别带 5 个参数。第一个参数是
类 System.Drawing.Pen 的实例。Pen 是许多帮助绘图的支持对象中的一个,它包含如何绘
制直线的信息。第一个 Pen 表示线条应是蓝色的,其宽度为 3 个像素;第二个 Pen 表示线
条应是红色的,其宽度为 2 个像素。后面的 4 个参数是坐标和大小。对于矩形,它们分别
表示矩形的左上角坐标(x,y)、其宽度和高度。对于椭圆,这些数值的含义表示相同的,但
它们是指椭圆假想的外接矩形,而不是椭圆本身。运行代码,会得到如图 30-1 所示的图形。
当然,本书不是彩页书,所以看不到颜色。
图 30-1
这个屏幕图说明了两个问题。首先,用户可以很清楚地看到窗口客户区域中的内容。
这是一个白色的区域—— 该区域受到 BackColor 属性设置的影响。还要注意,矩形放在该
区域的一角,因为我们指定了坐标(0,0)。其次,注意椭圆的顶部与矩形有轻度的重叠,这
与代码中给出的坐标有点不同,这是因为 Windows 在重叠的区域上放置了矩形和椭圆的线
条。在默认情况下,Windows 会试图把图形边框所在的线条放在中心位置—— 但这并不是
总能做到的,因为线条是以像素为单位来绘制的,但每个图形的边框理论上位于两个像素
之间。结果,1 个像素宽的线条就会正好位于图形顶边和左边的里面,而在右边和底边的
外面。这样,从严格意义上讲,相邻图形的边框就会有一个像素的重叠。我们指定的线条
宽度比较大,因此重叠区域也会比较大。设置 Pen.Alignment 属性(详见 SDK 文档说明),
就可以改变默认的操作方式,但这里使用默认的操作方式就足够了。
但如果运行这个示例,就会注意到窗体的执行过程有点奇怪。如果把它放在那里,或
者用鼠标在屏幕移动该窗体,它就工作正常。但如果最小化该窗体,再恢复它,绘制好的
图形就不见了。如果在示例中绘制另一个窗体,情况也是这样。如果在该窗体上拖动另一
997
第Ⅴ部分 显 示
个窗口,使之只遮挡一部分图形,再把该窗口拖离这个窗体,临时被挡住的部分就消失了,
只剩下一半椭圆或矩形了!
这是怎么回事?其原因是,如果窗口的一部分被隐藏了,Windows 通常会立即删除与
其中显示的内容相关的所有信息。这是必需的,否则存储屏幕数据的内存量就会是个天文
数字。一般的计算机在运行时,视频卡设置为显示 1024×768 像素、24 位彩色模式,这表
示屏幕上的每个像素占据 3 个字节,则显示整个屏幕就需要 2.25MB(本章后面会说明 24
位颜色的含义)。但是,用户常常让任务栏上有 10 个或 20 个最小化窗口。下面考虑一种最
糟糕的情况:20 个窗口,每个窗口如果没有最小化,就占用整个屏幕,如果 Windows 存
储了这些窗口包含的可视化信息,当用户恢复它们时,它们就会有 45MB。目前,比较好
的图形卡有 64MB 的内存,可以应付这种情况,但在几年前图形卡有 4MB 的内存就不错
了。剩余的部分需要存储在计算机的主内存中。许多人仍在使用旧机器,一些人甚至还在
使用 4MB 的图形卡。很显然,Windows 不可能这样管理用户界面。
在窗口的某一部分消失时,那些像素也就丢失了。因为 Windows 释放了保存这些像素
的内存。但要注意,窗口的一部分被隐藏了,当它检测到窗口不再被隐藏时,就请求拥有
该窗口的应用程序重新绘制其内容。这个规则有一些例外—— 窗口的一小部分被挡住的时
间比较短(例如,从主菜单中选择一个项目,该菜单项向下拉出,临时挡住了下面的窗口)。
但一般情况下,如果窗口的一部分被挡住,应用程序就需要在以后重新绘制它。
这就是示例应用程序的一个问题。我们把绘图代码放在 Form1 的构造函数中,当应用
程序启动时,就调用该函数一次,不能在以后需要时再次调用该构造函数,重新绘制图形。
在使用 Windows 窗体的服务器控件时,不需要知道这些,这是因为标准控件非常专业,
能在 Windows 需要时重新绘制它们自己。这是编写控件时不需要担心实际绘图过程的原因
之一。如果要应用程序在屏幕上绘图,还需要在 Windows 要求重新绘制窗口的全部或部分
时,确保应用程序会正确响应。下一节将修改这个示例,完成应用程序的响应。
30.1.3 使用 OnPaint()绘制图形
上面的解释让您觉得绘制自己的用户界面是比较复杂的,实际上并非如此。让应用程
序在需要时绘制自身是非常简单的。
Windows 会利用 Paint 事件通知应用程序完成一些重新绘制的要求。有趣的是,Form
类已经执行了这个事件的处理程序,因此不需要再添加处理程序了。Paint 事件的 Form1
处理程序处理虚方法 OnPaint()的调用,并给它传送一个参数 PaintEventArgs,这表示,我
们只需重写 OnPaint()执行画图操作。
我 们 选 择 重 写 OnPaint() , 也 可 以 为 Paint 事 件 添 加 自 己 的 事 件 处 理 程 序 ( 例 如
Form1_Paint 方法)来得到相同的结果,其方式与为其他 Windows Form 事件添加处理程序
一样。后一个方法更方便一些,因为可以通过 VS2005 属性窗口添加新的事件处理程序,
不必键入代码。但是我们采用重写 OnPaint()的方式要略为灵活一些,因为这样可以控制何
时调用基类窗口进行处理,在文档说明中推荐采用这种方式。用户最好采用这种方式,以
保持一致。
下面创建一个 Windows 应用程序 DrawShapes 来完成这个操作。与以前一样,使用属
998
性窗口把背景色设置为白色,再把窗体的文本改为 DrawShapes Sample,接着在 Form1 类
中添加如下代码:
第 30 章 使用 GDI+绘图
protected override void OnPaint( PaintEventArgs e )
{
base.OnPaint(e);
Graphics dc = e.Graphics;
Pen bluePen = new Pen(Color.Blue, 3);
dc.DrawRectangle(bluePen, 0,0,50,50);
Pen redPen = new Pen(Color.Red, 2);
dc.DrawEllipse(redPen, 0, 50, 80, 60);
}
注意,OnPaint()声明为 protected。OnPaint()一般在类的内部使用,所以类外部的其他
代码不知道存在 OnPaint()。
PaintEventArgs 是 一 个 派 生 自 EventArgs 的 类 , 一 般 用 于 传 送 有 关 事 件 的 信 息 。
PaintEvent Args 有另外两个属性,其中比较重要的是 Graphics 实例,它们主要用于优化绘
制窗口中需要绘制的部分。这样就不必调用 CreateGraphics(),在 OnPaint()方法中获取设备
环境了—— 用户总是可以得到设备环境。后面将介绍其他属性,它包含哪些窗口部分需要
重新绘制的详细信息。
在 OnPaint()的执行代码中,首先从 PaintEventArgs 中引用 Graphics 对象,再像以前那
样绘制图形。最后调用基类的 OnPaint()方法,这一步是非常重要的。我们重写了 OnPaint()
方法,完成了绘图工作,但 Windows 在绘图过程中可能会执行一些它自己的工作。这些工
作都在.NET 基类的 OnPaint()方法中完成。
注意:
对于这个示例,删除 base.OnPaint()的调用似乎并没有任何影响,但不要试图删除这个
调用。这样有可能阻止 Windows 正确执行任务,结果是无法预料的。
在应用程序第一次启动,窗口第一次显示出来时,也调用了 OnPaint(),所以不需要在
构造函数中复制绘图代码。
运行这段代码,得到的结果将与前面的示例的结果相同,但现在,当最小化窗口或隐
藏它的一部分时,应用程序会正确执行。
30.1.4 使用剪切区域
上一节的 DrawShapes 示例说明了在窗口中绘图的主要规则,但它并不是很高效。原
因是它试图绘制窗口中的所有内容,而没有考虑需要绘制多少内容。如图 30-2 所示,运行
DrawShapes 示例,当该示例在屏幕上绘图时,打开另一个窗口,把它移动到 DrawShapes
窗体上,使之隐藏一部分窗体。
999
第Ⅴ部分 显 示
图 30-2
但移动上面的窗口时,DrawShapes 窗口会再次全部显示出来,Windows 通常会给窗体
发送一个 Paint 事件,要求它重新绘制本身。矩形和椭圆都位于客户区域的左上角,所以
在任何时候都是可见的,在本例中不需要重新绘制这部分,而只需要重新绘制白色背景区
域。但是,Windows 并不知道这一点,它认为应引发 Paint 事件,调用 OnPaint()方法的执
行代码。OnPaint()不必重新绘制矩形和椭圆。
在本例中,没有重新绘制图形。原因是我们使用了设备环境。Windows 将利用重新绘
制某些区域所需要的信息预先初始化设备环境。在 GDI 中,被标记出来的重绘区域称为无
效区域,但在 GDI+中,该术语改为剪切区域,设备环境知道这个区域的内容,它截取在
这个区域外部的绘图操作,且不把相关的绘图命令传送给图形卡。这听起来不错,但仍有
一个潜在的性能损失。在确定是在无效区域外部绘图前,我们不知道必须进行多少设备环
境处理。在某些情况下,要处理的任务比较多,因为计算哪些像素需要改变为什么颜色,
将会占用许多处理器时间(好的图形卡会提供硬件加速,对此有一定的帮助)。
其底线是让 Graphics 实例完成在无效区域外部的绘图工作,肯定会浪费处理器时间,
减慢应用程序的运行。在设计优良的应用程序中,代码将执行一些检查,以查看需要进行
哪 些 绘 图 工 作 , 然 后 调 用 相 关 的 Graphics 实 例 方 法 。 本 节 将 编 写 一 个 新 示 例
DrawShapesWithClipping,修改 DisplayShapes 示例,只完成需要的重新绘制工作。在 OnPaint()
代码中,进行一个简单的测试,看看无效区域是否与需要绘制的区域重叠,如果是,就调
用绘图方法。
首先,需要获得剪切区域的信息。这需要使用 PaintEventArgs 的另一个属性。这个属
性 叫 做 ClipRectangle , 包 含 要 重 绘 区 域 的 坐 标 , 并 包 装 在 一 个 结 构 实 例
System.Drawing.Rectangle 中。Rectangle 是一个相当简单的结构,包含 4 个属性:Top、Bottom、
Left 和 Right。它们分别包含矩形的上下的垂直坐标、左右的水平坐标。
接着,需要确定进行什么测试,以决定是否进行绘制。这里进行一个简单的测试。注
意,在绘图过程中,矩形和椭圆完全包含在(0,0)到(80,130)的矩形客户区域中,实际上,点
(82,132)就已经在安全区域中了,因为线条大约偏离这个区域一个像素。所以我们要看看剪
切区域的左上角是否在这个矩形区域内。如果是,就重新绘制,如果不是,就不必麻烦了。
下面是代码:
1000