1. 程式人生 > >消除窗體/內容/控制元件閃爍(Win32 SDK、C++ 語言描述)

消除窗體/內容/控制元件閃爍(Win32 SDK、C++ 語言描述)

原文章地址:http://hi.baidu.com/ultraman_king/blog/item/d71d004a4f2a8a3909f7ef8d.html

〇、前提

本文采用 Win32 SDK 以及 C/C++ 語言描述,其中沒有用到 C++ 語言的功能。採用標準 Win32 應用程式模型,即從 WinMain() 進入,然後使用 RegisterClassEx() 註冊主視窗類,同時主視窗的訊息處理回撥過程是 WndProc(),其它的一些變數和函式在文章中描述。

一、閃爍的分類與原因

通常的閃爍分為:

        1. 視窗內容的閃爍,例如使用 TextOut() 直接在視窗客戶端繪製文字等;

        2. 視窗子控制元件閃爍,例如視窗中的 Button、TabControl 閃爍等。

所有閃爍的根本原因只有一個,就是同一個畫素使用不同的顏色值多次繪製,造成視覺上的閃爍現象。而應用在上面兩種閃爍的情況中,造成 Windows 為同一個畫素繪製多次的成因就分為很多種了:

        1. 視窗首先在 WM_ERASEBKGND 訊息中使用背景畫刷(如果在類樣式中指定的話)擦除背景,然後再在 WM_PAINT 中繪製內容(如之前說到的TextOut),造成了在內容處的同一個畫素繪製兩次,從而造成了 TextOut 輸出的內容會不斷閃爍;(這個現象在視窗每次需要 WM_PAINT 時就會出現);

        2. 視窗在 WM_PAINT 繪製完內容之後向所有的子視窗傳送 WM_PAINT 訊息,從而造成所有子視窗位置的畫素首先被視窗的 WM_PAINT 繪製一次,其次被這個子視窗的內容繪製一次(或多次),造成閃爍現象;

        3. 子視窗本身也需要處理 WM_ERASEBKGND 和 WM_PAINT 訊息,Windows 有些系統控制元件本身沒有經過良好的優化,造成閃爍,例如 TabControl。

下面就消除這幾種閃爍情況進行說明。

二、合適的類樣式和窗體樣式

最簡單的消除閃爍的方法就是首先需要指定合適的類樣式(CS_*)和窗體樣式(WS_* 以及 WS_EX_*),選用相應的樣式時可以考慮一下幾點:

        1. 所有具有子視窗的父視窗都需要加入 WS_CLIPCHILDREN 樣式;

        2. 所有子視窗都需要加入 WS_CLIPSIBLINGS 樣式;

        3. 謹慎考慮 CS_HREDRAW 和 CS_VREDRAW,需要根據視窗客戶區繪製的內容來決定;

 有關這些樣式的具體解釋參見 MSDN 或其它相關參考資料。其中只有第三個需要經過考慮,另外兩個幾乎在所有的情況下都是需要遵循的。如果窗體繪製的內容之固定位置的,例如不管視窗大小是什麼只是在 (15, 15) 位置處輸出固定的字串,那就不需要加入這兩個類樣式;但是如果視窗繪製的內容是需要根據視窗的大小不同而不同的,例如需要居中繪製一個字串,那麼就必須加入這兩個樣式。

三、雙緩衝技術

雙緩衝簡而言之就是將繪製同一個畫素的操作都在記憶體中悄悄進行,最後將整個記憶體影象一次性複製到螢幕上,這樣從螢幕的角度來看就是所有的畫素都只繪製了一次。更多有關雙緩衝的介紹參考相關網站資料等,這裡不再詳述。

雙緩衝用於解決同一個視窗中的繪製問題,如 WM_ERASEBKGND 和 WM_PAINT 的處理、Windows 系統控制元件本身 WM_PAINT 的不足。詳細來說就是使用 TextOut() 輸出居中的字串 和 Windows的TabControl控制元件本身的閃爍問題。

首先處理 TextOut() 的閃爍,以下列出相關的程式碼片斷(在主視窗的 WndProc 訊息處理中的程式碼片斷):

case WM_ERASEBKGND:
  return TRUE;            // 不進行擦除背景,在 WM_PAINT 訊息中進行擦除
