65.9K
CodeProject 正在变化。 阅读更多。
Home

Win32 API 中的自定义控件:视觉样式

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (67投票s)

2013年7月21日

CPOL

33分钟阅读

viewsIcon

141130

downloadIcon

5875

使用视觉样式 API,使您的控件与标准/通用控件保持一致的绘制。

本系列文章

引言

上次我们讨论了自定义控件绘制的基础知识。今天,我们将继续沿着相同的方向探索视觉样式(又名 XP 主题)。

为了创建高质量的自定义控件,正确使用此 API 至关重要,但任务相当复杂,因此本文比前几篇文章更长,请读者在阅读时耐心等待。在我们真正开始使用 API 之前需要一些时间,因为首先,我们必须从更广阔的角度审视 Windows 主题。

UXTHEME.DLL 和 COMCTL32.DLL

主题 API 由 Windows XP 中出现的库 UXTHEME.DLL 提供。以下两个屏幕截图演示了未主题化和主题化用户体验之间的区别

An unthemed dialog (Windows 2000)

未主题化的对话框 (Windows 2000)

A themed dialog (Windows 7, the default theme)

主题化的对话框 (Windows 7,默认主题 Aero)

此主题库的引入也影响了通用控件库 COMCTL32.DLL。出于兼容性原因,Windows XP(以及所有更新的 Windows 版本)配备了两个不同版本的 COMCTL32.DLL

在标准路径 C:\Windows\System32\ 中,可以找到旧版 COMCTL32.DLL 版本 5。此库版本包含列表视图、组合框等标准控件的实现,这些控件不知道 UXTHEME.DLL 的存在,因此与此库链接的应用程序通常不会主题化。

新版本 6 的 COMCTL32.DLL 位于 C:\Windows\WinSxS\ 下,作为并行程序集提供。只有明确声明与库版本 6 兼容的应用程序才使用它。其他应用程序则继续使用版本 5。

另请注意,历史上某些控件是在 USER32.DLL 中实现的(例如,BUTTONEDIT 等窗口类),而其他控件(称为通用控件)则驻留在 COMCTL32.DLL 中。随着 COMCTL32.DLL 版本 6 的引入,这种情况发生了变化,现在所有支持主题的控件都存在于其中。

注意: 标准控件的旧(未主题化)实现仍然在 USER32.DLL 中可用。如果您指示链接器将您的应用程序与 COMCTL32.DLL 链接,如果应用程序从未调用其中的任何函数,它可能会默默地忽略它。因此,我建议您在初始化应用程序时调用 InitCommonControls()。否则,应用程序可能只是未主题化而不是不工作,使用旧控件,完美地掩盖了问题的根本原因。

应用程序清单

应用程序向系统表明其希望使用 COMCTL32.DLL 版本 6 的最常用和自然的方式是在其清单中指定它。清单可以是单独的文件,但首选方式是将其作为资源嵌入到应用程序二进制文件中。

作为单独的文件,它的名称必须与应用程序的名称完全相同,并添加“.manifest”后缀(例如,应用程序 example.exeexample.exe.manifest),并且它必须位于同一目录中。

当作为资源嵌入时,它必须具有资源 ID 1 和类型 RT_MANIFEST,因此通常在应用程序的资源脚本(.RC 文件)中存在以下行

1 RT_MANIFEST path/to/manifest.xml

无论哪种方式,清单都是一个 XML 文档,它应该像这样

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
    <assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="app name" type="win32"/>
    <description>app description</description>
    <dependency>
        <dependentAssembly>
            <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" 
              version="6.0.0.0" processorArchitecture="*" 
              publicKeyToken="6595b64144ccf1df" language="*"/>
        </dependentAssembly>
    </dependency>
    <ms_asmv2:trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
        <ms_asmv2:security>
            <ms_asmv2:requestedPrivileges>
                <ms_asmv2:requestedExecutionLevel level="asInvoker" uiAccess="false"/>
            </ms_asmv2:requestedPrivileges>
        </ms_asmv2:security>
    </ms_asmv2:trustInfo>
</assembly>

当然,对于您的应用程序,清单应该进行调整,以便在第一个 <assemblyIdentity> 标签中,使用其名称、描述、版本以及它应该运行的处理器("X86""amd64""*" 表示任何 CPU 类型)。

当应用程序在 Windows XP 或更高版本上启动时,系统(特别是应用程序加载程序)会检查二进制文件并查找是否能找到清单。如果找到,应用程序加载程序将加载满足清单中要求的 DLL 版本。(严格来说,这仅适用于作为并行程序集可用的 DLL,而不适用于所有 DLL。但 COMCTL32.DLL 满足此要求。)

在我们的上下文中,清单中最有趣的部分是依赖程序集的规范,它被标识为 Microsoft.Windows.Common-Controls,我们在此明确要求系统使用 COMCTL32.DLL 版本 6。

