本文章是由機器翻譯。

Windows 與 C++

使用 Visual C++ 2012 建立傳統型應用程式

KennyKerr

 

Kenny Kerr
隨著對 Windows 8 的大力宣傳和 Windows 應用商店的應用現在越來越為大家所知,很多人向我提出關於桌面應用程式相關性和 Standard C++ 今後是否仍是一個可靠選擇的問題。這些問題有時很難回答,但是我可以告訴您的是,Visual C++ 2012 編譯器現在對於 Standard C++ 比以往任何時候都要重視。依本人愚見,無論針對的是 Windows 7、Windows 8 還是Windows XP,它仍然是構建美妙 Windows 桌面應用程式的最佳工具。

隨後不可避免要面對的問題是,在 Windows 中進行桌面應用程式開發有什麼好處,應該從哪裡入手。在本月的專欄中,我將探討使用 Visual C++ 創建桌面應用程式的基本知識。當JeffProsise 第一次向我介紹 Windows 程式設計時,(bit.ly/WmoRuR),Microsoft Foundation Classes (MFC) 作為一種構建應用程式的新方式非常被大家看好。儘管 MFC 仍然可用,它實際上已近暮年,對現代、靈活的替代工具的需要已驅使程式師們尋求新的方式。從 USER 和GDI(msdn.com/library/ms724515) 資源轉向 Direct3D 並以其作為螢幕內容呈現的主要基礎使這個問題變得更加複雜。

多年來,我一直提倡使用 Active Template Library (ATL) 及其擴展 Windows Template Library (WTL) 作為構建應用程式的理想選擇。但是,即便這些庫現在也呈現老態。隨著對 USER 和GDI資源的放棄,使用它們的理由更少了。那麼從何處著手呢?當然要從 Windows API 著手。我將向您展示,不使用任何庫創建桌面視窗實際並非最初想像的那樣困難。隨後將向您展示,如何利用 ATL 和 WTL 帶來的小小説明讓它變得更具 C++ 色彩,如果您喜歡這樣做的話。如果充分瞭解範本和宏之後的工作原理,您會發現使用 ATL 和 WTL 是非常明智的。

The Windows API

用 Windows API 創建桌面視窗的問題是,編寫代碼時可以採用的方式太多了 — 確實有太多的選擇。還有一個非常直接的視窗創建方式,是從 Windows 主包含檔入手:

#include <windows.h>

然後可以定義標準的應用程式進入點:

int __stdcall wWinMain(HINSTANCE module, HINSTANCE, PWSTR, int)

如果要編寫主控台應用程式,則完全可以繼續使用標準 C++ 主進入點函數,但我假設您不希望在每次應用程式啟動時都彈出主控台框。 wWinMain 函數歷史悠久。 __stdcall 調用約定解決了困擾 x86 體系結構的問題,從而提供了少量的調用約定。 如果針對的是x64或 ARM,則不會有問題,因為 Visual C++ 編譯器只在這些體系結構中實現一個調用約定 — 但這樣做也不會產生什麼妨礙。

這兩個 HINSTANCE 參數在過去尤其神秘。 在使用 16 位 Windows 的年代,第二個 HINSTANCE 是應用程式以前所有實例的控制碼。 這使得應用程式可以和自身以前的所有實例進行通信,當使用者不小心再次啟動它時,甚至可以切換回以前的實例。 現在這第二個參數始終是 nullptr。 You may also have noticed that I named the first parameter “module” rather than “instance.” Again, in 16-bit Windows, instances and modules were two separate things. 所有應用程式共用包含程式碼片段的模組,但是得到的包含資料段的實例都是唯一的。 現在,當前和之前的 HINSTANCE 參數應該更加合理。 32 位 Windows 引入了單獨的位址空間,因此,以前每個進程都需要映射自己的實例/模組,現在變成映射同一個實例/模組。 現在,這只是可執行代碼的基址。 Visual C++ 連結器實際上通過一個偽變數公開該位址,您可以通過對它進行聲明來訪問該位址,如下所示:

