Windows 应用的深色模式与对比度主题支持 ── DWM 的深色标题栏、WinForms/WPF 跟随系统主题、高对比度下的绘制

· 更新日期: · · 深色模式, 对比度主题, 高对比度, DWM, WinForms, WPF, Windows 11, 无障碍, 业务应用, Windows

「把公司里的 PC 换成 Windows 11 之后,用深色模式的员工说『只有我们的业务应用标题栏惨白,看得眼睛疼』」「低视力的员工启用对比度主题后,接单画面的状态显示不见了」── 这两种症状在最近一两年里咨询都在增加。

前者源于 Windows 11「颜色」的设置,后者源于「辅助功能」的设置,但在开发者眼里它们是同一个问题:「没能跟随主题」。而且实际上,对策的地基是共通的。不要把颜色硬编码,读取系统的设置,察觉变化,重新上色。就这三点。

上一篇「Windows 应用无障碍入门」讲的是屏幕阅读器读取的机制(UI Automation)以及命名、键盘、颜色的基础。其中提到了跟随对比度主题,但没有涉及浅色/深色这一颜色模式本身。本文作为它的姊妹篇,按照 DWM(桌面窗口管理器)绘制标题栏、WinForms/WPF 跟随系统主题、对比度主题下的绘制这一顺序,在实现层面把颜色模式(浅色/深色)与对比度主题这两条轴连起来。目标读者是用 WinForms/WPF/Win32 开发和维护业务应用的开发者,前提环境是 Windows 11(深色标题栏需要内部版本 22000 及以上)与 .NET 9/10 的 WinForms、WPF(.NET Framework 4.8 和 .NET 8 需要手动实现一部分),难度为中级

本文的脉络从整理主题的两条轴开始,依次串起默认变成浅色的原因、DWM 的深色标题栏、检测与跟随的机制、WinForms 与 WPF 的实现、对比度主题下的绘制、方针的确定方法与验证的本文结构整理主题的两条轴默认变成浅色的原因DWM 的深色标题栏检测与跟随的机制WinForms/WPF 的实现对比度主题下的绘制方针的确定与验证

图 1: 本文把主题的梳理、机制、实现、对比度主题、验证连成一条线。

1. 先说结论

  • Windows 的「主题」有两条轴。一条是「设置 > 个性化 > 颜色」的浅色/深色(颜色模式),另一条是「设置 > 辅助功能 > 对比度主题」。后者是被约束在大约 7:1 以上对比度的调色板,与浅色/深色是两回事。在对比度主题生效期间无法使用深色模式。判断的优先顺序是「对比度主题 → 浅色/深色」。12
  • 现有应用的标题栏仍是白色,是出于兼容性的默认行为。Windows 没有办法知道应用是否支持深色,因此把所有窗口默认按浅色处理。3
  • 把标题栏变成深色靠的是 DwmSetWindowAttributeDWMWA_USE_IMMERSIVE_DARK_MODE(值 20)。传入 BOOL 的 TRUE 后,系统处于深色时窗框就会用深色绘制。文档记载的支持范围是 Windows 11 内部版本 22000 及以上。43
  • 当前的模式用 UISettings.GetColorValue 读取,变化用 ColorValuesChanged 接收。前景色(默认文字色)如果偏亮就判定为深色,这是 Microsoft 的官方做法。事件不保证在 UI 线程上到达,所以要先切回 UI 线程再重新上色。35
  • WinForms 在 .NET 9 引入了 Application.SetColorMode,到 .NET 10 不再是实验性的。Application.Run 之前调用 SystemColorMode.System。它有三条约束:仅限 Windows 11、对比度主题下无效、运行期间的设置变更不会被跟随。672
  • WPF 在 .NET 9 引入了 Fluent 主题与 ThemeModeThemeMode="System" 即可跟随,窗口的深色化也一并被控制。不过从代码进行的操作在 .NET 10 中仍是实验性的(WPF0001),Fluent 样式仍「在进行中」。如果保持传统主题,就用 DynamicResource 替换浅色/深色的 ResourceDictionary。8910
  • 处于对比度主题时,要把颜色映射到系统颜色正确的配对上,省去文字背后的图片,并把多色的图形用前景与背景两色绘制。判断用 SPI_GETHIGHCONTRAST(WinForms 是 SystemInformation.HighContrast,WPF 是 SystemParameters.HighContrast),通知是 WM_SYSCOLORCHANGE / WM_THEMECHANGED(在 .NET 中是 SystemEvents.UserPreferenceChanged)。111213
  • 支持深色模式并不能代替无障碍支持。深色的调色板同样需要 4.5:1 的对比度,不只依赖颜色的表达方式在任何主题下都必要。1415

用一句话概括:所谓主题支持,就是「把颜色集中到一处,读取系统的设置,察觉变化并重新上色;但在对比度主题下要把配色全面交给系统颜色的配对」

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 28 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

2. 「主题」有两条轴 ── 浅色/深色与对比度主题

2.1. 浅色/深色(颜色模式)

Windows「设置 > 个性化 > 颜色」里的颜色模式,是决定操作系统与整个应用的前景色和背景色明暗关系的设置。Microsoft 的文档把浅色定义为「亮背景配暗前景」,把深色定义为「暗背景配亮前景」,并补充说这里的前景指的是「默认的文字色」。在深色模式下,前景(文字)变亮,背景变暗。3

这个设置保存在注册表 HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize 下的 AppsUseLightTheme(应用的模式)与 SystemUsesLightTheme(Windows 自身的模式)这两个 DWORD 值中,Microsoft 的设置参考文档里有记载。16 不过如后文所述,应用读取时的正规途径是 WinRT 的 UISettings

2.2. 对比度主题(高对比度)

在「设置 > 辅助功能 > 对比度主题」里选择的对比度主题,使用的是被约束到大约 7:1 以上对比度的调色板,面向的是强烈需要前景与背景在视觉上分离的用户。Windows 11 内置了 Aquatic、Desert、Dusk、Night sky 四种,用户不仅可以从中选择,还能分别编辑背景、文字、超链接、禁用文字、选中文字和按钮的颜色。用左 Alt+左 Shift+PrintScreen 可以快速切换,未选择时应用 Aquatic。1

Microsoft 的文档明确写道「不要把对比度主题与浅色/深色主题混为一谈」。浅色/深色使用的是宽泛的调色板,并不是为最大对比度而优化的。1 而且重要的是,在对比度主题生效期间无法使用深色模式。WinForms 的 Application.SetColorMode 在对比度主题下不提供深色,XAML 的 RequestedTheme 也会被系统覆盖。217

2.3. 判断的优先顺序

因此,应用的实现应当按下面的顺序进行。先判断是不是对比度主题,是的话就把配色全面交给系统颜色。不是的话,再选择浅色或深色中的一套调色板。

主题的两条轴与判断的优先顺序对比度主题生效时把配色全面交给系统颜色的配对,未生效时读取浅色/深色的颜色模式来选择应用的调色板的判断顺序生效未生效浅色深色对比度主题生效吗?交给系统颜色的配对颜色模式是?浅色的调色板深色的调色板无法使用深色模式

图 2: 把对比度主题的判断放在前面,只有未生效时才选择浅色/深色的调色板。

3. 为什么现有应用在深色模式下仍是白的

窗口由两个区域组成:由标题栏、边框、标题按钮构成的非客户区,以及由应用绘制的客户区。从 Windows Vista 起,非客户区由 DWM(桌面窗口管理器)合成绘制,应用通过 DwmSetWindowAttribute 指定绘制方式的属性。18

Microsoft 的文档坦率地说明了现有应用仍是白色的原因:「因为 Windows 不知道应用程序能否支持深色模式,出于向后兼容的理由,它假定其不能支持」。虽然也有 WinUI、Windows App SDK 这类原生处理深色模式的框架,但 Win32 应用大多不支持深色模式,所以 Windows 默认给出浅色的标题栏。3

窗口的两个区域与绘制的主体由标题栏和边框构成的非客户区由 DWM 绘制,客户区由应用或 UI 框架绘制,因此深色支持在两边都需要顶层窗口非客户区(标题栏・边框)客户区(画面的内容)由 DWM 合成绘制由应用或框架绘制用 DwmSetWindowAttribute 指示应用自己的调色板

图 3: 标题栏由 DWM 绘制、内容由应用绘制,所以深色支持既要对 DWM 下达指示,也要有应用自己的调色板。

由此得出两个结论。第一,要把标题栏变成深色,必须由应用显式地请求 DWM。第二,请求之后变成深色的只有标题栏,客户区必须自己重新上色。文档也说「要完整支持深色模式,应用的整个界面都需要遵循深色主题」,并声明官方指南只讲到检测与标题栏为止,不涉及客户区的重新上色。3 只把标题栏变黑而内容仍是白色的应用,比整体保持白色的应用更不自然。

默认变成浅色的机制Windows 不知道应用能否支持深色,出于兼容性默认使用浅色,只有应用通过 DWM 属性传入 TRUE 时才按系统的深色设置绘制窗框Windows 不知道能否支持为兼容性默认使用浅色应用传入 TRUE 了吗?始终是浅色的窗框按系统设置绘制

图 4: 不知道能否支持的 Windows 默认使用浅色,只有收到应用的显式指示时才跟随。

4. DWM 的深色标题栏 ── DwmSetWindowAttribute

4.1. DWMWA_USE_IMMERSIVE_DARK_MODE

