
做 Qt 桌面端的人大概都遇到过这种场景设计稿上的控件有圆角、渐变、悬浮高亮、按下回弹还能跟着主题切换配色然后你打开 Qt Designer 翻了半天发现 QPushButton 能调的只有背景色和边框最后只能拖一个 QWidget 上去自己写 paintEvent 一笔一笔画。画一个两个还行项目里一旦冒出几十个控件每个类里都复制一遍绘制代码、状态标志位和鼠标事件后面改一个圆角半径要全项目搜一遍。这个开源项目的起点就在这里——把散落在各个业务代码里的自绘逻辑收拢起来封装成一套可复用、可扩展的 Qt 自绘控件库。它解决的问题很具体让自绘控件有统一的基类、统一的状态机、统一的绘制规范业务侧只管用不再关心怎么画。适合已经写过 Qt 界面、被 QSS 折磨过想往组件化方向走的中级开发者也适合刚接手一个老项目、需要把一堆野生自绘控件整理成库的同学。下面我按实际做这个库的顺序把设计取舍、绘制细节、踩过的坑一条条摊开讲。1. 为什么值得把自绘控件单独做成一个库1.1 原生控件加 QSS 的天花板在哪先说清楚为什么要自绘。Qt 的 QSS 在简单换色、改边框、调内边距这些事上确实够用一旦需求越过这条线就开始难受了。第一是 QSS 画不出真正的投影只能用 border-image 切九宫格图切图一旦遇到不同 DPI 就糊改个颜色还得重新导图。第二是部分控件的 QSS 支持残缺QComboBox 的下拉列表、QScrollBar 的滑块、QMenu 的分隔线每个都有自己的支持范围写起来像在猜。第三是复杂状态QSS 的:hover、:pressed只能做瞬时切换做不了缓动动画想要 200ms 的渐变色过渡QSS 本身给不了。还有一个更隐蔽的问题QSS 走的是样式解析加绘制代理的路子控件数量一多、嵌套一深样式匹配的开销就上来了。我实测过一个列表里有几百个带复杂样式表的子控件滚动时会明显掉帧把样式改成手绘之后帧率稳住了。这不是说 QSS 不能用而是说当你的界面元素密度高、动效多的时候自绘反而是更可控的方案。注意自绘不等于抛弃 QSS。合理的做法是基础外观走 QSS特殊控件走自绘两者混用完全没问题。库里的控件也保留了通过 Q_PROPERTY 接收外部配色设置的能力。1.2 封装成库要解决的三类真实痛点第一类是绘制代码重复。没有库的时候每个自绘控件都是各自实现 paintEvent圆角怎么画、边框怎么描、禁用态怎么灰每个人写法都不一样。项目做了半年代码里能找出四五种圆角实现有的是 QPainterPath有的是 drawRoundedRect有的是贴图。统一到库里之后圆角、描边、内外边距这些公共绘制逻辑收敛到基类子类只负责画自己特有的部分。第二类是状态管理混乱。自绘控件通常得自己维护 hovered、pressed、checked、focused、disabled 这几个布尔量野生代码里经常出现鼠标移出没清标志、禁用之后还能响应点击这类问题。库的做法是把状态收进一个枚举状态机统一在事件入口处更新绘制时只读状态避免状态和绘制互相污染。第三类是样式与逻辑耦合。很多项目里控件的颜色是硬编码在 paintEvent 里的想做暗色主题就得改代码。库把颜色、尺寸、圆角、动画时长都抽成属性用 Q_PROPERTY 暴露出去既能从 C 侧设置也能被 Qt Designer 的属性编辑器识别还能挂到统一的主题管理对象上批量刷新。1.3 什么样的项目适合引入不是所有项目都值得上这套东西。如果你的界面就那么十几个标准控件QSS 调一调能交差直接上库反而是过度设计。真正适合的场景有这么几个UI 规范要求严格、控件外观有明确设计系统项目周期长、要支持多套主题甚至运行时换肤控件数量多、重复度高比如一套工业软件的仪表盘、一套音视频工具的传输控制条或者团队多人协作需要一个统一的控件规范来减少沟通成本。反过来如果只是做个内部小工具、界面两周就定稿不再动那老老实实用原生控件最省事。库的价值建立在“复用”和“演进”这两个词上用不上它们的时候引入库就是给自己加负担。2. 整体架构与模块拆分思路2.1 目录结构与构建方式库的目录结构我按“对外接口、内部实现、示例、测试”四块来切这个划分方式在后期维护时省了很多事qxwidgets/ ├── include/qxwidgets/ 对外头文件安装时只装这一层 ├── src/ 实现文件 │ ├── core/ 基类、状态机、主题 │ ├── widgets/ 具体控件 │ └── private/ 内部工具不对外暴露 ├── examples/ 每个控件一个可运行示例 ├── tests/ 单元测试 └── CMakeLists.txt构建用 CMake导出成带命名空间的 target宿主项目通过find_package引入。这里有个细节值得强调对外头文件目录和内部实现目录必须物理隔离。早期我把头文件混在一起结果宿主项目能 include 到私有头某次重构把私有类改名之后下游全炸了。隔离之后私有实现随便改只要对外接口稳定就不会影响使用者。CMake 侧关键的一段是这样add_library(qxwidgets SHARED ${SOURCES}) target_include_directories(qxwidgets PUBLIC $BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include $INSTALL_INTERFACE:include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src ) target_link_libraries(qxwidgets PUBLIC Qt::Widgets Qt::Gui)PUBLIC给 Qt::Widgets 是必须的因为对外头文件里出现了 QWidget 的继承关系宿主项目编译时得能找到 Qt 的头。这里如果写成 PRIVATE宿主项目编译会报找不到 QWidget 定义是个很典型的坑。2.2 基类设计与绘制职责的划分整个库的核心是一个抽象基类我把它叫QxAbstractWidget继承自 QWidget。它的职责就三件事管理状态、管理动画时钟、提供公共绘制工具函数。具体控件继承它重写一个纯虚的绘制函数。状态用一个枚举表示而不是一堆布尔量enum class QxState { Normal, Hovered, Pressed, Checked, Disabled };有人会问Checked 和 Hovered 是可以并存的用单枚举会不会丢信息。我的处理是把“选中”当成独立于交互状态的一个属性状态机只描述瞬时交互态选中态用单独的 bool 存。这样hovered checked能正确表达绘制时两个维度分别判断。早期我试过用位标志组合状态写起来很灵活但调试时经常搞不清某个组合该画成什么样最后退回“瞬时状态单枚举 持久属性单独存”的方案逻辑清晰得多。公共绘制工具函数包括按当前边距计算内容区矩形、画带圆角带描边的背景、画焦点虚线框、按状态取颜色。子类的 paintEvent 基本长这样void paintEvent(QPaintEvent*) override { QPainter p(this); p.setRenderHint(QPainter::Antialiasing); drawBackground(p); // 基类提供 drawContent(p); // 子类实现 drawFocusRing(p); // 基类提供 }这个拆分的好处是所有控件的外框绘制保持一致视觉上天然统一子类只要关心自己那部分内容代码量降下来一大截。2.3 为什么选 QWidget 而不是 QGraphicsItem选型的时候考虑过两条路一条是继承 QWidget 自绘另一条是走 QGraphicsView 框架用 QGraphicsItem。后者在大量图元、缩放、图元级动画上有天然优势但要接入现有的 QWidget 布局体系就得套一层 QGraphicsView焦点处理、Tab 顺序、无障碍支持都得重新适配改造成本高。库的定位是“能直接替换现有界面里的原生控件”所以 QWidget 路线更合适扔进任何 QLayout 就能用事件模型和 Qt 原生一致。如果哪天要做的是画布类应用图元成百上千还要拖拽缩放那确实该选 QGraphicsView这是两个不同的使用场景别混为一谈。2.4 属性系统与主题接入对外接口上凡是外观相关的量都做成 Q_PROPERTY比如圆角半径、边框宽度、主色、文字色、动画时长。这么做有三个好处Qt Designer 里能直接改省得写代码试可以用 QSS 的qproperty-xxx语法从样式表覆盖方便做主题可以挂 QPropertyAnimation 做属性动画。主题这块我做了个轻量的QxTheme单例内部维护一张“角色名到颜色”的表控件订阅主题变更信号后统一刷新。颜色不直接用 QColor 硬写而是用语义角色比如accent、surface、onSurface、danger。换主题的时候只改角色表控件不用动。这套命名参考了主流设计系统的思路好处是新增控件时不用再纠结该用哪个色号。3. 核心绘制技术细节拆解3.1 paintEvent 里那些容易写错的地方paintEvent 看着简单实际是性能问题的高发区。第一条经验不要在 paintEvent 里做任何分配型操作。新建 QFont、构造 QPainterPath、加载图片、查表算颜色这些都应该在构造函数或属性变更时算好缓存起来。我见过一个控件在 paintEvent 里每次QFontMetrics(font()).horizontalAdvance(text)文字长的时候一次绘制要算上百次滚动列表时直接卡成幻灯片。第二条分清 update() 和 repaint()。update() 是投递一个重绘请求Qt 会合并多次请求等事件循环回来统一画一次repaint() 是立刻同步重绘会打断当前的绘制流程。动画驱动重绘时一律用 update()除非你在写一个必须实时反馈的拖动预览才考虑 repaint()。第三条只重绘脏区。控件状态变化时如果只有某个小区域变了用update(rect)指定矩形别整个 update()。对于尺寸大的控件这一条能省掉大量无谓的绘制。第四条关于双缓冲Qt 在 QWidget 上本身就有 backing store正常情况下不会闪烁不需要自己搞 QPixmap 双缓冲。只有在做复杂合成、需要保留上一帧内容的场景才考虑自己缓存。盲目加一层缓冲反而增加内存和拷贝开销。3.2 圆角、描边、阴影的实现套路圆角背景最稳的写法是用 QPainterPath 构造圆角矩形然后 fillPath 填充、strokePath 描边。为什么不用 drawRoundedRect 直接画因为当你要在同一个路径上既填充又描边还要保证描边压在填充边缘内侧时路径操作更可控。描边有个经典问题画笔宽度为 2 时线会以路径为中心向内外各扩 1 像素如果路径贴着控件边界外侧那 1 像素就被裁掉了。解决办法是把绘制矩形往里缩画笔宽度的一半const qreal penW borderWidth; QRectF r rect().adjusted(penW / 2, penW / 2, -penW / 2, -penW / 2); QPainterPath path; path.addRoundedRect(r, radius, radius); p.fillPath(path, fillColor); p.setPen(QPen(borderColor, penW)); p.drawPath(path);阴影是另一个大坑。QGraphicsDropShadowEffect 用起来最省事但它是作用在整个 widget 上的图形效果会触发离屏渲染控件一多性能就崩而且在半透明背景上表现不稳定。库里的做法是自己画用多层逐渐透明的圆角矩形模拟层数控制在 6 到 8 层模糊半径小的时候够用性能也好。真要高质量模糊就用预渲染的思路——把阴影一次性画到 QPixmap 上缓存尺寸或颜色变了才重画。注意阴影绘制一定要给控件留出外边距否则阴影会被控件矩形裁掉。基类里专门有个 shadowMargin 属性layout 计算尺寸时会把它算进去。3.3 动画用 QVariantAnimation 而不是 QTimer自绘控件的动效如果自己用 QTimer 每 16ms 改一个浮点值再 update()能跑但会带来几个问题定时器精度受事件循环影响动画时长不准手动插值要自己写缓动函数多个属性同时动的时候同步困难。用 QVariantAnimation 或者 QPropertyAnimation 就舒服很多。库里的做法是给每个需要动画的视觉参数建一个 QVariantAnimationstartValue 和 endValue 设好easing curve 选QEasingCurve::OutCubicvalueChanged 里更新成员变量并 update()。好处是缓动曲线现成、时长精确、还能暂停恢复。一个实际经验动画对象要作为成员长期持有别在事件里临时 new 一个。临时对象在事件频繁触发时会造成大量创建销毁而且上一个动画还没跑完下一个就来了容易出现动画叠加导致数值跳变。正确做法是每次触发前先stop()再重设 startValue/endValue 再start()。3.4 高 DPI 下的绘制适配现在高分屏是常态绘制代码必须考虑 devicePixelRatio。好消息是 Qt 的 QPainter 在逻辑坐标系下工作大部分情况下不用你手动乘缩放系数。但有两类东西要注意一是位图资源用 QPixmap 时要设 devicePixelRatio否则会糊二是硬编码的像素尺寸比如 1px 的分隔线在 2 倍屏上画出来是 0.5 逻辑像素会发虚。库里的处理方式是把线宽统一走一个hairlineWidth()函数内部按当前屏幕的 DPR 返回合适值保证视觉上是清晰的一条线而不是模糊的半透明带。另外尺寸相关的常量尽量用相对值或主题变量少写魔法数字。4. 交互、事件与布局协作4.1 鼠标键盘触摸三套事件的统一自绘控件的事件处理比原生控件麻烦因为原生控件的行为是 Qt 内部实现的你只管连信号。自绘的话按下、松开、移入、移出、双击都得自己接。鼠标这块enterEvent和leaveEvent负责 hover 态mousePressEvent和mouseReleaseEvent负责 pressed 态。这里有个细节按下之后把鼠标拖出控件再松开按下的视觉状态应该被清除否则按钮会一直卡在按下态。处理方式是在 mouseReleaseEvent 里判断rect().contains(event-pos())在控件外就当作取消。键盘这块setFocusPolicy(Qt::StrongFocus)让控件能通过 Tab 获得焦点keyPressEvent里处理空格和回车触发focusInEvent/focusOutEvent控制焦点框的显示。很多自绘控件漏掉了键盘支持导致纯键盘用户根本没法操作这在做正式产品时是硬伤。触摸这块Qt 会把触摸事件合成鼠标事件一般情况不用单独处理。但如果要做长按、滑动这类手势就得开setAttribute(Qt::WA_AcceptTouchEvents)自己处理 QTouchEvent。库里对这块的策略是基础交互走鼠标事件需要手势的控件单独开触摸支持避免所有控件都开导致事件冲突。4.2 sizeHint 与 sizePolicy 的正确姿势自绘控件最容易出的布局问题是尺寸算不对。原生控件有内置的 sizeHint自绘的如果你不重写QWidget 默认返回一个无效尺寸放进布局里就表现得很怪要么被压成一条要么把别的控件挤走。正确做法是重写sizeHint()和minimumSizeHint()返回根据当前字号、内边距、内容算出来的尺寸QSize sizeHint() const override { QFontMetrics fm(font()); int w fm.horizontalAdvance(text()) padding.left() padding.right(); int h qMax(fm.height(), iconSize) padding.top() padding.bottom(); return QSize(w, h).grownBy(QMargins(shadowMargin, shadowMargin, shadowMargin, shadowMargin)); }配合setSizePolicy横向一般设 Preferred 或者 Fixed纵向通常 Fixed这样放在 QVBoxLayout 里不会被拉伸变形。如果控件内容会变比如文字长度变化记得在内容变化后调updateGeometry()否则布局不会重新计算。4.3 无障碍与可访问性这块经常被忽略但真要发布产品屏幕阅读器读不出你的自绘控件是会被提缺陷的。最低限度的做法调用setAccessibleName()和setAccessibleDescription()让辅助技术能读到控件语义对于开关类控件重写accessibleRole()返回合适的角色比如 QAccessible::CheckBox状态变化时发QAccessibleValueChangeEvent。这部分代码量不大但收益明显而且做在基类里一次搞定子类不用管。我建议无论项目大小都加上成本极低。5. 实操从零手写一个开关控件5.1 类声明与属性暴露选开关控件做示例是因为它麻雀虽小五脏俱全有状态、有动画、有自定义绘制、有键盘交互。头文件大概这样class QxSwitch : public QxAbstractWidget { Q_OBJECT Q_PROPERTY(bool checked READ isChecked WRITE setChecked NOTIFY toggled) Q_PROPERTY(qreal knobPos READ knobPos WRITE setKnobPos) Q_PROPERTY(QColor onColor READ onColor WRITE setOnColor) public: explicit QxSwitch(QWidget* parent nullptr); bool isChecked() const { return m_checked; } void setChecked(bool on); qreal knobPos() const { return m_knobPos; } void setKnobPos(qreal v); QSize sizeHint() const override; signals: void toggled(bool checked); protected: void drawContent(QPainter p) override; void mouseReleaseEvent(QMouseEvent* e) override; void keyPressEvent(QKeyEvent* e) override; private: bool m_checked false; qreal m_knobPos 0.0; // 0 表示左端1 表示右端 QVariantAnimation* m_anim nullptr; QColor m_onColor{0x2F, 0x6B, 0xED}; };knobPos单独做成属性而不是直接改 checked 时瞬间切换是为了让动画驱动它。动画只负责改这个 0 到 1 的数值绘制逻辑完全不关心是在动画还是静止。5.2 绘制实现与关键计算绘制分两层轨道和滑块。轨道是圆角矩形滑块是圆位置由 knobPos 插值决定。这里有个容易算错的地方——滑块的圆心位置。void QxSwitch::drawContent(QPainter p) { const QRectF r contentRect(); const qreal h r.height(); const qreal trackRadius h / 2; const qreal knobR trackRadius - 2; // 轨道颜色按是否开启做插值动画中间态也是渐变 QColor track m_checked ? m_onColor : QColor(0xC8, 0xCC, 0xD4); QPainterPath trackPath; trackPath.addRoundedRect(r, trackRadius, trackRadius); p.fillPath(trackPath, track); // 圆心在轨道内左右各留一个半径的行程 const qreal left r.left() knobR 2; const qreal right r.right() - knobR - 2; const qreal cx left (right - left) * m_knobPos; const qreal cy r.center().y(); p.setPen(Qt::NoPen); p.setBrush(Qt::white); p.drawEllipse(QPointF(cx, cy), knobR, knobR); }left和right的计算是重点加了 2 像素的间隙让滑块和轨道边缘不贴死视觉上更舒服。这种“留一点呼吸感”的细节设计稿上不会标但做出来和做不出来差别很明显。5.3 动画驱动与状态切换setChecked 里不直接改视觉只启动动画void QxSwitch::setChecked(bool on) { if (m_checked on) return; m_checked on; m_anim-stop(); m_anim-setStartValue(m_knobPos); m_anim-setEndValue(on ? 1.0 : 0.0); m_anim-start(); emit toggled(on); } // 构造函数里 m_anim new QVariantAnimation(this); m_anim-setDuration(160); m_anim-setEasingCurve(QEasingCurve::OutCubic); connect(m_anim, QVariantAnimation::valueChanged, this, [this](const QVariant v){ setKnobPos(v.toReal()); });160ms 这个时长不是随手写的。我试过 80ms 太快感觉像瞬移300ms 又太肉快速连点时有滞后感。150 到 200 之间是比较舒服的区间配合 OutCubic 缓动起步快收尾缓。当然这跟控件尺寸也有关尺寸大的控件可以适当拉长。setKnobPos里只做三件事存值、update()、如果颜色也需要跟着变就一起重算。不要在里面触发信号避免动画每帧都发通知。5.4 接入项目与样式覆盖编译进项目之后使用就是一行auto* sw new QxSwitch; sw-setChecked(true); layout-addWidget(sw); connect(sw, QxSwitch::toggled, this, Handler::onEnabledChanged);想改颜色可以走属性也可以走 QSSQxSwitch { qproperty-onColor: #16a34a; }这种写法要求属性是 Q_PROPERTY 且类型是 QColorQt 的样式表引擎会自动做类型转换。这个能力在做多主题时特别方便颜色全写在 qss 文件里代码不用重新编译。6. 常见问题与排查实录6.1 高频问题速查表现象大概率原因处理方式控件边缘有 1 像素残缺描边画笔超出控件矩形被裁绘制矩形内缩画笔宽度的一半阴影被切掉一半控件矩形没有给阴影留边距设置 shadowMargin 并在 sizeHint 里计入界面滚动时卡顿paintEvent 里有字体测量或路径构造移出 paintEvent改在属性变更时缓存悬停态移出后不消失箭头按钮或子控件没转发 enter/leave重写leaveEvent或给子控件装事件过滤器放进布局被压扁没重写 sizeHint 和 minimumSizeHint补上两个函数并设合适的 sizePolicy动画中途点击状态错乱动画对象被反复创建成员持有动画触发前先 stop高分屏线条发虚硬编码 1px 线宽用按 DPR 计算的 hairline 宽度禁用状态仍响应点击事件里没判 isEnabled入口统一判断或在基类拦截事件6.2 几个踩过的坑和排查思路第一个坑跟绘制时机有关。有次发现控件在窗口第一次显示时颜色不对之后才正常。查了很久才明白构造时用的是还没经过样式表解析的默认调色板窗口显示后调色板才更新。解决办法是重写changeEvent监听QEvent::PaletteChange和QEvent::StyleChange在这两个事件里刷新缓存的颜色。这个问题在支持系统深浅色切换的平台上特别容易触发。第二个坑是子控件遮挡。有些复合控件里面放了多个子控件鼠标在子控件上时父控件的 enterEvent 收不到导致整块区域的 hover 态不完整。处理方式有两种给子控件装事件过滤器统一转发或者干脆把子控件的setAttribute(Qt::WA_TransparentForMouseEvents, true)让鼠标事件穿透到父控件。选哪种取决于子控件本身要不要响应鼠标。第三个坑是动画和用户操作打架。快速连点开关时动画还在跑用户又点了如果直接改 checked 而不重置动画的起点视觉上会跳一下。正确做法是每次触发先m_anim-stop()把当前动画值作为新的起点。这个细节不处理快速操作时观感很差但慢慢点又看不出来很容易漏测。第四个坑跟布局刷新有关。控件文字动态变化之后尺寸算出来是对的但界面没变。原因是内容变化后没调updateGeometry()布局管理器不知道要重算。补上这一行就好但第一次遇到时会以为是 sizeHint 写错了白折腾半天。经验调试自绘控件有个笨但好用的办法——在 paintEvent 里临时把 contentRect 用红框描出来一眼就能看出是矩形算错了还是绘制溢出了。问题定位完记得删掉别留在提交里。还有一个心得值得单独说库里的每个控件都配一个独立的示例程序不依赖业务工程能单独跑起来看效果。这个投入早期看着多余后期收益巨大。改基类绘制逻辑的时候跑一遍示例就能确认有没有回归新同事接手时看示例比读代码快得多。示例程序同时也充当了控件的使用文档比写一堆 Markdown 说明管用。