extern "C" IMAGE_DOS_HEADER __ImageBase;

__ImageBase 的位址的值與 HINSTANCE 參數相同。 事實上,C 運行時庫 (CRT) 就通過這種方式獲取在第一時間傳遞給 wWinMain 函數的模組位址。 如果不希望將此 wWinMain 參數傳遞給應用程式,這是一種非常方便的快捷方式。 但是請注意,此變數指向當前模組,無論它是 DLL 還是可執行程式。因此對於明確地載入模組特定資源非常有用。

下一個參數提供任何命令列實參,最後一個參數是應傳遞給應用程式主視窗的 ShowWindow 函數(假設最開始是調用 ShowWindow)。 具有諷刺意味的是,它幾乎總是被忽略。 究其原因在於應用程式的啟動方式,即通過 CreateProcess 和友好地允許使用快速鍵 — 或其他某個應用程式 — 定義應用程式的主視窗最開始是最小化、最大化或正常顯示。

在 wWinMain 函數內,應用程式需要註冊一個視窗類。 視窗類用 WNDCLASS 結構描述,並通過 RegisterClass 函數註冊。 此註冊資訊用模組指標和類名組成的一對資料存儲在表中,使得在創建視窗時 CreateWindow 函數能夠查詢類資訊:

WNDCLASS wc = {};
wc.hCursor = LoadCursor(nullptr, IDC_ARROW);
wc.hInstance = module;
wc.lpszClassName = L"window";
wc.lpfnWndProc = []
 (HWND window, UINT message, WPARAM wparam, 
    LPARAM lparam) -> LRESULT
{
  ...
};
VERIFY(RegisterClass(&wc));

為了讓示例保持簡潔,我只使用常用的 VERIFY 宏作為預留位置,指示需要在何處添加一些錯誤處理,以管理由各種 API 函數報告的任何問題。 在確定首選錯誤處理策略時,只需將它們視為預留位置。

先前的代碼是描述標準視窗所需的最少代碼。 WNDCLASS 結構通過一對空的大括弧進行初始化。 以確保所有結構的成員都初始化為零或 nullptr。 唯一必須設置的成員是 hCursor,它指示滑鼠放在視窗上時使用何種滑鼠指標或游標;hInstance 和 lpszClassName 確定進程中的視窗類;lpfnWndProc 指向用來處理視窗接收到的消息的視窗過程。 在本示例中,可以說,我使用 lambda 運算式使一切保持內聯。 稍後我再介紹視窗過程。 接下來創建視窗:

VERIFY(CreateWindow(wc.lpszClassName, L"Title",
  WS_OVERLAPPEDWINDOW | WS_VISIBLE,
  CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
  nullptr, nullptr, module, nullptr));

CreateWindow 函數需要的參數較多,但其中大多數是預設值。 如前所述,第一個和倒數第二個參數表示 RegisterClass 函數創建的表項,以供 CreateWindow 查找視窗類資訊。 第二個參數指示將在視窗的標題列中顯示的文本。 第三個指示視窗的樣式。 WS_OVERLAPPEDWINDOW 常量是一種常用的樣式,它描述常見的頂層視窗,視窗帶標題列、按鈕、大小可調的邊框等等。 將該常量與 WS_VISIBLE 常量組合可以指示 CreateWindow 進一步顯示視窗。 如果省略 WS_VISIBLE,則需要調用 ShowWindow 函數才能使視窗首次出現在桌面上。

後面四個參數指示視窗的初始位置和大小,每一種情形中使用的 CW_USEDEFAULT 常量都只是告訴 Windows 選擇合適的預設值。 再後面兩個參數分別為視窗的父視窗和功能表提供控制碼(這兩個參數都不需要)。 最後一個參數提供的選項是在創建時向視窗過程傳遞指標大小的值。 如果一切順利,視窗會出現在桌面上,並返回一個視窗控制碼。 如果有問題,則返回 nullptr,此時需要調用 GetLastError 函數查找原因。 儘管大家都說 Windows API 不好用,但事實證明創建視窗實際非常簡單,其原因如下:

WNDCLASS wc = { ...
};
RegisterClass(&wc);
CreateWindow( ...
);

視窗出現後,重要的是應用程式儘快開始調度消息,否則應用程式看起來會毫無回應。 Windows 根本上是一個事件驅動、基於消息的作業系統。 對於桌面尤其如此。 Windows 負責創建和管理訊息佇列,應用程式負責清除佇列和調度消息,因為消息是發送給視窗的執行緒而不是直接發送給視窗。 這樣能夠提供很大的靈活性,但是簡單的消息迴圈不需要太過複雜,如下所示:

MSG message;
BOOL result;
while (result = GetMessage(&message, 0, 0, 0))
{
  if (-1 != result)
  {
    DispatchMessage(&message);
  }
}

或許並不令人意外,這個看似簡單的消息迴圈常常無法正確地實現。 根本原因在於,GetMessage 函數的原型是返回一個 BOOL 值,但實際上它是一個 int 值。 GetMessage 清除佇列,即從調用執行緒的訊息佇列檢索消息。 這可用於任何視窗或不用於任何視窗,但是在我們的例子中,執行緒只為單個視窗發送消息。 如果取消 WM_QUIT 消息的排隊,GetMessage 會返回零,指示視窗已消失並完成消息處理,應用程式應該終止。 如果問題非常嚴重,GetMessage 可能返回 -1,此時可以再次調用 GetLastError 獲取更多資訊。 否則,GetMessage 返回的任何非零值都指示消息已清除佇列,並準備發送給視窗。 當然這是 DispatchMessage 函數的用途。 當然,消息迴圈有許多不同形式。您能夠創建自己的消息迴圈,從而為應用程式的行為方式、它能接受何種輸入和轉換方式提供了多種選擇。 除了 MSG 指標外,GetMessage 的其餘參數可用來有選擇地篩選消息。

視窗過程甚至在 CreateWindow 返回前就開始接收消息,因此,它最好做好準備並等待。 但具體情形怎樣呢? 視窗需要使用消息映射或表。 其形式可能是視窗過程內一系列的 if-else 語句或一個大的 switch 語句。 這很快會變得非常臃腫,我們在不同的庫和框架上做了很多工作,試著對此進行一定的管理。 實際上它未必如此困難,多數情況下一個簡單的靜態表就足夠了。 首先,瞭解視窗消息的組成非常有説明。 最重要的是,可以用常量(如 WM_PAINT 或 WM_SIZE)對消息進行唯一標識。 可以說,為每條消息提供兩個實參,這兩個實參分別稱為 WPARAM 和 LPARAM。 根據具體的消息,它們可能不會提供任何資訊。 最後,Windows 需要某些消息的處理返回一個值,我們稱它為 LRESULT。 但是,應用程式處理的大多數消息都不傳回值,而應返回零。

根據此定義,我們可以將這些類型用作構建基塊來構建簡單的消息處理表:

typedef LRESULT (* message_callback)(HWND, WPARAM, LPARAM);
struct message_handler
{
  UINT message;
  message_callback handler;
};

最起碼我們隨後可以創建一個靜態訊息處理常式表,如圖 1 所示。

圖 1 靜態訊息處理常式表

static message_handler s_handlers[] =
{
  {
    WM_PAINT, [] (HWND window, WPARAM, LPARAM) -> LRESULT
    {
      PAINTSTRUCT ps;
      VERIFY(BeginPaint(window, &ps));
      // Dress up some pixels here!
EndPaint(window, &ps);
      return 0;
    }
  },
  {
    WM_DESTROY, [] (HWND, WPARAM, LPARAM) -> LRESULT
    {
      PostQuitMessage(0);
      return 0;
    }
  }
};