把标题栏变成深色的属性是 DWMWA_USE_IMMERSIVE_DARK_MODEDWMWINDOWATTRIBUTE 枚举的说明是这样的:「当深色模式的系统设置启用时,允许用深色模式的颜色绘制该窗口的窗框。出于兼容性的理由,所有窗口无论系统设置如何都默认为浅色模式。pvAttribute 指向一个 BOOL,TRUE 表示遵循深色模式,FALSE 表示始终为浅色模式。Windows 11 内部版本 22000 及以上支持」4

也就是说,TRUE 并不是「变成深色」,而是「如果系统是深色,可以变成深色」这样一种许可。只要应用一侧已经准备好把客户区涂成深色,传入 TRUE 就能让标题栏跟随系统设置。反过来,如果设计上让应用始终以浅色显示(即后文的「固定浅色」),保持默认的 FALSE 就没有问题。

官方指南的 C++ 代码是下面这样的。为了那些头文件里没有该常量的旧 SDK,其中还包含了自行定义值 20 的步骤。3

#include <dwmapi.h>
#pragma comment(lib, "dwmapi.lib")

#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif

// 是否为 Windows 11(内部版本 22000)及以上。前提是清单中声明了 Windows 10
// 以上的 supportedOS(没有的话版本会被截断为 Windows 8 级别)
bool IsWindows11OrGreater()
{
    OSVERSIONINFOEXW osvi{ sizeof(osvi) };
    osvi.dwMajorVersion = 10;
    osvi.dwMinorVersion = 0;
    osvi.dwBuildNumber = 22000;
    DWORDLONG mask = 0;
    VER_SET_CONDITION(mask, VER_MAJORVERSION, VER_GREATER_EQUAL);
    VER_SET_CONDITION(mask, VER_MINORVERSION, VER_GREATER_EQUAL);
    VER_SET_CONDITION(mask, VER_BUILDNUMBER, VER_GREATER_EQUAL);
    return ::VerifyVersionInfoW(
        &osvi, VER_MAJORVERSION | VER_MINORVERSION | VER_BUILDNUMBER, mask) != FALSE;
}

// honorDarkMode = true: 系统是深色时,标题栏也可以用深色绘制
void ApplyTitleBarTheme(HWND hwnd, bool honorDarkMode)
{
    if (!IsWindows11OrGreater())
    {
        // 文档记载的支持从内部版本 22000 起。低于它就不调用,遵循默认(浅色)
        LogInfo(L"DWMWA_USE_IMMERSIVE_DARK_MODE is not documented for this OS build; keeping the default light frame");
        return;
    }
    BOOL value = honorDarkMode ? TRUE : FALSE;
    HRESULT hr = ::DwmSetWindowAttribute(
        hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &value, sizeof(value));
    if (FAILED(hr))
    {
        // 在受支持的系统上失败属于异常。不要默默吞掉,记录 HRESULT 让问题浮现
        LogWarning(L"DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE) failed: 0x%08X", hr);
    }
}

顺带说明一下按操作系统版本区别调用的理由。文档记载的支持范围是 Windows 11 内部版本 22000 及以上。4 说同一个值在 Windows 10 上也有效的报告并不少见,但不应该让业务应用的显示依赖未记入文档的行为。如果设计成「先调用,失败就放弃」,那么在 Windows 10 上调用碰巧成功时,标题栏就会建立在未记入文档的行为之上变成深色。低于内部版本 22000 就不调用,遵循「标题栏会是浅色」这一有文档记载的默认行为;在受支持的系统上失败时,把 HRESULT 记入日志让问题浮现,仅此而已。网上还流传着使用值 19 的旧做法,或者调用 uxtheme.dll 的序号导出函数把通用控件变成深色的手法,但它们都是非公开 API,更新后行为改变时没有人会为你负责。

4.2. 调用的时机 ── HWND 存活时,以及每次被重建时

DwmSetWindowAttribute 是针对 HWND 调用的,因此必须在窗口句柄生成之后。而且 WinForms 的窗体会因为修改 ShowInTaskbar 之类的操作而重建句柄。重建后的新 HWND 上没有这个属性,所以调用的位置不是「构造函数」,而是「每次句柄生成时都会被调用的地方」。WinForms 是 OnHandleCreated,WPF 是 SourceInitialized

调用 DwmSetWindowAttribute 的时机在窗口句柄生成后设置 DWM 属性,句柄被重建时对新句柄再次设置,收到主题变更的通知时也重新设置的流程句柄重建主题变更的通知HWND 生成设置 DWM 属性显示中

图 5: DWM 属性绑定在 HWND 上,所以每次生成和每次重建都要重新设置。

