01-Qt主题架构
Qt 主题架构——从 Theme 到 Widget 的一键切换
一句话
主题 = 不改变「是什么」,主要改变「长什么样」。专业 Qt 项目用 Theme → Palette → Style → Widget 四层架构实现一键切换,整个程序所有颜色全部来自 Theme,切主题后所有控件自动刷新。
一、四层架构
1 | ThemeManager |
四层职责
- ThemeData(主题数据):定义主题的颜色等数据,不直接写 QSS
- ThemeManager(主题管理器):全局唯一,保存当前 Theme、发 signal、更新 QApplication Palette 与 QSS
- Palette / QSS(样式):QPalette 负责 Qt 原生颜色角色,QSS 负责复杂样式
- Widget(控件):自动刷新,不感知主题细节
核心原则
- 不要直接写 QSS,先定义主题(ThemeData)
- 整个程序所有颜色全部来自 Theme
- 切主题后所有 QWidget 自动刷新,整个程序不用自己刷新
二、ThemeData 与 ThemeManager
第一层 ThemeData(主题数据)
1 | struct Theme |
light 和 dark 各是一份 Theme 数据。整个程序所有颜色全部来自 Theme。
第二层 ThemeManager
1 | class ThemeManager : public QObject |
切换主题:
1 | ThemeManager::instance().setTheme(ThemeType::Dark); |
ThemeManager:保存当前 Theme、发 signal、更新 QApplication Palette、更新 QSS。整个程序不用自己刷新。
三、Palette 与 QSS 各司其职
Palette(解决 70%)
1 | QPalette p; |
所有 QLabel、QPushButton、QTextEdit 都会变。这是 Qt 官方推荐的方法。
QSS(解决剩下 30%)
Palette 解决不了的:圆角、hover、pressed、progressbar、scrollbar、menu、tab——交给 QSS。但是不写死颜色:
1 | QPushButton |
然后:
1 | style.replace("%BUTTON%", theme.button.name()); |
所以实际上:Theme → 生成 QSS → setStyleSheet。
四、Widget 不感知主题
最糟糕的写法
1 | button->setStyleSheet("background:black;"); // 写死颜色 |
正确写法
1 | button->setProperty("accent", true); |
1 | QPushButton[accent="true"] |
切主题以后 Accent 自动变。Widget 不应该知道深色/浅色/Accent。
自定义控件
1 | class MyButton : public QPushButton |
ThemeManager 的 themeChanged() 连接:
1 | connect(&ThemeManager::instance(), &ThemeManager::themeChanged, |
于是:切主题 → themeChanged → update() → paintEvent() → 重新绘制。
五、Design Token 抽象与目录结构
大项目先抽象 Design Token
大型项目(几万到几十万行代码)通常不会直接使用 QColor,而是先定义一层设计令牌(Design Tokens):
1 | Theme → ColorToken |
Widget 永远只引用 Token:
1 | theme.color(ColorToken::BackgroundPrimary); // 而不是 QColor("#202124") |
这样 Dark/Light/Blue/Green/HighContrast 都只需要替换 Theme。
推荐目录结构
1 | src/ |
这种架构的优点
- 主题数据与界面逻辑解耦:新增主题只需增加一份配置
- 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 | Theme |
Widget 根本不知道 Dark/Light/Blue/Green,它只知道「我要 PrimaryText、我要 SmallRadius、我要 DefaultIcon」,Theme 去决定这些 Token 的具体值。
1 | struct Theme { |
这样既能支持普通的明暗主题切换,也能支持「紧凑模式」「高对比度模式」等视觉风格,而不会影响界面的布局和交互逻辑。
九、最终总结:主题改变「长什么样」不改变「是什么」
一个 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 是典型的框架化子系统(全局唯一、有状态、发信号),通过依赖注入被自定义控件使用