因此,在实现可重用控件时,我们也应该自然地遵循并尊重应用程序的清单。如果应用程序声明它支持 COMCTL32.DLL 版本 6,自定义控件也应遵循并使用主题 API,就像 COMCTL32.DLL 所做的那样。当应用程序未主题化时,自定义控件应尊重这一点,其外观应与之一致。

即使控件是在单独的 DLL 中实现的,并且无法控制其代码在哪个应用程序中执行,我们也可以在运行时使用 COMCTL32.DLL 的版本作为决策的指标

#include <windows.h>
#include <shlwapi.h>

BOOL ShouldUseUxThemeDll(void) 
{
    HMODULE hDll;
    DWORD dwMajorVersion = 0;

    hDll = LoadLibrary(_T("COMCTL32.DLL"));
    if(hDll != NULL) {
        DLLGETVERSIONPROC fn_DllGetVersion;
        DLLVERSIONINFO vi;

        fn_DllGetVersion = (DLLGETVERSIONPROC) GetProcAddress(hDll, "DllGetVersion");
        if(fn_DllGetVersion != NULL) {
            vi.cbSize = sizeof(DLLVERSIONINFO);
            fn_DllGetVersion(&vi);
            dwMajorVersion = vi.dwMajorVersion;
        }
        FreeLibrary(hDll);
    }

    return (dwMajorVersion >= 6);
}

请注意,此函数的返回值在应用程序的生命周期内不会改变。(也就是说,除非应用程序通过 FreeLibrary()LoadLibrary() 执行了不当操作,更改了进程运行时 COMCTL32.DLL 的版本。这种情况非常罕见,我认为我们可以通过忽略它来解决此问题……)。

禁用视觉样式

即使应用程序使用清单请求 COMCTL32.DLL 版本 6,标准控件仍可能以未主题化的方式绘制,因为(在某些 Windows 版本中)用户可以在整个用户会话中(在控制面板中)或针对特定应用程序(特定 .exe 文件的属性,兼容性选项卡)禁用主题。

注意:控制面板中的设置会立即应用于所有(支持主题的)应用程序,甚至包括已经运行的应用程序。在应用程序属性的“兼容性”选项卡中禁用视觉样式不会影响已经运行的进程。它仅适用于更改后启动的任何 .EXE。

注意:至少在 64 位 Windows 7 上,在“兼容性”选项卡中为 64 位应用程序禁用视觉主题会被系统默默忽略。我猜微软认为没有必要为 64 位应用程序提供该兼容性选项:在 Windows XP 之前,根本没有 64 位程序。因此,很可能他们只是忘记在该对话框中隐藏或禁用 64 位应用程序的复选框。

在 Windows 8 中,禁用应用程序主题的选项已完全消失。

兼容性和测试

Windows 2000(及更早版本)根本不支持主题,并且其中不存在 UXTHEME.DLL 库。如果您希望支持 Windows 2000,那么您应该在运行时使用 LoadLibrary()GetProcAddress() 加载 UXTHEME.DLL。如果失败,请在您的控件实现中回退到非主题化的 GDI 代码路径。

即使存在 UXTHEME.DLL,不同的 Windows 版本也带有截然不同的默认主题和一小组替代主题(通常只是默认主题的颜色变体)。 (只有 Windows Vista 和 7 共享相同的主题。)

当然,所有这些都会增加需要测试控件的环境数量。作为最低限度,您应该在 Windows XP、Windows 7(或 Vista)和 Windows 10(或 8)上测试您的控件,并且在禁用主题的情况下也要测试。

使用 UXTHEME.DLL

要使用主题 API,您需要链接(或在运行时加载)UXTHEME.DLL,并包含其头文件 uxtheme.h。还有附带的头文件 vsstyle.hvssym32.h,我们稍后会提到。

注意UXTHEME.DLL 不遵循历史 Win32 API 字符串二重性。在处理字符串时,Win32 API 通常为同一件事提供两个函数,一个用于 Unicode(以“-W”后缀结尾的函数),另一个用于 ANSI 字符串(“-A”后缀),以及一个宏(没有后缀),它根据是否定义了宏 UNICODE 来解析为其中之一。

另一方面,UXTHEME.DLL 严格使用 Unicode。它包括用于绘制文本的函数以及各种字符串标识符(即主题类和子类名称以及字符串属性的值;这些术语将在文章中进一步解释)。

主题定义

UXTHEME.DLL 本身是一个相对简单的 DLL。它实际上并不知道如何绘制任何特定控件或其部分。相反,它的大多数函数只知道如何访问当前活动视觉主题的数据,为了本文的目的,我称之为主题定义。正是 Windows 上提供的主题定义(以及其缺乏文档)导致了自定义控件实现比 UXTHEME.DLL 本身更困难。

主题定义,通常是一个结构化存储,它定义了一些对象(类、子类、部分、状态;我们将在下面讨论它们)的层次结构,并将它们与各种资源(主要是图像)和描述它们许多方面的属性相关联。