WinForms 中的 P/Invoke 如下(DllImport 的安全写法请参阅「在 C# 中安全调用 Win32 API」)。

using System.Runtime.InteropServices;

public partial class MainForm : Form
{
    private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;

    [DllImport("dwmapi.dll")]
    private static extern int DwmSetWindowAttribute(
        IntPtr hwnd, int attribute, ref int value, int size);

    protected override void OnHandleCreated(EventArgs e)
    {
        base.OnHandleCreated(e);
        ApplyTitleBarTheme();
    }

    private void ApplyTitleBarTheme()
    {
        // 文档记载的支持从内部版本 22000 起。低于它就不调用,遵循默认(浅色)。
        // 这个判断在 .NET Framework 上也能用。但在 .NET Framework 上,如果清单中没有
        // 声明 Windows 10 以上的 supportedOS,版本会被截断为 Windows 8 级别
        //(.NET 5 以后也可以用 OperatingSystem.IsWindowsVersionAtLeast(10, 0, 22000))
        if (Environment.OSVersion.Version < new Version(10, 0, 22000))
        {
            _logger.LogInformation("Dark title bar is not documented for this OS build; keeping the default light frame");
            return;
        }
        // 1(TRUE) = 系统是深色时可以用深色绘制。0(FALSE) = 始终为浅色
        int honorDarkMode = 1;
        int hr = DwmSetWindowAttribute(
            Handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref honorDarkMode, sizeof(int));
        if (hr < 0)
        {
            _logger.LogWarning("DwmSetWindowAttribute failed: 0x{Hr:X8}", hr);
        }
    }
}

需要这段代码的是 .NET 8 及更早、.NET Framework 以及 Win32/MFC 的应用。在 .NET 9 以后的 WinForms 中使用 Application.SetColorMode,以及在 .NET 9 以后的 WPF 中使用 ThemeMode 时,窗口的深色化由框架代劳(ThemeMode 的文档明确写着「也控制窗口的背景材质与深色模式的应用」)。10 重复调用没有害处,但会让职责归属变得模糊,所以请只选其中一种。

在 WPF 中,SourceInitialized 的时点上 HWND 已经确定。用 WindowInteropHelper 取得句柄。19

using System.Windows.Interop;

public partial class MainWindow : Window
{
    protected override void OnSourceInitialized(EventArgs e)
    {
        base.OnSourceInitialized(e);
        var hwnd = new WindowInteropHelper(this).Handle;
        TitleBarTheme.Apply(hwnd, honorDarkMode: true); // 内容就是前面的 P/Invoke
    }
}

4.3. 标题栏的颜色、文字色、边框色与背景材质

在 Windows 11 中,除了深色/浅色的二选一,还新增了直接指定标题栏颜色本身的属性。

属性 内容 支持的内部版本
DWMWA_USE_IMMERSIVE_DARK_MODE 20 系统是深色时把窗框绘制成深色(BOOL) 22000
DWMWA_BORDER_COLOR 34 窗口边框的颜色(COLORREF)。用 DWMWA_COLOR_NONE 可以去掉边框 22000
DWMWA_CAPTION_COLOR 35 标题栏的颜色(COLORREF) 22000
DWMWA_TEXT_COLOR 36 标题文字的颜色(COLORREF) 22000
DWMWA_SYSTEMBACKDROP_TYPE 38 系统绘制的背景材质(Mica 或 Acrylic) 22621

这三个指定颜色的属性,传入 DWMWA_COLOR_DEFAULT(0xFFFFFFFF)就会恢复系统默认。关于边框色,请注意文档写明「随窗口激活之类的状态变化改变颜色是应用的责任」。4 背景材质用 DWM_SYSTEMBACKDROP_TYPE 枚举指定,DWMSBT_MAINWINDOW 在 Windows 11 上相当于 Mica,DWMSBT_TRANSIENTWINDOW 相当于 Acrylic,但文档也明确写着「材质的效果在将来的 Windows 中可能改变」。20

在业务应用中要慎重考虑它们的用武之地。把标题栏涂成品牌色后,你就要自己负责保证标题文字与标题按钮在那个颜色上的对比度。除了深色/浅色两种状态,还要再加上激活/非激活的组合。对多数业务应用来说,正确答案是「遵循系统默认(只把值 20 设为 TRUE)」,品牌色是真正需要时才有的选项。

如何决定标题栏的颜色遵循系统默认只需把值 20 设为 TRUE,而涂成品牌色后保证文字与标题按钮的对比度以及管理激活/非激活的颜色都成为应用的责任,恢复时要传入 DWMWA_COLOR_DEFAULT遵循系统默认涂成品牌色标题栏的颜色?只把值 20 设为 TRUE指定颜色属性(34~36)自己保证文字与按钮的对比度激活/非激活也要自己管理恢复时传入 COLOR_DEFAULT

图 6: 选择品牌色后对比度与状态管理的责任就转移到应用一侧,对多数业务应用来说遵循默认才是正确答案。

5. 系统主题的检测与跟随 ── 读取、察觉、重新上色

让客户区跟随主题的工作可以拆成三件事:读取当前的设置,察觉变化,然后重新上色

检测与跟随的三个步骤启动时用 UISettings 读取当前的颜色模式,通过 ColorValuesChanged 等通知察觉变化,切回 UI 线程后重新给应用的调色板上色的循环读取: UISettings.GetColorValue重新上色: 重新应用调色板察觉: ColorValuesChanged切回 UI 线程DWM 属性也重新设置

图 7: 启动时读取、靠通知察觉、切回 UI 线程重新上色的循环构成了主题跟随的骨架。

5.1. 读取 ── UISettings 与「前景偏亮就是深色」

Microsoft 的官方做法使用 WinRT 的 Windows.UI.ViewManagement.UISettings。用 GetColorValue(UIColorType::Foreground) 取得前景色(默认的文字色),以整数运算估算它的感知亮度来判断「是否偏亮」,前景偏亮就判定为深色模式。文档声明这个公式不是严格的亮度分析模型,而是足以进行明暗分类的近似。321

UISettings 是 WinRT 的类,但只要把 C# 的 WPF/WinForms 的 TargetFramework 写成 net8.0-windows10.0.19041.0 这样带 Windows SDK 版本的形式,就能直接调用(原理请参阅「WinRT 就是 COM」)。也可以直接读注册表的 AppsUseLightTheme,但注册表是设置的保存位置而不是 API 契约,所以读取时应以 UISettings 为准,把注册表留给诊断用途。

读取颜色模式的途径正规途径是用 WinRT 的 UISettings 取得前景色来判断明暗,注册表的 AppsUseLightTheme 只是保存位置,留给诊断用途现在是浅色还是深色UISettings.GetColorValue注册表 AppsUseLightTheme判断前景色的感知亮度偏亮就是深色保存位置。留给诊断用途

图 8: 正规的读取途径是 UISettings,注册表只不过是保存位置。

5.2. 察觉 ── ColorValuesChanged 不在 UI 线程上到达

检测变化同样使用 UISettingsColorValuesChanged 事件在颜色的值发生变化时触发,官方指南也用这个事件跟踪设置的变更。53 这里有一点在实务上需要注意。这个事件不保证在 UI 线程上到达。请先用 WPF 的 Dispatcher、WinForms 的 Control.Invoke,或者两者都能用的 SynchronizationContext 切回 UI 线程,再去操作控件。与 UI 线程打交道的方式整理在「WPF/WinForms 的 UI 线程与 async/await」中。

另外,更改主题色时这个事件同样会触发。如果只想在浅色/深色变化时重新上色,就在每次事件里重新判断,只有与上一次不同时才发出通知。而且按照第 2 章的优先顺序,如果对比度主题生效,就不能去看前景色的亮度。像 Aquatic 这种暗背景的对比度主题前景是亮的,只看亮度会误判成「深色」。对比度主题要作为独立的状态先行判断。

下面这个类把这个判断顺序集中到了一处。用于切回 UI 线程的 SynchronizationContext,以及对比度主题的判断(WinForms 是 SystemInformation.HighContrast,WPF 是 SystemParameters.HighContrast,见第 8 章)都由调用方传入。请注意创建的时机。在 WinForms 中,Program.Main 的时点上既没有消息循环也没有 ControlSynchronizationContext.Currentnull。请在窗体的构造函数或 OnLoad 这类控件已经生成之后的位置传入 SynchronizationContext.Current。WPF 则可以传入 new DispatcherSynchronizationContext(Application.Current.Dispatcher)

using Windows.UI.ViewManagement; // TargetFramework: net8.0-windows10.0.19041.0 以上

public enum ThemeState { Light, Dark, HighContrast }

public sealed class SystemThemeWatcher : IDisposable
{
    private readonly UISettings _settings = new();
    private readonly SynchronizationContext _ui;
    private readonly Func<bool> _isHighContrast;

    private bool _disposed;

    public ThemeState Current { get; private set; }
    public event EventHandler? Changed;

    // ui: UI 线程的 SynchronizationContext。传入控件生成之后的
    //     SynchronizationContext.Current,WPF 则传入 DispatcherSynchronizationContext
    // isHighContrast: () => SystemInformation.HighContrast(WinForms)
    //                 () => SystemParameters.HighContrast(WPF)
    public SystemThemeWatcher(SynchronizationContext ui, Func<bool> isHighContrast)
    {
        _ui = ui ?? throw new ArgumentNullException(nameof(ui));
        _isHighContrast = isHighContrast ?? throw new ArgumentNullException(nameof(isHighContrast));
        Current = Read();
        _settings.ColorValuesChanged += OnColorValuesChanged;
    }

    private ThemeState Read()
    {
        // 判断顺序如第 2 章所述: 对比度主题在先。暗背景的对比度主题
        // 前景是亮的,只看亮度会误判成「深色」
        if (_isHighContrast()) return ThemeState.HighContrast;
        // 与官方指南相同的判断: 前景(默认的文字色)偏亮就是深色
        var fg = _settings.GetColorValue(UIColorType.Foreground);
        bool isDark = (5 * fg.G + 2 * fg.R + fg.B) > 8 * 128;
        return isDark ? ThemeState.Dark : ThemeState.Light;
    }

    // 从 UI 线程调用。也可以从 UserPreferenceChanged 等其他途径的通知中调用
    public void Refresh()
    {
        if (_disposed) return;
        var next = Read();
        // 在浅色/深色之间,为了忽略只改主题色之类的变化,状态相同就不通知。
        // 对比度主题中是例外: 用户编辑主题的颜色后状态仍然是 HighContrast,
        // 所以状态相同也要通知,让代码重新取一遍系统颜色
        if (next == Current && next != ThemeState.HighContrast) return;
        Current = next;
        Changed?.Invoke(this, EventArgs.Empty);
    }

    private void OnColorValuesChanged(UISettings sender, object args)
    {
        // 不保证在 UI 线程上到达,所以切回 UI 后再判断并通知。
        // Dispose 之后才到达(已经排进队列)的调用由 Refresh 一侧的标志忽略
        _ui.Post(_ => Refresh(), null);
    }

    public void Dispose()
    {
        // 取消订阅只能停止今后的投递,已经抛进 UI 线程的调用仍然留着。
        // 立起标志,让残留的调用自己被忽略(要在 UI 线程上调用)
        _disposed = true;
        _settings.ColorValuesChanged -= OnColorValuesChanged;
    }
}

在 Win32 层面,设置变更时 WM_SETTINGCHANGE 会被发送到所有顶层窗口,22 在 .NET 中它以 SystemEvents.UserPreferenceChanged 的形式到达。23 视觉样式的切换(包括启用对比度主题)会发出 WM_THEMECHANGED24 系统颜色的变更会发出 WM_SYSCOLORCHANGE25 与其按通知的种类分别写不同的处理,不如不管来的是哪种通知都调用同一个「读取并重新上色」的处理,这样更不容易坏,也与上一篇文章介绍的跟随对比度主题是同一个形态。就上面的 SystemThemeWatcher 而言,就是从 UserPreferenceChangedStaticPropertyChanged 的处理程序里也调用 Refresh()

主题变更的通知途径与线程UISettings 的 ColorValuesChanged 可能在 UI 线程以外到达因此要切回 UI,WM_SETTINGCHANGE 以 SystemEvents.UserPreferenceChanged 的形式到达,WM_THEMECHANGED 与 WM_SYSCOLORCHANGE 到达窗口过程。三者都汇集到同一个重新应用的处理ColorValuesChanged切回 UI 线程UserPreferenceChanged读取并重新上色WM_THEMECHANGED 等

图 9: 通知的途径有多条,但全部汇集到同一个「读取并重新上色」的处理。

5.3. 重新上色 ── 把颜色集中到一处

能够重新上色的前提,是颜色集中在一处。如果 Color.White#FFFFFF 散落在窗体和 XAML 的各个角落,就无法枚举需要重新上色的地方。WinForms 就做一个「调色板」类(浅色用和深色用两个实例),控件在启动时和收到通知时从那里取色。WPF 就把颜色集中到 ResourceDictionary,XAML 用 DynamicResource 引用。WinUI 则从一开始就以 ThemeDictionaries 的形式提供了这个结构。

把颜色集中到一处的结构应用中各持有一份浅色用和深色用的调色板,各画面按当前的模式从选中的调色板取色,从而能够枚举出需要重新上色的对象浅色深色当前的模式使用哪一个?浅色的调色板深色的调色板各画面・各控件散落的 Color.White无法枚举需要重新上色的地方

图 10: 把调色板集中放在一处就能枚举重新上色的对象,散落的硬编码则做不到。

这个「把颜色集中起来」的工作,在对比度主题支持中,以及在后文的对比度检查中,都会直接派上用场。支持深色模式的最大成本不是 API 调用,而是这项整理。

6. WinForms 中的实现

6.1. .NET 9/10 ── Application.SetColorMode

WinForms 在 .NET 9 加入了深色模式的初步支持,在 .NET 10「完全集成」。传给 Application.SetColorMode 的值有三个。67

  • SystemColorMode.Classic ── 默认。与以往一样的浅色。
  • SystemColorMode.System ── 遵循 Windows 的浅色/深色设置。
  • SystemColorMode.Dark ── 深色。

调用的位置是 Application.Run 之前、创建 UI 元素之前。在 .NET 9 中它是实验性功能,不在项目文件里抑止 WFO5001 就会编译报错,从 .NET 10 起不再出现这个错误。26

static class Program
{
    [STAThread]
    static void Main()
    {
        ApplicationConfiguration.Initialize();
        Application.SetColorMode(SystemColorMode.System); // 在创建 UI 之前调用
        Application.Run(new MainForm());
    }
}

颜色模式改变后 System.Drawing.SystemColors 会切换到对应的颜色,标准控件也随之绘制。6 其机制是:SystemColors.UseAlternativeColorSet 这个实验性属性(SYSLIB5002)会「让系统的 KnownColor 返回替代的色集(目前是深色模式版)」;由于 Win32 的系统颜色本身不随浅色/深色设置改变,所以是 .NET 一侧持有替代色集。同一份文档还写着,对比度主题生效时始终返回 Windows 当前的颜色27

请记住 SetColorMode 文档里写明的三条约束。2

  1. 深色的颜色模式只能在 Windows 11 以上使用。
  2. 对比度主题生效时无法使用深色模式。
  3. 即使指定 SystemColorMode.System,运行期间 Windows 的设置发生变化时应用也不会自动跟随。

第三条在业务应用中容易引来询问。请把「由启动时的 Windows 设置决定,下次启动时生效」这一行为写进面向用户的说明。如果无论如何都想跟随运行期间的切换,就需要重建窗体的设计,多数情况下并不划算。

SetColorMode 的流程与约束SetColorMode 要在 Application.Run 之前调用,SystemColors 切换到替代色集后标准控件随之跟随。它有仅限 Windows 11、对比度主题下无效、不跟随运行期间的设置变更这三条约束SetColorMode(System)Application.Run 之前SystemColors 切到替代色集标准控件随之跟随仅限 Windows 11对比度主题下无效不跟随运行期间的变更

图 11: SetColorMode 只在启动前生效一次,并带有三条有文档记载的约束。

6.2. 自绘控件与 ApplyThemingImplicitly

标准控件会遵循应用的颜色模式,但 .NET 10 的文档举了两种例外情况。如果在自己组装、自己绘制的控件中使用了滚动条之类的 Win32 通用控件,那么不显式选择加入的话它们会保持浅色。反过来,如果想继承本来会跟随主题的现有控件并完全自己控制绘制,就要选择退出。7

两者都要重写 Control.CreateParams,并在读取 base.CreateParams 之前调用 SetStyle(ControlStyles.ApplyThemingImplicitly, true/false)。由于基类的构造函数会读取 CreateParams,放在自己的构造函数里就来不及了 ── 这是这个 API 的陷阱。7

public partial class GanttChartControl : Control
{
    protected override CreateParams CreateParams
    {
        get
        {
            // 要在读取 base.CreateParams 之前设置。放在构造函数里已经晚了
            SetStyle(ControlStyles.ApplyThemingImplicitly, true);
            return base.CreateParams;
        }
    }
}
能够设置 ApplyThemingImplicitly 的时机由于基类的构造函数读取 CreateParams 时 ApplyThemingImplicitly 就已经确定,因此必须在 CreateParams 的重写内、base.CreateParams 之前调用 SetStyle,放在派生类的构造函数里已经晚了基类构造函数读取 CreateParams在此之前需要 SetStyle派生类的构造函数在这里调用已经晚了

图 12: ApplyThemingImplicitly 必须在基类构造函数读取 CreateParams 之前就确定下来。

自绘本身(在 OnPaint 中用 GDI+ 绘制)只要使用 SystemColors / SystemBrushes / SystemPens,就会跟随替代色集。用 Color.White 上色的地方,在这里同样要换成前面说的调色板。

6.3. .NET Framework 4.8 与 .NET 8 及更早 ── 明确声明「固定浅色」

在没有 SetColorMode 的环境里,深色模式没有标准支持。选项有两个。

  1. 明确声明固定浅色。DWM 属性保持默认的 FALSE(始终是浅色的标题栏),客户区也一如既往。只有对比度主题支持(第 8 章)必须做到。
  2. 自己做全面支持。集中调色板,用 UISettings 读取并跟随,把 DWM 属性设为 TRUE,连通用控件的外观在内全部重新上色。

第 2 条容易变成「几乎全黑但个别地方仍是浅色」的状态,因为 Win32 通用控件(滚动条、表头、树的展开按钮等)的绘制应用无法完全控制。正如官方指南所说「整个界面都需要跟随」,3 半途而废的深色比固定浅色的体验更糟。对既有资产而言,选择第 1 条并明确声明「本应用为浅色显示」,在迁移到 .NET 10 的时机再切换到 SetColorMode,才是现实且容易说明的方针。

WinForms 按运行时区分的选项.NET 10 以上就使用 SetColorMode,.NET 9 则在抑止 WFO5001 的前提下使用同一个 API,.NET 8 及更早或 .NET Framework 则在明确声明固定浅色与连通用控件一起自己全面支持之间选择.NET 10 以上.NET 9.NET 8 及更早 / .NET Framework推荐有觉悟的话运行时是?SetColorMode(System)SetColorMode + 抑止 WFO5001怎么办?明确声明固定浅色自己做全面支持通用控件会残留

图 13: 在用不了 SetColorMode 的运行时上,明确声明固定浅色是现实的默认选择。

7. WPF 中的实现

7.1. .NET 9/10 ── Fluent 主题与 ThemeMode

.NET 9 的 WPF 自带了符合 Windows 11 Fluent 设计的新主题,支持浅色/深色与主题色。应用的方法有两种:设置 ThemeMode 属性,或者把 PresentationFramework.Fluent 的资源字典加入 MergedDictionaries8

ThemeMode 的值有 Light / Dark / System / None(默认。传统的 Aero2 主题)四个,设置在 Application 上作用于整个应用,设置在 Window 上只作用于那个窗口。8

<Application x:Class="OrderEntry.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="MainWindow.xaml"
             ThemeMode="System">
</Application>

文档明确写着,ThemeMode 不只是把 Fluent 主题的字典加载到资源里,还「控制窗口的背景材质与深色模式的应用」。也就是说,第 4 章的 DWM 属性由 WPF 一侧代劳。文档还说明 ThemeModeResources 被设计成同步工作,为的是避免窗口是深色而里面的控件是浅色这种不一致。10

WPF 的 ThemeMode 做了什么把 ThemeMode 设为 System 后会按 Windows 的设置把对应的 Fluent 主题字典加载到资源,同时也控制窗口的深色模式与背景材质的应用ThemeMode=System读取 Windows 的设置把 Fluent 字典加载到资源窗口的深色化与背景材质与 Resources 同步以防不一致

图 14: ThemeMode 把 Fluent 字典的加载与窗口的深色化一起控制起来。

采用之前有两点需要知道。第一,从代码读写 ThemeMode 的操作是实验性功能,访问时会产生 WPF0001 错误。抑止之后就能写成 Application.Current.ThemeMode = ThemeMode.Dark,但 API 参考在 .NET 10 中仍标着 [Experimental("WPF0001")],并注明「将来可能被删除」。810 第二,Fluent 样式的支持在 .NET 10 中仍「在进行中」。.NET 10 追加了 DatePicker、GridSplitter、GroupBox、TextBox 等的样式,并修复了 HighContrast 相关的崩溃,9 反过来说就是 .NET 9 的 Fluent 里缺少这些。请在做出采用判断之前,在实际设备上确认业务应用中使用的控件(尤其是 DataGrid 或第三方控件)在 Fluent 下会不会显示错乱。

7.2. 保持传统主题跟随 ── 替换 ResourceDictionary

不采用 Fluent 的(或者是 .NET 8 及更早、.NET Framework 的)WPF,深色模式没有标准支持。由于 Win32 的系统颜色不随浅色/深色设置改变,即使引用 WPF 的 SystemColors 也不会变暗。自己实现跟随的结构如下。

  1. 在浅色用的 Themes/Light.xaml 和深色用的 Themes/Dark.xaml 中,用相同的键定义颜色和画刷。
  2. 从 XAML 中像 {DynamicResource App.WindowBackgroundBrush} 这样用 DynamicResource 引用StaticResource 在加载时就被固定,不会跟随替换)。
  3. 在第 5 章 SystemThemeWatcher 的通知里,替换 MergedDictionaries 中对应的字典。对比度主题下无论放入哪个字典,8.4 的触发器都会把颜色换成系统颜色,所以放入浅色用的即可。