case WM_PAINT:
  {                       // 為了在 case 子句中宣告區域性變數,加入大括號
  HDC hdc = BeginPaint(hWnd, &ps);
  RECT rect;
  GetClientRect(hWnd, &rect);
  /************************ 雙緩衝程式碼開始 *************************/
  HDC dcBuffer = CreateCompatibleDC(hdc);
  HBITMAP memBM = CreateCompatibleBitmap(hdc, rect.right - rect.left, rect.bottom - rect.top);  // 建立記憶體影象
  SelectObject(dcBuffer, memBM);
  FillRect(dcBuffer, &rect, (HBRUSH)(GetClassLong(hWnd, GCL_HBRBACKGROUND) - 1));  // 擦除背景,WM_ERASEBKGND標準的處理方法,但是這需要在視窗類的宣告中將 wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE+1); 寫入這樣的程式碼
  DrawText(dcBuffer, TEXT("Hello, Windows!"), -1, &rect, DT_CENTER | DT_VCENTER | DT_SINGLELINE);  
  BitBlt(hdc, 0, 0, rect.right - rect.left, rect.bottom - rect.top, dcBuffer, 0, 0, SRCCOPY);  // 複製記憶體影象到螢幕
  DeleteObject(memBM);
  DeleteDC(dcBuffer);
  /************************ 雙緩衝程式碼結束 *************************/
  EndPaint(hWnd, &ps);
  break;
  }


視窗內容的繪製雙緩衝就是這樣的程式碼框架,然後是 Windows 系統控制元件 TabControl,可能是 Windows 在實現上的疏忽,即使父視窗用了 WS_CLIPCHILDREN 的情況下,在 Windows XP、Windows Vista/7 的經典主題樣式 下,TabControl 依然會閃爍,經過一些簡單的分析發現是由於TabControl 本身的 WM_ERASEBKGND 不像其它標準控制元件那樣可以避免閃爍,同時還由於子視窗的 WM_PAINT 訊息處理中也多次繪製了同一個畫素造成了嚴重的閃爍。

因此需要對這一個控制元件進行“特殊照顧”;在寫出程式碼片斷之前,首先需要了解“控制元件子類化”的概念,詳細內容參見 MSDN 或其它相關文件,經典的方法是使用 SetWindowLongPtr() 結合 GWLP_WNDPROC 進行子類化,但在 Windows XP 以後可以用更為簡單的 SetWindowSubclass() 子類化一個控制元件。還需要了解的就是“WM_PRINT、WM_PRINTCLIENT”訊息,它們都允許控制元件/視窗將當前的狀態繪製到一個指定的 HDC 中,而不是 WM_PAINT 中繪製到視窗 DC 中,而且 WM_PRINT 內部在某些情況下會呼叫 WM_PRINTCLIENT 進行繪製,因此我們的程式中只需要使用 WM_PRINT 訊息即可,根據 MSDN 的描述,所有的 Windows 系統控制元件都實現了這兩個訊息。

以下是處理TabControl閃爍的程式碼:

// TabControl 的子類化回撥函式(詳細內容參見 MSDN 的 Subclass Controls 一章)
LRESULT CALLBACK TabCtrlProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR, DWORD_PTR)
{
 HDC hdc, dcBuffer;
 HBITMAP memBM;
 PAINTSTRUCT ps;
 RECT rect;
 switch (uMsg) {
  case WM_ERASEBKGND:
   // 由於預設情況下本訊息中 lParam 傳入的是 0,因此預設情況下不進行繪製,以避免閃爍;但如果傳入的是 TRUE,那麼表示是由以下在處理 WM_PAINT 訊息的程式碼呼叫的,因此需要呼叫預設的繪製程式碼,也就是 break 到 switch 語句之外
   if (!lParam)     
    return TRUE;
   break;
  case WM_PAINT:
   GetClientRect(hWnd, &rect);
   hdc = BeginPaint(hWnd, &ps);
   dcBuffer = CreateCompatibleDC(hdc);
   memBM = CreateCompatibleBitmap(hdc, rect.right - rect.left, rect.bottom - rect.top);
   SelectObject(dcBuffer, memBM);          // 以上雙緩衝程式碼和之前的是一樣的
   SendMessage(hWnd, WM_ERASEBKGND, (WPARAM)(dcBuffer), TRUE);  // 傳送 WM_ERASEBKGND 訊息並傳入 lParam 為 TRUE,wParam 為緩衝的 DC,要求預設處理程式將背景擦除過程應用到 dcBuffer 上,詳細內容參見 MSDN 中關於 WM_ERASEBKGND 訊息的說明
   SendMessage(hWnd, WM_PRINT, (WPARAM)(dcBuffer), PRF_CLIENT | PRF_NONCLIENT);  // 傳送 WM_PRINT,要求控制元件將當前狀態繪製到 dcBuffer,詳細內容參見 MSDN 中關於 WM_PRINT 和 WM_PRINTCLIENT 訊息的說明
   BitBlt(hdc, 0, 0, rect.right - rect.left, rect.bottom - rect.top, dcBuffer, 0, 0, SRCCOPY);   // 複製到螢幕並清理記憶體影象
   DeleteObject(memBM);
   DeleteDC(dcBuffer);
   EndPaint(hWnd, &ps);
   return TRUE;
 }
 return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}
/************************ 在主視窗的 WndProc 過程的訊息處理中的相關程式碼片斷 *************************/
// 需要匯入標頭檔案 #include <commctrl.h>
//              #pragma comment(lib, "ComCtl32.lib")
// 並且需要在 WinMain 開始時使用 INITCOMMONCONTROLSEX icc;
//                            icc.dwSize = sizeof(INITCOMMONCONTROLSEX);
//                            icc.dwICC = ICC_TAB_CLASSES;
//                            InitCommonControlsEx(&icc);
// 這些程式碼來匯入 Common Controls v6.0 的 DLL
case WM_CREATE:
  hTab = CreateWindowEx(0, WC_TABCONTROL, TEXT(""), WS_CHILD | WS_VISIBLE | WS_CLIPCHILDREN, 450, 100, 200, 500, hWnd, NULL, GetModuleHandle(NULL), NULL);
  tabItem.mask = TCIF_TEXT | TCIF_IMAGE;
  tabItem.iImage = -1;
  tabItem.pszText = TEXT("Tab Item 1");
  TabCtrl_InsertItem(hTab, 0, &tabItem);
  tabItem.pszText = TEXT("Tab Item 2");
  TabCtrl_InsertItem(hTab, 1, &tabItem);
  SetWindowSubclass(hTab, TabCtrlProc, 0, 0);
  break;
/************************ 主視窗的 WndProc 過程的訊息處理相關程式碼片斷結束 *************************/


 WM_PRINT 訊息繪製的內容和 WM_PAINT 繪製到視窗 DC 上的內容是幾乎完全一致的,包括滑鼠互動、鍵盤互動、焦點框、顏色變化等,但是之所以說“幾乎”,是因為在處理 Windows Vista/7 的 Aero 主題下的一些動畫過程不會在 WM_PRINT 過程中體現,但是由於 TabControl 在 Aero 主題下也根本沒有動畫效果,因此不會影響。

 四、消除子視窗閃爍

這個方法一般被稱為是一種“錯覺”,即消除閃爍只是看上去的一種錯覺而已,為何稱為“錯覺”在描述完之後再進行分析。該方法主要用於一些透明的窗體,最著名的就是例如 BS_GROUPBOX 樣式的 Button,在所有的標準控制元件中,可以說 GroupBox 的閃爍問題是最為臭名昭著的,也是最難以解決的,因為如果像其它控制元件的父視窗一樣在它的父視窗中加入 WS_CLIPCHILDREN 樣式,那麼它的背景就相當於完全沒有繪製,因為事實上 BS_GROUPBOX 是透明的,具體結果可以自行編碼試驗。

