C#发现之旅第七讲 C#图形开发高级篇
袁永福 2008-5-15
系列课程说明
为了让大家更深入的了解和使用C#,我们将开始这一系列的主题为“C#发现之旅”的技术讲座。考虑到各位大多是进行WEB数据库开发的,而所谓发现就是发现我们所不熟悉的领域,因此本系列讲座内容将是C#在WEB数据库开发以外的应用。目前规划的主要内容是图形开发和XML开发,并计划编排了多个课程。在未来的C#发现之旅中,我们按照由浅入深,循序渐进的步骤,一起探索和发现C#的其他未知的领域,更深入的理解和掌握使用C#进行软件开发,拓宽我们的视野,增强我们的软件开发综合能力。
本系列课程配套的演示代码下载地址为 http://files.cnblogs.com/xdesigner/cs_discovery.zip 。其中的PenMarkLib.zip 就是本课程的演示代码。
本系列课程已发布的文章有
C#发现之旅第一讲 C#-XML开发
C#发现之旅第二讲 C#-XSLT开发
C#发现之旅第三讲 使用C#开发基于XSLT的代码生成器
C#发现之旅第四讲 Windows图形开发入门
C#发现之旅第五讲 图形开发基础篇
C#发现之旅第六讲 C#图形开发中级篇
C#发现之旅第七讲 C#图形开发高级篇
C#发现之旅第八讲 ASP.NET图形开发带超链接的饼图
C#发现之旅第九讲 ASP.NET验证码技术
C#发现之旅第十讲 文档对象模型
课程说明
经过以前几次课程,相信大家对图形编程有所了解了,并能自己动手开发一些简单的图形软件。今天我们就在以前图形开发课程的基础上演示使用C#开发一个能保存签名轨迹的图形软件。
这个软件的用户界面如图
功能需求
本软件的功能需求如下
- 用户能操作来开始一次签名和结束签名。
- 正在签名时,用户按下鼠标按键开始绘制一条线条,松开鼠标按键结束绘制一个线条。
- 一个签名可以包含多个不相连的线条。
- 可保存签名信息到XML文档,也可以从XML文档加载签名信息。
- 可以生成包含签名图形的图片文件。
软件设计
实现能实用的签名功能是很复杂的,则此简化了一些功能,目标软件仅操作签名信息,不涉及签名时的文档。于是本软件的设计如下
文档对象
实现复杂的图形软件首先是设计文档对象模型,使得内存中的一个个对象能包含要显示的数据,此处需要设计一套对象模型来包含签名信息。
经过分析,可以知道,一个文档中可以包含若干个签名,一个签名包含若干个线条,而一个线条包含若干个点,线条中的点相互连接来形成线条,而同一个签名中的线条是不相连的,但可以相交。
因此我们可以设计出如下的文档对象模型
点坐标数据列表PointArrayList ,该对象用于存放多个点坐标数据,在这里表示一条任意线段,用户绘制线条时程序可以使用该对象的Add方法增加点数据。
PenMarkInfo 对象表示一个签名,该对象定义了签名的时间,线条的颜色,线条宽度,还包含了若干个PointArrayList对象来保存签名轨迹线条定位信息。
PenMarkInfoDocument 对象表示整体的签名信息对象,该对象定义了多个签名对象,还定义了加载和保存文档数据的方法。
视图控件
设计的签名信息文档对象模型后,我们还需要设计一个控件来显示和操作签名信息文档。这个控件是从UserControl派生的,它重写了OnPaint方法来显示签名图形,重写了鼠标事件来添加新的签名信息。还有一些控件状态控制模块。
程序代码说明
现根据软件设计,使用VS.NET2003开发出这个签名软件,现对该软件的代码进行说明,首先说明一下文档对象模型相关的代码。
PointArrayList
本类型用于维护一个可变长度的专门用于存储点坐标数据的列表。该对象实现了ICollection接口,还实现了自定义的枚举器。本类型提供了Add,RemoveAt,Clear方法来维护点数据列表,还使用Offset方法来移动对象。内部还定义了一个MyPointEnumerator对象实现了自定义的枚举器。这里还定义了一个Bounds 属性来获得包含所有点的最小外切矩形区域。
在这里我们顺便研究一下C#中的枚举器结构。我们都知道,在VB.NET或C#中可以使用foreach 语法结构来遍历枚举一个数组中的所有的元素,使用foreach比使用for要简单不少。从本质上说能应用到foreach语法结构的对象都是实现了System.Collections.IEnumerable接口,该接口只有一个方法 GetEnumerator(),任何类型只要实现了IEnumerable接口即可用于foreach语句中。函数GetEnumerator()返回一个实现了System.Collection.Ienumerator的对象。
为了实现自定义的枚举器,我们需要定义两个类型,一个类型是实现了IEnumerable接口,另外一个实现了IEnumerator接口,其具体内部实现毫无限制,因此我们可以根据需要开发出各种各样的枚举器来应用到foreach语句中。
PointArryList对象没有明确的定义实现IEnumerable接口,但它实现了ICollection接口,而ICollection接口是从IEnumerable接口派生的,因此PointArrayList对象是间接实现了IEnumerable接口。
关于枚举器的详细情况可参考MSDN中的相关说明。
PenMarkInfo
本类型用于维护一个签名信息对象。这个对象保存了签名人的姓名,签名时间,签名线条颜色,宽度。此外还有一个Lines属性用于存放若干个PointArrayList对象,这个Lines属性就保存了多条签名书写轨迹信息。
PenMarkInfo对象定义了一个Bounds属性,用于获得包含所有签名轨迹的最小外切矩形。
PenMarkInfo对象定义了Lines属性,该属性返回一个列表,该列表的元素是PointArryList类型,用于保存多个签名线条的轨迹信息。这里使用了一个类型为XmlArrayItem的特性,这个特性影响对象的XML序列化。该特性说明该列表的元素类型是PointArrayList,而且保存数组元素的XML元素名称为Line。
该对象还定义了Draw函数来绘制签名图形,在Draw函数中使用了Graphics对象的DrawLines函数,该函数是根据N个点来绘制首尾相连的N减1个线段。Draw函数的代码如下
/// <summary> /// 绘制签名图形 /// </summary> /// <param name="g">图形绘制对象</param> /// <param name="ClipRectangle">前切矩形</param> public void Draw( Graphics g , Rectangle ClipRectangle ) { using( Pen pen = new Pen( this.Color , this.LineWidth )) { foreach( PointArrayList line in myLines ) { if( ClipRectangle.IntersectsWith( line.Bounds )) { g.DrawLines( pen , line.ToArray()); } } } } |
这里说明一下,一次调用DrawLines函数和多次调用DrawLine函数是有差别的。由于线段的两端是可以设置不同的样式,DrawLines能一次性绘制多个线段,而且相邻线段的端点经过了连接处理;而使用DrawLine是一条条绘制线段的,相邻线段的端点没有连接处理,当线条宽度很大或者图形进行的放大处理则会暴露出问题,绘制的图形不大美观。
为了向其他程序提供签名信息,本对象还定义了CreateBitmap函数,该函数能创建一个保存签名图形的位图对象。该函数演示里如何在内存中创建图片的过程。
在本函数中,首先是创建一个Bitmap对象,该图片对象的大小等于签名对象的大小,然后使用Graphics的FromImage函数在这个图片的基础上创建一个图形绘制对象,使用这个Graphics对象进行绘图操作都会在这个Bitmap上面留下痕迹,此时图形输出目标不是显示器或者打印机,而是内存中的一个图片。进行坐标转化后调用对象的Draw函数来绘制图形,绘制图形完毕后就提供这个bitmap对象给其他软件使用了。
/// <summary> /// 创建包含签名图形的图片对象 /// </summary> /// <returns>创建的图片对象</returns> public Bitmap CreateBitmap() { Rectangle bounds = this.Bounds ; Bitmap bmp = new Bitmap( bounds.Width , bounds.Height ); using( Graphics g = Graphics.FromImage( bmp )) { g.TranslateTransform( -bounds.Left , -bounds.Top ); Draw( g , bounds ); } return bmp ; } |
这种在内存中创建图片的方法可用于任何类型的.NET程序中,我们可以在ASP.NET,命令行程序或者Windows服务中使用这种方式来创建图形,如此我们知道图形编程不限于桌面软件开发,任何类型的软件中都可以使用图形编程。
PenMarkInfoDocument
本对象用于描述一个完整的签名文档信息对象,它可以包含若干个签名对象。并定义了加载和保存XML文件的功能。
本对象使用XML序列化来保存数据到XML文档,使用XML反序列化来从XML文档来加载对象数据。我们使用XmlSerializer对象来实现XML序列化和反序列化,该类型在名称空间System.Xml.Serialization下面。在XmlSerializer的帮助下,我们可以很方便的实现XML序列化和反序列化。
这段代码就是将对象序列化到XML文档中。只要创建一个XmlSerializer对象,指定要序列化的类型,指定XML书写器,然后调用它的Serialize方法即可完成序列化操作。
/// <summary> /// 将对象序列化到XML文档中 /// </summary> /// <param name="writer">XML文档书写器</param> public void Save( System.Xml.XmlWriter writer ) { XmlSerializer ser = new XmlSerializer( this.GetType()); ser.Serialize( writer , this ); } |
XML反序列化不能将加载的数据设置到一个现有对象,而是需要重新创建一个对象,在这个代码中定义了静态函数能从XML文档反序利化生成一个新的签名信息文档对象。其代码为
/// <summary> /// 根据XML文档反序列化生成签名信息文档对象 /// </summary> /// <param name="strFileName">XML文件名</param> /// <returns>生成的签名信息对象列表</returns> public static PenMarkInfoDocument Load( string strFileName ) { System.Xml.XmlTextReader reader = new System.Xml.XmlTextReader( strFileName ); XmlSerializer ser = new XmlSerializer( typeof( PenMarkInfoDocument )); PenMarkInfoDocument list = ( PenMarkInfoDocument ) ser.Deserialize( reader ); reader.Close(); return list ; } |
在这个代码中,我们首先根据指定的XML文件名创建XML文档读取器,创建一个XmlSerializer对象,指定要反序列化的对象类型,然后调用 Deserialize函数就可获得一个反序列化所得的对象。
对WEB系统,XML序列化和反序列化是WebService的基础,服务器端发送的数据首先序列化为XML文档然后使用HTTP协议发送出去,而客户端获得XML文档使用XML反序列化来获得对象数据。
关于XML序列化和反序列化可参考MSDN文档 Visual Studio.NET/.NET Frameword/使用.NET Framework编程/序列化对象/XML和SOAP序列化。
PenMarkControl
本类型从UseControl上派生的,用于在用户界面上显示和操作签名信息的。该类型是本演示程序中最复杂的部分。
我们首先看看这个控件是如何绘制用户界面的,我们找到该控件重写的OnPaint函数,其代码如下
/// <summary> /// 绘制用户界面 /// </summary> /// <param name="e">参数</param> protected override void OnPaint(PaintEventArgs e) { base.OnPaint (e); e.Graphics.TranslateTransform( this.AutoScrollPosition.X , this.AutoScrollPosition.Y ); System.Drawing.Rectangle ClipRect = e.ClipRectangle ; ClipRect.Offset( - this.AutoScrollPosition.X , - this.AutoScrollPosition.Y ); System.Collections.ArrayList list = new ArrayList(); list.AddRange( this.myDocument ); if( this.Marking ) { list.Add( this.myCurrentInfo ); } foreach( PenMarkInfo info in list ) { info.Draw( e.Graphics , ClipRect ); } if( myCurrentInfo != null ) { System.Drawing.Rectangle rect = myCurrentInfo.Bounds ; System.Windows.Forms.ControlPaint.DrawFocusRectangle( e.Graphics , rect ); } } |
在本函数中首先是对图形绘制对象和剪切矩形进行坐标转化。创建一个名为list的列表,列表中放置文档中已经有的签名对象和正在新建中的签名对象,然后遍历所有的签名对象,调用它们的Draw函数来绘制签名图形,最后根据当前签名信息来绘制焦点矩形,这里的myCurrentInfo就是当前签名信息对象。
这里使用了类型ControlPaint来绘制焦点矩形。在Windows用户界面中,表示一个控件获得输入焦点,可以在其界面上绘制焦点矩形。比如按钮,当按钮获得焦点时,按纽里就会绘制一个虚线的矩形边框,这个就是焦点矩形。类型ControlPaint中定义了一些静态方法,用以模拟绘制一些Windows标准控件的用户界面,比如细边框和3D的凸起或下陷边框,模拟绘制菜单,单选框,复选框等等。这个类型是一些Win32API函数的封装,这些API函数有DrawEdge,DrawFrameControl等等,ControlPaint还提供一些方法能反转屏幕上的像素,从而能实现橡皮筋技术,而标准的Graphics对象是没有像素反转功能的。
PenMarkControl还重写了鼠标处理方法来实现新增签名的功能。首先控件有两种状态,正在签名状态和普通状态,当控件处于正在签名状态,则用户的鼠标拖拽操作就能增加新的签名笔迹;否则用户的鼠标拖拽操作不会新增签名笔迹。控件定义了一个名为Marking的属性来表示控件是否处于新增签名状态。其代码如下
/// <summary> /// 正在签名中 /// </summary> /// <remarks>若当前签名对象存在而且还不属于文档则控件处于新增签名状态</remarks> public bool Marking { get{ return myCurrentInfo != null && myDocument.Contains( myCurrentInfo ) == false ;} } |
控件定义了BeginMark和EndMark方法来开始和结束新增签名操作。其代码为
/// <summary> /// 开始进行新增签名 /// </summary> /// <param name="UserName">签名者</param> /// <param name="LineWidth">签名线条宽度</param> public void BeginMark( string UserName , int LineWidth ) { if( myCurrentInfo != null ) { System.Drawing.Rectangle rect = myCurrentInfo.Bounds ; rect.Offset( this.AutoScrollPosition.X , this.AutoScrollPosition.Y ); this.Invalidate( rect ); } myCurrentInfo = new PenMarkInfo(); myCurrentInfo.Creator = UserName ; myCurrentInfo.CreationTime = DateTime.Now ; myCurrentInfo.LineWidth = LineWidth ; } /// <summary> /// 结束新增签名操作 /// </summary> public void EndMark() { if( this.Marking ) { if( myCurrentInfo.Lines.Count > 0 ) { myDocument.Add( myCurrentInfo ); } else { myCurrentInfo = null; } } } |
在BeginMark中,程序重新设置了当前签名信息对象为新对象,而且新对象还未加入到文档中,此时Marking 属性返回true。
在EndMark中,若正在新增的签名信息包含了至少一条签名笔迹则将对象添加到文档中,否则删除当前签名信息对象,此时Marking属性返回false。
这个控件重写了鼠标按键按下事件处理来开始新增签名轨迹,其代码为
/// <summary> /// 当前处理的线条点集合 /// </summary> private PointArrayList myCurrentLine = null; /// <summary> /// 最后一次点坐标 /// </summary> private System.Drawing.Point LastPoint = System.Drawing.Point.Empty ; /// <summary> /// 处理鼠标按键按下事件 /// </summary> /// <param name="e"></param> protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown (e); if( this.Marking ) { // 正在签名 myCurrentLine = new PointArrayList(); LastPoint = new Point( e.X , e.Y ); } else { // 判断鼠标光标是否命中某个签名的线条 int x = e.X - this.AutoScrollPosition.X ; int y = e.Y - this.AutoScrollPosition.Y ; foreach( PenMarkInfo info in myDocument ) { // 判断鼠标光标是否命中某个签名的某个线条 foreach( PointArrayList line in info.Lines ) { foreach( Point p in line ) { double r = ( p.X - x ) * ( p.X - x ) + ( p.Y - y ) * ( p.Y - y ); if( r < 13 ) { System.Drawing.Rectangle rect = info.Bounds ; if( myCurrentInfo != null ) { rect = System.Drawing.Rectangle.Union( rect , myCurrentInfo.Bounds ); rect.Offset( this.AutoScrollPosition.X , this.AutoScrollPosition.Y ); } myCurrentInfo = info ; this.Invalidate( rect ); goto EndElse ; } } } } EndElse: ; } } |
当用户按下鼠标按键时,若控件处于新增签名状态则开始一条签名轨迹操作,初始化一些全局变量。若控件不是新增签名状态,则修正鼠标光标坐标,并查找鼠标光标下签名对象,在这里判断鼠标光标和某个签名轨迹上的某点的距离的平方是否小于13,若找到这个签名对象则设置该签名对象为当前签名对象。然后声明控件的部分界面无效,需要重新绘制。
声明控件用户界面无效是调用控件的Invalidate方法,若带参数则声明用户界面部分无效,参数是一个矩形,在将来要调用的OnPaint方法的剪切矩形就是这个参数。若无参数的调用Invalidate方法,则是声明整个控件的用户界面无效,全部需要重新绘制。
脏矩形
在图形编程中,我们经常需要主动的声明用户界面无效,此时为了提高效率需要尽量减少声明无效的用户界面的面积,这样能减少未来调用的OnPaint方法中的工作量。此时就会用到一种名为“脏矩形”的图形编程技术。程序应当收集用户界面中真正需要重新绘制的区域,然后获得这些区域的最小外切矩形,该矩形就表示用户界面中被用户操作“弄脏”的区域,需要重新绘制,于是调用Invalidate方法,参数就是这个脏矩形。
在这里我们切换当前签名区域,真正需要重新绘制的区域是旧的当前签名外切矩形和新的当前签名的外切矩形。因为显示在旧的当前签名的焦点矩形需要檫掉,而新的当前签名要显示焦点矩形。我们使用Rectangle的Union获得这两个签名的最小外切矩形,也就是脏矩形,这个脏矩形采用的是文档视图坐标,由于控件的I年nvalidate方法采用的是控件客户区坐标,此时还需要针对折射原理对脏矩形进行坐标转化,生成控件客户区的脏矩形,然后调用控件的Invalidate方法声明控件部分用户界面无效。
这里还用到了比较少见的goto语句。学校里面的老师告诉我们,goto语句是万恶之源,但编程是注重实践的,不应当搞教条主义,根据我的个人经验,在少数情况下goto也是有用的,在这里有一个三重的foreach循环语句,为了快速退出这个套嵌循环结构,goto是最好的选择了。
有人会提出使用 return 语句代替goto语句,认为这里goto完成后就是退出函数,还不如直接用return语句。这里我就说明一下我的编程风格。我认为一个函数应当建议使用单入口单出口模式,函数的单入口是天生的,单出口则不一定了,单出口就是函数必然是在函数结尾处退出去,也就是说只有一个地方能退出函数。若在一个函数的代码中夹杂着return语句,则就不是单出口。一般而言,单出口的函数比较好维护,比如断点调试,修改函数的返回值。不过这只是一个建议,不是规范,实际编程中要记得有这点就可以了。
这个控件还处理的鼠标移动事件,其代码为
/// <summary> /// 处理鼠标移动事件 /// </summary> /// <param name="e">事件参数</param> protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove (e); if( this.Marking && myCurrentLine != null ) { using( System.Drawing.Graphics g = this.CreateGraphics()) { using( System.Drawing.Pen p = new Pen( myCurrentInfo.Color , myCurrentInfo.LineWidth )) { g.DrawLine( p , LastPoint , new Point( e.X , e.Y )); } } LastPoint = new Point( e.X , e.Y ); Point point = new Point( e.X - this.AutoScrollPosition.X , e.Y - this.AutoScrollPosition.Y ); myCurrentLine.Add( point ); } } |
在这里若控件处于新增签名轨迹的状态,则将当前鼠标光标位置转换为视图坐标后添加到当前轨迹点坐标列表中。这里使用了另外一种用户界面绘制过程。由于鼠标光标事件频繁发生,一秒内可能发生几十次,每发生一次需要在用户界面上绘制一小段线条,此时采用脏矩形技术是不合适的。此时我们可以调用控件的CreateGraphics对象,获得该控件的图像绘制对象,这有点类似Win32API函数GetDC,我们可以直接使用这个图形绘制对象来绘制用户界面。这种方式跳过了控件重绘事件处理机制,速度快,很适合频繁的绘制用户界面的操作。
控件还处理鼠标按键松开事件,其代码为
/// <summary> /// 处理鼠标按键松开事件 /// </summary> /// <param name="e">事件参数</param> protected override void OnMouseUp(MouseEventArgs e) { base.OnMouseUp (e); if( this.Marking ) { if( myCurrentLine != null ) { System.Drawing.Rectangle rect = myCurrentLine.Bounds ; if( rect.Width > 5 || rect.Height >= 5 ) { myCurrentInfo.Lines.Add( myCurrentLine); } myCurrentLine = null; } } } |
在这个代码中,若当前处理的线条轨迹存在而且不是很小,则添加到当前签名对象中去。此处判断轨迹边界的大小是为了忽略用户的误操作,用户可能不经意的点击了鼠标按键,则程序会生成一个轨迹信息,若这个轨迹太小,则程序就认为这个轨迹是误操作,也就忽略掉该轨迹了。
其实在Windows操作系统判断鼠标双击操作也采用类似的方法。用户连续两次快速按下和松开鼠标按键,则用户操作可能是双击操作,但也不一定是,此时Windows会判断两次鼠标点击操作的间隔时间和鼠标光标移动的距离,若间隔时间过长或者鼠标移动的距离过大,则不是双击操作,而是两个单击操作,Windows这样判断也是为了减少用户的误操作。
测试控件
控件编写好后我们就作了一个frmTest的窗体来测试这个用户控件。编译程序,打开窗体设计器,在工具箱的我的用户控件页面中可以看到有一个PenMakeControl的用户控件,若没有则鼠标右击工具箱,选择菜单项目“添加/移除项目”。在对话框中点击浏览选择刚刚编译生成的EXE或DLL文件,然后选中PenMarkControl即可在工具箱上新增PenMarkControl项目。我们在窗体上放置一个PenMarkControl,再放置一些按钮,添加一些代码来测试这个控件的各种功能。
提交程序
设置程序的项目类型为类库,重新编译,生成一个DLL文件,这个DLL文件就是我们可以提交给客户的文件。
小结
在本课程中,我们一起研究了使用C#开发一个具有一定复杂度的图像软件。在这个过程中我们了解了脏矩形技术,初步接触了文档对象模型,XML序列化。