存储的核心是一个仅包含资源的 DLL(尽管文件扩展名不同)。例如,在 Windows 7 上,DLL 通常位于路径 C:\Windows\Resources\Themes\Aero\aero.msstyles。但是,您的应用程序根本不应该依赖于此类实现细节。

在 Windows XP 上,默认主题定义名为 Luna。在 Vista 和 7 上,它是 Aero。您可能已经知道这些名称,因为它们甚至出现在微软的市场营销材料中。

主题定义所定义对象层次结构中的顶层类别是。请不要将主题类与面向对象编程或窗口类中的术语混淆。类由其字符串名称(OpenThemeData() 的第二个参数)标识,并且在大多数(但并非所有)情况下,它为特定的标准控件或用户界面的其他方面(例如,开始菜单、控制面板等)提供数据。

许多主题类实际上与标准控件的一些窗口类名(例如,BUTTONEDITTREEVIEW)具有相同的名称,但通常两者的命名空间不同,并且有一些标准窗口类在 Windows 主题定义中没有自己的专用对应物,反之亦然。一个突出的主题类示例可以是 TEXTSTYLE,它主要定义各种文本标签的字体属性,供应用程序通用使用。(请注意,此主题类是在 Aero 中添加的。Windows XP 上的 Luna 没有它。)

可能还有一些称为子类的东西,它们可以为相同类型的控件或 Windows 用户界面的其他元素提供不同、替代的数据,但我们稍后会介绍它们,以使主要原则更容易理解。

在层次结构的第二层,类之下是部分。给定类的每个部分都由一个整数标识,它描述了主题类的某个部分方面,可以独立于同一主题类的其他部分进行绘制,例如边框、背景、字形、滚动条或组合框的按钮。

在层次结构的第三层,部分之下是状态。像部分一样,每个状态也由一个整数标识。单个部分的不同状态用于反映控件的各种状态,例如,当它被禁用时,当它获得焦点时,或者当它处于热点状态时(即,在鼠标光标下)。不应该根据控件内部状态改变其外观的部分通常只有一个状态,通常由值 1 标识。其他会改变其外观以反映控件状态的部分可能会定义许多状态。

许多与主题相关的 API 函数将部分和状态以及主题句柄作为它们的输入。在极少数情况下,当您需要整体引用某个部分时,您可以使用状态 0,因为 API 保证没有状态具有此标识符。部分也是如此。部分 0 表示调用者没有特定的部分。

此外,每个层次结构级别上的每个对象(包括整个主题定义)都可以关联许多属性。这些属性差异很大,也可以有不同的类型,它们描述了对象的许多属性。同样,我们稍后会简要介绍它们。

Windows SDK 提供了头文件 vssym32.hvsstyle.h,它们为部分和状态(枚举)以及属性(定义为预处理器宏)提供了一些符号标识符。(网上一些旧的教程可能会使用 tmschema.h。但微软在较新的 Windows SDK 中声明此头文件已过时。它不够完整且与较新的头文件不兼容,因此在新代码中应避免使用它。)

例如,标准窗口类 BUTTON 有一个相应的主题类 BUTTON,它由以下部分组成

enum BUTTONPARTS {
    BP_PUSHBUTTON = 1,
    BP_RADIOBUTTON = 2,
    BP_CHECKBOX = 3,
    BP_GROUPBOX = 4,
    BP_USERBUTTON = 5,
    BP_COMMANDLINK = 6,
    BP_COMMANDLINKGLYPH = 7
};

然后还有其他枚举,列出了每个按钮部分的所有状态,例如 PB_PUSHBUTTON 的状态

enum PUSHBUTTONSTATES {
    PBS_NORMAL = 1,
    PBS_HOT = 2,
    PBS_PRESSED = 3,
    PBS_DISABLED = 4,
    PBS_DEFAULTED = 5,
    PBS_DEFAULTED_ANIMATING = 6
};

主题句柄管理

现在,我们终于可以开始探索主题 API 本身了。

一旦控件决定要使用视觉样式,它就需要一个有效的主题句柄(HTHEME)。该句柄是一个不透明类型,表示给定的主题类(或子类)。此句柄可以通过函数 OpenThemeData() 获取(它的第二个参数是类名),并通过 CloseThemeData() 释放。所有主题绘制函数都将此句柄作为它们的第一个参数。

注意:应用程序还必须准备好应对所需主题无法打开且 OpenThemeData() 返回 NULL 的情况。这可能是因为当前选定的主题不知道所需的主题类名,或者如上所述,主题已禁用。通常,控件应回退到不带主题、以旧方式绘制控件的代码路径,以适应灰色的旧式未主题化对话框。

此外,每当控件收到 WM_THEMECHANGED 消息时,它都必须重新打开主题句柄并重新绘制自身,该消息表示一些相关的主题设置已更改(例如,用户在控制面板中选择了不同的主题)。

因此,在典型的控件实现中,控件实现的相关部分可能如下所示

#include <uxtheme.h>

// Class name for this control.
static const WCHAR[] wszClass = L"BUTTON"

