借助 C++ 进行 Windows 开发

使用 Visual C++ 2012 创建桌面应用程序

Kenny Kerr

 

Kenny Kerr随着对 Windows 8 的大力宣传和 Windows 应用商店的应用现在越来越为大家所知,很多人向我提出关于桌面应用程序相关性和 Standard C++ 今后是否仍是一个可靠选择的问题。这些问题有时很难回答,但是我可以告诉您的是,Visual C++ 2012 编译器现在对于 Standard C++ 比以往任何时候都要重视。依本人愚见,无论针对的是 Windows 7、Windows 8 还是 Windows XP,它仍然是构建美妙 Windows 桌面应用程序的最佳工具。

随后不可避免要面对的问题是,在 Windows 中进行桌面应用程序开发有什么好处,应该从哪里入手。在本月的专栏中,我将探讨使用 Visual C++ 创建桌面应用程序的基本知识。当 Jeff Prosise 第一次向我介绍 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 是非常明智的。

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。 您可能也注意到了,我称第一个参数为“模块”而不是“实例”。同样,在 16 位 Windows 中,实例和模块是两个不同的概念。 所有应用程序共享包含代码段的模块,但是得到的包含数据段的实例都是唯一的。 现在,当前和之前的 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 成功地创建了一个桌面窗口!

ATL 方法

这些 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: 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 也能很好地编写应用程序。 欢迎下次与我一起继续探讨,届时我将为您介绍一种在应用程序窗口中实际呈现像素的现代方法。

Kenny Kerr 是加拿大的一名计算机程序员,他是 Pluralsight 的作者和 Microsoft MVP。 他的博客网址是 kennykerr.ca,您可以通过 Twitter twitter.com/kennykerr 关注他。

衷心感谢以下技术专家对本文的审阅: Worachai Chaoweeraprasit