Qt 主题架构——从 Theme 到 Widget 的一键切换

一句话

主题 = 不改变「是什么」,主要改变「长什么样」。专业 Qt 项目用 Theme → Palette → Style → Widget 四层架构实现一键切换,整个程序所有颜色全部来自 Theme,切主题后所有控件自动刷新。


一、四层架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
             ThemeManager

┌───────────────┴───────────────┐
│ │
LightTheme DarkTheme
│ │
└───────────────┬───────────────┘

ThemeData
(颜色、字体、圆角、间距...)

┌───────────────┴───────────────┐
│ │
QPalette QSS Generator
│ │
└───────────────┬───────────────┘

QApplication

所有 QWidget 自动刷新

四层职责

  1. ThemeData(主题数据):定义主题的颜色等数据,不直接写 QSS
  2. ThemeManager(主题管理器):全局唯一,保存当前 Theme、发 signal、更新 QApplication Palette 与 QSS
  3. Palette / QSS(样式):QPalette 负责 Qt 原生颜色角色,QSS 负责复杂样式
  4. Widget(控件):自动刷新,不感知主题细节

核心原则

  • 不要直接写 QSS,先定义主题(ThemeData)
  • 整个程序所有颜色全部来自 Theme
  • 切主题后所有 QWidget 自动刷新,整个程序不用自己刷新

二、ThemeData 与 ThemeManager

第一层 ThemeData(主题数据)

1
2
3
4
5
6
7
8
9
10
11
12
struct Theme
{
QColor window;
QColor panel;
QColor text;
QColor textDisabled;
QColor button;
QColor buttonHover;
QColor buttonPressed;
QColor border;
QColor accent;
};

lightdark 各是一份 Theme 数据。整个程序所有颜色全部来自 Theme。

第二层 ThemeManager

1
2
3
4
5
6
7
8
9
10
class ThemeManager : public QObject
{
Q_OBJECT
public:
static ThemeManager& instance();
void setTheme(ThemeType);
const Theme& theme() const;
signals:
void themeChanged();
};

切换主题:

1
ThemeManager::instance().setTheme(ThemeType::Dark);

ThemeManager:保存当前 Theme、发 signal、更新 QApplication Palette、更新 QSS。整个程序不用自己刷新。


三、Palette 与 QSS 各司其职

Palette(解决 70%)

1
2
3
4
5
6
QPalette p;
p.setColor(QPalette::Window, theme.window);
p.setColor(QPalette::WindowText, theme.text);
p.setColor(QPalette::Base, theme.panel);
p.setColor(QPalette::Button, theme.button);
qApp->setPalette(p);

所有 QLabel、QPushButton、QTextEdit 都会变。这是 Qt 官方推荐的方法。

QSS(解决剩下 30%)

Palette 解决不了的:圆角、hover、pressed、progressbar、scrollbar、menu、tab——交给 QSS。但是不写死颜色:

1
2
3
4
5
6
QPushButton
{
background:%BUTTON%;
color:%TEXT%;
border:1px solid %BORDER%;
}

然后:

1
2
3
style.replace("%BUTTON%", theme.button.name());
style.replace("%TEXT%", theme.text.name());
qApp->setStyleSheet(style);

所以实际上:Theme → 生成 QSS → setStyleSheet。


四、Widget 不感知主题

最糟糕的写法

1
button->setStyleSheet("background:black;");   // 写死颜色

正确写法

1
button->setProperty("accent", true);
1
2
3
4
QPushButton[accent="true"]
{
background:%ACCENT%;
}

切主题以后 Accent 自动变。Widget 不应该知道深色/浅色/Accent。

自定义控件

1
2
3
4
5
6
7
8
9
10
11
class MyButton : public QPushButton
{
protected:
void paintEvent(...)
{
auto t = ThemeManager::instance().theme();
painter.fillRect(rect(), t.button);
painter.setPen(t.text);
...
}
};

ThemeManager 的 themeChanged() 连接:

1
2
connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
this, QOverload<>::of(&QWidget::update));

于是:切主题 → themeChanged → update() → paintEvent() → 重新绘制。


五、Design Token 抽象与目录结构

大项目先抽象 Design Token

大型项目(几万到几十万行代码)通常不会直接使用 QColor,而是先定义一层设计令牌(Design Tokens):

1
2
3
4
Theme → ColorToken
BackgroundPrimary / BackgroundSecondary
TextPrimary / TextSecondary
Border / Accent / Success / Warning / Error

Widget 永远只引用 Token:

1
theme.color(ColorToken::BackgroundPrimary);   // 而不是 QColor("#202124")