typedef struct CustomData_tag CustomData;
struct CustomData_tag {
    HWND hwnd;       // handle of control window
    HTHEME hTheme;   // theme handle (NULL when not themed)
    // ...
};

static void
CustomPaint(CustomData* pData, HDC hDC, RECT* rcDirty, BOOL bErase)
{
    if(pData->hTheme != NULL) {
        // Paint with themes.
        // ...
    } else {
        // Fallback to old simple grayish look, painted with plain GDI.
        // ...
    }
}

static LRESULT CALLBACK
CustomProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
    CustomData* pData = (CustomData*) GetWindowLongPtr(hwnd, 0);

    switch(uMsg) {
        case WM_ERASEBKGND:
            return FALSE;

        case WM_PAINT:
        {
            PAINTSTRUCT ps;
            BeginPaint(hwnd, &ps);
            CustomPaint(pData, ps.hdc, &ps.rcPaint, ps.fErase);
            EndPaint(hwnd, &ps);
            return 0;
        }
 
        case WM_THEMECHANGED:
            if(pData->hTheme)
                CloseThemeData(pData->hTheme);
            pData->hTheme = OpenThemeData(hwnd, wszClass);
            InvalidateRect(hwnd, NULL, TRUE);
            return 0;

        case WM_CREATE:
            if(!DefWindowProc(hwnd, uMsg, wParam, lParam))
                return -1;
            pData->hTheme = OpenThemeData(hwnd, wszClass);
            return 0;

        case WM_DESTROY:
            if(pData->hTheme)
                CloseThemeData(pData->hTheme);
            break;

        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;
            SetWindowLongPtr(hwnd, 0, (LONG_PTR)pData);
            return TRUE;

        case WM_NCDESTROY:
            if(pData != NULL)
                free(pData);
            break;
    }

    return DefWindowProc(hwnd, uMsg, wParam, lParam);
}

请注意,为了可读性,此示例省略了使用 ShouldUseUxThemeDll() 检查旧的 COMCTL32.DLL 版本,如前面章节所建议的。本文顶部提供的可下载代码在这方面更完整。

另请注意,我们将在本文稍后(在一个简短的插曲之后)为 CustomPaint() 函数补充一些实际的绘图代码。

无用的 IsThemeActive() 和 IsAppThemed()

掌握了主题句柄管理的知识后,我们回到关于何时应该和何时不应该使用主题绘制的讨论。在检查应用程序或控件是否应该使用主题绘制时,有两个 UXTHEME.DLL 函数 IsThemeActive()IsAppThemed(),开发人员经常尝试使用它们来实现此目的,但它们会导致很多困惑。因此,我们来详细解释一下它们。

IsThemeActive() 只是询问用户是否在其会话中启用了主题。它通常返回 TRUE,除非用户在控制面板中选择了经典外观(因此,在 Windows 8 上,它总是返回 TRUE)。特别是,即使主题在应用程序属性中为特定应用程序禁用,或者应用程序不了解主题并使用 COMCTL32.DLL 版本 5,它也可能返回 TRUE

所有重要的是控制面板中的全局设置。因此,返回值并未说明调用者是否应该使用视觉主题。

另一个函数 IsAppThemed(),做了更多。在我看来,实际上是太多了。它检查是否全局允许主题(与 IsThemeActive() 相同),然后还检查应用程序是否希望使用主题(清单)以及主题是否未禁用(其属性的兼容性选项卡)。

它仅在所有三个条件都为真时才返回 TRUE。换句话说,它询问 OpenThemeData() 是否会在此时针对合理的有效输入参数返回一个有效的非 NULL 主题句柄。如果它返回 FALSE,则保证任何对 OpenThemeData() 的调用都将返回 NULL

因此,您可以直接调用 OpenThemeData() 并使用返回的句柄进行主题绘制,或者在它返回 NULL 时回退到无主题绘制。同样,您不需要 IsAppThemed() 做任何事情。

另请注意,这两个函数都取决于控制面板中的当前设置,并且这些设置可以在您的应用程序生命周期中的任何时候更改:我见过很多应用程序在应用程序初始化期间调用其中一个函数来决定是否使用主题 API。如果您需要这样做,请使用本文前面提供的 ShouldUseUxThemeDll(),并将其余部分留给 OpenThemeData()。它知道何时返回 NULL

主题绘制

好的,回到主题绘制。

我们来补充一下句柄管理部分的示例代码,以绘制一个类似按钮的控件。请注意,该控件不是交互式的,因此我们使用常量部分和状态。实际控件会通过监视窗口过程中的各种鼠标和键盘消息来记住和更改控件的状态。

static void CustomPaint(CustomData* pData, HDC hDC, RECT* rcDirty, BOOL bErase)
{
    RECT rect;
    GetClientRect(pData->hwnd, &rect);

    if(pData->hTheme != NULL) {
        int part;
        int state;

        part = BP_BUTTON;
        state = PBS_NORMAL;
       
        if(IsThemeBackgroundPartiallyTransparent(pData->hTheme, part, state))
            DrawThemeParentBackground(pData->hwnd, hDC, &rect);
        DrawThemeBackground(pData->hTheme, hDC, part, state, &rect, &rect);
    } else {
        // Fallback to old simple grayish look, painted with plain GDI.
        DrawFrameControl(hDC, &rect, DFC_BUTTON, DFCS_BUTTONPUSH);
    }
}