當視窗需要繪製時,會收到 WM_PAINT 消息。 因為桌面的呈現和複合有了改進,與早期 Windows 版本相比,這種情況大大減少了。 BeginPaint 和 EndPaint 函數是GDI遺留下來的,但是,即使要使用完全不同的呈現引擎繪圖,也還是需要它們的。 這是因為它們通過驗證視窗的繪圖圖面來通知 Windows 您已完成繪圖。 如果沒有這些調用,Windows 不會認為已回應 WM_PAINT 消息,視窗將沒有必要地接收持續的 WM_PAINT 消息流。

視窗消失後會收到 WM_DESTROY 消息,讓您知道視窗已銷毀。 這通常表示應用程式需要終止,但是消息迴圈中的 GetMessage 函數仍在等待 WM_QUIT 消息。 PostQuitMessage 函數負責對此消息進行排隊。 它只有一個參數,用於接收通過 WM_QUIT 的 WPARAM 傳遞的值,以此在應用程式終止時返回不同的結束代碼。

最後的難題是實現實際視窗過程。 我省略了之前用來準備 WNDCLASS 結構的 lambda 主體部分,但根據您現在所知道的,不難想像它具有以下結構:

wc.lpfnWndProc =
  [] (HWND window, UINT message,
      WPARAM wparam, LPARAM lparam) -> LRESULT
{
  for (auto h = s_handlers; h != s_handlers +
    _countof(s_handlers); ++h)
  {
    if (message == h->message)
    {
      return h->handler(window, wparam, lparam);
    }
  }
  return DefWindowProc(window, message, wparam, lparam);
};

for 迴圈查找匹配的處理常式。 幸好,Windows 提供預設消息處理供您選擇,這樣您就不需要自己進行處理。 這是 DefWindowProc 函數的工作。

就是這樣 — 如果到了這一步,說明您已經用 Windows API 成功地創建了一個桌面視窗!

The ATL Way

這些 Windows API 函數的麻煩在於,它們早在 C++ 成為當今的流行語言前就設計了,因此不太容易適應物件導向的方式。 此外,如果編碼足夠巧妙,這種 C 樣式 API 可以變得更適合普通的 C++ 程式師使用。 ATL 提供功能相同的類範本和宏庫,因此,如果需要管理大量視窗類或仍然依靠 USER 和GDI資源進行視窗實現,實在沒有理由不使用 ATL。 前一節中使用的視窗可用 ATL 表示,如圖 2 所示。

圖 2 用 ATL 表示視窗

class Window : public CWindowImpl<Window, CWindow,
  CWinTraits<WS_OVERLAPPEDWINDOW | WS_VISIBLE>>
{
  BEGIN_MSG_MAP(Window)
    MESSAGE_HANDLER(WM_PAINT, PaintHandler)
    MESSAGE_HANDLER(WM_DESTROY, DestroyHandler)
  END_MSG_MAP()
  LRESULT PaintHandler(UINT, WPARAM, LPARAM, BOOL &)
  {
    PAINTSTRUCT ps;
    VERIFY(BeginPaint(&ps));
    // Dress up some pixels here!
EndPaint(&ps);
    return 0;
  }
  LRESULT DestroyHandler(UINT, WPARAM, LPARAM, BOOL &)
  {
    PostQuitMessage(0);
    return 0;
  }
};

CWindowImpl 類提供必要的消息路由。 CWindow 基類提供很多成員函數包裝程式,主要是為了在每次函式呼叫時不必由您顯式提供視窗控制碼。 從本示例中的 BeginPaint 和 EndPaint 函式呼叫可以看出其作用。 CWinTraits 範本提供創建時使用的視窗樣式常量。