public static class AppTheme
{
    private static readonly Uri Light = new("pack://application:,,,/Themes/Light.xaml");
    private static readonly Uri Dark = new("pack://application:,,,/Themes/Dark.xaml");

    public static void Apply(ThemeState state)
    {
        var merged = Application.Current.Resources.MergedDictionaries;
        var current = merged.FirstOrDefault(d => d.Source == Light || d.Source == Dark);
        // 只有深色时才用深色字典。对比度主题下交给系统颜色(8.4)
        var next = new ResourceDictionary { Source = state == ThemeState.Dark ? Dark : Light };
        if (current is null)
        {
            merged.Add(next);
        }
        else
        {
            merged[merged.IndexOf(current)] = next; // 在同一个位置替换
        }
    }
}
WPF 中资源字典的替换在浅色用和深色用的字典中用相同的键定义颜色,XAML 用 DynamicResource 引用,在主题变更的通知里替换 MergedDictionaries 中的字典后引用目标就会更新主题变更的通知替换 MergedDictionaries 中的字典Light.xaml(相同的键)Dark.xaml(相同的键)DynamicResource 引用被更新StaticResource 引用仍是加载时的值

图 15: 替换相同键的字典后,只有 DynamicResource 的引用会跟随。

标准控件的模板(按钮的背景、滚动条的颜色)带的是传统主题的颜色,所以这里同样会残留「应用自己的面是深色、标准控件是浅色」的地方。请先估算逐个覆盖所需控件样式的工作量,再拿它与采用 Fluent 或固定浅色作比较。