上面的代码几乎是规范的。关于这个主题的每个教程都显示了类似这样的代码片段。有两点至关重要。

首先,主题通常支持(部分)透明控件,因此绘图代码必须反映这一点,并在需要时要求父窗口在透明区域内绘制自身。

例如,Windows 主题定义中的按钮具有圆角,这样,父控件的职责是绘制它们暴露的几个像素。

顺便说一句,尽管我没有验证过,但我相信 IsThemeBackgroundPartiallyTransparent() 实际上是通过向父级发送 WM_PRINTCLIENT 消息来施展一些魔法的。我们已经在上一篇文章中讨论过这条消息。

另一个有用的函数是 DrawThemeText()(自 Vista 起还有 DraweThemeTextEx()),它用于绘制控件标签。该函数使用主题定义设置的字体和其他文本属性。然而在实践中,Luna 和 Aero 都没有为大多数类/部分/状态定义任何字体或文本属性,这只会导致该函数使用当前选择到使用的设备上下文中的 HFONT 进行绘制,并且只有少数标准控件实际上使用此函数绘制自身。它们中的大多数都坚持使用旧的 GDI TextOutDrawText() 来使用由最新的 WM_SETFONT 确定的字体进行绘制,但那是另一个话题。

注意:当主题没有为所使用的类/部分/状态定义任何字体属性时,DrawThemeText() 只是使用设备上下文中当前选择的字体进行绘制。

API 提供了更多用于主题绘制的函数,例如 DrawThemeEdge()DrawThemeIcon()。据我所知,它们很少使用,我们在此不会特别关注它们。

我想强调的第二点是,如果主题数据不可用(OpenThemeData() 返回 NULL),我们必须以旧的、无聊的 GDI 方式绘制,这里使用 DrawFrameControl()

主题子类

现在我们来谈谈主题子类。首先,请注意,该术语与称为控件子类化的技术没有任何共同之处,后者通过 SetWindowLongPtr(hwnd, GWLP_WNDPROC, pfn_WinProc) 植入另一个窗口过程来增强现有控件。

主题子类是主题定义中属性和资源的替代集合,它通过一个额外的补充字符串标识符(子类名称)与普通主题类(以及同一类的所有其他子类)区分开来。

这允许应用程序通过强制为其指定特定的子类名称来改变某些控件的外观。一些作为 Windows 组成部分的程序(包括负责管理桌面和打开文件夹窗口的 explorer.exe)利用了此功能。Aero 主题定义为许多标准控件提供了一个名为 EXPLORER 的子类并非偶然。

下面的屏幕截图演示了 Aero (Windows 7) 中树视图控件的不同之处,具体取决于它使用的是标准类 TREEVIEW 还是被告知使用其 EXPLORER 子类

可以看出,这两个树形视图看起来有点不同,甚至项目尺寸也略有不同。从编程角度来看,这种差异是通过强制右侧控件使用子类的这一行代码实现的

SetWindowTheme(hwndTreeOnRight, L"EXPLORER", NULL);

这一行代码有以下效果:窗口管理器会记住与控件关联的子类名称,直到窗口销毁或再次使用该函数重置子类名称。每当控件实现调用 OpenThemeData()(将其自己的窗口句柄作为第一个参数)时,UXTHEME.DLL 会返回子类数据的句柄,而不是父类本身的句柄。此外,SetWindowTheme() 函数会立即向给定控件发送 WM_THEMECHANGED 消息。因此,作为响应,控件会重新打开主题句柄并重新绘制自身,立即获得子类定义的替代外观。

现在,您可能会问 SetWindowTheme() 的最后一个参数,特别是如果您阅读过 MSDN 上该函数的文档,因为那里的描述非常隐晦。老实说,我对此不太确定。我认为(但从未验证过)它允许将类(不是子类)名称与控件关联,从而覆盖控件本身在每次调用 OpenThemeData() 时使用的类名称。(这种推测基于以下信息:使用 L" " 作为最后一个参数会强制特定窗口句柄不带任何主题绘制自身。我猜这是因为主题定义不知道名为“空格”的类。)

主题属性

我之前已经透露,属性是与主题定义中的类、子类、部分和状态相关联的一些数据,或者是与主题定义本身相关联的数据。

API 定义了大量的属性,通常主题定义中的每个对象只定义其中的一小部分。各种属性类型不同,正是这种属性的可变性反映在 API 本身中:它的大多数函数都用于检索属性的值。

有各种枚举属性(GetThemeEnumValue())、整数属性(GetThemeIntValue())、字符串属性(GetThemeStringValue())、布尔属性(GetThemeBoolValue())、颜色属性(GetThemeColorValue)等等。在此列出所有它们的类型没有任何意义。