宏根據 MFC 的指令並與 CWindowImpl 一起使傳入消息與相應的成員函數匹配以進行處理。 每個處理常式都提供消息常量作為其第一個實參。 如果需要用單個成員函數處理各種消息,這個功能會非常有用。 最後一個參數預設為 TRUE,讓處理常式在運行時確定實際上是自己處理消息,還是讓 Windows(甚至某個其他處理常式)進行處理。 借助于 CWindowImpl,這些宏的功能非常強大,可用於處理返回的消息,鏈消息映射等等。

若要創建視窗,必須使用視窗從 CWindowImpl 繼承的 Create 成員函數,進而代您調用傳統的 RegisterClass 和 CreateWindow 函數:

Window window;
VERIFY(window.Create(nullptr, 0, L"Title"));

此時,執行緒再次需要快速開始調度消息,上節中的 Windows API 消息迴圈就足夠了。 如果需要管理單一執行緒上的多個視窗,ATL 方法無疑非常方便。但是對於單個頂層視窗,很大程度上與上一節的 Windows API 方法相同。

WTL: An Extra Dose of ATL

ATL 主要目的是簡化 COM 伺服器的開發,只提供了一個簡單(但極為有效)的視窗處理模型,WTL 則包含大量專門為支援創建更複雜、基於 USER 和GDI資源的視窗而設計的其他類範本和宏。 現在 SourceForge (wtl.sourceforge. net) 提供 WTL,但是對於使用現代呈現引擎的新應用程式而言,它不具備太多價值。 當然,還有少量有用的協助程式。 您可以使用 WTL atlapp.h 標頭檔的消息迴圈實現來代替我前面介紹的手動版本:

CMessageLoop loop;
loop.Run();

儘管放入應用程式和使用非常簡單,如果有複雜的消息篩選和路由需求,WTL 還是能實現強大的功能。 WTL 還為 atlcrack.h 提供了可代替 ATL 提供的通用 MESSAGE_HANDLER 宏的宏。 這些宏只是為了方便,但它們確實令新消息的啟動和運行變得更加簡單,因為它們可以說以破解方式打開消息,並在確定如何解釋 WPARAM 和 LPARAM 時避免了主觀臆斷。 WM_SIZE 是很好的例子,它將視窗的新工作區封裝為其 LPARAM 的低位字和高位字。 如果使用 ATL,代碼如下所示:

BEGIN_MSG_MAP(Window)
  ...
MESSAGE_HANDLER(WM_SIZE, SizeHandler)
END_MSG_MAP()
LRESULT SizeHandler(UINT, WPARAM, 
  LPARAM lparam, BOOL &)
{
  auto width = LOWORD(lparam);
  auto height = HIWORD(lparam);
  // Handle the new size here ...
return 0;
}

借助于 WTL,會稍微簡單一點:

BEGIN_MSG_MAP(Window)
  ...
MSG_WM_SIZE(SizeHandler)
END_MSG_MAP()
void SizeHandler(UINT, SIZE size)
{
  auto width = size.cx;
  auto height = size.cy;
  // Handle the new size here ...
}

請注意,新的 MSG_WM_SIZE 宏取代了原始消息映射中的泛型 MESSAGE_HANDLER 宏。 處理消息的成員函數也更加簡單。 可以看到,沒有任何不必要的參數或傳回值。 第一個參數僅是 WPARAM,如果需要知道是什麼引起了大小的變化,可以對它進行檢查。

ATL 和 WTL 的魅力在於,它們只是以一組標頭檔的形式提供的,您可以自行決定是否使用它們。 您可以使用需要的,忽略其餘。 但是,如本文所示,完全不依靠這些庫,只使用 Windows API 也能很好地編寫應用程式。 歡迎下次與我一起繼續探討,屆時我將為您介紹一種在應用程式視窗中實際呈現圖元的現代方法。

KennyKerr 是加拿大的一名電腦程式員,他是 Pluralsight 的作者和 Microsoft MVP。 他的博客網址是 kennykerr.ca,您可以通過 Twitter twitter.com/kennykerr 關注他。

衷心感謝以下技術專家對本文的審閱: Worachai Chaoweeraprasit