而如果將 WS_EX_TRANSPARENT 樣式加入到 GroupBox 中,由於每次窗體都會繪製背景,所以這又會造成嚴重的閃爍問題。這裡說到的一種解決方案就是在繪製父視窗的背景的時候將這個控制元件的內容也作為背景進行繪製,這樣就算繪製兩次,也是繪製兩次同樣顏色的畫素,而造成閃爍的原因是由於繪製多次不同顏色的畫素(參見第一節),即比如說父視窗 hWnd 有一個子控制元件 hGroup,它是一個 BS_GROUPBOX,然後在 hWnd 的客戶區域除了繪製 TextOut() 的字串以外,在 hGroup 相應位置的背景也需要使用第三節提到的 WM_PRINT 訊息來繪製,即擦除視窗背景的時候就同時繪製了這個控制元件,而視窗背景實用雙緩衝技術避免閃爍的,當然最後控制元件本身再在這個背景的基礎上繪製自身,相當於用同樣的畫素再次覆蓋這個背景;因此即使還是繪製了兩次,但是由於用的是相同顏色的畫素(因為 MSDN 中有提到 Windows 標準控制元件的 WM_PAINT 和 WM_PRINT 訊息繪製的內容是一樣的),因此也就給使用者感覺沒有閃爍。

相關的程式碼片斷如下(主視窗的 WndProc 訊息處理中,程式碼中省略了之前兩節的程式碼,最終可以將它們都合併起來,同時也省略了變數的宣告,其中 hGroup 是全域性變數,其它都是區域性變數,rGroup是RECT型別):

case WM_CREATE:
    hGroup = CreateWindowEx(WS_EX_TRANSPARENT, WC_BUTTON, TEXT("GroupBox"), WS_CHILD | WS_VISIBLE | BS_GROUPBOX | WS_CLIPSIBLINGS,  100, 100, 300, 500, hWnd, NULL, GetModuleHandle(NULL), NULL);
  break;
case WM_CTLCOLORSTATIC:
  return NULL;           // 將 GroupBox 的標題背景也設定為透明,否則將會有一些奇怪的顏色出現
case WM_PAINT:
   GetClientRect(hWnd, &rect);                           // 獲取視窗客戶區矩形
   GetWindowRect(hGroup, &rGroup);                       // 獲取 GroupBox 矩形(螢幕座標)
   ScreenToClient(hWnd, (LPPOINT)(&rGroup.left));        // 將 GroupBox 矩形從螢幕座標轉換成客戶區座標
   hdc = BeginPaint(hWnd, &ps);
   dcBuffer = CreateCompatibleDC(hdc);
   memBM = CreateCompatibleBitmap(hdc, rect.right - rect.left, rect.bottom - rect.top);
   SelectObject(dcBuffer, memBM);                        // 雙緩衝資源建立完畢
   FillRect(dcBuffer, &rect, (HBRUSH)(GetClassLong(hWnd, GCL_HBRBACKGROUND) - 1));      // 繪製視窗背景色
   SetWindowOrgEx(dcBuffer, -rGroup.left, -rGroup.top, NULL);                           // 將座標系原點平移到 GroupBox 左上角
   SendMessage(hGroup, WM_PRINT, (WPARAM)(dcBuffer), PRF_CLIENT | PRF_NONCLIENT);       // 呼叫系統預設的 GroupBox 繪製函式
   SetWindowOrgEx(dcBuffer, 0, 0, NULL);                                                // 將座標系恢復到原來的位置
   BitBlt(hdc, 0, 0, rect.right - rect.left, rect.bottom - rect.top, dcBuffer, 0, 0, SRCCOPY);
   DeleteObject(memBM);                                   // 清理雙緩衝資源
   DeleteDC(dcBuffer);
   EndPaint(hWnd, &ps);
   return TRUE;


 在程式碼中出現的相關 API 都可以參考 MSDN 或相關文件。和第三節提到的關於 Aero 主題下的動畫問題一樣,這個方法如果應用於一些具有不斷動畫的控制元件(如具有鍵盤焦點的按鈕)上時會不盡如人意,但好在 BS_GROUPBOX 沒有動畫,而且所有有關透明的控制元件都沒有動畫。同樣這個方法不僅僅適用於視窗上,也適用於子控制元件上,例如 TabControl。

五、更多解決方案

除了上面提到的幾種方法以外,還有很多其它的解決方案,有些更為徹底,而有些有稍許的BUG。