不过,好消息是,在大多数情况下,应用程序或控件不需要检索它们。相反,主题 API 的绘制函数会自动反映它们。例如,属性 TMT_BGTYPE 指定与其关联的类/部分/状态的背景是应该作为图像进行位图复制,绘制边框,还是根本不绘制背景。根据此情况,其他属性可能会指定使用什么图像或什么颜色以及如何使用。

函数 DrawThemeBackground() 检索所有相关属性并遵循它们。这就是我们不赘述属性的原因:它们有数百个,我只在非常特殊的情况下需要用到其中少数几个。(我们可能会在未来的文章中触及这些属性,当我们讨论需要使用特定属性的某个主题时。)

尽管如此,还是有一个例外:还存在一些全局属性,即不与主题定义中的任何对象相关,而是与整个主题定义本身相关的属性。我们将在下一节中介绍这些属性。

全局属性

如上所述,还有一些全局属性,只要使用特定的主题定义,这些属性就适用于整个系统。简而言之,这些属性中的大多数只是通过 USER32.DLL 提供的某些旧函数检索到的系统参数的替代。

所有名称以 GetThemeSys...() 开头的 UXTHEME.DLL 函数都属于这种性质。这组函数的一个优点是,当没有主题定义适用时(主题句柄为 NULL),这些函数会自动回退到使用相应的 USER32.DLL 函数为您检索正确的值。

所以我们提供一个这些如何相互转换的小表格

UXTHEME.DLL USER32.DLL
函数 基本常量 函数 基本常量
GetThemeSysBool() TMT_FLATMENUS SystemParametersInfo() SPI_GETFLATMENU
GetThemeSysColor()   GetSysColor()  
GetThemeSysColorBrush()   GetSysColorBrush()  
GetThemeSysFont() TMT_ICONTITLEFONT SystemParametersInfo() SPI_GETICONTITLELOGFONT
其他 SPI_GETNONCLIENTMETRICS
GetThemeSysInt()   无对应项  
GetThemeSysSize()   GetSystemMetrics()  
GetThemeSysString()   无对应项  

注意:表格中未填充基本常量的地方,这两个函数共享一组允许的参数(具有相同的含义)。

只有在您还想支持 Windows 2000 或更早版本,因此需要处理缺少 UXTHEME.DLL 的可能性时,您才可能需要手动实现回退。

故障排除

您现在可能会很容易地产生这样的印象:一旦解决了是否使用主题 API 的问题,并且知道如何管理主题句柄,那么使用视觉主题进行绘制就相当简单了。但在更复杂的控件中,您可能会遇到许多问题。下面我总结了我在实现我的控件,特别是 mCtrl 项目中的控件时所面临的问题。

不幸的是,其中许多问题没有简单的解决方案(据我所知)。尽管如此,我仍然认为在此列出这些问题是有价值的。在您决定采取任何特定路径之前,了解尽可能多的陷阱是有益的。

事实上,使用此 API 通常需要试错法才能找到实现控件的可行方法。

陷阱:严重缺乏文档

API 最大的问题是缺乏良好的文档。MSDN 相当好地记录了 API 的所有函数和类型。但是,主题定义对象(即类和子类)以及它们的部件和状态的文档主要由其标识符的纯列表组成。

没有任何文档说明每个类、子类、部分或状态的用途。通常,从类/子类或部分/状态标识符的名称中可以很明显地看出,但在许多情况下则不然。

即使很明显,也没有文档说明是哪个 Windows 引入了它们,或者它们在什么情况下可用。例如,某些部分仅在您将其切换到特定子类时才由标准控件使用。以 TREEVIEW 类为例,它定义了(其中包括)部分 TVP_HOTGLYPH。Luna 和 Aero 根本没有为 TREEVIEW 类定义此部分,因此使用它进行绘制无效。但 Aero 为 TREEVIEW 类的 EXPLORER 子类定义了它。

因此,在实现类似树形视图的控件时,您应该仅在有条件地使用该部分,通过使用 IsThemePartDefined() 检查它是否可用,如果不可用,则回退到部分 TVP_GLYPH

不幸的是,MSDN 没有提供任何关于每个类的哪些部分或状态被保证定义而哪些是可选的信息。显然,有些必须保证定义,否则主题绘制就没有多大意义:否则使用 API 将会使实现和测试都变得异常复杂。

解决这个问题的一个部分方法是运用一些常识,并使用一些工具来探索主题定义:通常,您可以从它们的视觉外观中识别它们的用途。网上有几种这样的工具。我将在本文末尾提供一个名为 Theme Explorer 的工具。

陷阱:并非真正为自定义控件设计

在实现自定义控件时,开发人员应将其设计为与标准控件保持一致。可以说,有人可能会认为这样的 API 的目的是使其他控件与标准控件在当前使用的主题下保持一致;并且任务应该相当简单。

实际上,现实情况有所不同。API 和 Windows 主题的定义方式常常导致在尝试实现预期目标时感到沮丧。

