C/C++ 使用 Win32 API 制作图形界面

转自我的洛谷博客(在线编辑器真不错)

前言

作者非常菜,如有错误请各位指正,不胜感激。

本篇文章会介绍一些关于 Win32 API 的 GUI 部分的内容。


注意:

  • 顾名思义,这东西不跨平台,只能在 Windows 上用;
  • 建议阅读本文前先复习一下指针;
  • 遇到新 API 函数请尽量先上 MSDN 查询;
  • 时刻注意内存安全。

一、 环境准备

我使用的是 Visual Studio 2017。

下载地址:https://my.visualstudio.com/Downloads?q=visual%20studio%202017

vs2017 下载

仅安装了 “使用 C++ 的桌面开发”。

但请注意同时选上“Windows SDK”!

如果上面的链接失效了,那么去微软官网下载当前最新版 VS 也可以。截止到我写这篇文章时,VS 的最新版本是 2019。

安装时,选择 Community 版本就可以,它是完全免费的。

如果提示 30 天使用期限,那么注册并登录微软帐号就能解决,这同样是完全免费的。

不建议使用 VC6.0,过于古老且在字符集方面存在问题。

二、 一些概念

下文看到新词 / 疑惑的地方可以先来这里找找


Win32,并不是指 32 位 Windows,而是现代 NT 内核 Windows 用户态的统称。

64 位 Windows 的用户态也可以称作 Win32。

Win32 api,顾名思义,就是 Windows 在用户态为开发者提供的 API 函数。这些函数种类多样,功能非常丰富。

这些 API 函数的用户态实现被微软存放在几个 dll 文件里,最常用的有两个:user32.dll 和 kernel32.dll。

我们编写程序的时候,一般需要 dll 对应的 lib 文件和 h 头文件才能调用 dll 里的函数。Windows SDK 为我们准备好了这些 lib 和 h 文件。

上面这段话部分新名词涉及到 x86 处理器和操作系统的知识,比较复杂,有机会再单独写。


在 VS 中,按住 Ctrl,点击任何一个函数 / 非标准数据类型(ATOM、BOOL 等)/ 宏,可以查看它们的原型 / 定义。


Win32 API 中看起来有很多新的数据类型,但事实上,它们全是标准 C++ 中的类型。
比如:

1
2
3
4
5
typedef unsigned long       DWORD;
typedef int BOOL;
typedef unsigned char BYTE;
typedef unsigned short WORD;
typedef float FLOAT;

微软的命名比较有特点,一般用的是匈牙利命名法:
变量名 = 属性 + 类型 + 对象描述。

属性 含义
g_ 全局变量
c_ 常量
m_ c++ 类成员变量
s_ 静态变量
类型 含义
a 数组
p 指针
fn 函数
h 句柄
l 长整型
b 布尔
f 浮点型(有时也指文件)
dw 双字
sz 字符串
n 短整型
d 双精度浮点
c(通常用 cnt) 计数
ch(通常用 c) 字符
i(通常用 n) 整型
by 字节
w
r 实型
u 无符号
描述 含义
Max 最大
Min 最小
Init 初始化
T(或 Temp) 临时变量
Src 源对象
Dest 目的对象
(来自 百度百科

其中,lp 或者 p 是最常用的两个,这两个前缀意义一样,都代表了指针。


用户通过控件与应用程序交互。在 Win32 API 中,每个控件都可以看成一个特殊的窗口。


在 Win32 程序中,字符集是一个比较要命的地方,不同字符集下的程序不能直接复制编译。本文使用的是创建项目时默认的 Unicode 字符集。

在为宽字节型变量赋值时,如果赋予的值是字符串字面量,要在字符串前(引号外)增加一个大写字母 L。如:

1
2
LPCWSTR a = L"abc";
WCHAR b[] = L"def";

如果不确定变量类型是宽字节还是窄字节(如 LPTSTR、TCHAR 等),那么可以使用 TEXT() 这个宏包裹住要赋给变量的字符串( TEXT(“abc”) ),它会自动判断应该使用的类型,用 _T() 也行,效果完全一样。

Win32 API 中很多函数都有 ANSI 和 宽字符(Unicode)两个版本,分别以大写字母 A 或大写字母 W 结尾。

推荐使用 Unicode 字符集。

wchar_t 是 C/C++ 语言标准的一部分,但标准里没有规定具体的实现。

就我的经验来说,Windows 对 wchar_t 的实现是 UCS-2 字符集,每个字符定长 2 字节。

(大端小端?这得看 CPU)


句柄是它所代表的对象的唯一标识,和它所代表的对象一一对应。它的作用大概类似于前端 HTML 标签的 id。
指针和句柄的作用有些像,但不完全一样。

  • 句柄会限制使用者的权限;
  • 句柄结构不公开

有了句柄,就可以对它所代表的对象进行操作。

但注意句柄不是对象本身。关于这个可以理解成:用钥匙可以控制(开)门,但钥匙本身不是门,并且门外无法用螺丝刀(指针)拆门。


Windows 操作系统基于消息控制机制。用户与系统之间的交互,程序与系统之间的交互,都是通过发送和接收消息来完成的。

程序在运行时,会不断收到操作系统发来的消息,程序需要使用判断语句(一般是 switch)来筛选并判断用户的操作(如:点击了某个按钮)进而做出正确的回应(如:执行一个函数)。


nullptr 是字面值常量,表示空指针,C++ 专属;
NULL 是宏定义,在 C 中表示 (void*)0,在 C++ 中表示 0(int 型)。

下文提到的所有 nullptr 在 C 语言中都可以用 NULL 代替。


Win32 API 的各种 ID、消息名之类的其实都是宏定义。

如:

1
2
#define WM_COMMAND                      0x0111
#define WM_PAINT 0x000F

写程序时直接写宏对应的整数也不是不能用。

宏定义的意义在于让程序读起来更清晰。


C/C++ 前置声明。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ATOM MyRegisterClass(HINSTANCE hInstance); // 重要

。。。

int APIENTRY wWinMain(。。。)
{
。。。
MyRegisterClass(hInstance);
。。。
}

ATOM MyRegisterClass(HINSTANCE hInstance)
{
。。。
}

且两个 MyRegisterClass 的 (HINSTANCE hInstance) 部分必须完全一样。


C/C++ 中,使用函数名时不带括号,就相当于使用指向这个函数的指针。

三、 完整的创建一个窗口

Win32 API 创建窗口的过程是:

WinMain 函数(入口点,注册窗口类 => 创建窗口 => 消息循环)
窗口过程函数(名字可自定义,处理消息)

这些代码几乎是固定的,还很长。因此,用 VS 创建项目时可以直接选择 Windows 桌面应用程序 来使用微软提供的模板。

创建项目

创建完成后,VS 的解决方案资源管理器里应该是这样的:

创建完成
(“luogu”取决于创建项目时设置的名称)

现在,来看看 luogu.cpp 中的内容。

1、 WinMain

这是 Win32 窗口程序的入口点,相当于 C++ 控制台程序的 main()。VS 项目属性中的 /SUBSYSTEM 可以控制程序的入口点。
比如:

子系统 含义
/SUBSYSTEM:CONSOLE 控制台
/SUBSYSTEM:WINDOWS 窗口

在 Unicode 字符集中,这个函数叫做 wWinMain。

TCHAR.H 中的定义的简化版:

1
2
3
4
5
#ifdef _UNICODE 
#define _tWinMain wWinMain
#else
#define _tWinMain WinMain
#endif

WinMain 的定义:

1
2
3
4
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPWSTR lpCmdLine,
_In_ int nCmdShow)

