主题切换与暗色模式
大约 10 分钟约 2897 字
主题切换与暗色模式
简介
主题切换是现代应用的标配功能,WPF 通过资源字典(ResourceDictionary)机制支持运行时动态切换主题。通过定义明暗两套颜色资源,在运行时替换资源字典即可实现主题切换。配合 MVVM 模式,可以实现流畅的主题管理。在工业上位机中,暗色模式适合长时间监控场景,减少眼部疲劳。
WPF 主题系统架构
┌─────────────────────────────────────────────┐
│ Application.Resources │
│ ┌─────────────────────────────────────┐ │
│ │ MergedDictionaries │ │
│ │ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ 控件样式 │ │ 主题颜色资源 │ │ │
│ │ │Styles.xaml│ │ Light.xaml │ │ │
│ │ │ │ │ 或 Dark.xaml │ │ │
│ │ └──────────┘ └──────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↓ DynamicResource 绑定
┌─────────────────────────────────────────────┐
│ 控件(Button, TextBox 等) │
│ Background="{DynamicResource SurfaceBrush}" │
│ Foreground="{DynamicResource TextBrush}" │
└─────────────────────────────────────────────┘StaticResource vs DynamicResource
| 特性 | StaticResource | DynamicResource |
|---|---|---|
| 查找时机 | 编译时/XAML 加载时 | 运行时每次使用时 |
| 性能 | 更快(一次性查找) | 稍慢(每次访问都查找) |
| 资源变更 | 不响应 | 自动响应 |
| 适用场景 | 不变的资源(字体、固定颜色) | 主题色、动态切换的颜色 |
核心原则:主题相关的颜色使用 DynamicResource,固定不变的资源使用 StaticResource。
特点
资源字典定义
明暗主题资源
<!-- ========== Themes/Light.xaml — 亮色主题 ========== -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 基础颜色定义 -->
<Color x:Key="PrimaryColor">#0078D4</Color>
<Color x:Key="PrimaryHoverColor">#106EBE</Color>
<Color x:Key="BackgroundColor">#FFFFFF</Color>
<Color x:Key="SurfaceColor">#F5F5F5</Color>
<Color x:Key="SurfaceVariantColor">#E8E8E8</Color>
<Color x:Key="TextColor">#1A1A1A</Color>
<Color x:Key="SecondaryTextColor">#666666</Color>
<Color x:Key="BorderColor">#E0E0E0</Color>
<Color x:Key="ErrorColor">#D13438</Color>
<Color x:Key="WarningColor">#FF8C00</Color>
<Color x:Key="SuccessColor">#107C10</Color>
<Color x:Key="AccentColor">#005A9E</Color>
<!-- 笔刷定义(控件通过 DynamicResource 引用笔刷)-->
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource PrimaryColor}"/>
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="{StaticResource PrimaryHoverColor}"/>
<SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}"/>
<SolidColorBrush x:Key="SurfaceVariantBrush" Color="{StaticResource SurfaceVariantColor}"/>
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}"/>
<SolidColorBrush x:Key="SecondaryTextBrush" Color="{StaticResource SecondaryTextColor}"/>
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/>
<SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}"/>
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}"/>
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}"/>
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/>
</ResourceDictionary><!-- ========== Themes/Dark.xaml — 暗色主题 ========== -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 基础颜色定义 — 暗色调 -->
<Color x:Key="PrimaryColor">#60CDFF</Color>
<Color x:Key="PrimaryHoverColor">#4DB8FF</Color>
<Color x:Key="BackgroundColor">#1F1F1F</Color>
<Color x:Key="SurfaceColor">#2D2D2D</Color>
<Color x:Key="SurfaceVariantColor">#383838</Color>
<Color x:Key="TextColor">#FFFFFF</Color>
<Color x:Key="SecondaryTextColor">#999999</Color>
<Color x:Key="BorderColor">#444444</Color>
<Color x:Key="ErrorColor">#FF6B6B</Color>
<Color x:Key="WarningColor">#FFA940</Color>
<Color x:Key="SuccessColor">#6BCB77</Color>
<Color x:Key="AccentColor">#3D9BFF</Color>
<!-- 笔刷定义(与 Light.xaml 使用相同的 Key)-->
<SolidColorBrush x:Key="PrimaryBrush" Color="{StaticResource PrimaryColor}"/>
<SolidColorBrush x:Key="PrimaryHoverBrush" Color="{StaticResource PrimaryHoverColor}"/>
<SolidColorBrush x:Key="BackgroundBrush" Color="{StaticResource BackgroundColor}"/>
<SolidColorBrush x:Key="SurfaceBrush" Color="{StaticResource SurfaceColor}"/>
<SolidColorBrush x:Key="SurfaceVariantBrush" Color="{StaticResource SurfaceVariantColor}"/>
<SolidColorBrush x:Key="TextBrush" Color="{StaticResource TextColor}"/>
<SolidColorBrush x:Key="SecondaryTextBrush" Color="{StaticResource SecondaryTextColor}"/>
<SolidColorBrush x:Key="BorderBrush" Color="{StaticResource BorderColor}"/>
<SolidColorBrush x:Key="ErrorBrush" Color="{StaticResource ErrorColor}"/>
<SolidColorBrush x:Key="WarningBrush" Color="{StaticResource WarningColor}"/>
<SolidColorBrush x:Key="SuccessBrush" Color="{StaticResource SuccessColor}"/>
<SolidColorBrush x:Key="AccentBrush" Color="{StaticResource AccentColor}"/>
</ResourceDictionary>主题管理
IThemeService 接口
// ========== 主题服务接口 ==========
/// <summary>
/// 主题枚举
/// </summary>
public enum AppTheme
{
Light,
Dark,
System // 跟随系统设置
}
/// <summary>
/// 主题服务接口 — ViewModel 通过此接口管理主题
/// </summary>
public interface IThemeService
{
AppTheme CurrentTheme { get; }
bool IsDarkMode { get; }
void SetTheme(AppTheme theme);
void ToggleTheme();
event Action<AppTheme, bool>? ThemeChanged;
}ThemeService 实现
// ========== 主题服务实现 ==========
/// <summary>
/// 主题管理服务 — 管理明暗主题切换
/// </summary>
public class ThemeService : IThemeService
{
private readonly ResourceDictionary _lightTheme;
private readonly ResourceDictionary _darkTheme;
private AppTheme _currentTheme;
private bool _isDarkMode;
public AppTheme CurrentTheme => _currentTheme;
public bool IsDarkMode => _isDarkMode;
public event Action<AppTheme, bool>? ThemeChanged;
public ThemeService()
{
_lightTheme = new ResourceDictionary
{
Source = new Uri("Themes/Light.xaml", UriKind.Relative)
};
_darkTheme = new ResourceDictionary
{
Source = new Uri("Themes/Dark.xaml", UriKind.Relative)
};
}
/// <summary>
/// 设置主题
/// </summary>
public void SetTheme(AppTheme theme)
{
_currentTheme = theme;
_isDarkMode = theme switch
{
AppTheme.Dark => true,
AppTheme.Light => false,
AppTheme.System => IsSystemDarkMode(),
_ => false
};
var appDictionaries = Application.Current.Resources.MergedDictionaries;
// 移除旧主题字典
var oldTheme = appDictionaries.FirstOrDefault(d =>
d.Source?.OriginalString.Contains("Light.xaml") == true ||
d.Source?.OriginalString.Contains("Dark.xaml") == true);
if (oldTheme != null)
appDictionaries.Remove(oldTheme);
// 添加新主题字典
var newTheme = _isDarkMode ? _darkTheme : _lightTheme;
appDictionaries.Add(newTheme);
// 保存用户偏好
SavePreference(theme);
// 通知订阅者
ThemeChanged?.Invoke(theme, _isDarkMode);
}
/// <summary>
/// 切换主题
/// </summary>
public void ToggleTheme()
{
SetTheme(_isDarkMode ? AppTheme.Light : AppTheme.Dark);
}
/// <summary>
/// 检测 Windows 系统暗色模式
/// </summary>
private bool IsSystemDarkMode()
{
try
{
using var key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(
@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
return key?.GetValue("AppsUseLightTheme") is int value && value == 0;
}
catch
{
return false;
}
}
/// <summary>
/// 保存主题偏好
/// </summary>
private void SavePreference(AppTheme theme)
{
Properties.Settings.Default.Theme = theme.ToString();
Properties.Settings.Default.Save();
}
/// <summary>
/// 加载主题偏好
/// </summary>
public void LoadPreference()
{
var saved = Properties.Settings.Default.Theme;
if (Enum.TryParse<AppTheme>(saved, out var theme))
SetTheme(theme);
else
SetTheme(AppTheme.System);
}
/// <summary>
/// 监听系统主题变化
/// </summary>
public void WatchSystemTheme()
{
SystemEvents.UserPreferenceChanged += (s, e) =>
{
if (e.Category == UserPreferenceCategory.General ||
e.Category == UserPreferenceCategory.Color)
{
if (_currentTheme == AppTheme.System)
{
Application.Current.Dispatcher.Invoke(() =>
SetTheme(AppTheme.System));
}
}
};
}
}主题切换 ViewModel
// ========== 主题切换 ViewModel ==========
/// <summary>
/// 主题切换 ViewModel
/// </summary>
public class ThemeSwitchViewModel : ObservableObject
{
private readonly IThemeService _themeService;
[ObservableProperty]
private AppTheme _currentTheme;
[ObservableProperty]
private bool _isDarkMode;
public ThemeSwitchViewModel(IThemeService themeService)
{
_themeService = themeService;
_currentTheme = themeService.CurrentTheme;
_isDarkMode = themeService.IsDarkMode;
_themeService.ThemeChanged += (theme, isDark) =>
{
CurrentTheme = theme;
IsDarkMode = isDark;
};
}
/// <summary>
/// 切换主题
/// </summary>
[RelayCommand]
private void ToggleTheme() => _themeService.ToggleTheme();
/// <summary>
/// 设置为亮色主题
/// </summary>
[RelayCommand]
private void SetLight() => _themeService.SetTheme(AppTheme.Light);
/// <summary>
/// 设置为暗色主题
/// </summary>
[RelayCommand]
private void SetDark() => _themeService.SetTheme(AppTheme.Dark);
/// <summary>
/// 跟随系统
/// </summary>
[RelayCommand]
private void SetSystem() => _themeService.SetTheme(AppTheme.System);
}控件样式
主题感知的控件样式
<!-- ========== Themes/Styles.xaml — 通用控件样式 ========== -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 卡片容器 -->
<Style x:Key="CardStyle" TargetType="Border">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="CornerRadius" Value="8"/>
<Setter Property="Padding" Value="16"/>
<Setter Property="Margin" Value="4"/>
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="8" ShadowDepth="2" Opacity="0.15"
Color="{DynamicResource TextColor}"/>
</Setter.Value>
</Setter>
</Style>
<!-- 主按钮 -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="Background" Value="{DynamicResource PrimaryBrush}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border Background="{TemplateBinding Background}"
CornerRadius="4" Padding="{TemplateBinding Padding}"
x:Name="border">
<ContentPresenter HorizontalAlignment="Center"
VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background"
Value="{DynamicResource PrimaryHoverBrush}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter Property="Opacity" Value="0.8"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 文本框 -->
<Style TargetType="TextBox">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="Padding" Value="8,6"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsFocused" Value="True">
<Setter Property="BorderBrush"
Value="{DynamicResource PrimaryBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 列表框 -->
<Style TargetType="ListBox">
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
</Style>
<!-- DataGrid -->
<Style TargetType="DataGrid">
<Setter Property="Background" Value="{DynamicResource BackgroundBrush}"/>
<Setter Property="Foreground" Value="{DynamicResource TextBrush}"/>
<Setter Property="BorderBrush" Value="{DynamicResource BorderBrush}"/>
<Setter Property="RowBackground" Value="{DynamicResource BackgroundBrush}"/>
<Setter Property="AlternatingRowBackground" Value="{DynamicResource SurfaceBrush}"/>
</Style>
</ResourceDictionary>使用主题资源
<!-- ========== 使用 DynamicResource 绑定主题色 ========== -->
<Window x:Class="MyApp.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Background="{DynamicResource BackgroundBrush}">
<Grid Margin="20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- 标题栏 -->
<StackPanel Grid.Row="0" Orientation="Horizontal" Margin="0,0,0,20">
<TextBlock Text="设备监控系统" FontSize="24" FontWeight="Bold"
Foreground="{DynamicResource TextBrush}"/>
<Button Content="切换主题" Command="{Binding ToggleThemeCommand}"
Style="{StaticResource PrimaryButton}" Margin="20,0,0,0"/>
</StackPanel>
<!-- 卡片 -->
<Border Grid.Row="1" Style="{StaticResource CardStyle}" Margin="0,0,0,20">
<StackPanel>
<TextBlock Text="设备状态" FontSize="18" FontWeight="SemiBold"
Foreground="{DynamicResource TextBrush}"/>
<TextBlock Text="在线设备: 12 / 离线设备: 3"
Foreground="{DynamicResource SecondaryTextBrush}" Margin="0,8,0,0"/>
</StackPanel>
</Border>
<!-- 数据区域 -->
<DataGrid Grid.Row="2" ItemsSource="{Binding Devices}" AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="设备名称" Binding="{Binding Name}"/>
<DataGridTextColumn Header="状态" Binding="{Binding Status}"/>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Window>App.xaml 资源合并
<!-- ========== App.xaml — 资源字典合并 ========== -->
<Application x:Class="MyApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- 通用控件样式(不包含颜色)-->
<ResourceDictionary Source="Themes/Styles.xaml"/>
<!-- 默认主题(启动时加载)-->
<ResourceDictionary Source="Themes/Light.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>主题切换附加属性
// ========== 图标适配暗色模式 ==========
/// <summary>
/// 暗色模式图标切换附加属性
/// 为同一个控件提供 Light/Dark 两套图标
/// </summary>
public static class ThemeIconHelper
{
public static readonly DependencyProperty LightIconProperty =
DependencyProperty.RegisterAttached(
"LightIcon", typeof(ImageSource), typeof(ThemeIconHelper),
new FrameworkPropertyMetadata(null, OnIconChanged));
public static readonly DependencyProperty DarkIconProperty =
DependencyProperty.RegisterAttached(
"DarkIcon", typeof(ImageSource), typeof(ThemeIconHelper),
new FrameworkPropertyMetadata(null, OnIconChanged));
public static ImageSource GetLightIcon(DependencyObject obj)
=> (ImageSource)obj.GetValue(LightIconProperty);
public static void SetLightIcon(DependencyObject obj, ImageSource value)
=> obj.SetValue(LightIconProperty, value);
public static ImageSource GetDarkIcon(DependencyObject obj)
=> (ImageSource)obj.GetValue(DarkIconProperty);
public static void SetDarkIcon(DependencyObject obj, ImageSource value)
=> obj.SetValue(DarkIconProperty, value);
private static void OnIconChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not Image image) return;
// 根据 IsDarkMode 选择图标
var isDark = IsSystemDarkMode();
image.Source = isDark ? GetDarkIcon(d) : GetLightIcon(d);
}
}
// XAML 使用
/*
<Image local:ThemeIconHelper.LightIcon="/Images/icon_light.png"
local:ThemeIconHelper.DarkIcon="/Images/icon_dark.png"/>
*/优点
缺点
总结
WPF 主题切换核心:定义 Light/Dark 两套 ResourceDictionary,使用 DynamicResource 引用颜色(不是 StaticResource),运行时替换 MergedDictionaries 实现切换。建议定义统一的颜色 Key 规范(PrimaryColor、BackgroundColor 等),所有控件样式统一引用主题颜色。通过 IThemeService 接口实现 MVVM 友好的主题管理。
关键知识点
- 主题颜色必须使用 DynamicResource 而非 StaticResource。
- 颜色定义为 Color,然后创建 SolidColorBrush 供控件使用。
- 运行时通过 MergedDictionaries.Add/Remove 切换主题。
- Windows 注册表检测系统暗色模式:AppsUseLightTheme。
- SystemEvents.UserPreferenceChanged 监听系统主题变化。
项目落地视角
- 建立统一的颜色 Key 命名规范(PrimaryColor、BackgroundColor 等)。
- 为所有自定义控件样式使用 DynamicResource 引用主题色。
- 图标使用单色图标 + OpacityMask 或提供明暗两套。
- 封装 IThemeService 接口,ViewModel 通过 DI 注入使用。
常见误区
- 使用 StaticResource 引用主题颜色,导致切换主题时不更新。
- 忘记在 App.xaml 中合并默认主题资源字典。
- 第三方控件使用了硬编码颜色,暗色模式下显示异常。
- 图片在暗色背景下不清晰(需要提供暗色版本或使用白色图标)。
进阶路线
- 研究 Material Design in XAML Toolkit 的主题系统。
- 实现 Accent Color(强调色)自定义功能。
- 学习 ColorZone、Palette 等高级主题概念。
- 实现主题预览功能(切换前预览效果)。
适用场景
- 需要支持明暗两种模式的桌面应用。
- 长时间监控场景(如工控上位机、安防监控)。
- 需要跟随系统主题设置的应用。
落地建议
- 建立主题设计规范文档,统一颜色和间距标准。
- 为所有自定义控件样式使用 DynamicResource。
- 图标优先使用单色图标(Foreground 切换即可适配暗色模式)。
- 主题切换添加过渡动画,提升用户体验。
排错清单
- 确认所有主题颜色使用 DynamicResource。
- 检查 MergedDictionaries 中是否正确添加/移除主题字典。
- 确认 Light.xaml 和 Dark.xaml 的颜色 Key 完全一致。
- 检查第三方控件是否有硬编码颜色。
复盘问题
- 如果把《主题切换与暗色模式》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《主题切换与暗色模式》最容易在什么规模、什么边界条件下暴露问题?
- 相比默认实现或替代方案,采用《主题切换与暗色模式》最大的收益和代价分别是什么?
