C#發現之旅第七講 C#圖形開發高級篇
C#發現之旅第七講 C#圖形開發高級篇
袁永福 2008-5-15
系列課程說明
為了讓大家更深入的了解和使用C#,我們將開始這一系列的主題為“C#發現之旅”的技術講座。考慮到各位大多是進行WEB數據庫開發的,而所謂發現就是發現我們所不熟悉的領域,因此本系列講座內容將是C#在WEB數據庫開發以外的應用。目前規劃的主要內容是圖形開發和XML開發,并計劃編排了多個課程。在未來的C#發現之旅中,我們按照由淺入深,循序漸進的步驟,一起探索和發現C#的其他未知的領域,更深入的理解和掌握使用C#進行軟件開發,拓寬我們的視野,增強我們的軟件開發綜合能力。
本系列課程配套的演示代碼下載地址為 https://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序列化。
posted on 2008-05-30 09:00 袁永福 電子病歷,醫療信息化 閱讀(10015) 評論(9) 收藏 舉報
浙公網安備 33010602011771號