Win32 API 中的自定义控件:绘图






4.99/5 (56投票s)
理解自定义控件绘图的基础知识,
本系列文章
- Win32 API 中的自定义控件:基础知识
- Win32 API 中的自定义控件:绘图
- Win32 API 中的自定义控件:视觉样式
- Win32 API 中的自定义控件:标准消息
- Win32 API 中的自定义控件:控件自定义
- Win32 API 中的自定义控件:自定义控件的封装
- Win32 API 中的自定义控件:滚动
引言
在上一篇文章中,我们学习了自定义控件实现的基础知识。今天,我们将更深入地探讨控件的绘图。这个主题非常重要,因为成功的控件必须外观精美,并且能够融入 Windows 环境。这项任务并不像听起来那么简单,尤其是考虑到大多数应用程序支持不止一个特定版本的 Windows,而且在过去的 10 年里,几乎每个 Windows 版本都改变了其控件的默认视觉外观。
多种绘图 API
随着时间的推移,微软提供了许多用于 2D 图形和绘图的 API。GDI (GDI32.DLL) 自古以来就可用,并且可以在任何地方使用。GDI+ (GDIPLUS.DLL) 是 Windows XP 及更高版本的一部分(但可以从 Microsoft 网站下载其可再发行版本)。Direct2D 是最新的,并且仅在 Windows 7 及更高版本上可用。
总的来说,较新的 API 能够提供更好的图形(例如,支持抗锯齿、Alpha 通道等)并且具有更好的性能特征(如果使用得当),因为它们可以将许多任务卸载到图形卡上,而 GDI 主要在主系统内存和 CPU 上运行。
然而,在这篇文章以及后续的文章中,我们仍将主要使用 GDI。对于自定义控件的实现,它通常已经足够,并且可以在任何地方工作,最重要的是,由于历史原因,控件接收的与绘图相关的消息都是以 GDI 为中心的。当然,使用较新的绘图 API 是可能的,但这并非我们的关注点。
说了这么多,本文并非关于 GDI 绘图。网上有很多关于这方面的内容。在本网站上,Paul Watt 对该主题进行了非常详尽的介绍
- Win32 绘图入门指南
- Win32 绘图进阶指南
- Win32 区域指南
- Win32 剪辑区域指南
- 使用 Win32 MsImg32.dll 进行图像合成指南
- Win32 内存 DC 指南(与本文介绍的双缓冲是很好的补充阅读材料)
Hello World
上一篇文章已经提供了一个简单的控件代码,该控件当然也能够自行绘制。让我们再次回顾一下该示例中的绘图代码。当控件的窗口过程收到 WM_PAINT
消息时,它只是调用了我们的函数 CustomPaint()
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);
}
代码示例已经展示了基本原理:每当控件需要绘制时,系统就会发送 WM_PAINT
消息。处理程序(此处为 CustomPaint()
函数)获取设备上下文句柄(BeginPaint()
),然后使用它通过 GDI 函数绘制控件(此处仅在控件中间绘制字符串 "Hello world"
),最后使用 EndPaint()
释放所有资源。
到目前为止,没有问题。但是,对于复杂控件的良好绘图,我们需要更好地理解事物是如何真正工作的。否则,你可能会发现自己在某个网络论坛上搜索一个普遍的问题:“如何防止控件在调整大小时闪烁?”
此外,复杂控件可能需要在 WM_PAINT
接收之外的其他时间进行绘制,或者甚至可能需要在其客户端区域之外进行绘制,无论是绘制到控件的非客户端区域,还是完全绘制到控件区域之外。
全局概览
那么 WM_PAINT
周围的事情是如何联系在一起的呢?从控件(或更普遍地说,任何由 HWND
句柄表示的窗口)的角度来看,除了 WM_PAINT
之外,还有几个重要的消息,以及与控件绘图紧密相关的其他概念。
首先,我们必须回答任何细心的读者已经提出的问题:系统何时决定控件需要被绘制?对于每个 HWND
,系统都会管理其窗口的更新区域(也称为脏区域)。该区域描述了窗口中内容无效且需要重绘的部分。当区域不为空时,系统就会知道控件(实际上是区域定义的其部分)需要重绘,并且当该窗口线程的消息队列中没有其他消息时,它最终会向控件发送 WM_PAINT
。
通常,系统不会记住窗口的内容。例如,每当窗口被移动到屏幕边缘导致其一部分被屏幕角遮挡,或者被另一个窗口遮挡时,窗口的实际内容就会丢失。当窗口再次完全可见后,系统会将窗口中未被遮挡的部分添加到更新区域,然后最终会导致请求重绘。
控件也可能需要重绘自身或其一部分,因为其状态或显示的数据已更改。为此,控件可以简单地调用一个 Win32API
函数来扩展更新区域 InvalidateRgn()
或其更具体的同类函数。在大多数情况下,要使之无效的区域呈矩形,因此在大多数情况下,会使用 InvalidateRect()
函数代替。
我已经注意到,重绘本身也比简单地发送 WM_PAINT
消息要复杂得多。
-
首先,系统会向控件发送
WM_ERASEBKGND
消息,要求控件擦除其背景。这可能在区域无效时立即发生。参数wParam
被设置为窗口的设备上下文,可用于绘制某些内容。但是,如果使用绘图,绘图应该非常快速,并且通常只包括用某个背景画笔填充该区域,从而有效地擦除其背景。如果控件将消息传递给DefWindowProc()
,就会发生这种情况。DefWindowProc()
简单地获取在窗口类注册期间指定的画笔(WNDCLASS::hbrBackground
),并用它填充控件。你是否曾看到过当你的机器在进行非常 CPU 密集型任务时,打开一个新窗口或将一个窗口置于 Z 顺序顶部时,屏幕上出现一个灰色的窗口?原因就在于此。那个灰色的窗口已经收到了WM_ERASEBKGND
,但尚未收到WM_PAINT
。返回值也很重要,如果消息执行了擦除操作,则应返回非零值,如果未执行,则返回零值。(DefWindowProc()
会遵循这一点,如果它用背景画笔填充了控件,则返回非零值,如果因为它设置WNDCLASS::hbrBackground
为NULL
而未填充,则返回零值。)我们很快就会看到它的用处。 -
如果更新区域还与窗口的非客户端区域相交,系统会发送
WM_NCPAINT
。对于顶级窗口,此消息负责绘制窗口标题栏、最小化和最大化按钮,以及窗口菜单(如果窗口有的话)。对于子窗口(即控件),它通常用于绘制边框以及滚动条(如果控件以类似标准列表视图和树视图控件的方式支持它们)。如果控件只是将消息传递给DefWindowProc()
,它会绘制由窗口样式WS_BORDER
和/或扩展样式WS_EX_CLIENTEDGE
指定的边框,以及由SetScrollInfo()
设置的滚动条。今天,我们暂时不讨论WM_NCPAINT
,将在以后的文章中返回讨论。 -
最后,发送
WM_PAINT
消息。但是,请注意,系统会特殊处理此消息,并且仅在处理完消息队列中的所有其他消息之后,并在消息队列为空时才发送。如果你仔细思考,这是有道理的。只要消息队列中还有其他消息,它们就可能改变控件的状态,从而导致需要再次重绘。
系统假定控件在处理 WM_PAINT
时使用 BeginPaint()
和 EndPaint()
。在这两个调用之间,应用程序应绘制控件的整个无效区域。
第一个函数 BeginPaint()
初始化指向的 PAINTSTRUCT
结构,并返回要绘制的设备上下文(HDC
)(与结构中存储的句柄相同)。在处理绘图时,有两个成员通常非常有用:成员 fErase
,如果窗口**未**使用 WM_ERASEBKGND
擦除(即,取决于该消息的返回值),则设置为 TRUE
;成员 rcPaint
,它是包含需要重绘的无效区域的最小矩形。
第二个函数 EndPaint()
释放 BeginPaint()
占用的任何资源(例如设备上下文),它还会验证无效区域,即调用此函数后,控件的无效区域将变为空。
闪烁问题
通常,当一个没有经验的控件开发人员创建他的第一个自定义控件时,他会对其感到满意,直到他将它用在一个与它所在的父窗口一起调整大小的应用程序中。运行时,他会对其丑陋的闪烁感到惊讶。这是一个普遍存在的问题,因此我们特别关注它。
如果你已经理解了上述章节中 Windows 如何处理绘图,你可能已经发现了罪魁祸首。
- 当用户调整主应用程序窗口大小时,会收到
WM_SIZE
消息,作为响应,它会调整控件的大小。 - 自定义控件的窗口过程很可能会将其
WM_SIZE
传播到DefWindowProc()
。该函数(合理地)使整个控件无效,假设其窗口类在注册时使用了CS_HREDRAW
和CS_VREDRAW
(这很常见)。 - 作为对无效化的响应,控件会收到
WM_ERASEBKGND
,它默认会根据注册类时使用的画笔填充整个窗口客户区域。 - 很快,控件就会收到
WM_PAINT
,开始全面而精彩的绘制。 - 当用户继续拖动鼠标调整窗口大小时,所有这些都会一次又一次地发生,导致闪烁的效果,即控件在短暂的连续中从擦除状态变为完全绘制状态,然后再变回来。
解决闪烁问题
如果你理解了问题的根源,制定避免问题的提示是相当容易的。但为了完整起见,我们还是在此列出它们。我尝试按照重要性顺序排列提示(当然,这在一定程度上取决于我的个人偏好)。
-
如果可能,不要依赖
WM_ERASEBKGND
,即让它返回非零值而不传递给DefWindowProc()
。通常,WM_PAINT
可以绘制脏区域的所有背景,无需此类擦除。 -
如果你需要从绘图中特别处理擦除,它通常可以优化为使
WM_ERASEBKGND
仍然不执行任何操作但返回非零值,而WM_PAINT
的处理程序可以在PAINTSTRUCT::fErase
设置时通过绘制常规绘图代码未覆盖的区域来执行“擦除”。 -
在合理的范围内,努力设计绘图代码,使其不会多次重绘相同的像素。例如,要绘制带红色边框的蓝色矩形,不要先
FillRect(red)
再FillRect(blue)
将内部内容从红色重绘为蓝色。而是将红色边框绘制为 4 个不与蓝色内容重叠的小矩形。 -
对于复杂的控件,通过妥善组织控件数据,绘图代码通常可以优化为跳过
PAINTSTRUCT::rcPaint
指定的无效矩形之外的大量绘图。 -
更改控件状态时,仅使控件的最小必需区域失效。
-
当调整大小时仍然发生闪烁时,请考虑不要使用
CS_HREDRAW
和CS_VREDRAW
。取而代之的是,在处理WM_SIZE
时手动使控件的相关部分失效。通常,控件的较小部分可能需要重绘。 -
当控件支持滚动条,并且使用它们会导致闪烁时,请确保滚动代码使用
ScrollWindow()
函数而不是使整个控件区域无效。(请注意,我们将在后续文章中讨论滚动的主题。) -
通常,当绘图性能更好时,闪烁效果也会减小。如果你的绘图方法不依赖于系统将剪辑矩形设置为控件的客户矩形,则可以在注册控件窗口类时使用窗口类样式
CS_PARENTDC
,从而减少BeginPaint()
的工作量。
请注意,尽管上述一些要点可能对开发人员来说工作量很大,但对于实现例如命中测试(WM_HITTEST
)等功能的完整控件来说,这通常是必须要做的工作。很多代码就可以重用。例如,考虑一个绘制某种表格的控件,其中每个单元格在用户单击时都提供某种交互式响应。那么代码必须了解表格的布局,而计算布局的代码就可以重用于绘图实现和命中测试实现。
如果以上所有方法都失败了,还有一个称为双缓冲的魔法棒,可以在所有情况下解决闪烁问题(假设通过 WM_ERASEBKGND
的擦除被抑制并推迟到 WM_PAINT
)。但使用它需要付出代价:资源消耗更高,尤其是内存。即使你在采取了所有措施后,在某些情况下控件仍然存在闪烁问题,我建议只有当应用程序明确要求时(例如,通过指定样式)才使用双缓冲,因为应用程序通常从不让闪烁发生的情况出现(例如,应用程序从不调整控件大小)。
双缓冲
双缓冲是一种绘图技术,其原理是你可以在内存中的位图上绘制控件,而不是直接在屏幕上绘制,然后在完成所有复杂绘图后,你可以快速地将整个位图复制(blit)到屏幕上。
下面我们用一个示例代码展示它可能的样子。我们从简单的控件实现开始,并在前一部分的基础上添加双缓冲代码。
/* custom.h
* (custom control interface)
*/
// ...
/* Style to request using double buffering. */
#define XXS_DOUBLEBUFFER (0x0001)
// ...
/* custom.c
* (custom control implementation)
*/
#include "custom.h"
static void
CustomPaint(HWND hwnd, HDC hDC, RECT* rcDirty, BOOL bErase)
{
// ... Paint the control here.
}
static void
CustomDoubleBuffer(HWND hwnd, PAINTSTRUCT* pPaintStruct)
{
int cx = pPaintStruct->rcPaint.right - pPaintStruct->rcPaint.left;
int cy = pPaintStruct->rcPaint.bottom - pPaintStruct->rcPaint.top;
HDC hMemDC;
HBITMAP hBmp;
HBITMAP hOldBmp;
POINT ptOldOrigin;
// Create new bitmap-back device context, large as the dirty rectangle.
hMemDC = CreateCompatibleDC(pPaintStruct->hdc);
hBmp = CreateCompatibleBitmap(pPaintStruct->hdc, cx, cy);
hOldBmp = SelectObject(hMemDC, hBmp);
// Do the painting into the memory bitmap.
OffsetViewportOrgEx(hMemDC, -(pPaintStruct->rcPaint.left),
-(pPaintStruct->rcPaint.top), &ptOldOrigin);
CustomPaint(hwnd, hMemDC, &pPaintStruct->rcPaint, TRUE);
SetViewportOrgEx(hMemDC, ptOldOrigin.x, ptOldOrigin.y, NULL);
// Blit the bitmap into the screen. This is really fast operation and although
// the CustomPaint() can be complex and slow there will be no flicker any more.
BitBlt(pPaintStruct->hdc, pPaintStruct->rcPaint.left, pPaintStruct->rcPaint.top,
cx, cy, hMemDC, 0, 0, SRCCOPY);
// Clean up.
SelectObject(hMemDC, hOldBmp);
DeleteObject(hBmp);
DeleteDC(hMemDC);
}
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
// ...
case WM_ERASEBKGND:
return FALSE; // Defer erasing into WM_PAINT
case WM_PAINT:
{
PAINTSTRUCT ps;
BeginPaint(hwnd, &ps);
if(GetWindowLong(hwnd, GWL_STYLE) & XXS_DOUBLEBUFFER)
CustomDoubleBuffer(hwnd, &ps);
else
CustomPaint(hwnd, ps.hdc, &ps.rcPaint, ps.fErase);
EndPaint(hwnd, &ps);
return 0;
}
// ...
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
正如所见,CustomPaint()
函数的原型已经改变:它现在能够将当前控件状态绘制到任何设备上下文,而不仅仅是屏幕上。BeginPaint()
和 EndPaint()
机制已直接移至 WM_PAINT
处理程序。当启用双缓冲时(控件具有 XXS_DOUBLEBUFFER
样式),会调用 CustomDoubleBuffer()
函数将控件绘制到临时位图中,然后该位图会被复制到 BeginPaint()
检索到的设备上下文中。
还有一些其他值得注意的事情,因为代码本身就很好地说明了这一点。
-
使用双缓冲时,我们始终将
bErase
设置为TRUE
,因为显然,当双缓冲时,内存中的位图必须从头开始完全绘制。 -
作为一项小优化,我们不是为控件的整个客户区域分配位图,而是只分配足以满足脏区域所需的最小大小。但是,然后我们需要通过临时安排适当的视口原点来补偿控件左上角与脏矩形左上角之间的坐标变化。我们为此目的使用了
OffsetViewportOrgEx()
函数。 -
控件每次收到
WM_PAINT
时都会创建和销毁位图。这是一个潜在的耗时操作,因此可以通过适当的位图缓存策略进行进一步优化以供重用。但是,由于位图可能很大且占用大量内存,因此不应一直持有它。在现实世界中,当用户使用控件时,控件会在短时间内收到许多WM_PAINT
消息,当用户不使用时(例如,当应用程序窗口最小化时)则长时间收不到消息,因此缓存应该比较智能。类似这样的东西会让示例变得更长,我相信读者可以把它当作一个发挥自己创造力的小练习。
(再次附带了一个 Visual Studio 2010 项目示例。它是此处提供的代码的一个略微增强且更完整的版本。)
消息 WM_PRINTCLIENT
还有另一个值得提及的消息,与控件绘图相关。希望成为 Windows 环境中良好公民的控件也应该支持 WM_PRINTCLIENT
消息。简而言之,此消息要求控件将其自身绘制到提供的设备上下文(通过 WPARAM
)。例如,Windows 中的打印就利用了这一点。各种工具,如屏幕缩放或缩略图实用程序可能会使用它。通过使用此消息可以实现一些编码技巧,例如在玻璃窗口边框上绘图(但这超出了我们今天的讨论范围)。
请注意,我们新版本的 CustomPaint()
函数正是为此目的而设计的,因此实现此消息是一项非常直接的任务。
static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch(uMsg) {
// ...
case WM_PRINTCLIENT:
{
RECT rc;
GetClientRect(hwnd, &rc);
CustomPaint(hwnd, (HDC) wParam, &rc, TRUE);
return 0;
}
// ...
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
下次:视觉样式
我省略了讨论一个与控件绘图密切相关的 API。它是一个提供视觉主题支持的 API(由 UXTHEME.DLL 实现)。由于它在 Windows XP 中引入,有时也称为XP 主题。如今,控件必须了解主题,才能融入 Windows 的外观和感觉。
虽然这是一个相当复杂的主题,但下次将专门用一整篇文章来讨论它。