7.3. 标题栏

如果使用 ThemeMode,WPF 会代劳。保持传统主题自己实现跟随时,就使用 4.2 展示的在 OnSourceInitialized 中设置 DWM 属性的做法,并在 SystemThemeWatcher 的通知里重新设置。

8. 对比度主题下的绘制 ── 守住系统颜色的配对

8.1. 检测与通知

在 Win32 中,给 SystemParametersInfo 传入 SPI_GETHIGHCONTRAST 接收 HIGHCONTRAST 结构体,用 dwFlagsHCF_HIGHCONTRASTON 位来判断。调用时需要先设置 cbSize1128 Microsoft 把它定位为「确认高对比度是否启用的唯一受支持的方法」。12

bool IsContrastThemeActive()
{
    HIGHCONTRASTW hc{};
    hc.cbSize = sizeof(hc);
    if (!::SystemParametersInfoW(SPI_GETHIGHCONTRAST, sizeof(hc), &hc, 0))
    {
        // 不要用默认值掩盖失败。带上错误码让原因能够浮现
        throw std::system_error(::GetLastError(), std::system_category(),
                                "SystemParametersInfo(SPI_GETHIGHCONTRAST)");
    }
    return (hc.dwFlags & HCF_HIGHCONTRASTON) != 0;
}

各框架都有包装了这个调用的属性。

环境 判断 变更的通知
Win32 / MFC SPI_GETHIGHCONTRAST + HCF_HIGHCONTRASTON WM_SYSCOLORCHANGEWM_THEMECHANGED
WinForms SystemInformation.HighContrast SystemEvents.UserPreferenceChanged
WPF SystemParameters.HighContrast(对应 SPI_GETHIGHCONTRAST SystemParameters.StaticPropertyChanged
WinUI 3 ThemeSettings.HighContrast(Microsoft.UI.System) ThemeSettings.Changed

WinForms 的无障碍指引要求在启动时确认 HighContrast,并用 UserPreferenceChanged 响应变化。13 WPF 的 SystemParameters.HighContrast 对应到 SPI_GETHIGHCONTRASTHCF_HIGHCONTRASTON29 静态属性的变化通过 StaticPropertyChanged 通知。30 WinUI 3 的 ThemeSettingsCreateForWindowId 绑定到窗口创建并订阅 Changed 事件,但要注意不持续保持对该对象的引用事件就会停止31

对比度主题的检测途径Win32 的 SPI_GETHIGHCONTRAST 是唯一受支持的判断方法,WinForms 的 SystemInformation.HighContrast、WPF 的 SystemParameters.HighContrast、WinUI 3 的 ThemeSettings.HighContrast 分别以各自框架的包装形式提供SPI_GETHIGHCONTRAST(唯一的判断方法)WinForms SystemInformationWPF SystemParametersWinUI 3 ThemeSettingsWin32 直接调用

图 16: 判断的根是同一个 Win32 API,各框架都有包装了它的属性。

8.2. 绘制的原则 ── 前景与背景的配对

Microsoft 的「High contrast parameter」列出了高对比度启用时应用应该做的三件事。11

  1. 把所有颜色映射到前景色与背景色的一组配对上。GetSysColorCOLOR_WINDOWTEXTCOLOR_WINDOW 的一组,或者 COLOR_BTNTEXTCOLOR_BTNFACE 的一组。
  2. 省去显示在文字背后的位图图像。对需要高对比度的用户来说它是视觉上的干扰。
  3. 用多色绘制的图像,改用文字用的前景色与背景色绘制。

「配对」是关键。Windows 8 以后的指引说明,COLOR_HIGHLIGHTTEXT 是按与 COLOR_HIGHLIGHT 的背景组合、COLOR_WINDOWTEXT 是按与 COLOR_WINDOW 的背景组合这个前提设计的,并要求不要把文字色硬编码因为用户会自定义颜色,所以要做出不依赖当前主题的 UI12 该指引举的例子 ── 「在 Aero 中文字始终是黑色、选中色是浅蓝,但在 High Contrast Black 中选中色变成黑色。以黑色文字为前提再使用系统的选中色,就会变成黑底黑字」── 正是开头那个「状态显示不见了」。

Windows 11 的对比度主题指引把这种对应关系整理成了表。1

用途 前景 背景
标题・正文・列表・边框・不可操作的 UI SystemColorWindowText SystemColorWindow
超链接 SystemColorHotlight SystemColorWindow
禁用・非激活的 UI SystemColorGrayText SystemColorWindow
选中・悬停・按下・进行中 SystemColorHighlightText SystemColorHighlight
按钮等可操作的 UI SystemColorButtonText SystemColorButtonFace

而且「不该做的事」也写得很明确。不要把 GrayText 用于补充文字或提示文字(只用于禁用状态)不要把 Hotlight 用于超链接以外的地方不要混用不兼容的前景/背景不要凭外观挑颜色(用户真的会改颜色)。指引还给出了设计准则:页面、窗格、弹出层的背景以 SystemColorWindow 为准,相邻的面背景颜色会相同,所以只在必要的边界处用对比度主题专用的边框分隔(浮出控件和对话框推荐 2px)。1

破坏配对后变得看不清的过程认定文字是黑色而只把选中背景换成系统的选中色,在 High Contrast Black 下选中色变成黑色就成了黑底黑字。前景与背景成对取用的话即使用户编辑颜色也能看清认定文字是黑色只有选中背景用系统的选中色High Contrast Black 下选中色是黑色黑底黑字前景与背景成对取用用户编辑颜色也能看清用户编辑颜色

图 17: 只把一边换成系统颜色可能变成黑底黑字,成对取用则即使颜色被编辑也能看清。

对比度主题下的绘制判断对比度主题生效时,把颜色映射到系统颜色的配对,省去文字背后的图片,把多色的图像用前景与背景两色绘制,不使用硬编码的颜色对比度主题生效把颜色映射到配对省去文字背后的图片多色的图形用两色绘制不使用硬编码的颜色

图 18: 对比度主题生效时的绘制可以归结为映射、省略、二色化、去硬编码这四点。

8.3. WinForms 中的实现

WinForms 的标准控件只要把 ForeColor / BackColor 保持默认,就会跟随系统颜色。只需按判断结果切换那些加了自定义颜色的地方和自绘的部分。指引里的例子是:平常是蓝底黄字的标签,在高对比度时恢复成 SystemColors.Window / SystemColors.WindowText13 相当于在前面的调色板结构上,再加一条对比度主题用的分支。

using Microsoft.Win32;

public partial class OrderForm : Form
{
    private readonly SynchronizationContext _ui;

    public OrderForm()
    {
        InitializeComponent();
        // 控件已经生成,所以里面装的是 WindowsFormsSynchronizationContext
        _ui = SynchronizationContext.Current
              ?? throw new InvalidOperationException("请在 UI 线程上创建。");
        ApplyColorScheme();
        SystemEvents.UserPreferenceChanged += OnUserPreferenceChanged;
    }

    private void ApplyColorScheme()
    {
        if (SystemInformation.HighContrast)
        {
            // 守住配对,把配色全面交给系统颜色,并去掉文字背后的图片
            statusLabel.BackColor = SystemColors.Window;
            statusLabel.ForeColor = SystemColors.WindowText;
            headerPanel.BackgroundImage = null;
        }
        else
        {
            var p = AppPalette.Current; // 浅色/深色的调色板(第 5 章)
            statusLabel.BackColor = p.PanelBackground;
            statusLabel.ForeColor = p.PanelForeground;
            headerPanel.BackgroundImage = Properties.Resources.HeaderPattern;
        }
    }

    private void OnUserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e)
    {
        // 这个事件同样不保证在 UI 线程上到达。切回 UI 后不按类别过滤,直接重新判断
        _ui.Post(_ =>
        {
            if (IsDisposed) return;
            ApplyColorScheme();
        }, null);
    }

    // 是静态事件,不解除的话窗体会泄漏。在没有被关闭就被释放的路径上也会经过的
    // Dispose(bool) 里解除(如果有设计器生成的 Dispose(bool),就写在那里)
    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            SystemEvents.UserPreferenceChanged -= OnUserPreferenceChanged;
        }
        base.Dispose(disposing);
    }
}

OnPaint 中的自绘要用 SystemBrushes.Window / SystemPens.WindowText 这类守住配对的系统画刷来画,而表示状态的彩色圆点这种多色图形,则换成前景色的边框加文字(「运行」「停止」)。不只依赖颜色的表达方式,与上一篇文章讲的成功准则 1.4.1 是同一件事。

WinForms 配色应用的分支启动时以及每次 UserPreferenceChanged 都调用 ApplyColorScheme,SystemInformation.HighContrast 为真就交给系统颜色的配对并去掉背景图片,为假就从浅色/深色的调色板取色启动时 / UserPreferenceChangedApplyColorSchemeSystemInformation.HighContrast?交给 SystemColors 的配对去掉背景图片从浅色/深色的调色板取

图 19: WinForms 在启动时和每次通知时调用同一个处理,是对比度主题就交给系统颜色。

8.4. WPF 中的实现

WPF 的 SystemColors 中,像 WindowBrushKey 这样的资源键只要用 DynamicResource 引用,画刷变化时就会自动更新(直接使用 WindowBrush 的静态引用不会更新)。32 想只在对比度主题下切换外观,就从 DataTrigger 引用 SystemParameters.HighContrast 的值。不过 SystemParameters.HighContrast 是静态属性,直接用的话不能成为绑定的活引用源。请准备一个订阅 StaticPropertyChanged 并保存值、用 INotifyPropertyChanged 发出通知的小代理,把它的实例指定给 Source 后绑定。30

