眼見為實(2):介紹Windows的視窗、訊息、子類化和超類化

來源:互聯網
上載者:User
眼見為實(2):介紹Windows的視窗、訊息、子類化和超類化

這篇文章本來只是想介紹一下子類化和超類化這兩個比較“生僻”的名詞。為了敘述的完整性而討論了Windows的視窗和訊息,也簡要討論了進程和線程。子類化(Subclassing)和超類化(Superclassing)是伴隨Windows視窗機制而產生的兩個複用代碼的方法。不要把“子類化、超類化”與物件導向語言中的衍生類別、基類混淆起來。“子類化、超類化”中的“類”是指Windows的視窗類別。

0 運行程式

希望讀者在閱讀本節前先看看"談談Windows程式中的字元編碼"開頭的第0節和附錄0。第0節介紹了Windows系統的幾個重要模組。附錄0概述了Windows的啟動過程,從上電到啟動Explorer.exe。本節介紹的是運行程式時發生的事情。

0.1 程式的啟動

當我們通過Explorer.exe運行一個程式時,Explorer.exe會調用CreateProcess函數請求系統為這個程式建立進程。當然,其它程式也可以調用CreateProcess函數建立進程。

系統在為進程分配內部資源,建立獨立的地址空間後,會為進程建立一個主線程。我們可以把進程看作單位,把線程看作員工。進程擁有資源,但真正在CPU上運行和調度的是線程。系統以掛起狀態建立主線程,即主線程建立好,不會立即運行,而是等待系統調度。系統向Win32子系統的管理員csrss.exe登記新建立的進程和線程。登記結束後,系統通知掛起的主線程可以運行,新程式才開始運行。

這時,在建立進程中CreateProcess函數返回;在被建立進程中,主線程在完成最後的初始化後進入程式的入口函數(Entry-point)。建立進程與被建立進程在各自的地址空間獨立運行。這時,即使我們結束建立進程,也不會影響被建立進程。

0.2 程式的執行

可執行檔(PE檔案)的檔案頭結構包含入口函數的地址。入口函數一般是Windows在執行階段程式庫中提供的,我們在編譯時間可以根據程式類型設定。在VC中編譯、運行程式的小知識點討論了Entry-point,讀者可以參考。

入口函數前的過程可以被看作程式的裝載過程。在裝載時,系統已經做過全域和靜態變數(在編譯時間可以確定地址)的初始化,有初值的全域變數擁有了它們的初值,沒有初值的變數被設為0,我們可以在入口函數處設定斷點確認這一點。

進入入口函數後,程式繼續運行環境的建立,例如調用所有全域對象的建構函式。在一切就緒後,程式調用我們提供的主函數。主函數名是入口函數決定的,例如main或WinMain。如果我們沒有提供入口函數要求的主函數,編譯時間就會產生連結錯誤。

0.3 進程和線程

我們通常把儲存介質(例如硬碟)上的可執行檔稱作程式。程式被裝載、運行後就成為進程。系統會為每個進程建立一個主線程,主線程通過入口函數進入我們提供的主函數。我們可以在程式中建立其它線程。

線程可以建立一個或多個視窗,也可以不建立視窗。系統會為有視窗的線程建立訊息佇列。有訊息佇列的線程就可以接收訊息,例如我們可以用PostThreadMessage函數向線程發送訊息。

沒有視窗的線程只要調用了PeekMessage或GetMessage,系統也會為它建立訊息佇列。

1 視窗和訊息1.1 線程的訊息佇列

每個啟動並執行程式就是一個進程。每個進程有一個或多個線程。有的線程沒有視窗,有的線程有一個或多個視窗。

我們可以向線程發送訊息,但大多數訊息都是發給視窗的。發給視窗的訊息同樣放線上程的訊息佇列中。我們可以把線程的訊息佇列看作信箱,把視窗看作收信人。我們在向指定視窗發送訊息時,系統會找到該視窗所屬的線程,然後把訊息放到該線程的訊息佇列中。

線程訊息佇列是系統內部的資料結構,我們在程式中看不到這個結構。但我們可以通過Windows的API向訊息佇列發送、投遞訊息;從訊息佇列接收訊息;轉換和指派接收到的訊息。

1.2 最小的Windows程式

Windows的程式員大概都看過這麼一個最小的Windows程式:

// 常式1
#include "windows.h"
static const char m_szName[] = "視窗";
//////////////////////////////////////////////////////////////////////////////////////////////////// 
// 主視窗回呼函數 如果直接用 DefWindowProc, 關閉視窗時不會結束訊息迴圈
static LRESULT CALLBACK WindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{ 
switch (uMsg) {
case WM_DESTROY:
PostQuitMessage(0);// 關閉視窗時發送WM_QUIT訊息結束訊息迴圈
break;
default:
return DefWindowProc(hWnd, uMsg, wParam, lParam);
} 
return 0;
}
//////////////////////////////////////////////////////////////////////////////////////////////////// 
// 主函數 
int __stdcall WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance,LPSTR lpCmdLine, int nCmdShow)
{
WNDCLASS wc; 
memset(&wc, 0, sizeof(WNDCLASS));
wc.style = CS_VREDRAW|CS_HREDRAW;
wc.lpfnWndProc = (WNDPROC)WindowProc;
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.hbrBackground = (HBRUSH)(COLOR_WINDOW);
wc.lpszClassName = m_szName;
RegisterClass(&wc);// 登記視窗類別
 
HWND hWnd;
hWnd = CreateWindow(m_szName,m_szName,WS_OVERLAPPEDWINDOW,100,100,320,240,
NULL,NULL,hInstance,NULL);// 建立視窗
ShowWindow(hWnd, nCmdShow);// 顯示視窗
 
MSG sMsg; 
while (int ret=GetMessage(&sMsg, NULL, 0, 0)) {// 訊息迴圈
if (ret != -1) {
TranslateMessage(&sMsg);
DispatchMessage(&sMsg);
}
}
return 0;
}

這個程式雖然只顯示一個視窗,但經常被用來說明Windows程式的基本結構。在MFC架構內部我們同樣可以找到類似的程式結構。這個程式包含以下基本概念:

  • 視窗類別、視窗和視窗過程
  • 訊息迴圈

下面分別介紹。

1.3 視窗類別、視窗和視窗過程

建立視窗時要提供視窗類別的名字。視窗類別相當於視窗的模板,我們可以基於同一個視窗類別建立多個視窗。我們可以使用Windows預先登記好的視窗類別。但在更多的情況下,我們要登記自己的視窗類別。在登記視窗類別時,我們要登記名稱、風格、表徵圖、游標、菜單等項,其中最重要的就是視窗過程的地址。

視窗過程是一個函數。視窗收到的所有訊息都會被送到這個函數處理。那麼,發到線程訊息佇列的訊息是怎麼被送到視窗的呢?

1.4 訊息迴圈

熟悉嵌入式多任務程式的程式員,都知道任務(相當於Windows的線程)的結構基本上都是:

while (1) {
等待訊號;
處理訊號;
}

任務收到訊號就處理,否則就掛起,讓其它任務運行。這就是訊息驅動程式的基本結構。Windows程式通常也是這樣:

while (int ret=GetMessage(&sMsg, NULL, 0, 0)) {// 訊息迴圈
if (ret != -1) {
TranslateMessage(&sMsg);
DispatchMessage(&sMsg);
}
}

GetMessage從訊息佇列接收訊息;TranslateMessage根據按鍵產生WM_CHAR訊息,放入訊息佇列;DispatchMessage根據訊息中的視窗控制代碼將訊息分發到視窗,即調用視窗過程函數處理訊息。

1.5 通過訊息通訊

建立視窗的函數會返回一個視窗控制代碼。視窗控制代碼在系統範圍內(不是進程範圍)標識一個唯一的視窗執行個體。通過向視窗發送訊息,我們可以實現進程內和進程間的通訊。

我們可以用SendMessage或PostMessage向視窗發送或投遞訊息。SendMessage必須等到目標視窗處理過訊息才會返回。我試過:如果向一個沒有訊息迴圈的視窗SendMessage,SendMessage函數永遠不會返回。PostMessage在把訊息放入線程的訊息佇列後立即返回。

其實只有投遞的訊息才是通過DispatchMessage指派到視窗過程的。通過SendMessage發送的訊息,線上程GetMessage時,就已經被指派到視窗過程了,不經過DispatchMessage。

1.5.1 視窗程序與控制台程式的通訊執行個體

大家是不是覺得“常式1”沒什麼意思,讓我們用它來做個小遊戲:讓“常式1”和一個控制台程式做一次親密接觸。我們首先將“常式1”的視窗過程修改為:

static LRESULT CALLBACK WindowProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
static DWORD tid = 0;
switch (uMsg) {
case WM_DESTROY:
PostQuitMessage(0);// 關閉視窗時發送WM_QUIT訊息結束訊息迴圈
break;
case WM_USER:
tid = wParam;// 儲存控制台程式的線程ID
SetWindowText(hWnd, "收到");
break;
case WM_CHAR:
if (tid) {
switch(wParam) {
case '1':
PostThreadMessage(tid, WM_USER+1, 0, 0);// 向控制台程式發送訊息1
break;
case '2':
PostThreadMessage(tid, WM_USER+2, 0, 0);// 向控制台程式發送訊息2
break;
}}
break;
default:
return DefWindowProc(hWnd, uMsg, wParam, lParam);
} 
return 0;
}

然後,我們建立一個控制台程式,代碼如下:

#include "windows.h"
#include "stdio.h"
static HWND m_hWnd = 0;
void process_msg(UINT msg, WPARAM wp, LPARAM lp)
{
char buf[100];
static int i = 1;
if (!m_hWnd) {
return;
}
switch (msg) {
case WM_USER+1:
SendMessage(m_hWnd, WM_GETTEXT, sizeof(buf), (LPARAM)buf);
printf("你現在叫:%s/n/n", buf);// 讀取、顯示對方的名字
break;
 
case WM_USER+2:
sprintf(buf, "我是視窗%d", i++);
SendMessage(m_hWnd, WM_SETTEXT, sizeof(buf), (LPARAM)buf);// 修改對方名字
printf("給你改名/n/n");
break;
}
}
 
int main()
{
MSG sMsg;
printf("Start with thread id %d/n", GetCurrentThreadId());
m_hWnd = FindWindow(NULL,"視窗");
if (m_hWnd) {
printf("找到視窗%x/n/n", m_hWnd);
SendMessage(m_hWnd, WM_USER, GetCurrentThreadId(), 0);
}
else {
printf("沒有找到視窗/n/n");
}
 
while (int ret=GetMessage(&sMsg, NULL, 0, 0)) {// 訊息迴圈
if (ret != -1) {
process_msg(sMsg.message, sMsg.wParam, sMsg.lParam);
}
}
return 0;
}

大家能看懂這遊戲怎麼玩嗎?首先運行“常式1”wnd,然後運行控制台程式msg。msg會找到wnd的視窗,並將自己的主線程ID發給wnd。wnd收到msg的訊息後,會顯示收到。這時,wnd和msg已經建立了通訊的渠道:wnd可以向msg的主線程發訊息,msg可以向wnd的視窗發訊息。

我們如果在wnd視窗按下鍵'1',wnd會向msg發送訊息1,msg收到後會通過WM_GETTEXT訊息獲得wnd的視窗名稱並顯示。我們如果在wnd視窗按下鍵'2',wnd會向msg發送訊息2,msg收到後會通過WM_SETTEXT訊息修改wnd的視窗名稱。

這個小例子示範了控制台程式的訊息迴圈,向線程發訊息,以及進程間的訊息通訊。

1.5.2 地址空間的問題

不同的進程擁有獨立的地址空間,如果我們在訊息參數中包含一個進程A的地址,然後發送到進程B。進程B如果在自己的地址空間裡操作這個地址,就會發生錯誤。那麼,為什麼上例中的WM_GETTEXT和WM_SETEXT可以正常工作?

這是因為WM_GETTEXT和WM_SETEXT都是Windows自己定義的訊息,Windows知道參數的含義,並作了特殊的處理,即在進程B的空間分配一塊記憶體作為中轉,並在進程A和進程B的緩衝區之間複製資料。例如:在1.5.1節的例子中,如果我們設定斷點觀察,就會發現msg發送的WM_SETTEXT訊息中的lParam不等於wnd接收到的WM_SETTEXT訊息中的lParam。

如果我們在自己定義的訊息中傳遞記憶體位址,系統不會做任何特殊處理,所以必然發生錯誤。

Windows提供了WM_COPYDATA訊息用來向視窗傳遞資料,Windows同樣會為這個訊息作特殊處理。

在進程間發送這些需要額外分配記憶體的訊息時,我們應該用SendMessage,而不是PostMessage。因為SendMessage會等待接收方處理完後再返回,這樣系統才有機會額外釋放分配的記憶體。在這種場合使用PostMessage,系統會忽略要求投遞的訊息,讀者可以在msg程式中實驗一下。

2 子類化和超類化

視窗類別是視窗的模板,視窗是視窗類別的執行個體。視窗類別和每個視窗執行個體都有自己的內部資料結構。Windows雖然沒有公開這些資料結構,但提供了讀寫這些資料的API。

