这个问题在 Stack Overflow 上有很多重复和几乎重复的问题,但几乎没有一个答案探讨了他们选择解决方案的缺陷。
有几种方法可以将任意数据指针与窗口关联起来,而且有两种不同的情况需要考虑。根据情况不同,可能性也不同。
情况1是当您正在编写窗口类时。这意味着您正在实现 WNDPROC
,并且您的意图是其他人在其应用程序中使用您的窗口类。通常,您不知道谁会使用您的窗口类以及使用它做什么。
情况2是当您在自己的应用程序中使用已经存在的窗口类。一般来说,您无法访问窗口类源代码,也无法修改它。
我假设问题不是最初将数据指针传递到 WNDPROC
中(那只需要通过 CreateWindow[ExW]
中的 CREATESTRUCT
和 lpParam
参数即可),而是如何存储它以供后续调用。
方法1:cbWndExtra
当 Windows 创建窗口实例时,它内部分配一个 WND
结构。此结构具有特定大小,包含各种与窗口相关的事物,如其位置、窗口类和当前 WNDPROC。在该结构末尾,Windows 可选地分配一些与结构相关的附加字节。数字在 WNDCLASSEX.cbWndExtra
中指定,它用于在 RegisterWindowClassEx
中。
这意味着只有在您注册窗口类时才能使用此方法,即您是 窗口类的作者。
应用程序无法直接访问 WND
结构。而是使用 GetWindowLong[Ptr]
。非负索引访问结构末尾的额外字节内存。"0"将访问第一个额外字节。
如果您是窗口类的作者,则这是一种干净且快速的方法。大多数 Windows 内部控件似乎都使用此方法。
不幸的是,这种方法与对话框(DialogBox
家族)并不兼容。除了提供对话框模板外,您还需要有一个对话框窗口类,这可能会变得难以维护(除非您必须出于其他原因进行维护)。如果您确实想在对话框中使用它,必须在对话框模板中指定窗口类名,在显示对话框之前确保注册此窗口类,并且您需要为对话框实现WNDPROC
(或使用DefDlgProc
)。此外,所有对话框都已经为对话框管理器保留了一定量的字节,以使其正常运行。cbWndExtra
所需的额外字节数为DLGWINDOWEXTRA
常数。这意味着你的东西需要出现在对话框已经保留的额外字节之后。将所有访问额外内存的偏移量增加DLGWINDOWEXTRA
(包括您在窗口类中指定的cbWndExtra
的值)。
另外,对话框专用的一个额外方法如下。
方法2: GWLP_USERDATA
前述的WND
结构体碰巧包含一个未被系统使用的指针大小的字段。可以使用带有负索引(即GWLP_USERDATA
)的GetWindowLongPtr
访问它。负索引将访问WND
结构体内的字段。请注意,根据此处,负索引似乎并不代表内存偏移量,而是任意的。
GWLP_USERDATA
的问题在于,过去和现在都不清楚这个字段的确切用途,因此,这个字段的所有者是谁也不清楚。请参见此问题。一般来说,人们普遍认为没有共识。很可能GWLP_USERDATA
是为窗口的用户而设计的,并非窗口类的作者。这意味着在WNDPROC中使用它是严格错误的,因为WNDPROC始终由窗口类的作者提供。
我个人相信,这是想出GWLP_USERDATA
的工程师的意图,因为如果这是真的,那么整个API就是完整、可扩展和具有未来性的。但如果不是真的,那么API既不完整也不可扩展,它将与cbWndExtra
重复。
据我所知,所有标准的Windows控件(例如
BUTTON
、
EDIT
等)都遵循此规则,不会在内部使用
GWLP_USERDATA
,而是将其留给使用这些控件的窗口。问题是,有太多的例子违反了这个规则,包括在MSDN和SO上,它们使用
GWLP_USERDATA
来实现窗口类,这使得一个控件用户最干净、最简单的方法与之关联的上下文指针被剥夺了,只因为有太多人在“错误”的道路上前行(按照我的“错误”的定义)。最坏的情况是,用户代码不知道
GWLP_USERDATA
已被占用,可能会重写它,从而导致应用程序崩溃。
由于关于
GWLP_USERDATA
所有权的长期争议,通常不安全使用它。如果你正在编写一个窗口类,你可能永远不应该使用它。如果你正在使用一个窗口,你只有当你确定它没有被窗口类使用时才能使用它。
方法三:SetProp
SetProp
函数系列实现对属性表的访问,每个窗口都有自己独立的属性。在API表面级别,这个表的键是一个字符串,但在内部它实际上是一个ATOM。
SetProp
可以被窗口类作者和窗口用户使用,但它也有问题,与
GWLP_USERDATA
不同。你必须确保用作属性键的字符串不会冲突。窗口用户可能不知道窗口类作者内部使用了哪些字符串。虽然冲突不太可能,但你可以完全避免它们,例如使用GUID作为字符串。当查看全局ATOM表的内容时,许多程序都以这种方式使用GUID。
SetProp
必须小心使用。大多数资源并没有解释这个函数的缺陷。在内部,它使用
GlobalAddAtom
。使用此函数时需要考虑以下几个因素:
在调用SetProp
(或任何其他使用全局ATOM表的API)时,可以使用ATOM
而不是字符串。获得ATOM
的方法是通过注册一个新字符串GlobalAddAtom
。 ATOM只是一个整数,它指向ATOM表中的一项。这将提高性能;SetProp
内部始终使用ATOM
作为属性键,而不是字符串。传递字符串会导致SetProp
和类似的函数首先在ATOM表中进行内部匹配。直接传递ATOM
会跳过在全局ATOM表中搜索字符串。
全局ATOM表中可能的字符串ATOM数目在系统范围内限制为16384。这是因为ATOM是16位之间的uints,从0xC000
到0xFFFF
(所有值低于0xC000
的都是指向固定字符串的伪原子(可以使用,但无法保证没有其他人在使用它们))。使用许多不同的属性名称不是个好主意,更不用说如果这些名称是在运行时动态生成的话。相反,您可以使用单个属性来存储包含所需所有数据的结构体的指针。
如果您使用GUID,那么可以在与每个窗口工作时都使用相同的GUID,甚至跨不同的软件项目使用相同的GUID,因为每个窗口都有其自己的属性。这种方法,您所有的软件将使用最多全局ATOM表中的两个条目(您作为窗口类作者需要最多一个GUID,您作为窗口类用户需要最多一个GUID)。实际上,为每个人定义两个可以用于其上下文指针的事实标准GUID可能是有意义的(但现实情况不太可能发生)。
由于属性使用GlobalAddAtom
,因此必须确保取消注册这些原子。当进程存在时,全局原子不会被清除并且会堵塞全局原子表直到操作系统重新启动。要做到这一点,您必须确保调用RemoveProp
。通常的好位置是WM_NCDESTROY
。
全局ATOM: 引用计数。这意味着计数器在某个点可能会溢出。为了防止溢出,一旦一个原子的引用计数达到65536,该原子将永远停留在原子表中,即使没有数量的GlobalDeleteAtom
也不能摆脱它。在这种情况下,操作系统必须重新启动以释放原子表。
如果您想使用SetProp
,请避免使用许多不同的原子名称。除此之外,SetProp
/GetProp
是一种非常干净和防御性的方法。如果开发人员同意为所有窗口使用相同的2个原子名称,则可以大大减少原子泄漏的危险,但这不太可能发生。
方法4:SetWindowSubclass
SetWindowSubclass
旨在允许覆盖特定窗口的WNDPROC
,以便您可以在自己的回调中处理一些消息,并将其余的消息委托给原始的WNDPROC
。例如,这可以用于监听EDIT
控件中的特定键组合,同时将其余的消息留给其原始实现。
SetWindowSubclass
的一个方便的副作用是,新的替换WNDPROC
实际上不是WNDPROC
,而是SUBCLASSPROC
。
SUBCLASSPROC
有两个额外参数,其中一个是DWORD_PTR dwRefData
。这是任意指针大小的数据。数据来自您,通过SetWindowSubclass
的最后一个参数传递。然后将数据传递给替换的SUBCLASSPROC
的每个调用。如果只有每个WNDPROC
都有此参数,那么我们就不会陷入这种可怕的情况!
这种方法只对窗口类作者有帮助。(1)在窗口的初始创建过程中(例如WM_CREATE
),窗口将自己子类化(如果适当,可以为dwRefData
分配内存)。最好在WM_NCDESTROY
中完成清理工作。通常放在WNDPROC
中的其余代码移至替换的SUBCLASSPROC
中。
甚至可以在对话框自己的WM_INITDIALOG
消息中使用它。如果使用DialogParamW
显示对话框,则可以在WM_INITDIALOG
消息中将最后一个参数用作SetWindowSubclass
调用中的dwRefData
。然后,所有其他对话框逻辑都放在新的SUBCLASSPROC
中,该程序将接收到每个消息的dwRefData
。请注意,这会略微更改语义。您现在是在对话框的窗口过程级别上编写代码,而不是对话框过程。
SetWindowSubclass
内部使用属性(使用 SetProp
)来存储和获取子类化信息,其原子名称为 UxSubclassInfo
。每个 SetWindowSubclass
实例都使用此名称,在几乎任何系统上它都已经存在于全局原子表中。它会将窗口的原始 WNDPROC
替换为一个名为 MasterSubclassProc
的 WNDPROC
。该函数使用 UxSubclassInfo
属性中的数据来获取 dwRefData
并调用所有已注册的 SUBCLASSPROC
函数。这也意味着你应该避免将 UxSubclassInfo
用作自己的属性名称。
方法五:Thunk
Thunk 是一个小型函数,其机器代码在运行时动态生成在内存中。它的目的是调用另一个函数,但具有似乎神奇地出现的额外参数。
这样可以定义一个类似于 WNDPROC
的函数,但它具有一个附加参数。此参数可以是“this”指针的等效项。然后,在创建窗口时,将原始存根 WNDPROC
替换为调用真实的、伪 WNDPROC
的 thunk,并带有一个附加参数。
这种方法的工作原理是,当创建 Thunk 时,它会在内存中生成机器代码,用于加载指令以将额外参数的值加载为 常量,然后跳转到通常需要一个附加参数的函数地址。Thunk 本身可以像常规的 WNDPROC
一样调用。
此方法可由窗口类作者使用,而且非常快速。但其实现并不是简单的。AtlThunk
函数族实现了这一点,但有一个怪癖。它不会添加一个额外参数。相反,它将 WNDPROC
的 HWND
参数替换为你的任意数据(指针大小)。但这不是个大问题,因为你的任意数据可以是一个包含窗口的 HWND
的结构体指针。
与 SetWindowSubclass
方法类似,您应该在窗口创建期间使用任意数据指针来创建 Thunk。然后,替换窗口的 WNDPROC
为 Thunk。所有真正的工作都在 Thunk 所指向的新的、伪 WNDPROC
中进行。
Thunks对全局原子表没有影响,也不存在字符串唯一性考虑。但是,像在堆内存中分配的所有内容一样,必须释放它们,然后该thunk可能不再被调用。由于
WM_NCDESTROY
是一个窗口接收到的最后一条消息,因此可以在那里执行此操作。否则,在释放thunk时,必须确保重新安装原始的
WNDPROC
。
请注意,在许多生态系统中,包括使用本机C函数与C#交互的情况下,通过这种方式将"this"指针伪造成回调函数几乎是无处不在的。
方法6:全局查找表。在您的应用程序中实现一个全局表,将HWND作为键和上下文数据作为值存储其中。您负责清理表,并在需要时使其足够快速。窗口类作者可以使用私有表来实现他们的实现,窗口用户可以使用自己的表来存储特定于应用程序的信息。不存在关于原子或字符串唯一性的问题。
这些方法适用于您是窗口类作者的情况:cbWndExtra、(GWLP_USERDATA)、SetProp、SetWindowSubclass、Thunk、Global lookup table。窗口类作者意味着您正在编写
WNDPROC
函数。例如,您可能正在实现一个自定义图片框控件,该控件允许用户平移和缩放。您可能需要其他数据来存储平移/缩放数据(例如作为2D变换矩阵),以便您可以正确地实现您的
WM_PAINT
代码。
推荐使用cbWndExtra,避免使用GWLP_USERDATA,因为用户代码可能依赖它。
这些方法适用于您是窗口用户的情况:GWLP_USERDATA、SetProp、Global lookup table。窗口用户意味着您正在创建一个或多个窗口,并在自己的应用程序中使用它们。例如,您可能正在动态创建可变数量的按钮,每个按钮都与不同的数据相关联,当单击按钮时会涉及到这些数据。
如果它是标准Windows控件,或者您确信控件不在内部使用它,请使用GWLP_USERDATA。否则,请使用
SetProp
。
在使用对话框时需要额外提及。默认情况下,对话框使用具有
cbWndExtra
设置为
DLGWINDOWEXTRA
的窗口类。您可以为对话框定义自己的窗口类,在该类中分配,例如,
DLGWINDOWEXTRA + sizeof(void*)
,然后访问
GetWindowLongPtrW(hDlg, DLGWINDOWEXTRA)
。但是,在这样做时,您会发现自己必须回答您不喜欢的问题。例如,你要使用哪个
WNDPROC
(答案:你可以使用
DefDlgProc
),或者你要使用哪些类样式(默认对话框恰好使用
CS_SAVEBITS | CS_DBLCLKS
,但是祝你好运找到权威参考)。
在
DLGWINDOEXTRA
字节内,对话框恰好保留了一个指针大小的字段,可以使用索引
DWLP_USER
使用
GetWindowLongPtr
进行访问。这是一种额外的
GWLP_USERDATA
,理论上存在相同的问题。实际上,我只看到过这种用法在传递给
DialogBox[Param]
的
DLGPROC
内部使用。毕竟,
窗口用户仍然具有
GWLP_USERDATA
。因此,在实际情况下,
窗口类实现中几乎可以在任何情况下安全使用它。