public sealed class ThemeSettings : INotifyPropertyChanged
{
    public static ThemeSettings Instance { get; } = new();

    public bool IsHighContrast { get; private set; } = SystemParameters.HighContrast;
    public event PropertyChangedEventHandler? PropertyChanged;

    private ThemeSettings()
    {
        // SystemParameters 的静态属性发生变化(重新取一遍 SPI_GETHIGHCONTRAST)时会收到通知
        SystemParameters.StaticPropertyChanged += (_, e) =>
        {
            if (!string.IsNullOrEmpty(e.PropertyName)
                && e.PropertyName != nameof(SystemParameters.HighContrast)) return;
            IsHighContrast = SystemParameters.HighContrast;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsHighContrast)));
        };
    }
}
<!-- 需要事先声明 xmlns:local="clr-namespace:OrderEntry" -->
<Style x:Key="CardStyle" TargetType="Border">
    <Setter Property="Background" Value="{DynamicResource App.CardBackgroundBrush}"/>
    <Setter Property="BorderBrush" Value="{DynamicResource App.CardBorderBrush}"/>
    <Setter Property="BorderThickness" Value="1"/>
    <Style.Triggers>
        <DataTrigger Binding="{Binding Source={x:Static local:ThemeSettings.Instance}, Path=IsHighContrast}"
                     Value="True">
            <!-- 守住配对: 背景用 Window,边框和文字用 WindowText。边界画粗一些 -->
            <Setter Property="Background"
                    Value="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"/>
            <Setter Property="BorderBrush"
                    Value="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
            <!-- 让里面的文字也继承。注意显式指定了 Foreground 的子元素会切断继承 -->
            <Setter Property="TextElement.Foreground"
                    Value="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"/>
            <Setter Property="BorderThickness" Value="2"/>
        </DataTrigger>
    </Style.Triggers>
</Style>
WPF 的系统颜色引用与对比度主题的触发器用 DynamicResource 引用 SystemColors 的资源键就会自动跟随画刷的变更,绑定到订阅 StaticPropertyChanged 的代理的 IsHighContrast 的触发器会对运行期间的切换做出反应并切换到守住配对的颜色。直接引用 WindowBrush 不会更新用 DynamicResource 引用 WindowBrushKey自动跟随画刷的变更StaticPropertyChanged代理的 IsHighContrastDataTrigger 做出反应切换到守住配对的颜色直接引用 WindowBrush不会更新

图 20: WPF 通过资源键的动态引用,以及绑定到中转静态属性变化的代理,跟随运行期间的切换。

即使使用 Fluent 主题,考虑到 .NET 10 才修复了 HighContrast 相关的崩溃,9 也请把对比度主题下的动作确认纳入采用条件。

8.5. WinUI 3 中的实现

WinUI 3 的标准控件从一开始就会跟随浅色/深色/对比度主题,应用自己的颜色则用 Default(深色)、LightHighContrast 这些键定义在 ResourceDictionary.ThemeDictionaries 中。在 HighContrast 中不要把颜色硬编码,而要用 ThemeResource 引用 SystemColorWindowColor 这类动态的系统颜色。带有 Light/Dark 的自定义控件必须同时准备 HighContrastHighContrast 会在找不到其他具名高对比度主题时充当回退键。331

<ResourceDictionary.ThemeDictionaries>
    <ResourceDictionary x:Key="Default">
        <SolidColorBrush x:Key="App.CardBackgroundBrush" Color="#2B2B2B"/>
    </ResourceDictionary>
    <ResourceDictionary x:Key="Light">
        <SolidColorBrush x:Key="App.CardBackgroundBrush" Color="#F3F3F3"/>
    </ResourceDictionary>
    <ResourceDictionary x:Key="HighContrast">
        <SolidColorBrush x:Key="App.CardBackgroundBrush"
                         Color="{ThemeResource SystemColorWindowColor}"/>
    </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>

还有一点,WinUI 有一个叫 HighContrastAdjustment 的机制,默认是启用的。它为了保持对比度会强制使用白色文字与黑色的高亮背景,指引推荐的是如果已经准备好正确使用系统颜色的主题字典,就把它设为 None,让自己的样式生效1

WinUI 的 ThemeDictionaries 的解析按当前的主题选中 Default(深色)、Light、HighContrast 中的某一个字典,在 HighContrast 中用 ThemeResource 引用动态的系统颜色。HighContrast 会在没有具名高对比度主题时充当回退键深色浅色对比度主题当前的主题是?Default 字典Light 字典HighContrast 字典用 ThemeResource 引用 SystemColor 系列没有具名主题时的回退

图 21: WinUI 中每个主题的字典会自动被选中,HighContrast 字典引用系统颜色。

9. 支持深色模式不能代替无障碍支持

把支持深色模式当作「已经做了无障碍支持」来汇报是错的。两者的关系可以这样梳理。

  • 对比度的标准同样适用于深色的调色板。WCAG 的成功准则 1.4.3 要求文本 4.5:1、大号文字 3:1,背景变暗也不会改变。14 在深灰背景上放中灰文字的深色设计,与浅色的「白底浅灰」有着同样的问题。
  • 避开纯黑与纯白是 Windows 11 的设计。Microsoft 的最佳实践说明,Windows 11 避开了纯白与纯黑,改用对眼睛更友好的色调。34 反过来说,在深色模式下把背景设为 #000000,会有人抱怨它与明亮文字之间对比过强产生的光晕。
  • 对色觉多样性的照顾与主题无关,始终必要。Microsoft 的颜色指南要求不要把颜色作为主要的传达手段而是作为视觉上的强化,并且不要把红与绿的组合作为唯一的区分依据。3515
  • 对比度主题是与深色模式相互独立的需求。如第 2 章所述,对比度主题生效期间无法使用深色模式,所以无论深色支持做得多完美,都送不到对比度主题的使用者手上。
深色模式与无障碍的关系支持深色模式是对外观偏好和环境的照顾,而对比度、不只依赖颜色的表达方式、对比度主题支持这些无障碍要求与主题无关,需要另外满足不能代替深色模式支持对偏好与环境的照顾无障碍对比度 4.5:1不只依赖颜色对比度主题支持

图 22: 深色模式支持是对偏好与环境的照顾,无障碍的要求要另外满足。

另一方面,第 5 章的「把颜色集中到一处」是两者共同的地基。调色板集中在一个地方,就能枚举出需要在浅色和深色下分别实测对比度的对象,对比度主题用的分支也能写在同一个地方。以深色支持为契机集中调色板,顺带检查对比度与对比度主题,是投资效率最高的顺序。

10. 方针的确定方法 ── 按应用类别的推荐

应用的种类 推荐的方针
新建的 WinForms(.NET 10) 使用 SetColorMode(System)。自绘以 SystemColors 为基础,含通用控件的自定义控件用 ApplyThemingImplicitly 选择加入
新建的 WPF(.NET 9/10) 在通过了所用控件的显示确认与对比度主题的动作确认之后采用 ThemeMode="System"。有困难就用传统主题+字典替换
现有的 WinForms/WPF(.NET Framework 4.8、.NET 8 及更早) 明确声明「固定浅色」,DWM 属性保持默认(FALSE)。对比度主题支持必须做,迁移到 .NET 10 时再转向深色支持
WinUI 3 默认跟随系统。自己的颜色定义在 ThemeDictionaries 中并包含 HighContrast,把 HighContrastAdjustment 设为 None
Win32 / MFC DWM 属性+自备调色板+在 WM_THEMECHANGED / WM_SYSCOLORCHANGE 中重新计算。官方指南只讲到检测与标题栏,通用控件的重新上色不在范围内
既有资产走向深色支持的路径既有资产先明确声明固定浅色,必须完成对比度主题支持,集中调色板做好准备之后迁移到 .NET 10,再切换到 SetColorMode 或 ThemeMode。半途而废的深色支持体验比固定浅色更糟因此不走这条路不走这条路明确声明固定浅色(现在)对比度主题支持(必须)调色板的集中(准备)迁移到 .NET 10切换到 SetColorMode / ThemeMode半途而废的深色支持比固定浅色更糟的体验

图 23: 既有资产从固定浅色开始,经过对比度主题支持与调色板集中,在迁移到 .NET 10 时走向深色支持。

「固定浅色」不是失败。它就是 Windows 默认的行为本身,也是有文档记载的动作。与其做出半途而废的深色支持、交付「只有标题栏是黑的」「只有滚动条是白的」的应用,浅色上保持一致对使用者来说要好得多。不过,唯独对比度主题支持是「固定」不了的。这属于上一篇文章讲的合理便利的范畴,不是主题的偏好,而是能不能用的问题。

11. 验证检查清单

做完之后,按下面的顺序在实际设备上确认。全部都能从设置界面在几十秒内切换。

  1. 在运行期间切换浅色/深色。在「设置 > 个性化 > 颜色」里改变模式,确认标题栏与客户区是否都跟随,或者是否符合「下次启动时生效」的规格(WinForms 的 SetColorMode 不跟随)。
  2. 触发句柄重建。WinForms 就在运行期间切换 ShowInTaskbar,确认标题栏的属性是否被保持。
  3. 四种对比度主题全部试一遍。用左 Alt+左 Shift+PrintScreen 切换,在 Aquatic、Desert、Dusk、Night sky 中分别确认文字、边框、选中行、禁用项、链接是否能看清。1
  4. 编辑对比度主题的颜色。用户真的会改颜色。把背景编辑成极端的颜色,把残留的硬编码逼出来。
  5. 查看日志。确认在受支持的系统上 DwmSetWindowAttributeSystemParametersInfo 失败时是否有记录,以及在低于内部版本 22000 的 Windows 10 上是否没有调用 DWM 属性而以浅色启动。
  6. 实测对比度。在浅色和深色下分别按 4.5:1 检查调色板中文字色与背景色的组合。14
