跳转至

通用GUI编程指南——从0开始 Win32 编程实战指南

写在最前面:这是 Windows 图形界面开发的起点,也是我们后续折腾 GUI 编程的基础。笔者发现,好像没有一个比较系统的,客户端的教程,可能大家都比较喜欢写后端,算法这些高端技术吧

为什么要搞这个

说实话,如果你已经习惯了现代框架的便利——Electron 一把梭、Qt 跨平台跑、WPF 标记语言写界面——直接上手 Win32 可能会让你有点不适。这些古老的 API、各种宏定义、匈牙利命名法,看起来就像上个世纪的产物(实际上确实也是)。

但问题是,很多东西的根源就在这里。你想理解 Windows 消息机制的本质吗?想搞清楚现代框架到底封装了什么吗?或者只是单纯想看看几十年前的程序员是怎么写界面的?那 Win32 就绕不过去。而且一旦你理解了这套体系,后续学 MFC、WPF 甚至其他平台的 GUI 编程都会顺畅很多。

再者,Win32 API 是 Windows 平台上最底层的图形界面 API,它没有被任何框架遮挡,你看到的就是操作系统给你的全部。这种透明度在某些场景下是无可替代的。

先把环境搭起来

在开始写代码之前,我们需要准备开发环境。好消息是,现在比以前容易多了——以前还得折腾单独的 SDK,现在 Visual Studio 一把就搞定。

你需要的是 Microsoft Visual Studio,推荐社区版(免费,功能够用)。安装时勾选「使用 C++ 的桌面开发」工作负载,它会自动把 Windows SDK、MSVC 编译器、链接器这些全部给你装好。

这里有个需要注意的点:Windows SDK 的每个版本都支持最新版 Windows 和若干历史版本。除非你需要维护运行在 Windows XP 上的古老程序(真有人要这么做吗?),否则直接装最新版就行。

另外要说明的是,Windows SDK 支持开发 32 位和 64 位应用程序。Windows API 设计得很巧妙——同一份代码可以编译成 32 位或 64 位程序,不需要改代码。这在当年从 16 位迁移到 32 位、后来从 32 位迁移到 64 位的过程中,节省了无数程序员的头发。

顺便提一句,Windows SDK 不支持驱动开发。如果你想要写硬件驱动,那得去折腾 Windows Driver Kit(WDK),这是另一个坑了,本系列不涉及。

搞懂那些奇怪的类型命名

当你第一次打开 Win32 的头文件或者示例代码时,可能会看到一堆看起来很奇怪的变量名和类型名:DWORD_PTRLPRECThWndpwsz……这些不是程序员故意炫技,而是 Windows 编程历史上形成的一套约定。

整数类型的 typedef

Windows 头文件里定义了大量的 typedef,很多都在 WinDef.h 里。下面这些是你会经常碰到的:

类型 位数 有符号/无符号
BYTE 8 位 无符号
WORD 16 位 无符号
DWORD 32 位 无符号
LONG 32 位 有符号
INT32 / UINT32 32 位 有符号 / 无符号
INT64 / UINT64 64 位 有符号 / 无符号
LONGLONG / ULONGLONG 64 位 有符号 / 无符号

你会发现这里面有明显的冗余。比如 DWORDUINT32 都是 32 位无符号整数,为什么要弄两个?原因只有一个——历史。Windows API 经历了几十年的演进,有些命名是为了向后兼容,有些是不同时期不同团队的习惯。但不管怎样,这些类型都有固定的大小,32 位和 64 位程序里都一样。

布尔类型的坑

BOOLint 的类型别名,不是 C++ 的 bool。头文件里还定义了两个常量:

#define FALSE    0
#define TRUE     1

但这里有个大坑:虽然定义了 TRUE,但大多数返回 BOOL 的函数实际上会返回任意非零值表示真。所以如果你这么写:

if (result == TRUE) // 错误写法!
{
    ...
}

可能会出 bug。正确的做法是:

// 写法一:直接判断
if (SomeFunction())
{
    ...
}

// 写法二:明确与 FALSE 比较
if (SomeFunction() != FALSE)
{
    ...
}

记住 BOOL 是整数类型,和 C++ 的 bool 不能混用。

指针类型的前缀游戏

Windows 里定义了大量「指向 X 的指针」类型,命名上通常带 P-LP- 前缀。比如 LPRECT 是指向 RECT 结构体的指针(RECT 用来描述一个矩形)。

下面这三个声明是完全等价的:

RECT*  rect;  // 标准写法
LPRECT rect;  // Long Pointer to RECT
PRECT  rect;  // Pointer to RECT

你可能会问,PLP 有什么区别?这又要追溯到 16 位 Windows 时代了。当年有「短指针」(near pointer,只能访问当前段)和「长指针」(far pointer,可以访问任意段)的区别。P 代表普通指针,LP 代表长指针。到了 32 位时代,这个区别已经不存在了,但为了方便移植旧代码,这些前缀一直保留到现在。

现在的建议是:能不用就不用,如果非要用,用 P 前缀就好。

指针精度的那些事儿

有些类型的大小是跟随指针大小的——32 位程序里 32 位,64 位程序里 64 位。这些类型包括:

  • DWORD_PTR
  • INT_PTR
  • LONG_PTR
  • ULONG_PTR
  • UINT_PTR

它们主要用于那些「整数可能要强制转换成指针」或者「需要进行指针算术」的场景。更广泛地说,它们出现在「32 位值在 64 位 Windows 上需要扩展到 64 位」的地方。

匈牙利命名法

你看到的 hWndpwsz 这种变量名,就是所谓的匈牙利命名法。这个命名法的发明者 Charles Simonyi 是匈牙利人,所以叫这个名字。

匈牙利命名法的核心思想是:在变量名前面加前缀,告诉你是什么类型或者用来干什么。常见的类型前缀包括:

前缀 含义
h handle(句柄)
p pointer(指针)
psz pointer to zero-terminated string(以 null 结尾的字符串指针)
pwsz pointer to wide zero-terminated string(宽字符串指针)
dw DWORD(32 位无符号整数)
w WORD(16 位无符号整数)
n integer(整数)
b boolean(布尔值)
cb count of bytes(字节数)

所以 hWnd 就是「窗口句柄」,pwsz 就是「指向宽字符串的指针」。

说实话,现代 C++ 开发里这套命名法已经不太流行了(很多编码规范甚至明确反对,相信我,大部分IDE会把你的类型整理的明明白白,甚至还会把你写的Doxygen注释都处理的妥妥当当),但阅读 Win32 代码时你必须能看懂。毕竟这些 API 几十年前就定型了,微软不可能为了迎合现代审美把所有函数名和参数名都改一遍。

字符串处理的那些事

Windows 里的字符串处理是个经典的大坑,主要原因是历史包袱——Unicode 诞生之前,Windows 用的是 ANSI 编码(每个字符一个字节)。后来要支持多语言,就引入了 Unicode(Windows 上用的是 UTF-16 编码,每个字符 16 位)。

为了平滑过渡,微软搞出了两套并行的 API。

宽字符和 wchar_t

Windows 用 UTF-16 来表示 Unicode 字符串。为了和 8 位的 ANSI 字符区分,UTF-16 字符被称为「宽字符」。Visual C++ 用内置类型 wchar_t 来表示宽字符,WinNT.h 里还定义了一个别名:

typedef wchar_t WCHAR;

声明宽字符字面量时,在字符串前面加 L 前缀:

wchar_t a = L'a';
wchar_t* str = L"hello";

下面是字符串相关的常用 typedef:

typedef 实际类型
CHAR char
PSTR / LPSTR char*
PCSTR / LPCSTR const char*
PWSTR / LPWSTR wchar_t*
PCWSTR / LPCWSTR const wchar_t*

A/W 双胞胎函数

当年微软引入 Unicode 支持时,为了让旧代码能平滑迁移,给很多函数搞了两个版本——一个接受 ANSI 字符串(函数名带 A 后缀),一个接受 Unicode 字符串(函数名带 W 后缀)。

比如设置窗口标题的函数就有两个:

SetWindowTextA(HWND hWnd, LPCSTR lpText);   // ANSI 版本
SetWindowTextW(HWND hWnd, LPCWSTR lpText);  // Unicode 版本

在内部,ANSI 版本会把字符串转换成 Unicode 再干活,所以效率上 Unicode 版本更高。头文件里还定义了一个宏:

#ifdef UNICODE
#define SetWindowText  SetWindowTextW
#else
#define SetWindowText  SetWindowTextA
#endif

你在文档里看到的通常是 SetWindowText(不带后缀),但实际上这是个宏,编译时会根据是否定义了 UNICODE 展开成 A 版本或 W 版本。

新项目应该始终使用 Unicode 版本。理由很简单:很多语言根本没法用 ANSI 表示,中文用户深有体会。而且现在最新的 Windows API 很多只有 Unicode 版本,根本不提供 ANSI 版本了。

