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

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





原作者:Ultraman_King    2011.8

原文章地址: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),這一點在以後的文章中會討論到。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章