例如:用GetClassLong和SetClassLong函數可以讀寫視窗類別的資料;用GetWindowLong和SetWindowLong可以讀寫指定視窗執行個體的資料。使用這些介面,可以在運行時讀取或修改視窗類別或視窗執行個體的視窗過程地址。這些介面是子類化的實現基礎。

2.1 子類化

子類化的目的是在不修改現有代碼的前提下,擴充現有視窗的功能。它的思路很簡單,就是將視窗過程地址修改為一個新函數地址,新的視窗過程函數處理自己感興趣的訊息,將其它訊息傳遞給原視窗過程。通過子類化,我們不需要現有視窗的原始碼,就可以定製視窗功能。

子類化可以分為執行個體子類化和全域子類化。執行個體子類化就是修改視窗執行個體的視窗過程地址,全域子類化就是修改視窗類別的視窗過程地址。執行個體子類化隻影響被修改的視窗。全域子類化會影響在修改之後,按照該視窗類別建立的所有視窗。顯然,全域子類化不會影響修改前已經建立的視窗。

子類化方法雖然是二十年前的概念,卻很好地實踐了物件導向技術的開閉原則(OCP:The Open-Closed Principle):對擴充開放,對修改關閉。

2.2 超類化

超類化的概念更簡單,就是讀取現有視窗類別的資料,儲存視窗過程函數地址。對視窗類別資料作必要的修改,設定新視窗過程,再換一個名稱後登記一個新視窗類別。新視窗類別的視窗過程函數還是僅處理自己感興趣的訊息,而將其它訊息傳遞給原視窗過程函數處理。使用GetClassInfo函數可以讀取現有視窗類別的資料。

3 MFC中的訊息迴圈和子類化

MFC將子類化方法應用得淋漓盡致,是一個不錯的例子。候捷先生的《深入淺出MFC》已經將MFC的主要架構分析得很透徹了,本節只是看看MFC的訊息迴圈,簡單分析MFC對子類化的應用。

3.1 訊息迴圈

隨便建立一個MFC單文檔程式,在視圖類中添加WM_RBUTTONDOWN的處理函數,並在該處理函數中設定斷點。運行,斷下後,查看呼叫堆疊:

CHelloView::OnRButtonDown(unsigned int, CPoint)
CWnd::OnWndMsg(unsigned int, unsigned int, long, long *)
CWnd::WindowProc(unsigned int, unsigned int, long)
AfxCallWndProc(CWnd *, HWND__ *, unsigned int, unsigned int, long)
AfxWndProc(HWND__ *, unsigned int, unsigned int, long)
AfxWndProcBase(HWND__ *, unsigned int, unsigned int, long)
USER32! 7e418734()
USER32! 7e418816()
USER32! 7e4189cd()
USER32! 7e4196c7()
CWinThread::PumpMessage()
CWinThread::Run()
CWinApp::Run()
AfxWinMain(HINSTANCE__ *, HINSTANCE__ *, char *, int)
WinMain(HINSTANCE__ *, HINSTANCE__ *, char *, int)
WinMainCRTStartup()
KERNEL32! 7c816fd7() 

WinMainCRTStartup是這個程式的入口函數。候捷先生已經詳細介紹過AfxWinMain。我們就看看CWinThread::PumpMessage中的訊息迴圈:

BOOL CWinThread::PumpMessage()
{
if (!::GetMessage(&m_msgCur, NULL, NULL, NULL)) {
return FALSE;
}
if (m_msgCur.message != WM_KICKIDLE && !PreTranslateMessage(&m_msgCur))
{
::TranslateMessage(&m_msgCur);
::DispatchMessage(&m_msgCur);
}
return TRUE;
}

這就是MFC程式主線程中的訊息迴圈,它把發送到線程訊息佇列的訊息指派到線程的視窗。

3.2 子類化

CWnd::CreateEx在建立視窗前調用SetWindowsHookEx函數安裝了一個鉤子函數_AfxCbtFilterHook。視窗剛建立好,鉤子函數_AfxCbtFilterHook就被調用。_AfxCbtFilterHook調用SetWindowLong將視窗過程替換為AfxWndProcBase,並將SetWindowLong返回的原視窗地址儲存到成員變數oldWndProc。上節呼叫堆疊中的AfxWndProcBase就是由此而來。

可見,通過CWnd::CreateEx建立的所有視窗都會被子類化,即它們的視窗過程都會被替換為AfxWndProcBase。MFC為什麼要這樣做?

讓我們再看看呼叫堆疊中的CWnd::WindowProc函數:

LRESULT CWnd::WindowProc(UINT message, WPARAM wParam, LPARAM lParam)
{
LRESULT lResult = 0;
if (!OnWndMsg(message, wParam, lParam, &lResult))
lResult = DefWindowProc(message, wParam, lParam);
return lResult;
}

按照侯捷先生的介紹,CWnd::OnWndMsg就是“MFC訊息泵”的入口,訊息通過這個入口流入MFC訊息映射中的訊息處理函數。訊息泵只會處理我們定製過的訊息,我們沒有添加過處理的訊息會原封不動地流過"訊息泵",進入DefWindowProc函數。在DefWindowProc函數中,訊息會傳給子類化時儲存的原視窗地址oldWndProc。

CWnd::CreateEx裡的鉤子會子類化所有視窗嗎?其實不盡然。的確,MFC所有視窗相關的類都是從CWnd派生的,這些類的執行個體在建立視窗時都會調用CWnd::CreateEx,都會被子類化。但是,通過對話方塊模板建立的視窗是通過CreateDlgIndirect建立的,不經過CWnd::CreateEx函數。

但這點其實也不是問題,因為如果我們想通過MFC定製一個控制項的訊息映射,就必須先子類化這個控制項,MFC還是有機會將視窗過程替換成自己的AfxWndProcBase。下一節將介紹對話方塊控制項的子類化。

4 子類化和超類化的例子

我寫了一個很簡單的對話方塊程式,用來示範子類化和超類化。這個對話方塊程式有兩個編輯框,我將編輯框的右鍵菜單換成了一個訊息框。兩個編輯框的定製分別採用了子類化和超類化技術:

4.1 子類化的例子

首先從CEdit派生出CMyEdit1,定製WM_RBUTTONDOWN的處理。很多文章都建議我們在對話方塊的OnInitDialog中用SubclassDlgItem實現子類化:

m_edit1.SubclassDlgItem(IDC_EDIT1, this);

這樣做當然可以。其實如果我們已經為IDC_EDIT1添加過CMyEdit1對象:

void CSubclassingDlg::DoDataExchange(CDataExchange* pDX)
{
 CDialog::DoDataExchange(pDX);
 //{{AFX_DATA_MAP(CSubclassingDlg)
 DDX_Control(pDX, IDC_EDIT1, m_edit1);
 //}}AFX_DATA_MAP
}

DDX_Control會自動幫我們完成子類化,沒有必要手工調用SubclassDlgItem。大家可以通過在PreSubclassWindow中設定斷點看看。

通過DDX_Control或者SubclassDlgItem子類化控制項的效果是一樣的,MFC都是把視窗過程替換成AfxWndProcBase。使用者添加過處理函數的訊息通過MFC訊息泵流入使用者的處理函數。

4.2 必經之路:PreSubclassWindow

PreSubclassWindow是一個很好的定製控制項的位置。如果我們通過重載CWnd::PreCreateWindow定製控制項,而使用者在對話方塊中使用控制項。由於對話方塊中的控制項視窗是通過CreateDlgIndirect建立,不經過CWnd::CreateEx函數,PreCreateWindow函數不會被調用。

其實,使用者要在對話方塊中使用定製控制項,必須用DDX或者SubclassDlgItem函數子類化控制項,這時PreSubclassWindow一定會被調用。

如果使用者直接建立定製控制項視窗,CWnd::CreateEx函數就一定會被調用,控制項視窗一定會被子類化以安裝MFC訊息泵。所以在MFC中,PreSubclassWindow是建立視窗的必經之路。

4.3 超類化的例子

我很少看到超類化的例子(除了羅雲彬的Win32彙編),在大多數應用中,子類化技術已經足夠了。但我還是寫了一個例子:CMyEdit2從CEdit派生。CMyEdit2::RegisterMe擷取視窗類別Edit的資訊,儲存原視窗過程,設定新視窗過程MyWndProc和新名稱MyEdit,登記一個新視窗類別。新視窗過程MyWndProc定製自己需要處理的訊息,將其它訊息送回原視窗過程。

我在對話方塊的OnInitDialog中先調用CMyEdit2::RegisterMe登記新視窗類別,然後建立視窗。這樣建立視窗必須經過CWnd::CreateEx,所以MFC還是會把視窗過程換成AfxWndProcBase。沒有被MFC訊息映射攔截的訊息才會流入MyWndProc。

5 結束語

這篇文章介紹了一些Windows和MFC的基礎知識。寫這篇文章的目的不是介紹什麼編程技巧,而是讓我們更瞭解程式運行時發生的事情。惟有深入其中,方能跳出其外,不受羈絆。

 

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.