举个例子,我记得我尝试实现一个带有一个或多个按钮的编辑控件,类似于标准组合框所配备的。唯一的区别应该是按钮上的不同字形,但除此之外,其外观应尽可能接近标准控件。

类似的例子是,当有人想在应用程序或另一个自定义控件中提供一个用于打开下拉菜单的小按钮时,只重用组合框的字形(下拉箭头)。

不幸的是,COMBOBOX 类有一个部分 CP_DROPDOWNBUTTON,它不仅绘制按钮的背景,还绘制字形。您可以同时拥有两者,或者什么都没有。

是的,API 有一个专门的函数可以从主题中获取位图(GetThemeBitmap())。所以人们可能会认为可以使用这个函数来检索字形位图。否则这个函数还有什么用呢?但是,看起来主题定义作者有不同的看法。组合框没有字形,背景部分只有一些像素,这些像素在缺乏经验的用户眼中可能会产生一些符号存在的错误印象……

我个人的看法是,主题 API,特别是它们定义的主题,是由一个完全没有考虑第三方自定义控件的人设计的。如果您问我,这是一种非常可悲的情况,因为它大大降低了 API 的潜力。

陷阱:在标准控件中未使用一致

然而,标准控件也存在问题。

当微软创建 COMCTL32.DLL 时,他们简单地省略了对某些控件或其样式的支持。一个众所周知的例子是,带有 BS_BITMAPBS_ICON 样式的按钮在 Windows XP 上未主题化绘制,尽管一旦支持 BS_PUSHBUTTON,其主题绘制的实现就微不足道。(这在 Windows Vista 中得到了修复。)

以类似的方式,自库版本 6 起,微软不再支持选项卡样式以及 TCS_BOTTOMTCS_VERTICAL,实际上,主题定义中没有与这些控件样式对应的数据。任何想要实现类似功能的自定义控件都不能完全依赖它。

这相当限制了自定义控件与标准控件的区别(即,定制程度)。

相反,Windows 主题定义中也有一些数据显然旨在用于某些目的,例如确定大小、边距或其他属性,但这些数据却常常未被标准控件的实现所使用。相反,控件使用了未文档化的神奇常量,这些常量硬编码在 COMCTL32.DLL 中。

例如,当我实现一个树形列表视图控件(mCtrl 项目)时,我遇到了这个问题。该控件旨在视觉上尽可能接近标准树形视图。我曾期望,当主题化时,标准控件会使用 GetThemeBackgroundContentRect() 在绘制树形视图项时应用一些边距。令我惊讶的是,任何尝试使用此函数都返回所有边距为 0,而标准树形视图显然在其周围有一些边距(如果您选择树形视图中的一个项目,很容易看到所有边缘都有几个像素的边距)。

换句话说:为了创建与标准控件一致的自定义控件,开发人员必须神奇地将标准 GDI 与主题 API 和神奇常量混合起来。这种特殊的混合被微软认为是每个标准控件的实现细节,它没有在任何地方进行文档化,因此必须猜测或逆向工程。此外,可怜的自定义控件开发人员 then 将面临一些危险,因为这种混合配方可能会在未来的 Windows 版本中发生变化。事实上,这不是任何开发人员都能感到高兴的情况。

我能提供的唯一部分解决方案是,Wine 的开发人员已经付出了巨大的努力,使他们的控件尽可能接近原始控件,因此最简单的方法是查看他们的代码。这不是我第一次推荐他们的代码库,我很确定也不是最后一次。(一些链接在本文末尾提供。)

陷阱:OpenThemeData() 和 Windows XP

根据 MSDN,OpenThemeData() 中的第二个参数功能比仅仅一个类名更强大。它特别说明如下

  • 它可以直接打开主题子类,使用 OpenThemeData(hwnd, L"subclass::class")
  • 它可以接受一个以分号分隔的类名列表,使用在主题定义中找到的第一个类名。

这两个功能都不错。不好的地方在于(未文档化的)事实,根据我的经验,它们在 Windows XP 上不起作用。因此,为了与 WinXP 兼容,您需要像本文前面解释的那样,仅通过 SetWindowTheme() 打开子类。

另一个功能可以用多次调用该函数来代替

const WCHAR* pszClassList[] = { L"class1", L"class2", ..., NULL };
HTHEME hTheme = NULL;
int i;

for(i = 0; pszClassList[i] != NULL; i++) {
    hTheme = OpenThemeData(hwnd, pszClassList[i]);
    if(hTheme != NULL)
        break;
}

陷阱:GetWindowTheme() 仅限于一个主题句柄

对于每个 HWND,窗口管理器会记住一个主题句柄。它是控件最近打开的主题句柄,即最近一次 OpenThemeData() 调用(其中给定的 HWND 作为第一个参数传入)的返回值。可以通过 GetWindowTheme() 查询此记住的句柄。同样,只有这一个句柄才能实际被 SetWindowTheme() 覆盖。