TCHAR 宏(现在不太需要了)

为了兼容性,Windows SDK 还提供了一套可以根据编译选项自动切换的宏:

Unicode 时 ANSI 时
TCHAR wchar_t char
TEXT("x")_T("x") L"x" "x"

比如这样写:

SetWindowText(TEXT("My Application"));

会在 Unicode 编译时展开成 SetWindowTextW(L"My Application"),在 ANSI 编译时展开成 SetWindowTextA("My Application")

不过说实话,现在所有新程序都应该用 Unicode,这些兼容宏意义不大了。但你在阅读老代码时还是会经常看到,所以得认识。

还有一个容易搞混的地方:Windows 头文件用 UNICODE 这个宏,而 C 运行时库用 _UNICODE(带下划线)。所以如果要定义就两个都定义,Visual Studio 新项目默认会帮你设置好。

什么是窗口

这个问题看起来很蠢——用 Windows 的人谁不知道窗口是什么?但从程序员的角度看,窗口的概念比你想的要宽泛得多。

应用程序窗口 vs 控件窗口

你通常想到的「窗口」,是这种带标题栏、最小化/最大化按钮、关闭按钮的东西:

这叫应用程序窗口或者主窗口。它由两部分组成:

  • 非工作区(Non-client Area):标题栏、边框、菜单栏这些,由操作系统统一管理
  • 工作区(Client Area):中间那块空白区域,你的程序在这里画内容

哦对,这个概念笔者想驻足一下,多聊聊。笔者在实习的时候,就被这个概念给困扰过。

┌─────────────────────────────────────────────┐
│  📁 我的应用程序          ➖ ⬜ ✖          │  ← 标题栏
├──────────────────────────────────────────────┤
│  文件(F)  编辑(E)  视图(V)  帮助(H)          │  ← 菜单栏
├╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┤
│                                              │
│                                              │
│           这里是工作区 (Client Area)          │  ← 你的程序在这里画画
│           你的代码完全掌控这里                │
│                                              │
│                                              │
└─────────────────────────────────────────────┘
↑                                             ↑
边框                                          边框
(以上带边框/标题栏/菜单的部分 = 非工作区)

嗯,我的ASCII绘制技术很烂。

可以看到,一个完整的,我们视觉意义上的窗口,并不是我们完全掌控的——以上面的程序为例子:

区域 说明
标题栏 (Title Bar) 显示窗口名称,可以拖动移动窗口
边框 (Border) 四条边,可以拖动改变窗口大小
菜单栏 (Menu Bar) 文件 / 编辑 / 视图 这一行
最小化/最大化/关闭按钮 右上角三个按钮
滚动条 (Scrollbar) 附着在边缘的滚动条

但是如果,我们就是想自己管呢?这就需要我们自己重新定义什么是Client说明不是Client了:我们需要拦截 WM_NCHITTESTWM_NCPAINT 等消息(靠,我当时修复的bug就是要自己重新定义WM_NCHITTEST),难度相当高。很多现代应用(Chrome、VS Code、QQ)会这样做,实现无边框/自定义标题栏效果。

有趣的是,按钮、编辑框、列表框这些 UI 控件,在 Win32 里也是窗口。它们和应用程序窗口的主要区别在于:控件窗口不能独立存在,必须「依附」于某个父窗口。当你拖动应用程序窗口时,里面的按钮会跟着移动——就是因为它们是父子关系。

所以从程序员的角度,窗口是一种能做这些事情的编程构造:

  • 占据屏幕的特定区域
  • 可以显示或隐藏
  • 知道如何绘制自己
  • 能够响应用户或操作系统的事件

父子关系和拥有关系

前面提到了控件窗口是应用程序窗口的「子窗口」,应用程序窗口是「父窗口」。父窗口给子窗口提供定位坐标系,还会裁剪子窗口的显示范围——子窗口的内容不会超出父窗口的边框。

还有另一种关系叫「拥有者-被拥有者」。比如你打开一个模态对话框,应用程序窗口就是这个对话框的「拥有者」,对话框是「被拥有的窗口」。被拥有的窗口永远显示在拥有者前面,拥有者最小化时它会跟着隐藏。

举一个具体的例子:一个应用程序弹出一个对话框,对话框里有两个按钮。

这里的关系是:应用程序窗口拥有对话框窗口,对话框窗口是两个按钮窗口的父窗口

窗口句柄(HWND)

