Win32 API 中的自定义控件:标准消息






4.92/5 (37投票s)
让你的控件响应系统或应用程序可能提出的问题。
本系列文章
- Win32 API 中的自定义控件:基础知识
- Win32 API 中的自定义控件:绘图
- Win32 API 中的自定义控件:视觉样式
- Win32 API 中的自定义控件:标准消息
- Win32 API 中的自定义控件:控件自定义
- Win32 API 中的自定义控件:自定义控件的封装
- Win32 API 中的自定义控件:滚动
引言
在之前的文章中,我们讨论了控件的绘制。今天,我们将重点关注处理许多消息,这些消息介导了控件、其对话框或其他父窗口之间的交互,以及控件与操作系统本身的交互。
请注意,与本系列所有文章一样,这并非参考文档。本文并非旨在取代 MSDN,而是对其进行补充。MSDN 通常会描述消息的作用、如何通过 WPARAM
和 LPARAM
传递参数,我认为在此重复这些内容毫无意义。相反,本文仅概述了控件通常需要实现的消息,并提供了一些从控件角度(而非尝试向控件发送消息的应用程序角度)进行实现的提示。
Unicode 和 ANSI 字符串
在我们开始讨论消息之前,让我们简要谈谈字符串以及标准控件如何处理 Win32 API 中的 Unicode 与 ANSI 双重性,因为这显然也会影响自定义控件如何处理字符串参数。
注意:在 Win32 API 上下文中,*Unicode* 表示采用小端字节序的 UTF-16 编码。(在 Windows 2000 上,它仅支持 UCS-2,即 UTF-16 的一个子集,不支持代理对。)*Windows ANSI* 或 Win32 API 上下文中的 *ANSI* 是一个不恰当的术语,表示由当前*代码页*确定的面向字节的编码。与 Unicode 不同,相同的字节序列在不同的代码页中具有不同的含义,并且通常可能无效。通常,所有新代码都应优先使用 Unicode,至少在内部如此。
大多数以字符串作为参数(或指向具有字符串成员的结构的指针)的控件特定消息实际上是两个不同的消息。Unicode 格式的消息带后缀 W
,ANSI 格式的消息带后缀 A
。当应用程序源代码使用不带任何后缀的消息名称时,预处理器宏 UNICODE
会在公共头文件中控制使用哪个。
然而,这主要适用于控件特定消息。你可以在 *commctrl.h* 中找到许多这种方法的例子,用于负责插入或设置列表视图或树视图等控件项的消息。
标准消息,特别是 WM_NCCREATE
、WM_CREATE
、WM_SETTEXT
和 WM_GETTEXT
,工作方式略有不同。这些消息没有 Unicode/ANSI 版本。相反,系统会记住窗口类是使用 RegisterClassW()
还是 RegisterClassA()
(或其 RegisterClassEx()
对应项)注册的。根据这一点,我们将生成的 HWND
称为*Unicode 窗口*或*ANSI 窗口*。
所有相关函数,如 CreateWindow()
、SetWindowText()
或 GetWindowText()
,都有 Unicode 或 ANSI 版本(同样,函数名称中分别带有后缀 W
或 A
)。每当调用该函数时,它会自动进行所需的转换,以相应形式将字符串传递给窗口过程。
随着全球化的推进,国际化和本地化成为任何现代软件的自然需求。因此,Unicode 在这个世界中越来越占据主导地位,标准控件在内部始终使用 Unicode(至少自 Windows 2000 以来)。我建议你在控件中遵循这一经验法则,除非你有非常充分的理由不这样做。
这通常意味着遵循以下步骤
- 使用 Unicode 版本的
RegisterClass()
注册您的控件。 - 将标准消息中的字符串解释为 Unicode。
- 在控件结构中以 Unicode 格式保存字符串。
- 实现新的控件特定消息时,要么为 Unicode 实现它,要么实现两个不同的消息。下面的代码演示了这一点。
注意:列表的前三点可以通过显式使用函数(如 RegisterClassW()
等)的 W
版本来实现,或者通过使用 Unicode 解析宏(不带任何后缀的名称)并通过适当的编译器选项将项目构建为 Unicode 项目来实现。在 Visual Studio 中,可以在项目属性中配置此项,gcc 编译器为此目的提供了 * -municode* 选项。
/* custom.h
* (public control interface) */
...
#define XXM_SOMESTRINGMESSAGEW (WM_USER+100)
#define XXM_SOMESTRINGMESSAGEA (WM_USER+101)
#ifdef UNICODE
#define XXM_SOMESTRINGMESSAGE XXM_SOMESTRINGMESSAGEW
#else
#define XXM_SOMESTRINGMESSAGE XXM_SOMESTRINGMESSAGEA
#endif
...
/* custom.c
* (control implementation) */
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
...
case XXM_SOMESTRINGMESSAGEW:
... // Handle input (WPARAM/LPARAM) as Unicode.
return 0;
case XXM_SOMESTRINGMESSAGEA:
... // Handle input (WPARAM/LPARAM) as ANSI.
// (Assuming the control holds the strings in Unicode,
// this usually involves converting the string with
// MultiByteToWideChar() (when string is input parameter)
// or WideCharToMultiByte() (output parameter).
return 0;
...
}
}
通知对 Unicode 和 ANSI 字符串的处理方式不同,我们将在本文末尾讨论。
控件的创建和销毁
我们已经在本系列第一部分中讨论了控件的创建和销毁。然而,让我们更仔细地审视一下。
消息 WM_NCCREATE
是控件在其生命周期中接收到的第一个消息。相应地,消息 WM_NCDESTROY
是最后一个消息。对于自定义控件的实现,这些消息适用于分配和设置控件所需的资源(例如,一些结构来保存控件的内部数据和状态),或分别释放它们。
注意,对于 WM_NCCREATE
,DefWindowProc()
将通过 LPARAM
作为 CREATESTRUCT::lpszName
从 CreateWindow()
传播的窗口文本设置。如果您的控件使用窗口文本,您应该将消息传播到 DefWindowProc()
。有关窗口文本的更多信息将在本文后面的专门章节中讨论。
在窗口创建期间,即在 CreateWindow()
的上下文中(并假设创建过程中没有失败),会发送 WM_NCCREATE
和 WM_CREATE
消息。但是在这两个消息之间还可能发送其他消息(通常是 WM_NCCALCSIZE
),以及在 WM_CREATE
之后(例如 WM_SIZE
、WM_MOVE
、WM_SHOWWINDOW
)。因此,WM_NCCREATE
应该执行最少的工作,以使其数据处于一致状态(这通常意味着为状态分配结构,并将其初始化为一些默认值)。这保证了其他消息处理程序可以毫无问题地使用数据。
作为经验法则,控件的大部分其他(不太关键的)设置应在 WM_CREATE
中处理。因此,例如,控件的非必需资源应在 WM_NCCREATE
中初始化为 NULL
,然后在 WM_CREATE
中分配。特别是如果资源分配涉及调用其他 Win32 API 函数:它们可能会导致向控件发送其他消息,而这种设计则保证了控件数据处于一致状态,并有助于避免细微的错误。一些设置甚至可以进一步推迟到其他消息,如 WM_SIZE
(例如,如果复杂控件需要计算一些布局)和其他适当的消息处理程序中。
当窗口被销毁时,即在 DestroyWindow()
的上下文中,控件会收到 WM_DESTROY
,然后是 WM_NCDESTROY
。好的做法是在 WM_NCDESTROY
中撤销 WM_NCCREATE
所做的工作,并在 WM_DESTROY
中撤销 WM_CREATE
所做的工作。我还建议 WM_DESTROY
保持控件处于一致状态,特别是将 CustomData
结构中所有已释放的指针重置为 NULL
。
注意:即使之前 WM_NCCREATE
消息失败(即返回 FALSE
),也会发送 WM_NCDESTROY
。同样,即使 WM_CREATE
失败(即返回 -1
),也会发送 WM_DESTROY
。这会影响错误路径,如下面的代码摘录所示。
如果通过自定义现有窗口类来实现控件,则必须特别小心。因此,良好的做法是在派生类中本地处理 WM_NCCREATE
和 WM_CREATE
之前,将其传播到原始窗口过程。然而,这是另一个话题,我们将在以后探讨。
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
HWND hwnd;
HTHEME hTheme;
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_NCCREATE:
if(!DefWindowProc(hwnd, uMsg, wParam, lParam))
return FALSE;
pData = malloc(sizeof(CustomData));
if(pData == NULL)
return FALSE;
ZeroMemory(pData, sizeof(CustomData));
pData->hwnd = hwnd;
// ... Set pData to consistent state (e.g. some reasnable default values)
SetWindowLongPtr(hwnd, 0, (LONG_PTR)pData);
return TRUE;
case WM_CREATE:
{
LRESULT lres;
lres = DefWindowProc(hwnd, uMsg, wParam, lParam);
if(lres == -1)
return -1;
// Further setup, e.g. opening theme handle if the control supports themed painting.
pData->hTheme = OpenThemeData(hwnd, L"...");
// ...
return lres;
}
case WM_DESTROY:
if(pData->hTheme != NULL) {
CloseThemeData(pData->hTheme);
pData->hTheme = NULL;
}
break;
case WM_NCDESTROY:
if(pData != NULL) { // pData is NULL if WM_NCCREATE failed before SetWindowLongPtr()!!!
// ... free all the other resources we alloc'ed in WM_NCCREATE
free(pData)
}
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
窗口样式和扩展样式
每个窗口都有与之关联的样式和扩展样式。两者实际上都是双字(DWORD
)位掩码,它们增强了控件的外观或行为。最初,样式和扩展样式根据 CreateWindow()
或 CreateWindowEx()
的参数设置。样式在 WM_NCCREATE
和 WM_CREATE
中作为 CREATESTRUCT
的成员传播。
稍后,在控件的生命周期中,可以通过使用 GWL_STYLE
或 GWL_EXSTYLE
索引的 SetWindowLong()
函数来更改它们。
扩展样式的所有位和样式的高 16 位具有系统定义的含义。样式中的低 16 位可用于控件特定目的。
系统定义的样式和扩展样式位通常由传递给 DefWindowProc()
的适当消息处理。例如,如果控件具有样式 WM_BORDER
,则 WM_NCCALCSIZE
的处理会通过使控件的客户区稍微小一点来为边框保留空间,而 WM_NCPAINT
则在空间中绘制边框。
(实际上,边框是未主题化的。我们最终会在后续文章中介绍非客户区的绘制和相关内容。)
然而,至少对于控件特定的样式,控件实现必须知道何时更改样式以反映它。通过消息 WM_STYLECHANGING
和 WM_STYLECHANGED
监视样式和扩展样式位的更改。WM_STYLECHANGING
在更改执行之前发送,如果控件希望,可以拒绝更改或强制进行不同的更改。
尽管控件总是可以通过 GetWindowLong()
查询当前样式,但通常更好、更方便的做法是将样式(至少是我们可能需要经常访问的低 16 位样式)本地缓存到我们的控件数据结构中。
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
DWORD style : 16;
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_NCCREATE:
{
CREATESTRUCT* cs = (CREATESTRUCT*) lParam;
// ...
pData->style = cs->style;
return TRUE;
}
case WM_STYLECHANGING:
{
/* Sometimes, for example, a control has to allow setting some style only
* during its creation, and it needs to block the change of it thenafter.
* It can be done by augmenting STYLESTRUCT::styleNew as we wish. */
if(wParam == GWL_STYLE) {
STYLESTRUCT* ss = (STYLESTRUCT*) lParam;
/* Prevent change in some styles we do not allow to change during control's life: */
if((ss->styleOld ^ ss->styleNew) & (XXS_CONSTSTYLE1 | XXS_CONSTSTYLE2)) {
ss->styleNew &= ~(XXS_CONSTSTYLE1 | XXS_CONSTSTYLE2);
ss->styleNew |= ss->styleOld & (XXS_CONSTSTYLE1 | XXS_CONSTSTYLE2);
}
}
break;
}
case WM_STYLECHANGED:
if(wParam == GWL_STYLE) {
STYLESTRUCT* ss = (STYLESTRUCT*) lParam;
/* Remember new style in our data structure for simple use */
pData->style = ss->styleNew;
/* If a style affecting look of the control changes, we may need to repaint it. */
if((ss->styleOld ^ ss->styleNew) & (XXS_ANOTHERLOOK | XXS_MORECOLOROUS)) {
if(!pData->bNoRedraw)
InvalidateRect(hwnd, NULL, TRUE);
}
}
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
窗口几何
每当控件被创建、调整大小、其(普通或扩展)样式改变或其状态栏状态改变时,系统都会向控件发送 WM_NCCALCSIZE
,以便它可以重新排列控件区域的哪些部分是非客户区或客户区。DefWindowProc()
中的默认实现会根据样式、扩展样式以及滚动条所需的空间(如果有)为边框、客户区边缘和滚动条保留一些空间。
此消息的自定义实现可能非常复杂,因为它需要考虑所有参数组合和预期的返回值,但它很少需要,默认实现几乎总是足够的。唯一的例外可能是当我们希望在粗糙控件的类样式 CS_HREDRAW
和 CS_VREDRAW
不足的情况下,告诉系统在调整大小后控件客户区的哪些部分保持有效。在这种情况下,实现可能如下所示
/* custom.c
* (control implementation) */
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_NCCALCSIZE:
if(wParam) {
// Return the desired combination of WVR_xxx constants, to
// specify which part of client should be preserved after the
// resize. For example:
return WVR_ALIGNTOP | WVR_ALIGNRIGHT;
}
// In other cases, pass the message into DefWindowProc().
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
系统知道控件的哪些部分属于非客户区后,它会向控件发送 WM_MOVE
(如果客户区位置改变)和 WM_SIZE
(如果客户区大小改变),并根据 WM_NCCALCSIZE
的返回值使客户区的部分无效。
每当控件的可见性发生改变时,控件会收到 WM_SHOWWINDOW
消息。这包括调用 ShowWindow()
时,也包括父窗口最小化/恢复时,或者当其他窗口最大化并因此完全遮挡控件时。可以通过分析其参数来区分这些情况。
窗口文本
为了设置和检索与窗口关联的文本,Win32 API 提供了 WM_SETTEXT
和 WM_GETTEXT
消息。然而,应用程序通常不直接使用这些消息,而是调用 SetWindowText()
和 GetWindowText()
函数,这些函数可以在调用者和窗口对字符串类型的看法不匹配时,在 Unicode 和 ANSI 之间转换字符串。
当传递给 DefWindowProc()
时,文本会直接从窗口管理器存储或检索。这允许 Windows 从属于另一个进程的窗口获取文本,即使该进程没有响应。Raymond Chen 在他的著名博客 The Old New Thing 中对此进行了很好的描述:GetWindowText 的秘密生活。
同样值得注意的是,对于某些实用工具(例如各种辅助工具),即使控件不显示任何特定文本,窗口文本也可能非常有用,因此将其设置为合理的值可以改善有某些残疾或限制的用户的体验。
如果您决定以不同方式处理消息并(例如)将文本直接存储在控件的结构中,那么您还应该始终如一地处理 WM_NCCREATE
和 WM_GETTEXTLENGTH
消息
/* custom.c
* (control implementation) */
#include <tchar.h>
#include <strsafe.h>
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
TCHAR* lpszText;
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_SETTEXT:
{
TCHAR* lpszText = (TCHAR*) lParam;
TCHAR* lpszTmp;
size_t sz;
if(lpszText != NULL)
StringCchLength(lpszText, STRSAFE_MAX_CCH, &sz);
else
sz = 0;
lpszTmp = (TCHAR*) malloc(sizeof(TCHAR) * (sz+1));
if(lpszTmp == NULL)
return FALSE;
StringCchCopyEx(lpszTmp, sz+1, lpszText, NULL, NULL, STRSAFE_IGNORE_NULLS);
if(pData->lpszText != NULL)
free(pData->lpszText);
pData->lpszText = lpszTmp;
return TRUE;
}
case WM_GETTEXT:
{
size_t sz = (size_t) wParam;
TCHAR* lpBuffer = (TCHAR*) lParam;
TCHAR* lpEnd;
if(pData->lpszText == NULL)
return 0;
StringCchCopyEx(lpBuffer, sz, pData->lpszText, &lpEnd, NULL, 0);
return (lpEnd - lpBuffer);
}
case WM_GETTEXTLENGTH:
{
size_t sz;
if(pData->pszText == NULL)
return 0;
StringCchLength(pData->lpszText, STRSAFE_MAX_CCH, &sz);
return sz;
}
case WM_NCCREATE:
{
CRERATESTRUCT* cs = (CREATESTRUCT*) lParam;
size_t sz;
// ...
if(cs->lpszText != NULL)
StringCchLength(cs->lpszText, STRSAFE_MAX_CCH, &sz);
else
sz = 0;
pData->lpszText = (TCHAR*) malloc(sizeof(TCHAR) * (sz+1));
if(pData->lpszText == NULL)
return FALSE;
StringCchCopyEx(pData->lpszText, sz+1, cs->lpszText, NULL, NULL, STRSAFE_IGNORE_NULLS);
return TRUE;
}
case WM_NCDESTROY:
// ...
if(pData->lpszText != NULL)
free(lpszText);
// ...
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
注意:在大多数情况下,依赖 DefWindowProc()
并在需要时(例如在绘制代码中)调用 GetWindowText()
就足够了(而且工作量肯定更少)。除非您有充分的理由手动操作,否则您通常应该优先选择这种方式,如代码摘录所示。
窗口字体
每个在绘制过程中绘制文本的控件,都应该允许应用程序指定它应该使用的字体。为此,API 提供了 WM_SETFONT
和 WM_GETFONT
消息。它们的实现足够简单,无需进一步解释
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
HFONT hFont;
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_SETFONT:
pData->hFont = (HFONT) wParam;
if((BOOL) lParam && !pData->bNoRedraw)
InvalidateRect(hwnd, NULL, TRUE);
return 0;
case WM_GETFONT:
return (LRESULT) pData->hFont;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
剩下的就是绘制代码,以便在绘制文本时将字体选择到设备上下文中。如果 hFont
为 NULL
,则控件应使用 GetStockObject(SYSTEM_FONT)
返回的字体。请注意,此字体已在新设备上下文中预选。
使用 DrawThemeText()
绘制文本时,请记住,该函数仅在其为主题类/部件/状态组合定义了字体时,才使用主题定义中指定的字体。如果未定义,则该函数使用已选入设备上下文的字体。因此,即使对于主题代码路径,将字体选入设备上下文也是一个好主意。
绘制
窗口绘制是基于处理 WM_ERASEBKGND
、WM_PAINT
和 WM_PRINTCLIENT
,我们已经在本系列之前的一篇文章中描述过了。
除此之外,还有许多标准控件支持的 WM_SETREDRAW
消息。此消息允许应用程序在控件状态改变时暂时禁用其重绘功能。例如,这常用于列表视图和树视图等控件。通常,应用程序可能需要用大量项目填充这些控件,而控件在每次插入后都会刷新自身以反映其所持有的数据。
如果应用程序知道它会一次性插入数百个项目,它可以禁用重绘,插入所有项目,重新启用重绘并强制重绘
/* app.c * (some application) */ ... SendMessage(hwnd, WM_SETREDRAW, FALSE, 0); // Disable repaint ... // Huge number of messages which change control's state SendMessage(hwnd, WM_SETREDRAW, TRUE, 0); // Re-enable repaint RedrawWindow(hwnd, NULL, NULL, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN);
注意:复杂的控件可能还需要重绘其子窗口或其非客户区,因此我们使用了带有所有标志的 RedrawWindow()
而不是简单的 InvalidateRect(hwnd, NULL, TRUE)
。但是,如果已知没有子窗口或框架,则 InvalidateRect()
就足够了。
如果您想支持此消息(如果设置或填充控件可能导致过多重绘,您就应该这样做),那么在控件实现端,这通常意味着只需在控件数据中记住一个标志,并且仅在未设置该标志时使控件区域无效
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
// Note the inverted logic of this meber.
// I.e. ZeroMemory() in WM_NCCREATE sets it correctly to FALSE.
BOOL bNoRedraw;
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_PAINT:
{
// Adapted WM_PAINT handler from the article about painting.
// We only added the test for pData->bNoRedraw.
PAINTSTRUCT ps;
BeginPaint(hwnd, &ps);
if(!pData->bNoRedraw) {
if(GetWindowLong(hwnd, GWL_STYLE) & XXS_DOUBLEBUFFER)
CustomDoubleBuffer(hwnd, &ps);
else
CustomPaint(hwnd, ps.hdc, &ps.rcPaint, ps.fErase);
}
EndPaint(hwnd, &ps);
return 0;
}
case WM_SETREDRAW:
pData->bNoRedraw = !wParam;
if(!pData->bNoRedraw) // Repaint automatically on re-enabling.
InvalidateRect(hwnd, NULL, TRUE);
return 0;
case XXM_CHANGESTATE:
... // modify control's state by modifying data in pData
// (e.g. insert/set/delete items of any kind in it).
if(!pData->bNoRedraw) // Avoid repaint when disabled by the app.
InvalidateRect(hwnd, NULL, TRUE);
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
注意:根据 MSDN,当重新启用重绘时,应用程序本身应该使控件区域无效并重绘。然而,许多标准控件会自动执行此操作。我决定在上述示例中遵循这种做法。这种方法的优点还在于,控件可能更清楚地知道具体需要重绘什么,因此它可能能够仅使其区域的相关部分无效。
我推荐阅读 The Old New Thing 博客上的另一篇文章,作为关于 WM_SETREDRAW
主题的进一步补充阅读:WM_SETREDRAW 有一个默认实现,但您可能会做得更好。
另一个与绘制相关的消息是 WM_NCPAINT
。我们将在另一篇文章中讨论它,所以现在跳过它。
与对话框的交互
通常,控件的父窗口是对话框。对话框也因其使用对话框模板(DIALOG
或 DIALOGEX
资源)间接创建的简单性而广受欢迎,因为它自动化了手动创建大量窗口的许多代码。当然,对话框模板或其项可以分别引用控件的窗口类。
注意:对话框模板只能引用与模板所在实例句柄(HINSTANCE
)相同的实例句柄注册的控件类,或者如果该类是全局的(即使用了类样式 CS_GLOBAL
)。
对话框的正常行为由其对话框过程决定,该过程通常是通过调用 DefDlgProc()
外部可用的代码,因此自定义对话框可以以与窗口过程使用 DefWindowProc()
类似的方式将消息传播给它。
对话框通常为用户的舒适和效率提供一些功能。其中一些功能需要控件的配合才能良好运行。这些功能由 DefDlgProc()
提供,它通常从对话框的过程中调用。应用程序也可以为普通(非对话框)窗口模拟其中一些功能。例如,请参阅 Raymond Chanin 的另一篇文章:在非对话框中使用 TAB 键导航。
那么,让我们看看如何使控件为这种协作做好准备。
对话框资源可以指定要使用的字体。如果是这样,对话框过程会向它创建的每个控件发送 WM_SETFONT
。我们之前已经讨论过这个消息,所以让我们继续。
对话框的另一个重要功能是键盘导航。用户经常通过按 *TAB* 或 *SHIFT+TAB* 热键来改变控件的焦点。对话框过程还可以使用 *空格键* 或 *回车键* 等其他键。因此,重要的是要告诉对话框你的控件需要哪些键来操作,这样对话框就知道它是否可以将其用于导航,或者是否应该将其传递给控件。这通过处理 WM_GETDLGCODE
来完成。
此消息的处理程序通常只是一行代码,用于指定描述控件需求的位掩码
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
BOOL bNoRedraw; // inverted logic, i.e. ZeroMemory() WM_NCCREATE leaves this on the right default.
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_GETDLGCODE:
return DLGC_flag1 | DLGC_flag2;
// For illustrative purpose only. Refer to MSDN for all possible flags and their meaning.
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
另一个功能是对话框外观中焦点和加速键的视觉反馈。历史上,这些总是显示的。但自 Windows XP 以来,微软决定这可能会干扰主要使用鼠标而很少使用键盘的用户。因此,Windows 默认开始隐藏焦点矩形和加速键标记,除非用户开始使用键盘导航。作为最终用户,您可能喜欢或不喜欢它,作为开发人员,您应该让控件遵循它。不喜欢它的用户可以在控制面板中禁用隐藏。
该逻辑基于每个可以具有焦点或快捷方式的控件对 WM_UPDATEUISTATE
消息的处理。手动处理时,可以这样实现
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
DWORD dwUiState;
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_UPDATEUISTATE:
switch(LOWORD(wParam)) {
case UIS_CLEAR: pData->dwUiState &= ~HIWORD(wp); break;
case UIS_SET: pData->dwUiState |= HIWORD(wp); break;
case UIS_INITIALIZE: pData->dwUiState = HIWORD(wp); break;
}
if(!pData->bNoRedraw)
InvalidateRect(hwnd, NULL, FALSE);
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
其余部分只需在您的绘制代码中遵循标志即可。例如,如果条件 (pData->dwUiState & UISF_HIDEACCEL)
成立,您应该通过在 DrawText()
中使用 DT_HIDEPREFIX
标志来反映它。
或者,您可以依赖 DefWindowProc()
中的消息实现,只在绘制代码中通过向控件发送 WM_QUERYUISTATE
来查询当前状态。
再次,进一步推荐的补充阅读包括 The Old New Thing 上的一些文章
与系统的交互
控件可能还需要处理一些消息,这些消息会通知它 Windows 设置中的某些更改,这些更改可能会影响控件及其外观或行为。
其中一条消息是 WM_SYSCOLORCHANGE
,每当 GetSysColor()
返回的颜色发生变化时,系统会将其发送给所有顶级窗口。顶级窗口应将此消息分发给所有子窗口,包括我们的控件。(注意:这取决于应用程序开发人员。据我所知,DefWindowProc()
不会这样做。)如果控件的绘制依赖于系统颜色,它应该用新颜色重新绘制自己。
另一个用途相似的消息是 WM_THEMECHANGED
,它在主题偏好改变时发送。同样,控件应该用新的主题数据重新绘制自己,这需要重新打开主题句柄。我们已经在上一篇文章中讨论过这一点。
监控焦点
通常,控件在获得或失去焦点时需要重新绘制自身。这可以通过跟踪 WM_SETFOCUS
和 WM_GETFOCUS
消息非常简单地完成。
/* custom.c
* (control implementation) */
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_SETFOCUS:
case WM_GETFOCUS:
if(!pData->bNoRedraw)
InvalidateRect(hwnd, NULL, FALSE);
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
键盘消息
处理键盘的消息有很多,下面我们来介绍一下。
在低级别,有 WM_KEYDOWN
和 WM_KEYUP
消息。顾名思义,前者在您按下键盘上的键时发送,在您一直按住键并触发自动重复时也会发送(您可以通过检查其参数来区分这两种情况)。后者在键释放时发送。
除此之外,还有 WM_CHAR
(或 WM_DEADCHAR
)。其中一些可以在 WM_KEYDOWN
和 WM_KEYUP
之间发送,将虚拟键代码翻译成实际字符。翻译是在应用程序的消息循环中通过调用 TranslateMessage()
函数完成的。当函数看到 WM_KEYDOWN
时,它只会插入相应的 WM_CHAR
,然后通过另一次调用 GetNextMessage()
稍后检索。
注意:处理低级别还是高级别消息确实很重要。消息转换涉及键盘及其布局的配置。它还受大写锁定、Shift 或其他功能键状态的影响。如果您正在开发游戏,您可能更关注 WM_KEYDOWN
和 WM_KEYUP
。如果是文本编辑器,则更可能是 WM_CHAR
用于处理普通文本输入,而 WM_KEYDOWN
和 WM_KEYUP
用于处理 <INSERT> 或光标箭头键等功能键。
上述大部分键盘消息也有其 *SYS* 对应消息:WM_SYSKEYDOWN
、WM_SYSKEYUP
和 WM_SYSCHAR
。实际上,它们在相同的情况下发送,但用于通常用于 Windows 定义任务的组合键。例如,与 *<ALT>* 组合以进入窗口菜单等。处理它们允许您覆盖默认处理(假设您没有将它们传递给 DefWindowProc()
)。在正常情况下,这种分离允许您专注于处理非 SYS 消息并将这些消息保留在 DefWindowProc()
上,而不用担心任何冲突。
此外,还有 WM_UNICHAR
消息,它能够处理任何 UTF-32 Unicode 码点。由于 Windows 内部以 UTF-16
(自 Windows XP 起支持代理对)工作,因此系统永远不会向您发送此消息。这更多是为了您自己的便利:如果支持此消息是有益的,您可以在您的窗口过程中实现它,并且您以及第三方应用程序(通常是用于亚洲语言的各种输入法编辑器)都可以将其发送给您。
尽管标题悲观,但有关处理键盘消息的更多深入阅读可以在文章 WM_KEYDOWN-WM_CHAR 输入模型出了什么问题?中找到。
鼠标消息
鼠标处理涉及更多消息。有用于处理鼠标指针移动的消息(WM_MOVE
)、鼠标按钮操作的消息(例如,WM_LBUTTONDOWN
用于左键按下),以及用于复合操作(如单击或双击)的消息(例如,WM_LBUTTONDBLCLK
)。大多数消息也有两种类型:普通(客户区)和非客户区,其名称以 WM_NC
开头(例如,WM_NCMOUSEMOVE
,WM_NCNCBUTTONUP
)。
当鼠标事件发生在控件的客户区内,或者鼠标捕获生效时,会发送普通消息:控件可以根据其逻辑,通过 SetCapture()
函数要求系统向它发送所有鼠标消息,直到它通过 ReleaseCapture()
释放捕获(或者直到另一个窗口通过 SetCapture()
窃取捕获。当控件失去捕获时,它会收到 WM_RELEASEDCAPTURE
通知)。当鼠标操作发生在非客户区(且捕获未生效)时,会发送“NC”消息。
鼠标捕获经常被使用,例如,在实现类似按钮的控件(或控件的一部分具有类似按钮的性质)时。当其按钮被按下(WM_LBUTTONDOWN
)时,您会进行捕获,这样即使后续按钮释放发生在控件的客户区之外,也会发送给控件
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
BOOL bLeftButtonPressed;
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_LBUTTONDOWN:
pData->bLeftButtonPressed = TRUE;
SetCapture(hwnd);
break;
case WM_LBUTTONUP:
{
POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
RECT rc;
GetClientArea(hwnd, &rc);
if(PtInRect(&rc, pt))
SendMessage(GetParent(hwnd), WM_COMMAND, GetWindowLong(hwnd, GWL_ID), 0);
ReleaseCapture();
}
// No break here, fall through
case WM_RELEASEDCAPTURE:
pData->bLeftButtonPressed = FALSE;
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
注意:当鼠标捕获生效时,持有它的窗口会收到所有鼠标消息。因此,根据其逻辑,对于其中一些消息,控件可能需要手动检查鼠标消息是否确实发生在它内部。
同样重要的是要记住,许多消息只有在您明确请求时才会发送:双击消息(WM_LBUTTONDBLCLK
、WM_RBUTTONDBLCK
等)仅在窗口类使用类样式 CS_DBLCLKS
注册时才会发送。
消息 WM_MOUSELEAVE
和 WM_MOUSEHOVER
仅在其跟踪通过 TrackMouseEvent()
启用时发送。请注意,该函数启用一次消息发送,并且 WM_MOUSELEAVE
会完全禁用跟踪,因此通常需要再次调用该函数
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
BOOL fTrackingMouse;
// ...
};
static void
TrackMouse(HWND hwnd)
{
TRACKMOUSEEVENT tme;
tme.cbSize = sizeof(TRACKMOUSEEVENT);
tme.dwFlags = TME_LEAVE | TME_HOVER;
tme.hwndTrack = hwnd;
tme.dwHoverTime = HOVER_DEFAULT;
TrackMouseEvent(&tme);
}
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_MOUSEMOVE:
if(!pData->fTrackingMouse) {
TrackMouse();
pData->fTrackingMouse = TRUE;
}
break;
case WM_MOUSEHOVER:
... // handle mouse hover (often used for example to show a tooltip)
TrackMouse();
break;
case WM_MOUSELEAVE:
pData->fTrackingMouse = FALSE;
... // handle mouse leave
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
另请注意,没有 WM_MOUSEENTER
消息。您可以将 WM_MOUSEMOVE
用于此目的:每当 WM_MOUSEMOVE
到来且标志 fTrackingMouse
未设置时,则鼠标进入了窗口的客户区。
滚动消息
有一些消息允许控件支持滚动:WM_HSCROLL
、WM_VSCROLL
、WM_MOUSEWHEEL
和 WM_MOUSEHWHEEL
。然而,我们将在后续文章中更详细地探讨滚动。
Accessibility
实际上,控件还应该支持与辅助功能和自动化工具的协作。这通过处理 WM_GETOBJECT
来完成,该消息应该返回指向实现特定 COM 接口(取决于 WPARAM
和 LPARAM
)的 COM 对象的指针。
然而,这是一个相当复杂的话题。此外,我承认由于我对此了解有限,目前无法对此说太多。希望我们以后会回到这个话题。
Notifications
到目前为止,我们主要讨论了系统或应用程序需要与控件通信的情况。但通常,控件也需要向应用程序告知其更改。这通常通过一些通知消息来完成。
主要有两种通知消息:WM_COMMAND
和 WM_NOTIFY
。前者只能携带控件本身的标识(即我们的控件)和一个可打包到其中的 16 位值,因此其典型用例是触发某个动作。例如,标准按钮在点击时会触发 WM_COMMAND
。
后一条消息 WM_NOTIFY
携带更多数据,并且可进一步扩展。参数 LPARAM
实际上是指向 NMHDR
结构或任何其他(控件和通知代码特定)包含任何额外数据的结构的指针。控件只需确保任何此类自定义结构以 NMHDR
开头
/* custom.h
* (public control interface) */
// Notification code
#define XXN_MYNOTIFICATION 101
typedef struct XXNM_NOTIFICATIONDATA {
NMHDR hdr; // NMHDR has to be the 1st member of the struct.
... // Other data.
} XXNM_NOTIFICATIONDATA;
/* custom.c
* (control implementation) */
static void
SendNotification(HWND hwnd)
{
XXNM_NOTIFICATIONDATA nd;
nd.hdr.hwndFrom = hwnd;
nd.hdr.idFrom = GetWindowLong(hwnd, GWL_ID);
nd.hdr.code = XXN_MYNOTIFICATION;
... // setup the other struct members
SendMessage(GetParent(hwnd), WM_NOTIFY, nd.hdr.idFrom, (LPARAM) &nd);
}
然而,如果结构包含字符串,还会有一个额外的复杂性。控件负责以父窗口期望的编码提供字符串。如果父窗口不在您的控制之下,即当您无法确定父窗口想要什么时,您必须通过 WM_NOTIFYFORMAT
消息询问它,要么在每次发送此类消息之前,要么在创建控件时,然后在每次要求刷新信息时
/* custom.c
* (control implementation) */
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
BOOL fUnicodeNotifications;
// ...
};
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_NOTIFYFORMAT:
{
LRESULT format;
if(lParam == NF_REQUERY) {
format = SendMessage(cs->hwndParent, WM_NOTIFYFORMAT, (WPARAM) hwnd, NF_QUERY);
pData->fUnicodeNotifications = (format == NFR_UNICODE);
} else {
format = (pData->fUnicodeNotifications ? NFR_UNICODE : NFR_ANSI);
}
return format;
}
case WM_NCCREATE:
{
CREATESTRUCT* ss = (CREATESTRUCT*) lParam;
LRESULT format;
// ...
format = SendMessage(cs->hwndParent, WM_NOTIFYFORMAT, (WPARAM) hwnd, NF_QUERY);
pData->fUnicodeNotifications = (format == NFR_UNICODE);
return TRUE;
}
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
可下载示例
附件的示例项目(在本页顶部)实现了一个按钮。它大致对应于具有 BS_PUSHBUTTON
或 BS_DEFPUSHBUTTON
样式的标准按钮。当然,这样的代码在任何应用程序中重用都没有用,因为您可以简单地使用标准按钮。
但是,自定义控件的代码仍然演示了本文中描述的很大一部分消息的处理,以及我们之前在关于绘制和视觉样式的文章中已经学到的内容,因此它可以作为您进一步实验的代码。
示例应用程序由一个带有两个按钮的简单对话框组成:一个标准按钮和一个自定义控件实现,因此您可以实时比较它们。唯一的主要区别是(使用默认的 Aero 主题)在 Windows Vista 及更高版本上,真实按钮在改变状态(例如在热状态和普通状态之间)时使用过渡动画。后续文章将专门介绍几种控件动画方法,因此我们肯定会在以后弥补这一空白。
实际代码
今天的真实世界代码供进一步研究,包含了一些 Wine 源代码。它可以让您很好地了解这些函数对各种标准消息的期望。
DefWindowProc()
的实现:https://github.com/mirrors/wine/blob/master/dlls/user32/defwnd.cDefDlgProc()
的实现:https://github.com/mirrors/wine/blob/master/dlls/user32/defdlg.cCreateWindow()
的实现:https://github.com/mirrors/wine/blob/master/dlls/user32/win.c
注意:请记住,Wine 中尚未实现一些对于运行 Windows 应用程序不重要的消息。例如,上述代码不涉及 WM_UPDATEUISTATE
及其相关消息。
下次:调整现有控件
通常,通过调整现有控件来创建新控件比从头开始实现要省力得多。下次,我们将探讨几种实现方法及其优缺点。