这个函数定义时带了一个 APIENTRY,看定义:

1
2
3
4
5
6
#define CALLBACK    __stdcall
#define WINAPI __stdcall
#define WINAPIV __cdecl
#define APIENTRY WINAPI
#define APIPRIVATE __stdcall
#define PASCAL __stdcall

上述下划线开头的关键字是函数调用约定,代表了汇编语言层面函数的调用方法(如何传参之类的)。

通过这些宏的字面意义,能发现 WinMain 这个函数是由系统调用的,我们只管把它定义出来并写好里面的代码就行了。

虽然这个函数由系统调用,但我认为了解一下它的每个参数的意义还是有必要的。

(1)、 hInstance

程序当前实例的句柄。

(2)、 hPrevInstance

程序上一个实例的句柄。当同一个程序打开两次时,第一次打开的窗口就是上一个实例的窗口。

此参数已废弃,逆向 msvcrt 即可发现,运行库总是将此参数设为 0。

(3)、 lpCmdLine

类似于控制台程序中的 argv,但不能像 argv 那样根据空格自动拆分传入的参数。

比如:

1
C:\>test.exe 1 2 3 a b cd a

lpCmdLine 的值就是 “1 2 3 a b cd a”

(懂 JS / PHP 的读者可以理解成 lpCmdLine 是 JavaScript 的 location.search,argv[] 是 PHP 的 $_GET[]。当然,不考虑数据类型)

(4)、 nCmdShow

指定程序的窗口如何显示。

用户在使用程序时可以手动改变这个参数的值:

nCmdShow

看看它可能的值和对应的宏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define SW_HIDE             0
#define SW_SHOWNORMAL 1
#define SW_NORMAL 1
#define SW_SHOWMINIMIZED 2
#define SW_SHOWMAXIMIZED 3
#define SW_MAXIMIZE 3
#define SW_SHOWNOACTIVATE 4
#define SW_SHOW 5
#define SW_MINIMIZE 6
#define SW_SHOWMINNOACTIVE 7
#define SW_SHOWNA 8
#define SW_RESTORE 9
#define SW_SHOWDEFAULT 10
#define SW_FORCEMINIMIZE 11
#define SW_MAX 11

这些值在一般程序中并不需要手动指定,直接使用这个传过来的参数就行。不过手动指定一般也不会有什么问题。


注意:因为 C++ 的函数重载,以及要保证堆栈平衡,所以 WinMain 的定义必须原封不动的照抄 MSDN。

定义完了 WinMain,再来看看微软给我们的示例中 WinMain 都干了哪些事。

UNREFERENCED_PARAMETER

作用:告诉编译器这个变量已经使用了,不用报警告。

这个宏后面跟的变量在前面已经说了,一个是已经没用的参数,一个是最简流程用不上的命令行参数。
MSVC 用最高级别的警告编译时,如果检测到有变量定义了但是未使用时会报 warning C4100,这个宏可以让编译器忽略它。

LoadString

作用:初始化全局字符串。废话,微软注释原话

