Win32 API 中的自定义控件:自定义控件的封装






4.87/5 (22投票s)
为摆脱自定义控件特定的代码,减少父窗口过程的负担的一些技术。
本系列文章
- Win32 API 中的自定义控件:基础知识
- Win32 API 中的自定义控件:绘图
- Win32 API 中的自定义控件:视觉样式
- Win32 API 中的自定义控件:标准消息
- Win32 API 中的自定义控件:控件自定义
- Win32 API 中的自定义控件:自定义控件的封装
- Win32 API 中的自定义控件:滚动
引言
上次,我们讨论了一些自定义控件的技术。然而,我们发现所有技术或多或少都需要自定义控件代码与父窗口过程(通常是对话框或顶级窗口)之间进行一定程度的直接协作。如果您简要查看上一篇文章中介绍的自定义技术,您会发现所有者绘制、自定义绘制和通知,甚至在设计上都需要父窗口过程的积极协作。
乍一看,超类化和子类化从这个角度来看似乎更好。但实际上,复杂的自定义需要跟进底层控件逻辑的变化。例如,如果超类化或子类化控件覆盖了 WM_PAINT
,它可能需要在内部状态发生变化时得到通知,以便能够正确绘制控件。通常,底层控件通过向其父窗口发送通知来告知外界,因此最简单的解决方案是在父窗口中捕获通知,然后将其重新发送回自定义控件,以便其自定义窗口过程可以处理它。因此,即使是超类化和子类化也存在同样的问题。
这会产生根本性的影响:在其他对话框或应用程序中重用此类控件会更加复杂,因为需要将父窗口中的逻辑重新实现到新的窗口中。因此,我们将在本文中尝试解决这个问题。
软件工程师多年前就认识到,封装逻辑上相关的代码是可重用代码的理想属性。在 OOP(面向对象编程)范例中,封装甚至是其中最重要和最核心的方面之一。封装允许轻松地获取可重用代码,并在应用程序的其他模块中,甚至在应用程序之外(例如,作为独立的库,以便可以在其他应用程序中重用)重用它。阅读、审查和维护此类代码也更容易,因为它允许读者在概念上将应用程序代码分解成更小、更容易理解的块。
尽管 OOP 指南和手册主要讨论类,但我认为该概念在所有应用程序设计级别都非常有价值:在将应用程序分解为核心程序和一组库时,在将代码拆分为具有可理解的公共接口的源文件时,以及在我们的上下文中,也包括自定义控件。
我并不是说封装每个你自定义的 Win32 控件都是个好主意。如果你的应用程序中有自定义控件的单个实例,并且该控件在逻辑上与父窗口绑定,你知道你将来不会在其他地方重用该控件,那么你可以随意偷懒。实际上,封装代码可能比自定义本身更复杂,而且可能不值得付出这些努力。
但是,如果你的自定义控件需要在多个对话框中重用,如果它在父窗口代码中的处理很复杂,如果对话框过程本身就很复杂,如果你的对话框过程需要理解控件内部逻辑的细节,尽管这与应用程序的更高层逻辑无关,那么封装可能会为你带来有趣的收益。
那么,自定义控件的封装实际上意味着什么?这意味着我们想在概念上理解为控件实现的所有逻辑,都应该从父窗口过程中移除,并与其分离。如果你再次查看上一篇文章中介绍的所有自定义技术,你可能会注意到,父窗口过程实际上必须处理由自定义控件发送的某些通知。
包装器窗口
解决该问题的显而易见的方法是将自定义控件包装在另一个窗口中,将控件包装起来面向外部世界。
包装器窗口是我们要自定义的核心控件的父窗口,它实现了自定义控件的父窗口过程中必须包含的所有内容。也就是说,它跟踪来自自定义控件(原始控件过程)的通知,并且它也可以与子类或超类过程紧密协作。
对于外部世界来说,包装器窗口就是控件本身。从应用程序的角度来看,执行大部分工作的包装器子窗口只是控件的实现细节,应用程序根本不应该关心它。
因此,包装器实际上充当了应用程序(对话框过程)和核心控件(子窗口)之间的代理。也就是说,包装器必须理解构成自定义控件公共 API 的所有消息,并在大多数情况下将其重新发送给子窗口。同样,包装器会将所有由子窗口发送的公开可见的通知重新发送给应用程序。
虽然这种方法很直接,但它也有些笨拙和不优雅:包装器的存在只是在应用程序中增加了一个不必要的间接层,它消耗了一些与 HWND
句柄相关的系统资源,而它实际上并没有做任何有用的工作,除了进行一些连接。此外,它使所有窗口的树状层次结构加深了一级,这可能会对通过 Windows 自动化 API 或辅助功能 API 查看层次结构的实用程序产生一些负面影响。
消息反射
处理该问题的更好方法是将通知(和其他类似通知的消息)从对话框过程重新发送回控件本身,并在子类或超类过程中直接处理它们,而不是使用包装器窗口。在 Win32 开发领域,这项技术非常普遍,以至于它有自己的名称:消息反射。
Win32API 头文件olectl.h包含以下预处理器宏定义
#define OCM__BASE (WM_USER+0x1c00)
#define OCM_COMMAND (OCM__BASE + WM_COMMAND)
#define OCM_CTLCOLORBTN (OCM__BASE + WM_CTLCOLORBTN)
#define OCM_CTLCOLOREDIT (OCM__BASE + WM_CTLCOLOREDIT)
#define OCM_CTLCOLORDLG (OCM__BASE + WM_CTLCOLORDLG)
#define OCM_CTLCOLORLISTBOX (OCM__BASE + WM_CTLCOLORLISTBOX)
#define OCM_CTLCOLORMSGBOX (OCM__BASE + WM_CTLCOLORMSGBOX)
#define OCM_CTLCOLORSCROLLBAR (OCM__BASE + WM_CTLCOLORSCROLLBAR)
#define OCM_CTLCOLORSTATIC (OCM__BASE + WM_CTLCOLORSTATIC)
#define OCM_DRAWITEM (OCM__BASE + WM_DRAWITEM)
#define OCM_MEASUREITEM (OCM__BASE + WM_MEASUREITEM)
#define OCM_DELETEITEM (OCM__BASE + WM_DELETEITEM)
#define OCM_VKEYTOITEM (OCM__BASE + WM_VKEYTOITEM)
#define OCM_CHARTOITEM (OCM__BASE + WM_CHARTOITEM)
#define OCM_COMPAREITEM (OCM__BASE + WM_COMPAREITEM)
#define OCM_HSCROLL (OCM__BASE + WM_HSCROLL)
#define OCM_VSCROLL (OCM__BASE + WM_VSCROLL)
#define OCM_PARENTNOTIFY (OCM__BASE + WM_PARENTNOTIFY)
#define OCM_NOTIFY (OCM__BASE + WM_NOTIFY)
如您所见,所有 Win32API 通知和类似通知的消息都有其对应的OCM_前缀,其数值 ID 比 OCM__BASE
增加。
使用这些宏,在父窗口过程中实现消息反射就相当直接了。
- 父窗口的代码需要处理上面列表中的标准
WM_xxxx
消息。 - 如果消息是由想要自己处理该消息的自定义控件发送的,则父窗口需要将相应的
OCM_xxxx
消息(带有原始WPARAM
和LPARAM
值)发送给控件。 - 控件的超类或子类过程应该处理
OCM_xxxx
消息,或者直接返回零。
唯一可能不清楚的是第二点:父窗口如何知道哪些控件期望某些消息被反射?在具有少量控件的简单对话框中,您可以简单地知道需要这种处理的控件的 ID,并在父窗口的过程中硬编码它们。
但也有一个简单的通用方法:假设所有控件都设计良好,并且它们为消息反射的目的保留了列出的 OCM_xxxx
范围,那么父窗口默认可以将所有此类消息重新发送给任何控件,当它没有更好的方法来处理该消息时。(请注意,在 Win32 API 自定义控件:基础 中建议保留 0x2000 (OCM_BASE
) 到 0x23ff (OCM_BASE+WM_USER-1
) 的范围。)
当发送给这种没有处理 OCM_xxxx
消息的礼貌的未自定义控件时,它会被传递给 DefWindowProc()
。该函数不知道如何处理 OCM_xxxx
消息,因此它什么也不做,直接返回零。
下面是一个展示如何处理反射的代码片段。当然,反射可以直接分散在所有相关的消息处理程序中,但下面的代码将反射隔离到一个地方:DefParentProc()
,它是我们标准 DefWindowProc()
的包装器,并且可以被您应用程序中的所有父窗口过程重用。
#include <olectl.h>
static LRESULT
DefParentProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
case WM_NOTIFY:
{
NMHDR* nmhdr = (NMHDR*) lParam;
if(nmhdr->hwndFrom != NULL)
return SendMessage(nmhdr->hwndFrom, uMsg + OCM__BASE, wParam, lParam);
break;
}
// All of these provide the control's HHWND in LPARAM
case WM_COMMAND:
case WM_CTLCOLORBTN:
case WM_CTLCOLOREDIT:
case WM_CTLCOLORDLG:
case WM_CTLCOLORLISTBOX:
case WM_CTLCOLORMSGBOX:
case WM_CTLCOLORSCROLLBAR:
case WM_CTLCOLORSTATIC:
case WM_VKEYTOITEM:
case WM_CHARTOITEM:
if(lParam != 0)
return SendMessage((HWND) lParam, uMsg + OCM__BASE, wParam, lParam);
break;
// All of these provide ID of the control in WPARAM:
case WM_DRAWITEM:
case WM_MEASUREITEM:
case WM_DELETEITEM:
case WM_COMPAREITEM:
if(wParam != 0) {
HWND hwndControl = GetDlgItem(hwnd, wParam);
if(hwndControl)
return SendMessage(hwndControl, uMsg + OCM__BASE, wParam, lParam);
}
break;
// Note we do not reflect WM_PARENTNOTIFY -> OCM_PARENTNOTIFY as that
// usually does not make much sense.
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
LRESULT CALLBACK
MainWinProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
case WM_COMMAND:
switch(HIWORD(wParam)) {
case ID_SOMECONTROL:
... // Handle some control explicitly
return 0;
case ID_OTHERCONTROL:
... // Handle other control explicitly
return 0;
}
break; // Propagate the message into DefParentProc() below.
case WM_NOTIFY:
switch(wParam) {
case ID_SOMECONTROL:
... // Handle some control explicitly
return 0;
case ID_OTHERCONTROL:
... // Handle other control explicitly
return 0;
}
break; // Propagate the message into DefParentProc() below.
... // and so one for all messages the parent needs to handle explicitly
}
// Pass unhandled messages into DefParentProc()
return DefParentProc(hwnd, uMsg, wParam, lParam);
}
注入式消息反射
严格来说,前面介绍的消息反射并没有实现完全的代码封装。虽然它可以以通用方式实现,但父窗口仍然需要与子控件进行一些协作(即执行反射)。
当父窗口过程不在我们的直接控制之下,或者当对控件的可重用性要求非常高,以至于您希望避免在父窗口过程中对控件进行任何特殊处理时,我们需要能够从自定义(超类或子类)过程中获取来自原始控件过程的通知。
通过一些额外的工作,这可以实现。并且可以使用您应该已经从上一篇文章中了解到的技术来实现:子类化。控件可以在其创建过程中(WM_NCCREATE
),自定义其父窗口并强制其进行消息反射。
然而,我们需要小心:我们不能盲目地反射所有子控件的消息,因为我们不知道父窗口过程处理了什么,又没有处理什么,而且我们不希望与其发生冲突。在下面的示例中,我们通过维护一个需要进行反射的控件列表来解决这个问题。父窗口子类过程会查询列表,如果 HWND
在列表中,它会进行反射而不是调用原始窗口过程。
这有一个副作用:父窗口无法覆盖行为,但可以说,这甚至是一个优点:自定义控件会看到来自底层原始控件发送的通知,父窗口无法以任何方式破坏其逻辑。
#include <olectl.h>
// A container keeping list of the HWND handles for controls which need the message reflection.
// (Its implementation is left to the reader as an exercise.)
typedef struct { ... } CONTROL_LIST;
CONTROL_LIST* ControlListCreate(void);
void ControlListDestroy(CONTROL_LIST* pList);
BOOL ControlListAppend(CONTROL_LIST* pList, HWND hWnd);
void ControlListRemove(CONTROL_LIST* pList, HWND hWnd);
BOOL ControlListContains(CONTROL_LIST* pList, HWND hWnd);
BOOL ControlListEmpty(CONTROL_LIST* pList);
// Parent window subclass procedure. For messages which may need the reflection, it consults
// the list of controls and, according to the result, it either does the reflection or calls
// the original parent window procedure.
static LRESULT CALLBACK
ParentSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwData)
{
CONTROL_LIST* pControlList = (CONTROL_LIST*) dwData;
UINT i;
// Reflect the message (if needed):
switch(uMsg) {
case WM_NOTIFY:
{
NMHDR* nmhdr = (NMHDR*) lParam;
if(ControlListContains(pControlList, nmhdr->hwndFrom))
return SendMessage(nmhdr->hwndFrom, uMsg + OCM__BASE, wParam, lParam);
break;
}
// All of these provide the control's HHWND in LPARAM
case WM_COMMAND:
case WM_CTLCOLORBTN:
case WM_CTLCOLOREDIT:
case WM_CTLCOLORDLG:
case WM_CTLCOLORLISTBOX:
case WM_CTLCOLORMSGBOX:
case WM_CTLCOLORSCROLLBAR:
case WM_CTLCOLORSTATIC:
case WM_VKEYTOITEM:
case WM_CHARTOITEM:
if(ControlListContains(pControlList, (HWND) lParam)
return SendMessage((HWND) lParam, uMsg + OCM__BASE, wParam, lParam);
break;
// All of these provide ID of the control in WPARAM:
case WM_DRAWITEM:
case WM_MEASUREITEM:
case WM_DELETEITEM:
case WM_COMPAREITEM:
if(wParam != 0) {
HWND hwndControl = GetDlgItem(hwnd, wParam);
if(ControlListContains(pControlList, hwndControl)
return SendMessage(hwndControl, uMsg + OCM__BASE, wParam, lParam);
}
break;
// Note we do not reflect WM_PARENTNOTIFY -> OCM_PARENTNOTIFY as that
// usually does not make much sense.
}
// If not reflected, call the original parent procedure:
return DefSubclassProc(hWnd, uMsg, wParam, lParam);
}
// The implementation of the customized control.
// Note that on creation/destruction we update the list of controls that need the message
// reflection and also we make sure that the parent is subclassed for the lifetime
// of the control. The subclass then does the reflection for us.
static LRESULT CALLBACK
ControlSuperclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
case WM_NCCREATE:
{
CREATESTRUCT* pCs = (CREATESTRUCT*) lParam;
CONTROL_LIST* pControlList;
// Ensure the parent window shall reflect notification to us:
if(GetWindowSubclass(hWnd, ParentSubclassProc, 0, (DWORD_PTR*) &pControlList)) {
if(!ControlListAppend(pControlList, hWnd))
return -1;
} else {
pControlList = ControlListCreate();
if(pControlList == NULL)
return -1;
ControlListAppend(pControlList, hWnd);
SetWindowSubclass(hWnd, ParentSubclassProc, 0, (DWORD_PTR) pControlList);
}
// Break to CallWindowProc() so the underlying control is properly
// created and initialized:
break;
}
case WM_NCDESTROY:
{
LRESULT lRes;
CONTROL_LIST* pControlList;
lRes = CallWindowProc(lpfnOriginalProc, hWnd, uMsg, wParam, lParam);
GetWindowSubclass(hWnd, ParentSubclassProc, 0, (DWORD_PTR*) &pControlList);
ControlListRemove(pControlList, hWnd);
if(ControlListEmpty(pControlList)) {
ControlListDestroy(pControlList);
RemoveWindowSubclass(GetParent(hWnd, ParentSubclassProc, 0));
}
return lRes;
}
// Customize the underlying control as desired. We may handle the reflected
// messages (OCM__xxx) as needed.
....
}
// By default, propagate the message to the underlying control procedure:
return CallWindowProc(lpfnOriginalProc, hWnd, uMsg, wParam, lParam);
}
显然,如果您尝试使用 SetWindowParent()
将控件重新归属到另一个父窗口,那么提供的代码就会失败。可以付出更多努力来添加此类支持,但可以说,这不值得。特别是考虑到大多数标准控件不支持这一点,正如 Raymond Chen 的博文 为什么在我重新归属我的控件后,它会将通知发送到错误的窗口? 中所述。
下次:滚动支持和非客户区
本文结束后,我们将正式结束控件自定义的话题。
下次,我们将探讨如何为您的控件添加滚动条支持,以及如何在非客户区进行绘制(特别是与视觉样式相关的)。起初,这两者可能看起来是两个完全不相关的议题,但如果您意识到滚动条是非客户区的一部分,那么这两者之间的联系就变得有意义了。
敬请关注,本系列文章尚未结束,尽管与上一篇文章相比,本次延迟了比我预期的要长得多的时间。希望下一篇文章的延迟会更短