主题支持的验证步骤按运行期间切换浅色/深色、句柄重建、四种对比度主题、编辑主题的颜色、确认失败日志、实测对比度的顺序在实际设备上确认运行期间切换浅色/深色句柄重建四种对比度主题编辑主题的颜色确认失败日志实测对比度

图 24: 验证从切换设置开始,以日志与对比度的确认收尾。

12. 总结

  • Windows 的主题有「浅色/深色」和「对比度主题」两条轴,后者生效期间无法使用深色模式。判断以对比度主题为先。
  • 现有应用的标题栏是白的,是出于兼容性的默认行为;给 DwmSetWindowAttributeDWMWA_USE_IMMERSIVE_DARK_MODE(值 20,Windows 11 内部版本 22000 及以上)传入 TRUE,系统是深色时就会用深色绘制。每次 HWND 生成都要设置,失败要记入日志。
  • 当前的模式用 UISettings.GetColorValue 的前景色亮度判断,用 ColorValuesChanged 察觉变化,切回 UI 线程后重新上色。颜色要集中到一处。
  • WinForms 用 .NET 9/10 的 Application.SetColorMode(SystemColorMode.System)。要记住仅限 Windows 11、对比度主题下无效、不跟随运行期间的变更这三条约束,以及自定义控件的 ApplyThemingImplicitly
  • WPF 用 .NET 9/10 的 ThemeMode="System"。从代码进行的操作是实验性的、Fluent 还在进行中,所以要评估后采用。传统主题就用字典替换+DynamicResource。
  • 对比度主题下用 SPI_GETHIGHCONTRAST 系判断,把颜色映射到系统颜色的配对,省去文字背后的图片,把多色的图形用两色绘制。GrayText 表示禁用状态,Hotlight 只用于链接。
  • 深色支持不能代替无障碍支持。深色下同样要 4.5:1、不只依赖颜色、对比度主题支持需要另外做到。
  • 既有资产明确声明「固定浅色」是现实解,唯独对比度主题支持固定不了。

作为第一步,推荐的做法是:选一个主力画面,先用左 Alt+左 Shift+PrintScreen 启用对比度主题看一看,再用同样的按键切回来,然后把 Windows 的颜色设置切换成深色试试(对比度主题生效期间无法使用深色模式,所以两者要分别试)。几分钟就能看出自家应用「把颜色放在了哪里」。

相关文章

相关的咨询领域

小村软件有限公司承接 WinForms/WPF 业务应用的深色模式支持(调色板的集中、迁移到 .NET 9/10 的 SetColorMode / ThemeMode 的评估、DWM 属性的接入)、对比度主题下显示错乱的诊断与修复,以及 Win32/MFC 资产跟随主题的咨询。从「改成深色模式后员工来投诉了」这个阶段开始也没关系。

参考链接

  1. Microsoft Learn, Contrast themes. 关于对比度主题使用大约 7:1 以上的受约束调色板且不得与浅色/深色主题混为一谈、Aquatic・Desert・Dusk・Night sky 四种与颜色的编辑、用左 Alt+左 Shift+PrintScreen 切换、SystemColor 系资源的前景/背景配对与用途、把 GrayText 限定为禁用状态而把 Hotlight 限定为链接、硬编码颜色造成的显示错乱、边界的边框、ThemeDictionaries 的 HighContrast、把 HighContrastAdjustment 设为 None,以及用 Microsoft.UI.System.ThemeSettings 检测。  2 3 4 5 6 7 8

  2. Microsoft Learn, Application.SetColorMode(SystemColorMode) Method. 关于要在创建 UI 元素之前调用、即使指定 System 应用也不会在系统设置变化时自动适应,以及深色的颜色模式只能在 Windows 11 以上使用且在高对比度模式下无法使用。  2 3 4

  3. Microsoft Learn, Support Dark and Light themes in Win32 apps. 关于颜色模式中前景与背景的定义、Windows 不知道应用能否支持深色因而出于兼容性默认给出浅色标题栏、用 UISettings.GetColorValue 取得前景色并按感知亮度判断明暗来检测深色模式的做法、用 ColorValuesChanged 跟踪、用 DwmSetWindowAttribute 与 DWMWA_USE_IMMERSIVE_DARK_MODE(值 20)启用深色标题栏,以及整个界面都需要遵循深色。  2 3 4 5 6 7 8 9 10

  4. Microsoft Learn, DWMWINDOWATTRIBUTE enumeration (dwmapi.h). 关于 DWMWA_USE_IMMERSIVE_DARK_MODE 在系统深色设置启用时允许用深色绘制窗框以及所有窗口默认为浅色、DWMWA_BORDER_COLOR・DWMWA_CAPTION_COLOR・DWMWA_TEXT_COLOR 的 COLORREF 指定与用 DWMWA_COLOR_DEFAULT 恢复默认、Windows 11 内部版本 22000 及以上的支持,以及 DWMWA_SYSTEMBACKDROP_TYPE 从内部版本 22621 起支持。  2 3 4

  5. Microsoft Learn, UISettings.ColorValuesChanged Event. 关于颜色的值发生变化时触发的事件。  2

  6. Microsoft Learn, What’s new in Windows Forms for .NET 9. 关于实验性的深色模式初步支持、颜色模式变化时 SystemColors 随之改变、SystemColorMode 的 Classic・System・Dark 三个值、在启动代码中调用 Application.SetColorMode,以及抑止 WFO5001。  2 3

  7. Microsoft Learn, What’s new in Windows Forms for .NET 10. 关于深色模式的完全集成与 SetColorMode 不再是实验性的、自绘控件内的 Win32 通用控件不选择加入就会保持浅色,以及需要在 CreateParams 的重写内、base.CreateParams 之前调用 SetStyle(ControlStyles.ApplyThemingImplicitly) 而放在构造函数里来不及。  2 3 4

  8. Microsoft Learn, What’s new in WPF for .NET 9. 关于支持浅色/深色与主题色的 Fluent 主题、ThemeMode 的 Light・Dark・System・None 四个值与在 Application/Window 上的设置、通过资源字典应用,以及从代码设置 ThemeMode 是实验性的并需要抑止 WPF0001。  2 3 4

  9. Microsoft Learn, What’s new in WPF for .NET 10. 关于 Fluent UI 样式的支持仍在进行中、追加了 DatePicker・GridSplitter・GridView・GroupBox・Hyperlink・Label・NavigationWindow・RichTextBox・TextBox 的 Fluent 样式,以及修复了 HighContrast 相关的崩溃。  2 3

  10. Microsoft Learn, Application.ThemeMode Property. 关于它控制以浅色・深色・系统中的哪种模式加载 Fluent 主题并且也控制窗口的背景材质与深色模式的应用、ThemeMode 与 Resources 被设计成同步以避免不一致,以及它带有 Experimental(“WPF0001”) 特性且将来可能被删除。  2 3 4

  11. Microsoft Learn, High contrast parameter. 关于在初始化时和处理 WM_SYSCOLORCHANGE 时用 SPI_GETHIGHCONTRAST 取得 HIGHCONTRAST 结构体并确认 HCF_HIGHCONTRASTON、启用时把所有颜色映射到 COLOR_WINDOWTEXT 与 COLOR_WINDOW 或 COLOR_BTNTEXT 与 COLOR_BTNFACE 的一组配对、省去文字背后的位图图像,以及用前景色与背景色绘制多色的图像。  2 3

  12. Microsoft Learn, High-contrast mode. 关于 Aero 中是黑色文字与浅蓝的选中色但 High Contrast Black 中选中色变成黑色因而可能出现黑底黑字、COLOR_HIGHLIGHTTEXT 以与 COLOR_HIGHLIGHT 组合为前提而 COLOR_WINDOWTEXT 以与 COLOR_WINDOW 组合为前提、不要把文字色硬编码、因为用户会自定义颜色所以要做出不依赖主题的 UI、在 WM_THEMECHANGED 中重新计算颜色,以及 SPI_GETHIGHCONTRAST 是唯一受支持的确认方法。  2 3

  13. Microsoft Learn, Walkthrough: Creating an Accessible Windows-based Application. 关于用 SystemInformation.HighContrast 判断、启用时使用系统的配色并为用颜色传达的信息附加视觉线索且省去文字背后的图片、启动时的确认与对 UserPreferenceChanged 事件的跟随,以及用 SystemColors 切换标签配色的例子。  2 3

  14. W3C / Web Accessibility Infrastructure Committee(WAIC) 日语译本, Web Content Accessibility Guidelines (WCAG) 2.1 日本語訳. 关于成功准则 1.4.3(对比度(最低))的文本 4.5:1・大号文字 3:1,以及成功准则 1.4.1(颜色的使用)。  2 3

  15. Microsoft Learn, Color in Windows. 关于 Windows 拥有浅色与深色两种颜色模式、主题色与主题的选择会反映到用户的整体体验,以及确保对比度和照顾色觉多样性。  2

  16. Microsoft Learn, Reference for Windows 11 and Windows 10 settings. 关于 HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize 下的 AppsUseLightTheme 与 SystemUsesLightTheme 是表示应用与 Windows 浅色/深色的 DWORD 值。 

  17. Microsoft Learn, Theming in Windows apps. 关于去掉 RequestedTheme 后会遵循系统设置、用户选择高对比度主题时系统会覆盖 RequestedTheme,以及在自定义模板中不要把颜色硬编码而要使用主题画刷。 

  18. Microsoft Learn, DwmSetWindowAttribute function (dwmapi.h). 关于设置窗口非客户区 DWM 绘制属性的函数,以及它从 Windows Vista 起可用。 

  19. Microsoft Learn, Retrieve a window handle (HWND). 关于从 WPF 的 WindowInteropHelper 取得 Handle 的方法。 

  20. Microsoft Learn, DWM_SYSTEMBACKDROP_TYPE enumeration (dwmapi.h). 关于 DWMSBT_MAINWINDOW 在 Windows 11 上相当于 Mica、DWMSBT_TRANSIENTWINDOW 相当于 Acrylic、材质的效果在将来的 Windows 中可能改变,以及 Windows 11 内部版本 22621 及以上的支持。 

  21. Microsoft Learn, UISettings.GetColorValue(UIColorType) Method. 关于返回指定 UIColorType 的颜色值的方法。 

  22. Microsoft Learn, WM_SETTINGCHANGE message. 关于 SystemParametersInfo 更改系统范围的设置时或策略设置变化时发送给所有顶层窗口的消息。 

  23. Microsoft Learn, SystemEvents.UserPreferenceChanged Event. 关于用户设置变更时触发的静态事件,以及不解除处理程序会造成内存泄漏。 

  24. Microsoft Learn, WM_THEMECHANGED message. 关于在主题的启用・禁用・切换之后广播给所有窗口,以及既有的主题句柄会失效因而需要重新打开。 

  25. Microsoft Learn, WM_SYSCOLORCHANGE message. 关于系统颜色设置变更时发送给所有顶层窗口,使用系统颜色的画刷需要重新创建,以及需要转发给通用控件。 

  26. Microsoft Learn, Compiler Error WFO5001. 关于 .NET 9 中 SetColorMode 与 SystemColorMode 作为评估用途的实验性功能受到保护,以及 .NET 10 以后不再适用该错误。 

  27. Microsoft Learn, SystemColors.UseAlternativeColorSet Property. 关于设为 true 后系统的 KnownColor 会返回替代的色集(目前是深色模式版)、它是 SYSLIB5002 的实验性功能,以及 Windows 上高对比度主题生效时系统的 KnownColor 始终返回 Windows 当前的颜色。 

  28. Microsoft Learn, HIGHCONTRASTW structure (winuser.h). 关于 dwFlags 的 HCF_HIGHCONTRASTON(0x00000001),以及在 SPI_GETHIGHCONTRAST 中使用时需要指定 cbSize。 

  29. Microsoft Learn, SystemParameters.HighContrast Property. 关于对应到 SPI_GETHIGHCONTRAST 与 HCF_HIGHCONTRASTON 的 WPF 静态属性。 

  30. Microsoft Learn, SystemParameters.StaticPropertyChanged Event. 关于 SystemParameters 的任一属性发生变化时触发的静态事件。  2

  31. Microsoft Learn, ThemeSettings Class (Microsoft.UI.System). 关于用 CreateForWindowId 绑定到窗口创建并通过 Changed 事件接收高对比度的变化,以及释放引用后对象会被销毁使事件不再触发。 

  32. Microsoft Learn, SystemColors.WindowBrushKey Property. 关于用资源键创建动态引用后画刷变更时会自动更新,而通过 WindowBrush 的静态引用不会自动更新。 

  33. Microsoft Learn, ResourceDictionary.ThemeDictionaries Property (Microsoft.UI.Xaml). 关于拥有 Light 与 Dark 主题字典的自定义控件也要准备 HighContrast 的字典、HighContrast 是没有其他高对比度主题时的回退键、Default 在找不到主题的 ResourceDictionary 时使用,以及在 HighContrast 中可以使用 SystemColorButtonFaceColor 这类系统颜色资源。 

  34. Microsoft Learn, Windows app development best practices. 关于 Windows 11 避开纯白与纯黑改用对眼睛更友好的色调,以及深色/浅色主题是适应用户视觉偏好的手段。 

  35. Microsoft Learn, Color (Windows UX guidelines). 关于把颜色作为视觉上的强化而非主要的传达手段、根据用途选择主题颜色与系统颜色并把前景与背景按对应的组合使用、用 WM_THEMECHANGED 处理主题变更,以及 High Contrast Black 在 Windows 11 上对应 Aquatic 而 High Contrast White 对应 Desert。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