把资源文件中的字符串放到 cpp 文件的变量中。(个人理解)
应该主要用于多语言。

有两个版本,且定义不同。
看定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
WINUSERAPI
int
WINAPI
LoadStringA(
_In_opt_ HINSTANCE hInstance,
_In_ UINT uID,
_Out_writes_to_(cchBufferMax,return + 1) LPSTR lpBuffer,
_In_ int cchBufferMax
);

WINUSERAPI
int
WINAPI
LoadStringW(
_In_opt_ HINSTANCE hInstance,
_In_ UINT uID,
_Out_writes_to_(cchBufferMax,return + 1) LPWSTR lpBuffer,
_In_ int cchBufferMax
);

(1)、 hInstance

当前实例句柄。

(2)、 uID

看图按顺序找:
uID

(3)、 lpBuffer

接受资源文件中的字符串的变量名。

两个版本的不同之处,主要是数据类型差异。

ANSI 版本这里需要窄字符型变量,Unicode 版本这里要宽字符型变量。

(4)、 cchBufferMax

lpBuffer 的长度。


MyRegisterClass

与窗口类有关,在下一小节细讲。

InitInstance

保存实例句柄并创建主窗口,需要我们定义,后面细讲。

1
2
3
4
if (!InitInstance (hInstance, nCmdShow))
{
return FALSE;
}

这段主要是为了检测窗口是否创建成功,没成功就直接退出程序了。

LoadAccelerators

1
HACCEL hAccelTable = LoadAccelerators(hInstance, MAKEINTRESOURCE(IDC_LUOGU));

作用:加载放在资源文件里的快捷键表(快捷键定义)。

看图:
快捷键

MSG

消息循环。

MSG 结构体:

1
2
3
4
5
6
7
8
typedef struct tagMSG {
HWND hwnd; // 窗口句柄
UINT message; // 收到的消息
WPARAM wParam; // 消息附加参数
LPARAM lParam; // 消息附加参数
DWORD time; // 消息创建时的时间
POINT pt; // 消息创建时鼠标的位置
} MSG, *PMSG, NEAR *NPMSG, FAR *LPMSG;

例如
message 可以告诉程序,用户点击了一个按钮,
wParam 可以告诉程序,用户具体点击了哪一个按钮。

GetMessage

从消息队列中取出消息。当收到 WM_QUIT 这个消息时,返回 0;收到其余消息返回非 0。

1
while (GetMessage(&msg, nullptr, 0, 0))

就是以此判断程序是否应该退出。

收到 WM_QUIT => GetMessage 返回 0 => while 循环结束 => 程序退出。

我的电脑上的 winuser.h 显示 GetMessage 这个函数是有 A、W 两个版本的,但是这两个版本在定义上看起来一模一样,MSDN 上则显示它只有一个版本。此处展示的是 winuser.h 中的宽字符版本:

1
2
3
4
5
6
7
8
WINUSERAPI
BOOL
WINAPI
GetMessageW(
_Out_ LPMSG lpMsg, // 指向 MSG 结构的指针
_In_opt_ HWND hWnd, // 窗口句柄,通常为 NULL / nullptr,表示接受当前进程所有窗口的消息。
_In_ UINT wMsgFilterMin, // 指定要获取的消息的最小值,通常设置为 0。
_In_ UINT wMsgFilterMax); // 指定要获取的消息的最大值,通常设置为 0。

当 wMsgFilterMin 和 wMsgFilterMax 均为 0 时,程序接收所有消息。

TranslateAccelerator

处理菜单命令的快捷键。如果用户按下了某键,且在指定的快捷键表中有该键的条目,该函数就会将 WM_KEYDOWN 或 WM_SYSKEYDOWN 消息转换为 WM_COMMAND 或 WM_SYSCOMMAND 消息,然后将 WM_COMMAND 或 WM_SYSCOMMAND 消息直接发送到指定的窗口进程。在窗口过程处理完消息之前,TranslateAccelerator 不会返回。(修改自 MSDN)

这个函数一样有两个版本(MSDN 上也是这么说的),但是我没找到他俩明面上的区别,使用函数时编译器也会自动帮助选择版本,所以这里依然以 W 版本举例。

看定义:

1
2
3
4
5
6
7
WINUSERAPI
int
WINAPI
TranslateAcceleratorW(
_In_ HWND hWnd, // 要转换快捷键按键消息的窗口的句柄
_In_ HACCEL hAccTable, // 快捷键表的句柄,来自前面提到过的 LoadAccelerators *
_In_ LPMSG lpMsg); // 指向 MSG 结构体的指针,指向的 MSG 结构体必须经过 GetMessage

* 也可以由 CreateAcceleratorTable 函数创建

MSDN 上有这么一句话:当 TranslateAccelerator 返回非零值并且转换了消息时,应用程序不应使用 TranslateMessage 函数再次处理消息。(经谷歌翻译)所以以下这段代码就很好理解了:

1
2
3
4
5
if (!TranslateAccelerator(msg.hwnd, hAccelTable, &msg))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}

TranslateAccelerator 先判断系统发来的消息是否是关于按键的,不是就直接把消息交给 DispatchMessage 处理(返回 0);是的话就对比按键消息和快捷键表,有相符的就给对应窗口发去 WM_COMMAND 或 WM_SYSCOMMAND 消息,并在附加参数中指明前面图片里显示的快捷键 ID(返回非 0); 没符合的就返回 0,让 TranslateMessage 处理这个按键消息。

