简介:面向Qt桌面应用开发者的QSS样式表学习与素材包,适合希望摆脱默认控件外观、统一界面视觉风格的初中级开发者。内容围绕Qt Style Sheets的选择器、伪类、层叠与继承等机制展开,并配以可直接查阅的样式示例与界面工程素材,帮助理解样式表如何绑定到窗口和控件、如何组织可复用的皮肤资源,从而在不重新编译的前提下调整整体视觉风格。压缩包共1274个文件,约19.39MB,以1096张png界面切图与效果图、55个css样式文件为主体,另含31个cpp与30个h源码、17个ui设计文件及qrc资源脚本,便于对照界面效果与实现代码;gif、ttf、ico等则补充动效、字体与图标素材。已有3098人学习,适合按模块检索、临摹改造并快速搭建自己的QSS样式库。
1. 解包之后先别急着 import:QSS 素材合集的真实用法
拿到 QSS 样式表素材合集,很多人的第一动作是解包、挑一个顺眼的 .qss,在 main 里qApp.setStyleSheet(...),然后跑起来发现界面只换了一半:按钮圆角有了,滚动条还是系统灰,QComboBox 的下拉箭头依旧是个凸起的方块。这不是素材坏了,是 qss 这套东西本身只认“规则是否命中”这一件事:没被选择器命中的控件,一律回落到当前 QStyle 的默认绘制,跟你贴了多少行样式没有任何关系。
这份合集真正能省掉的是“从零手写几十条 QSS 规则”的体力活,配色、圆角、子控件、伪状态这些高频改动点已经摆在那里,你把选择器对上就能用。它省不掉的是三件必须自己做判断的事:你的控件树长什么样、图片资源路径怎么解析、换肤的时候怎么让控件重刷。任何一套皮肤换上去“像又不像”,八成问题都出在这三件事上,跟素材质量无关。
适合手上已经有 Qt / PySide / PyQt 界面、想快速套一层统一视觉的开发者,也适合想读懂别人 qss 写法的人。下面按“先认清文件结构、再挂进工程、再处理换肤、最后排查”的顺序走,每一步都能直接抄。
2. 拆开这套素材:qss 文件、贴图与预览图各管什么
2.1 三类文件的真实分工
解包后先别打开编辑器,先按类型分堆。QSS 素材合集里的东西基本落在三类:.qss文本负责颜色、边框、圆角、字号、内边距这些能用数值描述的部分;位图或矢量图(png / svg / 九宫格切片图)负责箭头、勾选标记、滑块、关闭按钮这类样式表画不出来的形状;预览图和说明文件告诉你每套样式在什么分辨率、什么字号下截的图。
| 文件类型 | 承担的角色 | 处理方式 | 缺了会怎样 |
|---|---|---|---|
.qss | 规则、颜色、边框、间距、字号 | 读入字符串,必要时替换占位符 | 没有主体样式 |
.png/.svg | 箭头、勾选、滑块、图标 | 打进 qrc,用:/路径引用 | 下拉框、勾选框出现系统默认丑图 |
| 九宫格切片图 | 用于border-image的可拉伸边框 | 记下 slice 数值,跟着分辨率一起改 | 拉伸后边框变形或糊 |
| 预览图 / 说明 | 目标效果、基准字号、推荐基准风格 | 只做对照,不参与加载 | 调出来的效果和预期对不上 |
大部分“套上去不像预览图”的问题,根因都是只拿了 .qss 没拿配套图,或者拿了图但路径没对上。素材合集给的是视觉方案,不是一个自包含的可执行程序,缺件的时候它不会报错,只会安静地画成默认样式。
2.2 规则模型:选择器、子控件、伪状态三层
qss 的心智模型就三层,理解了这三层,看别人的样式表基本不用猜。
第一层是选择器,决定这条规则打给谁。类型选择器QPushButton命中的所有 QPushButton 以及它的子类;带点的类选择器.QPushButton只命中类名精确等于QPushButton 的实例,你自己继承出来的class PrimaryButton(QPushButton)是匹配不到的——这是个血泪坑,很多人抄了别人的.QPushButton规则,自己封装一层按钮就彻底不生效。ID 选择器#primaryButton匹配setObjectName()设的名字,优先级最高。
第二层是子控件,用::双冒号。QComboBox 的下拉按钮是::drop-down、箭头是::down-arrow;QScrollBar 由::handle、::add-line、::sub-line、::add-page、::sub-page组成;QCheckBox 的勾是::indicator。这些部件不会因为你给父控件设了背景就自动跟着变,必须逐个指定。
第三层是伪状态,用单冒号,:hover、:pressed、:checked、:disabled、:focus、:!checked(取反)都算。伪状态规则要写在基础规则之后,同权重下后写的覆盖先写的。
/* 类型选择器:命中所有 QPushButton 及其子类 */ QPushButton { background: #2b2d31; border: 1px solid #3a3d42; border-radius: 6px; padding: 6px 14px; color: #e6e6e6; } /* ID 选择器:只命中 objectName 为 primaryButton 的那一个,权重更高 */ #primaryButton { background: #3d7eff; border-color: #3d7eff; color: #ffffff; } /* 伪状态:必须写在基础规则后面,否则被覆盖 */ QPushButton:hover { background: #35383d; } QPushButton:pressed { background: #24262a; } QPushButton:disabled { color: #6b6f76; border-color: #2f3237; } #primaryButton:hover { background: #5a92ff; } /* 子控件:下拉箭头不写就永远是系统默认绘制 */ QComboBox::drop-down { width: 24px; border: none; background: transparent; } QComboBox::down-arrow { image: url(:/skins/dark/arrow_down.png); width: 10px; height: 10px; }属性值的类型也要心里有数:颜色支持#rgb、#rrggbb、rgb()、rgba()和颜色名;长度支持px、pt、em、ex,不支持百分比,写width: 50%会被静默忽略;边框样式是solid、dashed、dotted、none这类关键字。凡是不认识的属性,Qt 一般只在某些版本下打一行 warning 就跳过,界面不会有任何变化,所以“写了没反应”要先去查这个属性到底支不支持,而不是怀疑自己选择器写错了。
2.3 十分钟验证:先把一套样式挂到最小窗口上
不要一上来就往业务工程里塞。新建一个空目录,两个文件,跑通再迁移。
# main.py —— 最小验证:先确认一套 qss 能命中,再谈别的 import sys from PySide6.QtCore import QFile, QTextStream from PySide6.QtWidgets import QApplication, QPushButton, QVBoxLayout, QWidget def load_qss(path: str) -> str: """读样式文件,读不到就立刻抛,别让空字符串静默跑下去""" f = QFile(path) if not f.open(QFile.ReadOnly | QFile.Text): raise RuntimeError(f"打不开样式文件: {path}") return QTextStream(f).readAll() app = QApplication(sys.argv) # 基准风格先定死:Windows 原生风格会和 qss 混合绘制,导致半套样式 app.setStyle("Fusion") app.setStyleSheet(load_qss("skins/dark/app.qss")) win = QWidget() lay = QVBoxLayout(win) btn = QPushButton("普通按钮") btn2 = QPushButton("主按钮") btn2.setObjectName("primaryButton") # 让 #primaryButton 选择器命中 lay.addWidget(btn) lay.addWidget(btn2) win.resize(360, 200) win.show() sys.exit(app.exec())app.setStyle("Fusion")这行容易被跳过,但它决定了后面所有的调试体验。在部分平台上,原生风格会自己绘制一部分控件外观,qss 只覆盖到其中一半,于是你看到的就是“边框变了、背景没变”这种玄学现象。先把基准风格切成 Fusion,绘制路径统一,问题会少掉一大半。
setObjectName()的位置也有讲究。它在setStyleSheet()之前设,规则能直接命中;在之后设,就需要手动 repolish(第 4 章会写)。验证阶段建议先设名字再挂样式表,减少变量。
验证通过的标准不是“界面好看”,而是三件事同时成立:按钮有圆角、hover 有颜色变化、控制台没有Could not create pixmap之类的输出。三个都过了,再往工程里搬。
3. 接进工程:加载路径、qrc 打包与配色变量
3.1 三种加载方式与各自的边界
素材进了真实工程之后,第一个要定的是“样式从哪来”。常见三种做法,适用场景完全不同。
| 加载方式 | 典型写法 | 适用场景 | 主要问题 |
|---|---|---|---|
| 读磁盘文件 | open(path, encoding="utf-8").read() | 开发期调样式,改完重启就生效 | 打包后路径找不到 |
| qrc 资源 | :/skins/dark/app.qss | 正式发布 | 每次改 qss 要重新编译资源 |
| 内嵌字符串常量 | 模块里的DARK_QSS = """...""" | 极小的固定样式 | 无法编辑,颜色改一次要改代码 |
开发期我一般用磁盘文件加载,改 qss 不重新编译,改完重启看效果;发版前统一转成 qrc,把路径问题一次性掐死。中间那段“磁盘能跑、打包后白板”的时间,是绝大多数人踩坑的地方。
3.2 url() 路径:把所有资源收敛到 qrc
qss 里url(...)的相对路径解析基准,跟你“以为的”经常不一致——它跟你用哪种方式把样式表交给 Qt(字符串、文件、资源)以及 Qt 版本都有关系。我在同一份 qss 上遇到过开发机正常、换台机器图片全丢的情况。后悔药很简单:所有 url 一律写 qrc 的绝对路径,从根上绕开基准问题。
<!-- skins.qrc:把整套皮肤的资源平铺进资源系统 --> <RCC> <qresource prefix="/skins"> <file>skins/dark/app.qss</file> <file>skins/dark/arrow_down.png</file> <file>skins/dark/check.svg</file> <file>skins/dark/scrollbar_handle.png</file> </qresource> </RCC># Python 工程:编译成 py 模块,import 之后资源才注册 pyside6-rcc skins.qrc -o skins_rc.py # PySide6 pyrcc5 skins.qrc -o skins_rc.py # PyQt5 # C++ 工程:CMake 里打开 AUTORCC,把 qrc 直接当源文件加进去 # set(CMAKE_AUTORCC ON) # add_executable(app main.cpp mainwindow.cpp skins.qrc)编译出来的skins_rc.py必须被import一次,资源才会注册进 Qt 的虚拟文件系统。只在 main 里写了import却放在了if __name__ == "__main__":之后,PyInstaller 打包后就会丢资源——这个坑一年能坑到好几批人。
另外注意大小写:qrc 里的路径是大小写敏感的,Arrow.png和arrow.png就是两个东西,Windows 上开发看不出来,换到 Linux 跑 CI 直接白图。
3.3 把硬编码颜色抽成占位符
qss 没有 CSS 变量,不支持--main-color和var(),也没有calc()。素材合集里每套皮肤都是一堆重复的十六进制色值,想换个主色得全局替换,很容易改漏一处,结果 hover 和默认态颜色对不上。
我的做法是先在 qss 里把高频色值换成占位符,运行时做一次字符串替换。
import re # 占位符用 @name@ 而不是 {name}:后者会和 qss 合法的规则块花括号冲突 _TOKEN_RE = re.compile(r"@([A-Za-z0-9_]+)@") def render(template: str, tokens: dict) -> str: def _sub(m): key = m.group(1) if key not in tokens: # 缺变量要立刻炸,静默替换成空字符串会得到一个不合法颜色,界面直接错乱 raise KeyError(f"配色表缺少变量: @{key}@") return tokens[key] return _TOKEN_RE.sub(_sub, template) DARK_TOKENS = { "bg": "#1e1f22", "panel": "#2b2d31", "border": "#3a3d42", "fg": "#e6e6e6", "fg_dim": "#8b9099", "accent": "#3d7eff", "accent_hover": "#5a92ff", "danger": "#c0392b", } with open("skins/dark/app.qss", encoding="utf-8") as fp: qss = render(fp.read(), DARK_TOKENS) app.setStyleSheet(qss)配套的 qss 里就写成background: @panel@; border: 1px solid @border@;。这样一套模板可以喂不同的配色表,浅色、深色、品牌定制都是同一份规则,改配色不用碰样式逻辑。
有一点要注意:占位符只在“运行前”替换,替换完就是一个普通字符串。别指望在替换之后还能改 token 让界面跟着变,那是换肤要做的事,得重新 render 一遍再设回去。
4. 换肤与作用域:多套 QSS 共存不打架
4.1 全局样式表与控件级样式表的优先级
QApplication.setStyleSheet()设的是全局,对所有 widget 生效并向下传递。widget.setStyleSheet()设的是局部,作用在这个 widget 及其所有子 widget 上,并且优先级高于全局。
这条规则本身不复杂,翻车点在于很多人用局部样式表时写了个类型选择器:
# 反例:看起来只想改这一个面板,实际把面板里所有 QWidget 子类都改了 panel.setStyleSheet("QWidget { background: #2b2d31; }")结果这个面板里的 QLabel、QLineEdit、QCheckBox 全被刷成同一个底色,输入框边框消失,看着像界面坏了。正确的写法是用 ID 或者属性把范围收窄:
/* 正例 1:只改 objectName 为 sidePanel 的那个控件本身 */ #sidePanel { background: @panel@; border-right: 1px solid @border@; } /* 正例 2:用动态属性做语义标记,比 objectName 更容易批量管理 */ QPushButton[role="danger"] { background: @danger@; color: #ffffff; }我给工程的约定是:全局样式表只管“通用外观”(字体、滚动条、输入框、表格),局部样式表只允许出现 ID 选择器,一个类型选择器都不许写。这条规矩执行下来,样式冲突能砍掉九成。
4.2 换肤要重刷:什么时候需要 unpolish / polish
设置QApplication.setStyleSheet()时,Qt 会自动重算所有控件的样式并重绘,这个不用你操心。真正需要手动干预的是另外两种情况。
第一种,改了objectName。选择器是按名字算的,名字变了,之前算出来的样式不会自己失效。第二种,改了动态属性(setProperty),比如按钮从普通态变成危险态,qss 里写的是QPushButton[role="danger"],属性值变了但控件不知道要重算。
这时候要走一遍 unpolish → polish → update:
def repolish(widget): """改完 objectName 或动态属性后调用,强制重算样式""" style = widget.style() style.unpolish(widget) # 丢掉旧的样式缓存 style.polish(widget) # 按当前属性重新解析一遍规则 widget.update() # 触发重绘 btn.setProperty("role", "danger") repolish(btn)顺序不能反。先update()再polish(),你会看到界面闪一下旧样式再变新样式;先polish()不unpolish(),属性没变的情况下 Qt 会跳过重算,结果就是“代码明明跑了,颜色没变”。
setProperty有个隐藏细节:属性值类型会影响匹配。setProperty("role", "danger")传的是字符串,qss 里写[role="danger"]能命中;但如果传的是枚举或者数字,qss 里的匹配写法要跟着变,例如[level="2"]。类型对不上就是静默不命中,没有任何报错。
4.3 一个够用的主题管理器
工程里不需要很重的框架,一个百来行的类就够。它管三件事:注册配色表、应用某一套主题、以及应用完之后统一 repolish 那些需要刷新的控件。
import re from PySide6.QtCore import QFile, QTextStream _TOKEN_RE = re.compile(r"@([A-Za-z0-9_]+)@") class ThemeManager: def __init__(self, app, root=":/skins"): self._app = app self._root = root self._tokens = {} # name -> 配色表 self._current = None # 当前主题名,用于去重 def register(self, name: str, tokens: dict): self._tokens[name] = tokens def apply(self, name: str): if name == self._current: return # 同一套主题重复应用会白刷一次全量控件 if name not in self._tokens: raise KeyError(f"未注册的主题: {name}") tpl = self._read(f"{self._root}/{name}/app.qss") qss = _TOKEN_RE.sub(lambda m: self._tokens[name][m.group(1)], tpl) self._app.setStyleSheet(qss) # 全局设置会自动重算并重绘 self._current = name @staticmethod def _read(path: str) -> str: f = QFile(path) if not f.open(QFile.ReadOnly | QFile.Text): raise RuntimeError(f"读不到主题文件: {path}") return QTextStream(f).readAll()用法是启动时register("dark", DARK_TOKENS)、register("light", LIGHT_TOKENS),切换时apply("light")一句话。这里没有再手动遍历所有控件 repolish,因为全局样式表变更 Qt 自己会处理;手动遍历反而会让大窗口切换时明显卡顿——几百个控件的 polish 是实打实的 CPU 开销。
5. 排查清单:QSS 写完不生效的五种现场
5.1 自定义 QWidget 背景画不出来
现象:给自定义控件写了background-color和border-radius,一点反应没有;同一个 qss 文件里 QPushButton 的规则却正常生效。
原因:QWidget 子类默认不绘制样式表背景。qss 的背景是 QStyle 在绘制时画上去的,而 QWidget 的paintEvent默认什么都不做,样式表规则虽然被解析了,却没人拿它去画。
解决:两选一。最省事的是打开WA_StyledBackground属性,让 Qt 自己按样式表绘制背景:
class Card(QWidget): def __init__(self, parent=None): super().__init__(parent) # 关键一行:不设这个,qss 的 background 对这个类完全不生效 self.setAttribute(Qt.WA_StyledBackground, True)如果你必须自己写paintEvent,那就得手动走一遍 QStyle,否则属性白设:
def paintEvent(self, event): opt = QStyleOption() opt.initFrom(self) p = QPainter(self) self.style().drawPrimitive(QStyle.PE_Widget, opt, p, self)注意QStyleOption.initFrom(self)这一步,它把调色板、布局方向、状态位都带过去,漏掉它,:hover、:disabled这类伪状态在你这个控件上不会生效。
5.2 只换了半套:子控件没写全
现象:按钮、输入框都变了,但滚动条还是系统默认的灰白块,QComboBox 的下拉箭头还是凸起的方块,看着像两个年代的界面拼在一起。
原因:复杂控件由多个子控件拼成,每个子控件独立取规则。QScrollBar 拆成 handle、add-line、sub-line、add-page、sub-page 五个部件,你只写了QScrollBar { background: ... },剩下四个照样走默认绘制,于是出现“底色变了、滑块还是原样”的半套效果。
解决:成套写,一个不漏。QComboBox 至少要给::drop-down和::down-arrow;QScrollBar 要给竖直和水平两套。
/* 竖直滚动条:五个子控件缺一不可 */ QScrollBar:vertical { background: transparent; width: 10px; margin: 0; } QScrollBar::handle:vertical { background: #4a4e55; border-radius: 5px; min-height: 24px; /* 太小时滑块会缩成一个点,必须给下限 */ } QScrollBar::handle:vertical:hover { background: #5d6269; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; border: none; background: none; } QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }min-height那条不要省。列表项特别多的时候,滑块的计算高度会小到几乎看不见,用户会以为滚动条坏了。
5.3 图片不显示,控制台偶发一行 pixmap 报错
现象:border-image或image:引用的图片位置一片空白,控件还在,形状没了。控制台可能有一行Could not create pixmap from ...,也可能什么都没有。
原因:三种。一是相对路径基准不对(第 3.2 节说的那个问题);二是 qrc 编译出来的资源模块没被 import,:/路径实际指向空;三是大小写在 Linux 上不匹配。
解决:把所有 url 改成:/绝对路径,在启动时加一段断言,把“运行时才发现”变成“启动就报错”:
from PySide6.QtGui import QPixmap for res in (":/skins/dark/arrow_down.png", ":/skins/dark/check.svg"): if QPixmap(res).isNull(): raise RuntimeError(f"资源没注册或路径写错: {res}")注意QPixmap加载 svg 依赖 Qt Svg 模块和它的图片格式插件,打包工具经常漏掉这一块。svg 在开发机上好好的、打包后集体消失,基本都是插件没带上。
5.4 高 DPI 下糊成一团
现象:1080p 上一切正常,4K 屏或者系统缩放 150% 之后,图标发虚、圆角边缘有锯齿、九宫格边框粗细不均匀。
原因:qss 里的px是设备无关的逻辑像素,Qt 会按缩放比例放大,但你的 png 只有一份 1x 分辨率,放大就是插值拉伸。border-image-slice的数值同样需要跟着缩放,切片位置错了边框就变形。
解决:图标优先用矢量(svg),必须用位图就提供 2x 版本并按@2x命名交给 QIcon;qss 里的图标尽量走qproperty-icon或者代码里setIcon(),而不是image: url(...),因为 QIcon 有@2x自动挑选机制,qss 的 url 没有。九宫格的 slice 值用无单位整数,跟着基准图尺寸走,不要写死成border-image-slice: 12 12 12 12;之后再也不改。
5.5 改完属性不刷新,以及越用越卡
现象:两种。一是setProperty改了之后颜色纹丝不动,重启程序才变;二是界面开得越多越卡,新建窗口时明显顿一下。
原因:前者是动态属性变化不触发样式重算,Qt 不知道要重新匹配选择器;后者通常是每个控件都setStyleSheet()了一遍大字符串,而样式表解析是有成本的,控件树一大,解析次数就是指数级的体感。
解决:属性变化一律走 4.2 的repolish()。性能问题则是把样式表收敛到QApplication一级,只在换肤时设一次;确实需要局部样式的,用 ID 选择器写最短的几条,别把整份皮肤贴到单个控件上。
# 反例:在循环里给每个卡片单独设一遍全局样式 for card in cards: card.setStyleSheet(open("skins/dark/app.qss").read()) # 正例:全局设一次,卡片只靠 objectName / 动态属性命中 app.setStyleSheet(qss) for card in cards: card.setProperty("role", "card") repolish(card)6. 进阶:给素材合集配一个样式自检脚本
素材合集用久了会发现一个规律:出问题的从来不是规则本身,而是规则引用的东西。图片找不到、占位符拼错、花括号少一个、属性名写成了 CSS 里的box-shadow——这些错误 Qt 全都静默吞掉,界面上只表现为“没生效”。所以我在把任何一套 qss 迁进工程之前,会先跑一遍静态检查,把这类低级错误挡在编译之前。
import re from pathlib import Path # Qt 不支持的 CSS 属性,写了也白写,直接拦下来 UNSUPPORTED = { "box-shadow", "text-shadow", "transition", "animation", "transform", "flex", "grid", "calc", "opacity", } URL_RE = re.compile(r'url\(\s*["\']?([^"\')]+)["\']?\s*\)') TOKEN_RE = re.compile(r"@([A-Za-z0-9_]+)@") def check(qss_path: Path, tokens: set[str]): text = qss_path.read_text(encoding="utf-8") # 1) 花括号配平:少一个括号,后面所有规则一起失效 if text.count("{") != text.count("}"): print(f"[括号] {qss_path.name}: {{={text.count('{')} }}={text.count('}')}") # 2) 不支持的属性名 for prop in UNSUPPORTED: if re.search(rf"^\s*{prop}\s*:", text, re.M): print(f"[属性] {qss_path.name}: {prop} Qt 不支持,删掉或换写法") # 3) 占位符是否都在配色表里 for name in set(TOKEN_RE.findall(text)): if name not in tokens: print(f"[变量] {qss_path.name}: @{name}@ 在配色表里不存在") # 4) 相对路径的图片,按 qss 同级目录找 for url in URL_RE.findall(text): if url.startswith(":/"): continue # qrc 路径交给运行时断言 if not (qss_path.parent / url).exists(): print(f"[图片] {qss_path.name}: 找不到 {url}") DARK = {"bg", "panel", "border", "fg", "accent", "danger"} for f in Path("skins").rglob("*.qss"): check(f, DARK)这四个检查项对应四类最常见的事故:括号少一个导致整份样式静默失效;写了 CSS 里存在但 qss 不认的属性;占位符改名之后渲染出空颜色;图片路径在打包后断掉。脚本跑一遍,能挡掉我遇到过的八成“样式没生效”。
有一点要提醒:这个脚本只做静态检查,它能告诉你“引用的东西存不存在”,不能告诉你“规则有没有命中控件”。真正的命中验证还得靠 2.3 那个最小窗口,或者 Qt 自带的QT_STYLE_DEBUG之类的手段,把控件实际生效的样式打出来看。
从那以后我每次往工程里加一套新皮肤,都强制走一遍“自检脚本 → 最小窗口验证 → 再合并”这三步,宁可多花十分钟,也不要在发布前一天晚上对着一半变色的界面猜是哪个子控件没写。希望帮到你。
本文还有配套的精品资源,点击获取