首先當然就是 Windows XP 引入的 WS_EX_COMPOSITED 樣式,這個樣式在 Windows 內部為你進行整個視窗(包括客戶繪製和所有的子孫視窗)的雙緩衝,然後一次性顯示到螢幕上,在 Windows XP、Vista/7 以及它們的經典主題中都可以正確使用,而且閃爍效果能夠得到完美的消除,效能也不錯。但只有一個缺點,那就是在 Windows XP 主題,或 Win Vista/7 的 Aero Basic 主題下,標題欄的按鈕不會有高亮效果,這個算是它的一個 BUG。而在 Windows Vista/7 的普通 Aero 主題(即開啟透明效果)中,由於非客戶區是交給 DWM 進行繪製的,因此不會影響,可以說在開啟透明效果 Aero 主題下這個樣式可以做到完美解決,而且非常的簡便,只需要將父視窗的擴充套件樣式新增WS_EX_COMPOSITED 即可(至今沒有見到過有商業程式使用這個樣式來避免閃爍)。

第二種方法也是利用 Windows XP 引入的 WS_EX_LAYERED 樣式,這個樣式是用於設定視窗透明度或透明顏色掩碼的,但是如果應用了這個樣式,並且透明度設定到 254(255 表示完全不透明),這樣使用者察覺不到有那麼大約0.5%不到的透明度,同時在內部 Windows 也會將其作為雙緩衝處理,並且在所有主題下,非客戶區的按鈕(最小化、最大化、關閉按鈕等)都能正確地進行互動,而不像 WS_EX_COMPOSITED 樣式在某些主題下非客戶區按鈕在滑鼠移動上去時沒有高亮效果。這個方法的缺點就是效能很低,繪製效果非常慢(沒有見過有使用這個樣式來避免閃爍而不是處理透明的商業程式例項)。

第三種方法就是使用其它的庫,例如 WPF,它內部使用了無 HWND 的技術,整個窗體的子控制元件都沒有 HWND,這樣整個窗體的繪製工作就能由主窗體全權掌管,自然也就完全不閃爍了(例如 Visual Studio 2010)。

第四種方法,就是要麼忽略不計,允許視窗有煩人的閃爍問題,並期待今後的作業系統有更完美的解決方案,而事實上 Windows Vista/7 在避免閃爍的問題上確實做了不少很有成效的努力,但仍不夠完美(例如 Visual Studio 2008 的查詢對話方塊);要麼就將視窗設定為不能調整大小,也能完全避免閃爍(例如 Office Word 等許多 Windows 附帶程式的對話方塊)。

六、應用

利用第一節到第四節的一些功能,在 Windows 環境下的絕大多數閃爍問題都可以完美解決,但是也並不是所有的環境中都需要使用這個技術。例如在一些無法調整大小的對話方塊中,根本就無需考慮閃爍問題,因為根本就不會發生由於調整大小而產生的閃爍。

對於 BS_GROUPBOX,需要使用第四節的方法,對於 TabControl 和視窗背景的繪製需要使用第三節的方法,對於所有其它的標準空間只需要在父視窗中包含 WS_CLIPCHILDREN 樣式即可解決。

下圖就是使用以上方法繪製的一些控制元件和視窗背景,可以看到在處理 TabControl 時的一些問題,如 Button 按鈕的背景周圍有一圈藍色,這是因為它的父視窗設定成為了主視窗的緣故,而如果把按鈕的父視窗設定成為 TabControl,則背景就正常了,成為 Button2。GroupBox的父視窗也設定成為了 TabControl,這個程式在 XP 的 Luna 主題下、Win Vista/7 的 Aero 主題下,或任何 Windows 經典主題下到表現良好(右下角的調整框也是繪製在視窗背景中的,呼叫了相關的 Theme API,並且處理了 WM_NCHITTEST 訊息,參考 MSDN)。



 七、後續工作

這裡只解決了自己註冊視窗類的一些視窗問題,而沒有處理到對話方塊,但原理都是一樣的。這裡處理 TabControl 的方法是自己建立控制元件並進行子類化,並且把 TabControl 作為父視窗來承載它的子視窗,但在Windows中更為通用的辦法是使用一些對話方塊單獨編輯每一頁的控制元件集合,並且和 TabControl 之間並不是父子關係,而是兄弟關係(Siblings),這一點在以後的文章中會討論到。