TranslateMessage

翻译剩余的按键消息,包括虚拟键(Windows 定义的关于键盘上各种功能键和鼠标按钮的宏,一般由 VK 开头)为 WM_CHAR 或 WM_DEADCHAR 或 WM_SYSCHAR 或 WM_SYSDEADCHAR 消息,并把消息添加到消息队列里。

经过 TranslateMessage 处理的按键消息不会丢失。

定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
WINUSERAPI
BOOL
WINAPI
TranslateMessage(
_In_ CONST MSG *lpMsg); // 指向 MSG 结构体的指针,指向的 MSG 结构体必须经过 GetMessage
```

#### DispatchMessage

从 GetMessage / TranslateMessage 获得消息并分发给窗口的窗口过程函数(处理消息)。这个窗口过程函数需要我们定义,后面会讲到。

这个函数依然有两个看不出外在区别的版本,这里依然只展示 W 版本。
定义:
```cpp
WINUSERAPI
LRESULT
WINAPI
DispatchMessageW(
_In_ CONST MSG *lpMsg); // 指向 MSG 结构体的指针,指向的 MSG 结构体必须经过 GetMessage

return

return 本身没啥好讲的,重点在 (int) msg.wParam。

Win32 程序在关闭时也会收到一条消息,这就是为什么有些编辑类软件在点击 X 时会提示内容未保存。

如果程序确认要退出了,就需要调用另一个函数,这个函数会发送真正的退出消息 WM_QUIT,前面讲过的消息循环会因此而退出。此时 msg 中不是空的,msg.wParam 中正是前面调用 “退出函数” 时往 “退出函数” 里传的参数。

因此,(int) msg.wParam 就可以让我们自定义程序的退出代码。


WinMain 函数完整的过了一遍,继续来看看 MyRegisterClass。


2、 MyRegisterClass

前面说过,这个函数与窗口类有关。事实上,这个函数就是用来注册窗口类的。

窗口类是一个属性集,是用于创建窗口的模板。每个窗口类都有一个对应的窗口过程函数,且需要我们指定。所以窗口过程函数的名字可以自定义。

1
ATOM MyRegisterClass(HINSTANCE hInstance)

注册窗口类这个过程需要程序当前实例的句柄,这也是这个函数唯一的参数。

ATOM 是非标准数据类型,可以按照第二大节第一小节的方法看它的(套娃式)定义。

按照微软提供的模板,创建窗口类需要用到 WNDCLASSEXW,这其实是一个结构体。看结尾字母就能知道,它也有 A、W 两个版本。

定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
typedef struct tagWNDCLASSEXA {
UINT cbSize;
/* Win 3.x */
UINT style;
WNDPROC lpfnWndProc;
int cbClsExtra;
int cbWndExtra;
HINSTANCE hInstance;
HICON hIcon;
HCURSOR hCursor;
HBRUSH hbrBackground;
LPCSTR lpszMenuName;
LPCSTR lpszClassName;
/* Win 4.0 */
HICON hIconSm;
} WNDCLASSEXA, *PWNDCLASSEXA, NEAR *NPWNDCLASSEXA, FAR *LPWNDCLASSEXA;

typedef struct tagWNDCLASSEXW {
UINT cbSize;
/* Win 3.x */
UINT style;
WNDPROC lpfnWndProc;
int cbClsExtra;
int cbWndExtra;
HINSTANCE hInstance;
HICON hIcon;
HCURSOR hCursor;
HBRUSH hbrBackground;
LPCWSTR lpszMenuName;
LPCWSTR lpszClassName;
/* Win 4.0 */
HICON hIconSm;
} WNDCLASSEXW, *PWNDCLASSEXW, NEAR *NPWNDCLASSEXW, FAR *LPWNDCLASSEXW;

一项一项来看。

1、 cbSize

WNDCLASSEX 的大小,直接用 sizeof(WNDCLASSEX) 就行。

2、 style

窗口类的样式,可以任意排列组合。

一般以 CS 开头,注意不要和其他样式混了。

https://docs.microsoft.com/en-us/windows/win32/winmsg/window-class-styles

https://www.cnblogs.com/yunqie/p/6613870.html

上面第一个链接介绍了所有窗口类样式,第二个是第一个的机器翻译版本。

一般来说默认的就够用(默认的值代表窗口被遮挡又显示时可以正常显示原有内容)。

3、 lpfnWndProc

窗口类对应的窗口过程函数的名称。

前面说过窗口过程函数的名字可以自定义,只要在这里指定就行。

4、 cbClsExtra

窗口类的额外信息,填 0 就行。

5、 cbWndExtra

窗口类的额外信息,一般填 0。

6、 hInstance

实例的句柄,也就是这个函数的参数。

7、 hIcon

程序的图标,可以直接填 0 使用默认图标,也可以依照模板加载资源文件中声明的图标文件:

1
wcex.hIcon          = LoadIcon(hInstance, MAKEINTRESOURCE(IDI_LUOGU));

LoadIcon

1
2
3
4
5
6
WINUSERAPI
HICON
WINAPI
LoadIconW(
_In_opt_ HINSTANCE hInstance,
_In_ LPCWSTR lpIconName);

