Win32 API 中的自定义控件:基础知识






4.97/5 (69投票s)
纯 Win32 API 中自定义控件开发入门简介。
本系列文章
- Win32 API 中的自定义控件:基础知识
- Win32 API 中的自定义控件:绘图
- Win32 API 中的自定义控件:视觉样式
- Win32 API 中的自定义控件:标准消息
- Win32 API 中的自定义控件:控件自定义
- Win32 API 中的自定义控件:自定义控件的封装
- Win32 API 中的自定义控件:滚动
引言
许多(如果不是全部)关于 Win32 API 编程的教科书和教程都涉及实现自定义控件的主题。但是据我所知,没有真正全面的资料(或者至少我不知道)涵盖比这个主题非常基础的更多内容。
其中大部分资料只提供了一些关于如何注册新窗口类的信息,以及关于几个消息(最著名的是 WM_PAINT
)的简单信息,然后就切换到其他主题了。如果你曾经尝试过实现自己的非简单控件,我敢肯定你也会同意我的观点,那就是控件的实现者必须了解更多,而且在这条路上会遇到许多陷阱。
本文旨在作为该系列的第一部分,该系列最终将尝试更深入地涵盖该主题,包括最佳实践,希望这些能对您实现易于应用程序开发者使用、符合 Windows 外观和感觉并有助于创建优秀应用程序的良好自定义控件有所帮助。
由于这是该系列的第一部分,它将总结基础知识,因此最终它将只是我先前所批评的那些其他资料的一种变体。我只能希望亲爱的读者能原谅我缓慢的开端。
几点说明
本文(以及整个系列)面向熟悉 C 语言并且至少熟悉 Windows API 基本知识的 Windows 开发人员。您应该了解“消息循环”、“窗口过程”或“窗口类”等术语,并熟悉这些概念。
请注意,文章将附带一些 C 代码示例。代码的编写主要是为了更好地说明问题,而不是为了实现上的完整性。特别要注意的是,为了提高可读性,错误处理大部分都被省略了。实际应用程序当然应该更认真地处理错误。
在整个系列中,代码示例将遵循一些与所实现控件相关的标识符的编码约定
XXS_STYLE
:我们将使用前缀XXS_
来表示自定义控件特定的样式位。XXM_MESSAGE
:我们将使用前缀XXM_
来表示自定义控件特定的消息。XXN_NOTIFICATION
:我们将使用前缀XXN_
来表示自定义控件特定的通知代码。
简单控件
我们将从一个实现非常简单的自定义控件的代码开始。它也可以作为您实验的骨架。它包含三个源文件。第一个文件是描述控件接口的头文件。目前它非常简单,因为它实际上并没有提供任何有趣的功能。
下面的代码大致对应于我最初批评的那些资料的状态,因此我认为不需要详细的注释。
/* File custom.h
* (custom control interface)
*/
#ifndef CUSTOM_H
#define CUSTOM_H
#include <tchar.h>
#include <windows.h>
/* Window class */
#define CUSTOM_WC _T("CustomControl")
/* Register/unregister the window class */
void CustomRegister(void);
void CustomUnregister(void);
#endif /* CUSTOM_H */
以下是自定义控件的实现
/* File custom.c
* (custom control implementation)
*/
#include "custom.h"
static void
CustomPaint(HWND hwnd)
{
PAINTSTRUCT ps;
HDC hdc;
RECT rect;
GetClientRect(hwnd, &rect);
hdc = BeginPaint(hwnd, &ps);
SetTextColor(hdc, RGB(0,0,0));
SetBkMode(hdc, TRANSPARENT);
DrawText(hdc, _T("Hello World!"), -1, &rect, DT_SINGLELINE | DT_CENTER | DT_VCENTER);
EndPaint(hwnd, &ps);
}
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
case WM_PAINT:
CustomPaint(hwnd);
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
void
CustomRegister(void)
{
WNDCLASS wc = { 0 };
wc.style = CS_GLOBALCLASS | CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = CustomProc;
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.lpszClassName = CUSTOM_WC;
RegisterClass(&wc);
}
void
CustomUnregister(void)
{
UnregisterClass(CUSTOM_WC, NULL);
}
最后是一个使用该控件的简单应用程序
/* File main.c
* (application doing actually nothing but creating a main window and
* the custom control as its only child)
*/
#include <tchar.h>
#include <windows.h>
#include "custom.h"
static HINSTANCE hInstance;
static HWND hwndCustom;
#define CUSTOM_ID 100
#define MARGIN 7
static LRESULT CALLBACK
MainProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
case WM_SIZE:
if(wParam == SIZE_MAXIMIZED || wParam == SIZE_RESTORED) {
WORD cx = LOWORD(lParam);
WORD cy = HIWORD(lParam);
SetWindowPos(hwndCustom, NULL, MARGIN, MARGIN,
cx-2*MARGIN, cy-2*MARGIN, SWP_NOZORDER);
}
break;
case WM_CREATE:
hwndCustom = CreateWindow(CUSTOM_WC, NULL, WS_CHILD | WS_VISIBLE,
0, 0, 0, 0, hwnd, (HMENU) CUSTOM_ID, hInstance, NULL);
break;
case WM_CLOSE:
PostQuitMessage(0);
break;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
int APIENTRY
_tWinMain(HINSTANCE hInst, HINSTANCE hInstPrev, TCHAR* lpszCmdLine, int iCmdShow)
{
WNDCLASS wc = { 0 };
HWND hwnd;
MSG msg;
hInstance = hInst;
CustomRegister();
wc.style = CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = MainProc;
wc.hInstance = hInstance;
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.hbrBackground = (HBRUSH)(COLOR_BTNFACE + 1);
wc.lpszClassName = _T("MainWindow");
RegisterClass(&wc);
hwnd = CreateWindow(_T("MainWindow"), _T("App Name"), WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT, 350, 250, NULL, NULL, hInstance, NULL);
ShowWindow(hwnd, iCmdShow);
while(GetMessage(&msg, NULL, 0, 0)) {
if(IsDialogMessage(hwnd, &msg))
continue;
TranslateMessage(&msg);
DispatchMessage(&msg);
}
CustomUnregister();
return (int)msg.wParam;
}
(在本文顶部附带了包含这些源文件的 MS Visual Studio 2010 项目。)
窗口类和 CS_GLOBALCLASS
假设您能够实现简单的 Windows 应用程序,那么前面的代码应该不会让您感到惊讶:自定义控件的实现方式与您第一个 Win32 Hello World 程序中的典型应用程序窗口非常相似。在其窗口创建和使用之前必须先注册其窗口类,并且存在众所周知的窗口过程。
唯一可能不同的是类样式 CS_GLOBALCLASS
,它使窗口类成为“全局”的。实际上,如果实现的控件在单个模块中注册和使用(例如,它同时在 EXE 中注册和使用,或者在一个 DLL 中),则不需要使用它(实际上不应该使用),而是应该将 WNDCLASS::hInstance
设置为模块的句柄。它是传递给 WinMain()
的 HINSTANCE
句柄,或者在 DLL 的情况下是传递给 DllMain()
的句柄。然后,当您创建控件时,必须指定相同的实例句柄。
然而,如今许多应用程序由多个程序组成,甚至更多的是它们经常使用许多 DLL,也许还支持一些插件(动态加载的 DLL),这些插件可能需要重用该控件,在这种情况下,CS_GLOBALCLASS
非常方便:当您使用它时,系统会找到该类,即使传递给 CreateWindow()
的实例句柄与控件窗口类注册时的实例句柄不匹配。
当调用 CreateWindow()
(或 CreateWindowEx()
)时,系统首先查找指定的本地类(即,未带 CS_GLOBALCLASS
标志且具有匹配 HINSTANCE
句柄注册的窗口类)。如果未找到,它将搜索全局类(即,具有 CS_GLOBALCLASS
样式的类)。
这意味着整个进程中具有 CS_GLOBALCLASS
样式的类共享一个公共命名空间,并且它们甚至可以被具有特定 HINSTANCE
句柄注册的本地窗口类本地覆盖。另外请注意,来自 USER32.DLL 和 COMCTL32.DLL 的所有标准控件也使用此类样式,因此最好避免为您的本地类使用这些名称(除非您确实想实现覆盖效果,当然)。
因此,在注册全局类时,您在选择其名称时应该谨慎,以最大程度地减少意外覆盖的可能性。我建议使用反向域名来生成唯一的类名称(例如,"com.company.control"
),以降低此类名称冲突的风险。
控件数据
一个非简单的自定义控件通常需要一些内存来存储其数据和内部状态。这通常通过定义一个在控件生命周期内存在的结构来实现。该结构的指针通常存储在控件窗口的额外字节中。以下代码演示了如何做到这一点
typedef struct CustomData_tag CustomData;
struct CustomData_tag {
// ... the control data
};
void
CustomRegister(void)
{
WNDCLASS wc = { 0 };
wc.style = CS_GLOBALCLASS | CS_HREDRAW | CS_VREDRAW;
wc.lpfnWndProc = CustomProc;
wc.hCursor = LoadCursor(NULL, IDC_ARROW);
wc.lpszClassName = CC_WINDOWCLASS;
wc.cbWndExtra = sizeof(CustomData*); // <-- Here we are
RegisterClass(&wc);
}
然后,控件的窗口过程可以使用 SetWindowLongPtr()
和 GetWindowLongPtr()
来设置和访问该结构。下面的代码显示了上面介绍代码的窗口过程的扩展版本中结构的创建和销毁
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
// Retrieve the pointer to the control data so it may be used in all message handlers below:
CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);
switch(uMsg) {
// ...
case WM_NCCREATE:
// Allocate, setupo and remember the control data:
pData = malloc(sizeof(CustomData));
if(pData == NULL)
return FALSE;
SetWindowLongPtr(hwnd, 0, (LONG_PTR)pData);
// ... initialize CustomData members
return TRUE;
case WM_NCDESTROY:
if(pData != NULL) { // <-- "If" required as we get here even when WM_NCCREATE fails.
// ... free any resources stored in the data structure
free(pData);
}
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
我想强调几点
- 许多教程在
WM_CREATE
(和WM_DESTROY
)中分配(和释放)数据。我建议使用它们的“NC”对应项,如上面的代码所示。这样更安全,因为在窗口创建过程中,WM_NCCREATE
是发送的第一个消息,然后发送几个其他消息,最后才发送WM_CREATE
消息。如果您曾经在代码中添加过对这些消息的处理程序,您可能会轻易地引入对NULL
指针的解引用,从而导致众所周知的后果:应用程序崩溃。同样,WM_NCDESTROY
是控件被销毁时收到的最后一条消息,而不是WM_DESTROY
。 - 请注意,Windows 在这里并不遵循常见的错误处理模式,即释放/终止代码仅在相应的初始化成功后才被调用。如果
WM_NCCREATE
失败(即,当它返回FALSE
时),Windows 仍然会发送WM_NCDESTROY
。因此,应用程序必须确保在尝试在WM_NCDESTROY
处理程序中解引用指针之前,该指针不是NULL
。对于WM_CREATE
和WM_DESTROY
也是如此:如果前者返回-1
,后者仍然会被调用。 - 此外,许多教程都避免使用“额外字节”,而是将
GWL_USERDATA
用作SetWindowLongPtr()
和GetWindowLongPtr()
的参数。然而,对于通用控件来说,这是一个错误的想法,因为控件应该将GWL_USERDATA
留给应用程序,因为应用程序也可能需要为其自身目的将一些附加数据与控件关联起来。
定义控件接口
当然,所有提供有用功能的控件都必须通过某种接口来提供。标准控件的接口主要包括定义它们的样式、扩展样式、控件特定消息和通知。该接口也可能使用普通函数,但我作为一致性原则的信徒,将遵循标准控件的方式,主要坚持使用消息和通知。
因此,实际上所有需要的是在暴露控件接口的头文件中指定控件特定常量的数值代码。让我们更仔细地看看每个类别。
消息
控件可以接收一些标准的(系统)消息,这些消息可以使其顺利集成到应用程序中。控件特定消息用于更改其内部状态、触发其某些功能或询问它可能持有的某些数据。
Win32 API 定义了几个消息代码范围,如果您想避免麻烦,最好遵守它们
- 范围 0 到 0x03ff (
WM_USER-1
): 系统保留的消息。控件应根据需要响应它们。除非控件需要执行特殊操作,否则通常可以将其传递给DefWindowProc()
,该函数会以默认方式处理消息。许多系统消息以及如何在您的代码中处理它们将是后续文章的一部分。 - 范围 0x0400 (
WM_USER
) 到 0x7fff (WM_APP-1
): 此范围用于控件特定的消息。您作为控件的设计者,通常会在描述控件接口的头文件中定义这些消息。此外,我建议避免使用 0x2000 (OCM_BASE
) 到 0x23ff (OCM_BASE+WM_USER-1
) 范围内的消息代码,因为这些代码通常用于一种称为消息反射的技术。(也许我们会在后续文章中更深入地探讨这一点。) - 范围 0x8000 (
WM_APP
) 到 0xbfff: 如果自定义控件仅由您的应用程序内部使用,则可以使用这些。可重用的通用控件应避免使用此范围,并将此范围留给应用程序进行控件子类化。 - 范围 0xc000 到 0xffff: 这些仅用于与
RegisterWindowMessage()
结合使用,与我们的主题无关。 - 0xffff 以上的消息 由 Microsoft 保留。
也就是说,在大多数情况下,控件特定消息应落入由 WM_USER
定义的范围内。
#define XXM_DOSOMETHING (WM_USER + 100) #define XXM_DOSOMETHINGELSE (WM_USER + 101)
(示例中的值 100 只是一个任意值,用于说明将消息放置在控件特定消息代码范围内的某个位置。)
要实现消息,只需在窗口过程中添加另一个分支
static LRESULT CALLBACK CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch(uMsg) { // ... case XXM_DOSOMETHING: // do something return TRUE; case XXM_DOSOMETHINGELSE: // do something else return FALSE; } return DefWindowProc(hwnd, uMsg, wParam, lParam); }
最后但同样重要的是,您还应该进行一些文档记录,并描述消息实际上做了什么,它们如何解释从 SendMessage()
(或其他类似函数)传递的 wParam
和 lParam
,以及应用程序应如何解释其返回值。
样式
标准控件的外观和行为也可以通过设置其样式和扩展样式来确定。样式和扩展样式都是与每个窗口 (HWND
) 相关联的 DWORD
位掩码。这两个位掩码在窗口创建时设置:CreateWindow()
根据其参数之一指定的样式设置样式,并将扩展样式设置为零,而 CreateWindowEx()
允许同时指定两者。
应用程序也可以稍后使用 SetWindowLong()
分别通过 GWL_STYLE
和 GWL_EXSTYLE
来更改两者。
样式的最高字和扩展样式的所有位的含义由系统定义,尝试覆盖它们是一个非常糟糕的主意。自定义控件应(只要在特定控件的上下文中合理)尊重这些位,并根据它们的含义进行行为。
样式的低 16 位可供控件实现使用,您可以定义它们来增强控件的特定于您需求的行为。
#define XXS_BEMOREFANCY 0x0001 #define XXS_HIGHESTCUSTOMSTYLE 0x8000
Notifications
如果自定义控件需要通知应用程序有关某些事件或状态更改,或者需要询问应用程序某事,它可以发送标准的通知消息 (WM_NOTIFY
)。当然,它必须遵守该消息提供给应用程序的约定,即 WPARAM
必须指定控件的 ID,而 LPARAM
必须指向 NMHDR
或以它开头的控件特定结构。
自定义控件发送通知的一般代码可能如下所示
static void
CommonSendSomeNotification(HWND hwnd)
{
NMHDR hdr;
hdr.hwndFrom = hwnd; // The custom control handle
hdr.idFrom = GetWindowLong(hwnd, GWL_ID);
hdr.code = XXN_NOTIFICATIONCODE;
SendMessage(GetParent(hwnd), WM_NOTIFY, hdr.idFrom, (LPARAM) &hdr);
}
作为控件接口的一部分,代码 XXN_NOTIFICATIONCODE
当然应该在描述控件接口的头文件中定义。请注意,与消息不同,Win32 API 没有为通知定义任何范围。但是,如果您查看 Windows SDK 头文件,您可能会看到标准通知代码设置了最高有效位(它们被定义为转换为 UINT
的负数),因此,如果您在 0 到 0x7fffffff 的范围内定义控件特定通知,则不会与系统通知代码发生冲突。
#define XXN_NOTIFICATIONCODE 0x1
真实代码
没有什么能取代研究真实代码的可能性,而且在实现功能齐全的自定义控件时,您很可能需要不止一次。我可以为您提供两个(我希望)可以作为良好示例的参考。第一个是 Wine (http://www.winehq.org) 项目,特别是它对 USER32.DLL 和 COMCTL32.DLL 的(重新)实现
- https://github.com/mirrors/wine/tree/master/dlls/user32
- https://github.com/mirrors/wine/tree/master/dlls/comctl32
在自定义控件方面,Wine 项目具有两种价值:首先,您可以将代码视为任何其他自定义控件的实现。其次,也许更重要的是,由于 Wine 努力尽可能地与原生 Windows 实现兼容,其源代码对于您希望设计出与原生 Windows 控件尽可能一致的自定义控件也非常有价值。它们可以很好地说明这些控件可以做什么或不能做什么。相信我:在许多方面,Wine 代码比 MSDN 或其他地方提供的任何文档都更完整。
因此,例如,如果您实现了一个“看起来有点像树视图”但“太不相同而无法仅通过子类化来定制标准树视图”的控件,那么研究 Wine 的树控件实现可能是正确的第一步。
第二个参考是由我(主要)提供的一个“大杂烩”。它是 mCtrl 项目 (http://mctrl.org),实现了几个自定义控件。该项目远未完成,但已经可以很好地说明本文系列的示例代码。这也不是偶然的,因为本文以及我计划撰写的后续文章都基于我在开发过程中获得的经验。项目代码托管在 github 上,并提供了自定义控件的实现(子目录 src)以及说明用法(子目录 examples)的简单示例应用程序。
这两个项目都是开源的,当然,您也可以在您的项目中重用和修改代码,前提是您遵守您感兴趣的项目许可条件。
敬请期待
正如文章开头已经提到的,这只是关于自定义控件实现的广泛主题的系列文章的第一篇。下次,我们将更深入地探讨如何绘制自定义控件。此外(当实现新控件时这是一个相当普遍的麻烦),还将探讨如何避免频繁重绘时出现的闪烁,例如,当控件正在调整大小时。