把 Windows 切换成深色模式后,只有自家 WinForms 应用的标题栏仍然是白的。这是为什么?
因为 Windows 没有办法知道一个应用是否支持深色模式,出于兼容性考虑,它把所有窗口默认按浅色模式处理。包含标题栏在内的非客户区由桌面窗口管理器(DWM)绘制,只有应用自己通过 DwmSetWindowAttribute 给 DWMWA_USE_IMMERSIVE_DARK_MODE(值 20)传入 TRUE,系统处于深色时才会用深色绘制。文档记载该属性从 Windows 11 内部版本 22000 起支持。在 .NET 9 以后的 WinForms 中使用 Application.SetColorMode,或在 .NET 9 以后的 WPF 中使用 ThemeMode 时,这个调用由框架代劳,所以需要自己调用的只有 .NET 8 及更早、.NET Framework 以及 Win32/MFC 应用。另外,如果只把标题栏变黑而客户区仍然是白的,反而更不自然。请在准备好把整个应用重绘成深色时,再启用这个属性。
深色模式和对比度主题(高对比度)是一回事吗?
是两回事。浅色/深色是「设置 > 个性化 > 颜色」里的颜色模式,使用的是把前景与背景明暗对调的宽泛调色板。对比度主题是在「设置 > 辅助功能 > 对比度主题」里选择的,使用的是被约束在大约 7:1 以上对比度的调色板(Aquatic、Desert、Dusk、Night sky 四种,以及用户自行编辑颜色后的版本)。Microsoft 的文档明确要求不要把两者混为一谈,而且在对比度主题生效期间无法使用深色模式(WinForms 的 SetColorMode 在对比度主题下不提供深色,XAML 的 RequestedTheme 也会被系统覆盖)。在应用的实现中,请按照先判断是不是对比度主题、是就把配色全面交给系统颜色、不是才选择浅色/深色调色板的优先顺序来写。
让 WinForms 应用支持深色模式最快的办法是什么?
如果是 .NET 9 以后,最快的办法是在 Program.cs 的 Application.Run 之前调用 Application.SetColorMode(SystemColorMode.System)。在 .NET 9 中它是实验性功能,需要在项目文件里抑止 WFO5001,从 .NET 10 起不用抑止就能使用。调用 SetColorMode 后 SystemColors 会切换到深色用的替代色集,标准控件也随之绘制。有三点需要注意。第一,深色模式只能在 Windows 11 以上使用,并且在对比度主题下无效。第二,即使指定 SystemColorMode.System,应用运行期间 Windows 的设置发生变化时也不会自动跟随(下次启动时才生效)。第三,如果自绘控件内部使用了滚动条之类的 Win32 通用控件,就需要重写 CreateParams,并在 base.CreateParams 之前调用 SetStyle(ControlStyles.ApplyThemingImplicitly, true)(放在构造函数里已经来不及)。
WPF 应用要做什么才能跟随深色模式?
.NET 9 以后的 WPF 自带了符合 Windows 11 Fluent 设计的新主题,只要在 App.xaml 的 Application 元素上写 ThemeMode="System",就会加载与 Windows 浅色/深色设置匹配的 Fluent 主题。ThemeMode 还会控制窗口的深色化(标题栏)和背景材质的应用。不过,从代码读写 ThemeMode 属性的操作在 .NET 10 中仍是实验性的(WPF0001),Fluent 样式本身在 .NET 10 的文档里也仍被称为「还在进行中」。要在业务应用中采用,请先评估自己使用的控件在 Fluent 下会不会显示错乱再决定。如果保持传统主题(.NET 8 及更早以及 .NET Framework 也一样),就准备浅色用和深色用的 ResourceDictionary 并通过 MergedDictionaries 替换,XAML 一侧用 DynamicResource 引用,切换的检测使用 UISettings.ColorValuesChanged。标题栏则在 SourceInitialized 中从 WindowInteropHelper 取得 HWND 后调用 DwmSetWindowAttribute。
为什么在对比度主题(高对比度)下文字会消失或看不清?
典型原因是把颜色硬编码了,或者破坏了系统颜色的前景与背景的组合(配对)。在对比度主题下,用户可以自由编辑背景、文字、链接等颜色,因此「文字应该是黑的」「选中行应该是浅蓝的」这类前提全部不成立。例如只把背景固定为 #E6E6E6,在某些主题下前景会变成白色,白字压在浅灰上就看不清了。原则有三条。用 SPI_GETHIGHCONTRAST(WinForms 用 SystemInformation.HighContrast,WPF 用 SystemParameters.HighContrast)判断状态;把所有颜色换成系统颜色中正确的配对(WindowText 与 Window、ButtonText 与 ButtonFace、HighlightText 与 Highlight);去掉文字背后的图片和多色的图形,只用前景色与背景色绘制。GrayText 只能表示禁用状态,Hotlight 不得用于超链接以外的地方。变化会通过 WM_SYSCOLORCHANGE 或 WM_THEMECHANGED(在 .NET 中是 SystemEvents.UserPreferenceChanged)通知,请在那里重新计算颜色并重绘。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表