从参数 hInstance 句柄对应的实例的资源文件中加载参数 lpIconName 对应的图标资源的句柄。

这里就是从当前程序资源文件里加载一个图标的句柄。

这个函数也有两个版本,不过不需要我们关心,MAKEINTRESOURCE 会帮助我们区分字符集。

MAKEINTRESOURCE

这是一个宏,用于转换资源 ID。

资源的 ID(如:IDI_LUOGU),也是一个宏定义,实际上就是一个数。但是微软的各种 Load 函数需要的参数是字符串。这个宏就是起到一个强制转换的作用:

1
2
#define MAKEINTRESOURCEA(i) ((LPSTR)((ULONG_PTR)((WORD)(i))))
#define MAKEINTRESOURCEW(i) ((LPWSTR)((ULONG_PTR)((WORD)(i))))

由于:

1
2
3
4
5
6
7
8
9
10
11
#ifdef UNICODE
#define MAKEINTRESOURCE MAKEINTRESOURCEW
#else
#define MAKEINTRESOURCE MAKEINTRESOURCEA
#endif // !UNICODE

#ifdef UNICODE
#define LoadIcon LoadIconW
#else
#define LoadIcon LoadIconA
#endif

所以只要同时使用 LoadIcon 和 MAKEINTRESOURCE 就可以了。

8、 hCursor

窗口使用的鼠标指针样式。

这里的 LoadCursor 和上面的 LoadIcon 同理,nullptr 在这里是指使用系统的资源,IDC_ARROW 就是默认的指针样式,看定义能发现这就是一个参数定死的 MAKEINTRESOURCE 宏。

9、 hbrBackground

窗口背景颜色。

注意必须是一个 HBRUSH 类型的句柄,如果你和我环境相同的话,那么在 winuser.h 的 9349 行往下应该能发现一堆宏定义。这些都是可以使用的颜色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#define COLOR_SCROLLBAR         0
#define COLOR_BACKGROUND 1
#define COLOR_ACTIVECAPTION 2
#define COLOR_INACTIVECAPTION 3
#define COLOR_MENU 4
#define COLOR_WINDOW 5
#define COLOR_WINDOWFRAME 6
#define COLOR_MENUTEXT 7
#define COLOR_WINDOWTEXT 8
#define COLOR_CAPTIONTEXT 9
#define COLOR_ACTIVEBORDER 10
#define COLOR_INACTIVEBORDER 11
#define COLOR_APPWORKSPACE 12
#define COLOR_HIGHLIGHT 13
#define COLOR_HIGHLIGHTTEXT 14
#define COLOR_BTNFACE 15
#define COLOR_BTNSHADOW 16
#define COLOR_GRAYTEXT 17
#define COLOR_BTNTEXT 18
#define COLOR_INACTIVECAPTIONTEXT 19
#define COLOR_BTNHIGHLIGHT 20

至于给这些预定义颜色 +1 的操作,微软就这么定义,我也不知道为啥。

如果找了一圈发现上面没有你喜欢的颜色,那么也可以使用 CreateSolidBrush 函数配合 RGB 宏创建自己的画刷。但需要注意这个函数创建的画刷需要使用 DeleteObject 函数释放!至于在哪里释放,我没用过这个函数,但在前面讲过的的消息循环(while 语句)被跳出(结束)后,主函数 return 前释放,应该是个不错的选择。

10、 lpszMenuName

定义窗口使用的菜单栏,直接 MAKEINTRESOURCE + 资源文件里 menu 部分定义的菜单的 ID 就行了。如果是 0,就代表这个窗口没有菜单。

实际编译完的菜单

VS2017 资源编辑器中的菜单

如果你想用纯代码方式创建菜单,请参考:https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-createmenu

11、 lpszClassName

窗口类名。

为窗口类定义一个字符串的名字。

模板这里是定义了一个全局变量,然后从资源文件里加载了一个字符串作为窗口类的名字。

12、 hIconSm

窗口的小图标,和上面的 hIcon 的使用方式没啥区别。

return RegisterClassExW(&wcex);

注册这个定义好的窗口类并返回。

3、InitInstance

创建窗口,保存当前实例句柄。

1
hInst = hInstance;

保存当前实例句柄。

hInst 是一个全局变量,hInstance 是操作系统传给我们的参数,是局部变量。

CreateWindowW

真正创建窗口的函数。

这个函数很有意思,它本身就是一个宏定义。这大概是因为它指向的 CreateWindowExW 本身有一些一般用不到的参数,所以微软弄了一个简化版。

注:上述函数依然有对应的 A 结尾的版本。

看看参数:

(1)、 lpClassName

创建窗口使用的窗口类名,就是之前定义的那个字符串。

微软这么设计大概是因为有一些系统保留的窗口类,字符串形式方便使用。

(2)、 lpWindowName

窗口名。

如果这个窗口有标题栏,那么标题栏上也会显示这个字符串。

(3)、 dwStyle

窗口样式。

注意不要和上面的窗口类样式弄混了,这个一般以 WS 开头。

此处模板给的参数是 WS_OVERLAPPEDWINDOW,这个就是通常窗口所使用的样式,微软为了方便,做了个宏定义的集合:

1
2
3
4
5
6
#define WS_OVERLAPPEDWINDOW (WS_OVERLAPPED     | \
WS_CAPTION | \
WS_SYSMENU | \
WS_THICKFRAME | \
WS_MINIMIZEBOX | \
WS_MAXIMIZEBOX)