这样 Dark/Light/Blue/Green/HighContrast 都只需要替换 Theme。

推荐目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
src/
├── theme/
│ ├── Theme.h / Theme.cpp // Theme 数据
│ ├── ThemeManager.h / ThemeManager.cpp
│ ├── ThemeLoader.cpp // 从 JSON/XML 加载主题(可选)
│ ├── QssGenerator.cpp // 根据 Theme 生成 QSS
│ └── themes/
│ ├── light.json
│ └── dark.json
├── widgets/
│ ├── Button/
│ ├── Tree/
│ └── Editor/
└── main.cpp

这种架构的优点

  • 主题数据与界面逻辑解耦:新增主题只需增加一份配置
  • Palette 与 QSS 各司其职:Palette 负责 Qt 原生颜色角色,QSS 负责复杂样式
  • 自定义控件统一响应:通过 ThemeManager 的通知机制重绘,无需每个地方手动更新
  • 易于扩展:支持高对比度、品牌色、自定义主题,甚至运行时加载 JSON 配置,都不需要修改业务代码

六、主题不只是配色:哪些会变哪些不变

层次 主题是否会改变 常见程度
颜色(Color) ✅ 一定会 ★★★★★
字体(Font) ✅ 可能会 ★★★☆☆
图标(Icons) ✅ 经常会 ★★★★☆
间距(Padding/Margin) ✅ 有时会 ★★☆☆☆
圆角(Radius) ✅ 有时会 ★★★☆☆
阴影(Shadow) ✅ 有时会 ★★★☆☆
动画(Animation) 偶尔 ★★☆☆☆
控件布局(Layout) 一般不会 ★☆☆☆☆
控件行为(Behavior) 基本不会 ☆☆☆☆☆

最常见的是「配色主题」(Light/Dark):区别通常只有背景、文字、按钮、边框、Hover、Selection、Highlight。VSCode、Qt Creator、Notepad++ 都属于这一类。


七、Layout 不变与 UI Mode 的区别

真正不会变化的是 Layout

Light 和 Dark 的布局一样:File 菜单、Tree、窗口结构都不变。不会突然「按钮跑右边、菜单变顶部、Tree 消失」。

这种已经不是 Theme

突然改变布局、改变交互的,已经不是 Theme,而是:

1
UI Mode / Workspace / Perspective

例如 IDE 的 Debug 模式、Photoshop Workspace、CAD Workspace——这些不是主题。


八、大项目把 Theme 拆成多个 Token

不是单个结构体,而是:

1
2
3
4
5
6
7
8
9
10
Theme
├── Color
├── Typography
├── Radius
├── Spacing
├── Border
├── Shadow
├── Icon
├── Animation
└── Cursor

Widget 根本不知道 Dark/Light/Blue/Green,它只知道「我要 PrimaryText、我要 SmallRadius、我要 DefaultIcon」,Theme 去决定这些 Token 的具体值。

1
2
3
4
5
6
struct Theme {
ColorPalette colors; // 窗口、文字、按钮、强调色
Typography typography; // 默认字体、字号、字重
IconSet icons; // 图标资源或图标着色策略
Metrics metrics; // 圆角、边距、间距
};

这样既能支持普通的明暗主题切换,也能支持「紧凑模式」「高对比度模式」等视觉风格,而不会影响界面的布局和交互逻辑


九、最终总结:主题改变「长什么样」不改变「是什么」

一个 QPushButton

  • 业务功能:还是按钮
  • 位置布局:通常不变
  • 点击行为:不变
  • 信号槽:不变

但可以改变:背景色、文字色、字体、边框、圆角、Hover/Pressed 状态、图标、阴影、一些视觉尺寸。

好的边界:

1
业务/UI结构 → Widget/Layout → Theme → 视觉表现

而不是:

1
Dark Theme → 重新创建一套界面

后者就已经更接近 UI 模式 / Workspace / Skin,而不只是 Theme 了。


与其他线的关系

  • 与系统角色主题:主题系统是 GUI 基础设施层的一个子系统——被所有 View 依赖,自己依赖 Theme 数据
  • 与 GUI-MVVM(04-系统角色/08):MVVM 管「用户操作→状态变化→界面更新」;主题管「视觉表现→控件重绘」——切主题和 ViewModel 状态变化都通过通知机制驱动 View 刷新
  • 与图像与绘画主题:主题的 Design Token(颜色/字体/圆角/间距)就是视觉领域的「参数层」——把容易产生歧义的视觉细节变成精确可验证的数据
  • 与接口与注入线:ThemeManager 是典型的框架化子系统(全局唯一、有状态、发信号),通过依赖注入被自定义控件使用