窗口不是 C++ 对象,你不能直接用指针指向它。程序通过一个叫句柄(Handle)的值来引用窗口。句柄本质上是一个不透明的数字,操作系统用它来标识某个对象。

你可以把 Windows 想象成有一张大表,里面记录了所有已创建的窗口。当你想操作某个窗口时,就通过句柄去这张表里查找。

窗口句柄的类型是 HWND,通常读作「aitch-wind」。创建窗口的函数 CreateWindowCreateWindowEx 会返回这个句柄。

HWND hWnd = CreateWindowEx(
    0,                      // 扩展窗口样式
    CLASS_NAME,             // 窗口类名
    L"Learn to Program Windows",  // 窗口标题
    WS_OVERLAPPEDWINDOW,    // 窗口样式
    CW_USEDEFAULT, CW_USEDEFAULT, 400, 300,  // 位置和大小
    NULL,       // 父窗口句柄
    NULL,       // 菜单句柄
    hInstance,  // 实例句柄
    NULL        // 额外数据
);

之后你要对窗口做任何操作——移动它、改变大小、隐藏它——都需要把这个句柄传给相应的函数:

BOOL MoveWindow(HWND hWnd, int X, int Y, int nWidth, int nHeight, BOOL bRepaint);

这里要强调一下:句柄不是指针!如果你的变量名是 hwnd,千万别尝试用 *hwnd 这种方式「解引用」,否则等着程序崩溃吧。

坐标系统

Windows 里的坐标用「设备无关像素」来度量。根据你在做什么,可能会用到三种不同的坐标系:

  • 屏幕坐标:原点是屏幕左上角,用来定位窗口在屏幕上的位置
  • 窗口坐标:原点是窗口(包括非工作区)左上角
  • 客户区坐标:原点是工作区左上角,用来在窗口内绘制内容

不管哪种坐标系,原点 (0, 0) 都是区域的左上角,X 轴向右增长,Y 轴向下增长。

WinMain:程序的入口点

习惯了写控制台程序的同学,都知道 main 函数是入口点:

int main(int argc, char* argv[])
{
    return 0;
}

但 Windows 图形界面程序不用 main,而是用 WinMainwWinMain。下面是 wWinMain 的签名:

int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow);

我们逐个来看这四个参数:

  • hInstance:实例句柄。当你的可执行文件被加载到内存时,操作系统用这个值来标识它。某些 Windows 函数需要这个句柄,比如加载图标或位图。

  • hPrevInstance:历史遗留产物,现代 Windows 上永远是 NULL。16 位 Windows 时期用它来判断是否已经有程序实例在运行。

  • pCmdLine:命令行参数,Unicode 字符串形式。注意这和 main 函数的 argv 数组不同,它是单个字符串。

  • nCmdShow:一个标志,告诉程序主窗口应该怎么显示——最小化、最大化还是正常显示。

函数返回一个 int 值。操作系统其实不用这个返回值,但你可以用它来传递状态码给其他程序(比如通过批处理脚本检测)。

函数名前面的 WINAPI调用约定,定义了函数如何从调用方接收参数(比如参数在栈上的顺序)。确保你的 wWinMain 声明和上面一模一样,别随意改。

WinMainwWinMain 的区别在于命令行参数:WinMain 用 ANSI 字符串(PSTR),wWinMain 用 Unicode 字符串(PWSTR)。现在推荐用 wWinMain

不过有个有趣的地方:即使你用的是 wWinMain(或者定义了 UNICODE),编译器也可能会链接到 WinMain。这是因为 Microsoft 的 C 运行时库(CRT)提供了自己的 main 实现,在里面会调用 WinMainwWinMain

CRT 在 main 里还要做不少事情,比如调用全局对象的构造函数(静态初始化)。虽然你可以告诉链接器用不同的入口点,但如果你链接了 CRT,最好还是用默认值。否则跳过 CRT 初始化代码,全局对象可能不会正确初始化,后果就是未定义行为——这种 bug 通常很难排查。

下面是一个最简单的 WinMain 函数:

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
    PSTR lpCmdLine, int nCmdShow)
{
    return 0;
}

这个程序能编译通过也能运行,只是什么都不会做——连个窗口都不会弹出来。但至少我们有了入口点。

下一步干什么

到这里,我们已经了解了 Win32 编程的基本概念:开发环境、类型约定、字符串处理、窗口模型、入口点函数。

但这些只是铺垫,真正的实战是写出一个能显示窗口、响应消息的完整程序。那将是下一篇的内容——我们会从零开始,手写一个真正的 Windows GUI 程序。


相关资源