https://docs.microsoft.com/en-us/windows/win32/winmsg/window-styles

上面是微软官方对所有窗口样式的说明。

(4/5)、 x/y

希望窗口建立时窗口左上角的屏幕坐标。

当 x 为 CW_USEDEFAULT,y 为 0 时,系统会自动让窗口出现在一个合适的地方。

(6/7)、 nWidth/nHeight

窗口的大小(长 / 宽)。

和上面 x/y 同理,填 CW_USEDEFAULT 时系统会自动设置大小。

不过这里可能会不太方便:设置控件位置 / 往窗口上绘图时都只能使用绝对的窗口坐标,不支持自动改变坐标 / 使用相对比例。

(8)、 hWndParent

窗口的父窗口。

我们要创建一个独立的窗口,因此填 nullptr 代表没有。

(9)、 hMenu

窗口所使用的菜单。

刚才在窗口类里已经定义了菜单,因此这里填 nullptr 就好。

(10)、 hInstance

当前实例句柄。

(11)、 lpParam

传给窗口过程函数的参数,会附带在窗口创建时的消息 WM_CREATE 里。

如果没有需要传的参数,一般填 nullptr 就行。

(12)、 返回值

创建好的窗口的句柄。

注意区分 “当前实例句柄” 和 “窗口句柄” !


继续下段代码:

1
2
3
4
if (!hWnd)
{
return FALSE;
}

验证窗口是否被成功创建,配合前文提到的代码可实现 “如果窗口创建失败就自动退出” 的功能。

ShowWindow

显示窗口。

顾名思义,让创建好的窗口显示出来。

这里终于用上了前面提到的 nCmdShow。

UpdateWindow

更新一次窗口,用于让窗口里除了控件以外的东西正常显示。

return

返回真,代表窗口成功创建。

4、 WndProc

到这里几个用于创建窗口的函数就都结束了,接下来是窗口过程函数,也就是用于为窗口添加内容,处理用户操作的函数。

前面应该提过,这个函数不需要我们自己调用,它是由前面的消息循环(while)中的某个 API 函数调用的。每当程序收到一个消息,这个函数就会被运行一次。

看看参数:

1
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam)

hWnd

窗口句柄。

message

收到的消息正文。

wParam / lParam

收到的消息的附加内容。


在模板里,这个函数里面就是一个 switch 语句,用于判断收到的消息是什么。

介绍 4 个最基础的消息,剩余的可以去 MSDN 查找。

主要的一些消息:https://docs.microsoft.com/en-us/windows/win32/winmsg/window-notifications

更多的内容(不止关于消息):https://docs.microsoft.com/en-us/windows/win32/winmsg/windowing

WM_COMMAND

当程序中的任意控件被触发(如用户点击按钮)都会收到这个消息,需要依靠附加内容判断具体被触发的控件。

1
int wmId = LOWORD(wParam);

wmId 的值就是控件的 Id,因此继续用 switch 判断就可以了。

模板里的 Id 因为代表的都是在资源文件里定义的内容(菜单,对话框之类的),所以都是定义在资源文件里的。以后我们自己使用代码创建按钮等控件时,选择 Id 的值以及为了方便阅读代码而定义宏之类的操作都需要我们自己来做。

WM_PAINT

窗口重绘消息。

1
2
3
4
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
// TODO: 在此处添加使用 hdc 的任何绘图代码...
EndPaint(hWnd, &ps);

这一堆东西都是为了在创建出的窗口中绘图而准备的。

我并不太了解 GDI 的各种函数,因此读者可以自行查阅 MSDN,并在注释:
// TODO:
之后,EndPaint 之前编写自己的绘图代码。

注意,GDI+ 需要额外头文件和静态链接库,具体是:

1
2
3
#include <objidl.h>
#include <gdiplus.h>
#pragma comment(lib, "gdiplus.lib")

C++ 中,GDI+ 的命名空间是 Gdiplus。

微软没有对 C 语言提供 GDI+ 支持。

WM_CREATE

窗口被创建时收到的消息。

这个消息被收到时,CreateWindow 函数还没有返回。

WM_DESTROY

窗口将被销毁时收到的消息,最常见的情况是用户点击了窗口右上角的 X。

此时窗口还没被销毁,可以在这里做一些保存之类的操作。

前面讲消息循环的时候我提到过有个函数会发送真正的退出消息 WM_QUIT,就是下面这个:

PostQuitMessage

它的唯一一个参数,我前文有提到会保存在 msg.wParam 里并最终 return 给操作系统。一般没出错的程序传 0 就行。


Windows 有非常多的消息,有些消息我们在程序里用不到,因此需要让操作系统帮我们处理。switch 的 default 分支承担了此工作。

DefWindowProc

这个函数的 4 个参数对应了窗口过程函数的 4 个参数,直接把窗口过程函数的形参作为这个函数的实参传进去就可以。


补充一个函数:

DestroyWindow

窗口将要被关闭时会收到 3 个参数,按先后顺序排列是:

WM_CLOSE WM_DESTROY WM_QUIT

DefWindowProc 如果收到了 WM_CLOSE,就会调用 DestroyWindow 给窗口发送 WM_DESTROY,我们收到 WM_DESTROY,再调用 PostQuitMessage 让程序收到 WM_QUIT 进而退出消息循环。