在实现更复杂的控件时,通常是看起来像多个标准控件组合的控件,您可能需要打开多个不同的主题句柄来绘制它。当然,这是可能的,但您必须仔细考虑哪个应该被视为主句柄。对于其他主题句柄,应将 NULL 作为 OpenThemeData() 的第一个参数传递,以免主句柄被其替换。

陷阱:GetThemeIntList() 旨在崩溃

遗憾的是,函数 GetThemeIntList() 和相应的结构 INTLIST 设计不当。该结构包含成员 iValues,实际上是一个固定大小的整数数组。不幸的是,该大小取决于预处理器值 _WIN32_WINNT,并且没有像许多 WinAPI 函数那样使用通常名为 cbSize 的成员进行运行时检查。

如果 _WIN32_WINNT 未定义,或低于 0x0600 (_WIN32_WINNT_VISTA),则数组大小为 10,否则为 402。Vista 及更高版本上的 UXTHEME.DLL 实现盲目地假定它可以向数组写入多达 402 个整数。也就是说,如果您的应用程序使用例如 _WIN32_WINNT_WINXP 设置的 _WIN32_WINNT 进行构建,您可能会在 Vista 及更高版本上发生缓冲区溢出,可能导致应用程序崩溃或其他数据覆盖。

解决方案很简单:如果您需要使用此函数,请将您的项目与预定义的 _WIN32_WINNT 设置为 0x600 或更高版本进行构建;或者使用足够大的缓冲区来容纳包含 402 个整数的完整结构。

希望 Microsoft 在未来的 Windows 版本中不会进一步增加限制。

主题浏览器

我已经指出,特别是访问主题定义的标识符文档很差。为此,我开发了一个用于探索当前主题的小工具,名为 Theme Explorer。它允许查看主题(在各种状态下)的外观,并有助于更容易地识别或猜测其用途以及如何使用。

最初我为开发自己的自定义控件而创建它,但我认为它可能对更广泛的受众有用。

 

Theme Explorer screenshot

主题浏览器截图

左侧是探索对象的选择器(第一层是类或子类,树中更深的是部分和状态),右侧则使用 DrawThemeBackground() 以几种大小绘制部分/状态,并列出与对象关联的属性。

可以从 mCtrl 项目网站下载,其源代码托管在 github 上

(该工具在 GNU GPL 版本 2 或(由您选择)任何更高版本下获得许可。)

该工具有一个相当严重的限制:它只能探索当前正在使用的主题,因此要有用,您必须在 Windows XP 或更高版本(系统启用主题支持)上运行它。要在不同的 Windows 版本中探索主题,您需要在所有这些版本中使用此工具。尽管如此,我希望您能发现它有用。

可下载示例

您可以在本页顶部下载示例包。它包含一个 Visual Studio 2010 解决方案,其中有三个小项目:一个 DLL,其中包含本文介绍的支持主题的自定义控件实现,以及两个仅托管自定义控件的小型应用程序。实际上,这两个应用程序共享它们的源代码,它们之间唯一的区别是一个应用程序清单启用了视觉主题支持,而另一个没有。

该 DLL 使用函数 ShouldUseUxThemeDll() 来检测是否应该使用主题绘制。当该函数返回 FALSE 时,该 DLL 会使用其自己的 dummy_OpenThemeData()(而不是 OpenThemeData()),该函数只返回 NULL

只有当 ShouldUseUxThemeDll() 返回 TRUE 时,示例才会加载 UXTHEME.DLL,其余部分取决于 OpenThemeData() 的返回值。

实际代码

如果您想研究真实世界的代码,我可以向您推荐以下内容

WineCOMCTL32.DLL 的重新实现(大部分)是支持主题的。特别是,历史上在 USER32.DLL 中实现的控件在其内部被子类化,因此在这种情况下,源代码只关注使控件主题化,这使它们成为进一步研究该主题的完美候选者

它的 UXTHEME.DLL 重新实现也可能引起本文读者的兴趣

mCtrl 实现了 UXTHEME.DLL 的包装器,用于处理库(或其某些导出符号)在特定 Windows 版本中缺失的情况

除此之外,它的大多数控件都支持主题,使用包装器,所以我们只列出几个更有趣的

下次:处理标准消息

今天就到这里。这篇文章比我最初预期的要长得多。另一方面,它的长度反映了我对创建良好自定义控件这个主题的重视程度,也反映了有关它缺乏文档的事实。我希望本文能稍微改善这种情况,并且您发现此处提供的一些信息是有用且易于理解的。稍后我们将回到控件绘制,例如,讨论一些动画技术或绘制非客户区。即使在这些领域,UXTHEME.DLL 也有一些东西可以提供。

话虽如此,我们首先应该稍微偏离一下,探索自定义控件实现的其他方面。每个控件都应该支持应用程序和系统用于与其通信的许多标准消息。MSDN 通常从应用程序的角度记录消息,但我们应该主要从控件的角度来看待它们,这将是下一篇文章的主题。

© . All rights reserved.