最好的GDI入門教程是《Window程序設計》的第五章,如果你沒有任何GDI基礎,最好精讀這一章,因為本文并不會介紹GDI的方方面面,事實上這也是不可能完成的任務。我只將以前學習GDI時遇到的幾個難點拿出來講講。 GDI對象的用法 GDI對象就是畫筆,畫刷,字體這類資源,以我的經驗,GDI對象的管理是一件麻煩的事,如果操作不當,很容易引起GDI泄漏。 Delphi用TPen,TBrush,TFont三個類來表示畫筆畫刷和字體,用Canvas表示設備描述表。以TPen為例,一個TPen并不表示一個GDI對象,真正的GDI對象被保存在“池”里面,TPen只是根據自己的屬性到“池”里面尋找對應的GDI對象,如果找不到將會創建一個新的,而這些GDI對象都有一個引用計數,代表它被多少個TPen對象引用,只要引用計數為0,這個GDI馬上被DeleteObject并從“池”中移走。 各位可以想象Delphi對WinAPI封裝到什么程度,這就是為什么你即使不會Windows編程也可以很快上手Delphi。這樣封裝的好處是非常明顯的,你不必理會什么時候需要DeleteObject,你只要向Pen設置你喜歡的顏色,寬度,風格,然后調用Canvas的函數來繪制就行了。但它的壞處也非常明顯,你設的樣式越多,表明“池”中的GDI對象也會越多,Delphi程序的GDI普遍偏高就緣于此,更可怕的是我們對這種偏高的GDI數量有些束手無策。 MFC則完全不一樣,它僅僅利用棧對象的自動消毀來簡化GDI對象的管理,其余的差不多就是一層簡單的包裝。所以你必須了解GDI的用法,有下面三條規則,這是從Windows程序設計引過來的: 1. 最后必須刪除自己創建的所有GDI對象。 2. 當GDI對象正在一個有效的DC中使用時,不要刪除它。 3. 不要刪除現有對象(StockObject)。 我們用簡單的例子來說明這三條規則,請看下例: HPEN hPen = ::CreatePen(PS_SOLID, 2, RGB(0xFF, 00, 00)); ::SelectObject(hDC, hPen); ::Rectangle(hDC, 10, 10, 100, 100); 參照上面三條規則,明顯違反了第一條規則,創建一個畫筆之后,最后沒有調用DeleteObject消毀hPen,這會發生什么事情呢,GDI對象不斷的泄漏,在XP系統下GDI達到10000時程序就死掉了。 把代碼修改如下: HPEN hPen = ::CreatePen(PS_SOLID, 2, RGB(0xFF, 00, 00)); ::SelectObject(hDC, hPen); ::Rectangle(hDC, 10, 10, 100, 100); :eleteObject(hPen); 這樣是不是就正確了呢,如果hDC在DeleteObject之后還繼續繪制的話就違反規則2了,這會導致后面的繪制失去Pen的風格。 更加安全的做法是這樣的: HPEN hPen = ::CreatePen(PS_SOLID, 2, RGB(0xFF, 00, 00)); HPEN hOldPen = ::SelectObject(hDC, hPen); ::Rectangle(hDC, 10, 10, 100, 100); :eleteObject(::SelectObject(hOldPen)); SelectObject會返回原有的GDI對象,將它保存起來,最后再用SelectObject恢復回來。 不過事情總不是絕對的,如果hDC在DeleteObject之后不再繪制并且將被ReleaseDC的話,其實也可以不保存hOldPen,這樣即可以簡化代碼,也可以提高繪制效率。 有些情況要設的樣式比較多,用上面保存舊GDI對象的方式有些麻煩,可以用SaveDC和RestoreDC來簡化操作,就像下面這樣: HPEN hPen = ::CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); HBRUSH hBrush = ::CreateSolidBrush(RGB(0xFF, 0, 0)); int nDCSave = ::SaveDC(hDC); ::SelectObject(hDC, hPen); ::SelectObject(hDC, hBrush); ::Rectangle(hDC, 10, 10, 100, 100); ::RestoreDC(hDC, nDCSave); :eleteObject(hPen); :eleteObject(hBrush); SaveDC將當前的DC樣式保存起來,直到RestoreDC時恢復回來。 規則3比較容易理解,凡是用GetStockObject取出來的GDI對象都不必手動刪除。 映射方式 映射方式是GDI里最難理解的概念,其中涉及到設備坐標,邏輯坐標,窗口,視口這些術語。 如果不使用GDI函數,我們所要面對的只是設備坐標。設備坐標可以分為三種,屏幕,窗口,客戶區,這之間的區別僅僅是原點的位置,屏幕坐標以屏幕左上角為原點,窗口坐標以窗口左上角為原點,客戶區坐標以客戶區左上角為原點,此外,設備坐標的XY軸都向右向下增長,并且單位是像素。究竟要使用哪種坐標由所調用的函數或所處理的消息決定。比如,當我們調用GetCursorPos時,取得的點以屏幕坐標為準;而處理WM_MOUSEMOVE時,參數所指的點以窗口所在的客戶區坐標為準。需要窗口坐標的情況不是很多,常用于GetWindowDC邏輯坐標向設備坐標的映射。 如果使用GDI函數就必須理解邏輯坐標,因為GDI函數的位置參數都以邏輯坐標為準。比如這個函數: BOOL Rectangle( HDC hdc, // handle to DC int nLeftRect, // x-coord of upper-left corner of rectangle int nTopRect, // y-coord of upper-left corner of rectangle int nRightRect, // x-coord of lower-right corner of rectangle int nBottomRect // y-coord of lower-right corner of rectangle ); 后面的四個參數都是邏輯坐標。假想一個虛擬的平面,這個平面使用邏輯坐標,Rectangle先在虛擬平面上畫出矩形,然后利用“某種方式”將矩形從虛擬平面映射到屏幕上的窗口來,這個過程就是映射: ![]() 映射到何種設備坐標取決于DC是怎么取到的,簡單來說如果是通過GetDC或BeginPaint則為客戶區坐標,如果通過GetWindowDC則為窗口坐標。 問題的復雜性在于邏輯坐標并不像設備坐標那樣固定不變,它的單位是可變的,XY軸增長的方向也是可變的,甚至于邏輯坐標的原點也不一定映射為設備坐標的原點。 邏輯坐標的XY軸的單位與增長方向由SetMapMode決定;邏輯坐標的原點映射到設備坐標什么地方由SetWindowOrgEx或SetViewportOrgEx決定。 我們分別討論這兩個問題,為了簡化復雜性,當討論一個問題時,都假定另一個問題為默認情況,比如討論XY軸的單位和增長方向時,假定邏輯坐標的原點就映射為設備坐標的原點,反過來也一樣。 MapMode的默認值是MM_TEXT,這和設備坐標是一樣的,即單位是像素,XY軸向右向下增長,如果hDC是客戶區的設備描述表,我們調用Rectangle(hDC, 10, 10, 100, 100),矩形就正確無誤地在客戶區的(10, 10, 100,100)處顯示出來。 現在用SetMapMode將映射方式設為MM_LOENGLISH,再調用Rectangle: ::SetMapMode(hDC, MM_LOENGLISH); ::Rectangle(hDC, 0, 0, 100, 100); 這時客戶區并沒有出現矩形,因為MM_LOENGLISH的XY軸是向右向上增長的,且邏輯單位是0.01in,映射方式就像下圖所示: ![]() 矩形被映射到客戶區上邊了,要想屏幕顯示矩形,須作如下修改: ::SetMapMode(hDC, MM_LOENGLISH); ::Rectangle(hDC, 0, 0, 100, -100); 但最終結果仍然不是一個100×100像素的矩形,因為MM_LOENGLISH的邏輯單位是0.01in,相當于0.96像素,最終結果是96×96像素的矩形,正確的代碼是這樣的: ::SetMapMode(hDC, MM_LOENGLISH); int npx_X = ::GetDeviceCaps(hDC, LOGPIXELSX); int npx_Y = ::GetDeviceCaps(hDC, LOGPIXELSY); ::Rectangle(hDC, 0, 0, 100/(npx_X*0.01), -100/(npx_Y*0.01)); 這個轉換僅對于MM_LOENGLISH有效,其他的映射方式要作不同的轉換,為了簡化這種轉換,Windows提供了DPtoLP和LPtoDP,用于在邏輯坐標和設備坐標之間進行點的轉換。現在,代碼變成這樣: ::SetMapMode(hDC, MM_LOENGLISH); RECT rcBound; ::SetRect(&rcBound, 0, 0, 100, 100); toLP(hDC, (LPPOINT)&rcBound, 2); ::Rectangle(hDC, rcBound.left, rcBound.top, rcBound.right, rcBound.bottom); 注意rcBound指定的是設備坐標,但Rectangle需要邏輯坐標,所以用DPtoLP轉換一下。 DPToLP是非常有用的函數,MiniDraw在繪圖時用到這個函數,用于處理滾動條出現的情況。 絕大多數情況下,我們都用MM_TEXT作為映射方式,這種方式下邏輯坐標單位與方向都與設備坐標一樣,理解起來比較容易。 第二個問題是邏輯坐標的原點映射到設備坐標什么地方?默認都是原點映射到原點,即邏輯坐標的(0,0)對應設備坐標的(0,0)。 什么情況下需要改變這種映射方式,我想最常見的是客戶區出現滾動條的時候。比如一個圖形的位置是(100, 100),我們將滾動條往下拉一點,此時圖形的實際位置可能變成(0,0),但我們仍然會認為圖形就在(100,100)處,只是整個客戶區往上滾動了一點。 SetWindowOrgEx和SetViewportOrgEx指定了原點的映射方式。我用這樣的方式來理解原點的映射。比如: ::SetWindowOrgEx(hDC, 100, 100, NULL); 理解為:邏輯坐標的(100,100)映射為設備坐標的(0,0),其他的點以此為依據映射,用下圖表示: ![]() 又比如: ::SetViewportOrgEx(hDC, 100, 100, NULL); 理解為邏輯坐標的(0,0)映射為設備坐標的(100,100),其他的點以此為依據映射,用下圖表示: ![]() 又比如: ::SetWindowOrgEx(hDC, 100, 100, NULL); ::SetViewportOrgEx(hDC, 200, 200, NULL); 理解為:邏輯坐標的(100,100)映射為設備坐標的(200,200),其他的點以此為依據映射。 這樣的描述是否有助于對窗口和視口的理解呢?更加詳細的介紹請看Windows程序設計的第五章。 映射方式僅僅是某個DC的屬性,如果這個DC釋放,則映射方式也沒有什么用了,即使存在兩個DC,他們都對應同一個窗口,映射方式仍然是獨立的。 這篇文章并沒有分析MFC如何封裝GDI,相比之下,我覺得理解GDI的一些重點知識顯得更加重要,理解了這些知識,再看MFC的CDC,那不過就是將hDC和GDI函數組織起來的數據結構。 |