如果我们想自定义一个控件用于退出程序,但 WM_DESTROY 里又有需要在关闭前运行的代码,那么就可以在 WM_COMMAND 的处理逻辑中调用 DestroyWindow,达到使用自定义方式关闭程序的同时不浪费 WM_DESTROY 中的代码的效果。


非常开心啊,写 gu 了半年,终于把创建窗口的基本流程写完了。接下来是一些更高级的内容。

四、 通过控件让用户与程序交互

控件,是用户与程序交互的途径,Win32 程序可以使用下列几大类控件:

一些控件

通过设置不同的样式,可以做出非常多种不同的控件。

正式编程前,先介绍微软官方的一个小工具:Control Spy,这是一个方便开发者尝试控件的软件,功能非常强大,读者可以自行尝试研究一下。


前面提到过,Win32 中,每个控件都是一个窗口,因此,显而易见的,创建控件的函数也是 CreateWindow。

为了方便,这里直接介绍 CreateWindowEx。

CreateWindowEx

lpWindowName,x,y,nWidth,nHeight,hWndParent,hInstance,lpParam 参数的功能均与 CreateWindow 一致,这里不再重复。注意创建控件的时候,hWndParent 填刚才创建好的窗口的句柄,x / y 是相对窗口左上角的坐标。

dwStyle

首先,因为控件是子窗口,因此要加上 WS_CHILD。

其次,Windows 将一些相近的控件放到了一个类里面,因此,在 Control Spy 里能看到这个参数随着控件的改变还可以填一些别的值。大多数值的意义可以通过宏定义的名称理解,因此读者可以通过 Control Spy 自己试一试各个值的含义。记得改变值以后要点击右边的 Apply 按钮。

dwExStyle

窗口的扩展样式,在 MSDN 里有详细介绍可选的值的作用。

如果用不上这个参数的话,请直接使用 CreateWindow。除了这个扩展样式的参数外,这两个函数剩下的参数表示的意义相同。

lpClassName

窗口类名,创建控件时则是微软预定义的控件类名。

很遗憾,我并没有找到微软对这个控件类名的汇总,因此,介绍一个新软件:Spy++。

这是 VS 自带的一个软件,我们主要用到它的搜索功能。

在 VS 上方菜单栏中找到“工具”-“Spy++”,就能打开这个软件。打开以后内容很复杂,我们不用管,继续在新打开的软件的菜单栏里找到“搜索”-“查找窗口”,点击它。

guyktJ.gif

上面的 GIF 图为剩余操作步骤,按步操作即可。

hMenu

控件的唯一 ID。

微软认为控件不需要菜单,因此这个参数就被用来标识控件了。

消息循环中 WM_COMMAND 消息只能告诉我们有控件被触发,而附加消息里的值就是在这个参数里指定的。

当此参数用于标识控件时,传入的值必须是整数,且需要 (HMENU) 强制转换类型。

为了增强程序可读性,可以定义宏定义来代表 ID,如:

1
#define IDC_MYBTN 1234

由于这个 ID 是我们指定的,因此,上面例子中的 1234 可以是任意的数,只要确保没有重复即可。


如果留心的话,读者应该能发现上面创建出来的控件是“拟态”风格(就像现实中突出来的一个按钮一样),而更多的程序的控件则是蓝边框,鼠标移上去有渐变效果的版本。

这其实可以在链接的时候选择的,如果你的编译环境高于 VS 2015,那么在源文件最上方加入下面这行代码,就可以让控件使用上面我提到的新样式了。

1
#pragma comment(linker,"\"/manifestdependency:type='win32' \name='Microsoft.Windows.Common-Controls' version='6.0.0.0' \processorArchitecture='*' publicKeyToken='6595b64144ccf1df' language='*'\"")

关于控件的创建就是这些内容,读者可以参考 VS 的模板代码,通过在 WM_COMMAND 消息处理代码的 switch 语句中增加分支,来响应自己的控件。

分支的值和前面创建控件时定义的标识 ID 的值需要完全对应。

五、 使用对话框快捷的创建窗口与控件

用 CreateWindow 创建控件其实是一件很麻烦的事,所以可以拖控件创建窗口的对话框就出现了。

观察一下 VS 的模板,应该可以发现“About”这个窗口就是用对话框创建的。

这项技术对于创建简单窗口来说非常方便,唯一的缺点是对高分屏和系统缩放的支持很糟糕(Windows 传统),会出现文字模糊等情况。

下面来具体说一下用对话框作为主窗口的过程。

1、 创建一个对话框

创建对话框.gif

参照上图创建一个对话框(上传此图时 imgtu 出了点问题,因此换用 sm.ms 图床,速度可能较慢,见谅)。

可以看到 VS 为我们准备了功能非常丰富的可视化编辑器,读者可自行尝试用此工具设计程序的界面。

MFC 的控件虽然显示在了控件工具箱区域里,但是是不可以使用的(会导致窗口无法被显示)。

2、 将对话框作为主窗口

对话框不涉及窗口类的内容,所以直接在 WinMain 里用 CreateDialog 函数创建就行。

这种情况下,WinMain 里只包括 CreateDialog,ShowWindow 和关于消息循环部分的代码(对话框创建窗口的消息循环部分的代码与传统方式创建窗口的代码完全相同)。

CreateDialog

这也是一个嵌套宏定义,默认指向 CreateDialogW。

看看参数:

hInstance

当前实例句柄,用 WinMain 的形参 hInstance 就行。

lpName

对话框的 ID,使用 MAKEINTRESOURCE 宏创建,如:

MAKEINTRESOURCE(IDD_MYDLG)

IDD_MYDLG 部分请参考上面的动图填写创建出来的对话框的真实 ID。

hWndParent

对话框的父窗口。

我们要把对话框当作主窗口使用,因此请在此参数位置填写 GetDesktopWindow(),直接使用此函数获取桌面的窗口句柄并作为参数使用。

lpDialogFunc

对话框过程函数。

请填写函数名,但末尾不要加括号,并且需要 (DLGPROC) 强制转换类型。


对话框也需要过程函数,要求参数和返回类型是:

1
2
3
4
5
6
7
INT_PTR Dlgproc(
HWND unnamedParam1,
UINT unnamedParam2,
WPARAM unnamedParam3,
LPARAM unnamedParam4
)
{...}

Dlgproc 可以自由填写,在上面的 lpDialogFunc 中指定即可。

对话框的过程函数与传统窗口的过程函数处理代码几乎完全相同,处理控件消息的方式也一样。区别大约有三点:创建操作,删除操作,让消息由系统代处理。

对话框窗口过程函数中让系统代处理消息

传统窗口中,我们使用 DefWindowProc 函数忽略消息,但在对话框中,万万不可这样做。

对话框中的操作其实更简单:如果我们处理了消息,就让窗口过程函数返回 TRUE;如果我们打算忽略这条消息,让系统处理它,那么直接返回 FALSE 就行。

注意:TRUE 和 FALSE 都是宏定义,不要用 C++ 自带的 bool 类型的 true / false 或者 C 语言新标准的 _Bool 类型代替。

WM_INITDIALOG

对话框完全创建完成后会收到这条消息,此时可以对对话框进行操作。

注意:此消息的返回值带有特殊含义,当返回 TRUE 时,键盘焦点会自动移动到这个对话框上,当返回 FALSE 时则不会改变键盘焦点。

对话框的关闭消息

此消息在 WM_SYSCOMMAND 中。

当对话框收到 WM_SYSCOMMAND 消息,且 wParam 等于 SC_CLOSE 时,就代表用户执行了关闭对话框的操作。

此时可以用 DestroyWindow 函数 或 PostQuitMessage 函数关闭对话框并结束消息循环。


关于对话框的内容就介绍到这里,读者可参考 VS 模板中 About 窗口的代码与传统窗口的过程函数,来处理可视化制作出的对话框上的各种控件的消息。

(对话框分模态对话框和非模态对话框,本节介绍的是非模态对话框,即 CreateDialog 函数执行完后会立刻返回。模态对话框的创建要比非模态对话框简单,具体代码参看 VS 模板的 About 窗口即可。)

六、 使用 EasyX 在窗口中绘图。

鉴于 EasyX 作者面对网络的极度保守的思想,不再保留这部分内容。

当然实现这个功能还是很简单的,直接复制 dc 就行。

七、 高 DPI 支持

这方面的内容比较复杂,下面是一篇来自看雪论坛的文章,非常详细的介绍了这部分内容,可以参考。

《Win32 应用程序 DPI 适配的设计与实现》
https://bbs.pediy.com/thread-267416.htm
(已获得转载链接授权)

关于对话框,在 Win10 1703 版本以上,有一个方便的解决办法:

1
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);

(所以下载 Windows sdk 的时候要下最新版啊,要不然就只能 函数指针 + LoadLibrary + GetProcAddress 从 dll 里动态加载函数了。)

八、 定时器

这东西的效果是定时通知程序(发消息 / 调用回调函数)

主要是两个函数 SetTimer,KillTimer。

SetTimer

设置一个定时器,有 4 个参数。

hWnd

要通知的窗口的句柄。

这个参数不能为 NULL,需要填写上面 CreateWindow 函数,或 CreateDialog 函数的返回值。

nIDEvent

定时器的标识。

看类型就能知道,这里填写一个非 0 的,不重复的 unsigned int 类型的数就行。

uElapse

每两次通知之间的时间间隔,以毫秒为单位。

注:1000 毫秒 = 1 秒。

lpTimerFunc

回调函数,填回调函数名,不加括号。

当此参数为 0 时,定时器会按照参数定期向窗口过程函数发送消息 WM_TIMER。

但因为这个消息的优先级低,因此时间很可能不准,所以不推荐使用。

回调函数的定义:

1
2
3
4
5
6
7
8
9
10
VOID CALLBACK TimerProc (
HWND hwnd,
UINT message,
UINT iTimerID,
DWORD dwTime)
{

 // 此处的代码会被定期执行。

}

KillTimer

用于结束一个定时器,第一个参数是想结束的 SetTimer 使用的 hWnd ,第二个参数是这个 SetTimer 使用的 nIDEvent。


后记

这篇文章到这里就结束了,看起来有点长,但我所写的这些内容,其实只是一些关于 Win32 窗口部分的非常非常基础的内容。

读者可以自行在网络上查找一些更高级的内容(比如使用 GDI 绘图),来让自己的程序的图形界面更高级,更漂亮。