简介:面向Qt开发者的自定义控件示例工程,以仪表盘、电池电量、LCD数字、进度条、指示灯等实用控件为案例,展示通过继承QWidget、重写paintEvent绘图、处理鼠标事件、定义信号与槽来实现个性化界面组件的方法。资源共32个文件,以14个cpp源文件与13个头文件为主体,涵盖各类控件的实现逻辑,同时包含pro工程配置、ui界面文件及图标资源,压缩包仅62KB,结构清晰、便于快速导入Qt Creator阅读和运行。已有754人学习下载。阅读这些源码可直观理解QPainter绘制、事件系统、Q_PROPERTY属性声明、QSS样式支持等关键知识点,适合想提升界面定制能力、准备在项目中嵌入自定义控件的初中级Qt开发者参考借鉴。
1. qt 自定义控件不是画个圆就算完:它到底解决什么问题
如果你是做界面的,大概率会遇到这样的需求:产品想要圆环形进度指示器、带状态色块的实时数据灯,或可拖拽调整数值的标尺。标准控件栏里 QProgressBar 是长条矩形,QDial 是旋钮,没有一个能直接满足,只能走 qt 自定义控件这条路。简单说,就是在 QWidget 上接管绘制、输入事件和属性动画,把空白区变成有业务意义的可复用组件。真正难的其实不在画图,而在让它在布局、样式表、设计器插件和高 DPI 下都稳定。我按实际封装环形进度控件的路径拆解:先写最小可用的 paintEvent,再加交互和动画,进 Designer,最后把高频踩坑全挑出来。读完你可以照着落地,也能量出值不值得投入。
2. 从零画一个控件:paintEvent、sizeHint 与事件到底谁先执行
2.1 自定义控件三条路线:继承、组合还是从 QFrame 起步
动手前先决定路线,因为方案直接决定后续维护成本。最常见的做法是继承 QWidget 并重写 paintEvent,适合仪表盘、信号灯这类外观完全由自己决定的控件。组合路线是拿若干现成 QLabel、QLayout 拼接,适合卡片这类以排版为主的组合体,缺点是不适合需要统一自绘的形态,因为每个子控件的背景、边框要分别设置。还有一条路线是把 QFrame 当基类,借助它已有的边框和背景逻辑,但它自身对 QSS 的处理和 QWidget 不太一致,边框何时归 style 画、何时归 paintEvent 画,很容易引起困惑。我一般在需要 frameShape 时才选 QFrame,否则一律直接继承 QWidget,这样可以完全掌控绘制流程,也便于把绘制代码集中在同一处审查。
选环形进度作为示例,是因为它的绘制覆盖了坐标系换算、画笔、文本、事件命中这些自定义控件的通用问题,而且效果一眼能看明白。大家也不需要把这个控件名记死,后面讲的每一段都可以迁移到开关、滑块、仪表盘。下面从类定义开始,对照解释每个方法。
2.2 最小实现:RingProgress 头文件与 paintEvent 代码
头文件只需要三块:Q_OBJECT 宏、进度属性、两个重写方法。具体看:
// ring_progress.h #ifndef RING_PROGRESS_H #define RING_PROGRESS_H #include <QWidget> class RingProgress : public QWidget { Q_OBJECT Q_PROPERTY(int progress READ progress WRITE setProgress NOTIFY progressChanged) public: explicit RingProgress(QWidget *parent = nullptr); int progress() const { return m_progress; } void setProgress(int value); QSize sizeHint() const override; QSize minimumSizeHint() const override; protected: void paintEvent(QPaintEvent *event) override; signals: void progressChanged(int value); private: int m_progress = 0; }; #endif // RING_PROGRESS_HQ_OBJECT 负责生成信号槽元对象代码,Q_PROPERTY 是让 QPropertyAnimation、QML 和 Designer 属性表能识别 progress 这一项的关键。READ 是读取当前值,WRITE 是外部改数值的唯一入口,NOTIFY 是变化通知信号。三个都不能省,尤其 NOTIFY,少了它,界面上其他依赖这个属性的组件就不会知道进度变了。
实现文件里,构造、刷新、绘制三件事分开看:
// ring_progress.cpp #include "ring_progress.h" #include <QPainter> #include <QStyleOption> RingProgress::RingProgress(QWidget *parent) : QWidget(parent) { setAttribute(Qt::WA_StyledBackground, true); // 让 QSS 能作用于背景 setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); } void RingProgress::setProgress(int value) { value = qBound(0, value, 100); // 夹紧到合法范围,防止负值或超过 100 if (m_progress == value) return; m_progress = value; emit progressChanged(value); update(); // 唯一主动触发重绘的地方 } void RingProgress::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 先让 QStyle 画标准背景,QSS 的 background-color 才生效 QStyleOption opt; opt.initFrom(this); style()->drawPrimitive(QStyle::PE_Widget, &opt, &painter, this); int margin = 4; QRectF drawArea = QRectF(rect()).adjusted(margin, margin, -margin, -margin); qreal side = qMin(drawArea.width(), drawArea.height()); QRectF square(0, 0, side, side); square.moveCenter(drawArea.center()); qreal penWidth = side * 0.1; QPen bgPen(palette().color(QPalette::Mid), penWidth); painter.setPen(bgPen); painter.drawEllipse(square); int progress = qBound(0, m_progress, 100); qreal spanAngle = -progress * 3.6; // 百分比转角度,负号表示逆时针 QPen fgPen(palette().color(QPalette::Highlight), penWidth, Qt::SolidLine, Qt::RoundCap); painter.setPen(fgPen); painter.drawArc(square, 90 * 16, spanAngle * 16); painter.setPen(palette().color(QPalette::Text)); QFont valueFont = this->font(); valueFont.setPointSize(qMax(8, int(side / 5))); painter.setFont(valueFont); painter.drawText(square, Qt::AlignCenter, QStringLiteral("%1%").arg(progress)); } QSize RingProgress::sizeHint() const { return QSize(120, 120); } QSize RingProgress::minimumSizeHint() const { return QSize(40, 40); }paintEvent 里最关键的是角度换算。QPainter 的角度单位是 1/16 度,0 度位置在时钟 3 点钟方向,进度环通常要起点在 12 点方向,因此起点是 90 * 16;spanAngle 用负数表示逆时针绕回 12 点。progress 从 0 到 100,对应 0 到 360 度,所以每 1% 是 3.6 度,代码里直接乘 3.6 再乘 16。qBound 的重复出现不是多余:外部通过拖拽或动画传进来的值可能越界,绘制前再夹一次可防止画到环外。penWidth 取 side * 0.1,让圆环粗细随控件大小等比变化,而不会在小窗口里占用过多空间;字体大小按 side/5 缩放,保证中心文本比例协调。如果希望圆环更粗,可以把系数从 0.1 改到 0.15,但注意当 penWidth 超过 side 的约三分之一时,圆弧内径会小到看不清。
sizeHint 和 minimumSizeHint 要一起重写。sizeHint 返回理想尺寸,minimumSizeHint 返回可接受的最小尺寸。布局系统会优先参考 sizeHint,在空间紧张时压缩到 minimumSizeHint。如果只设 sizeHint 不设 minimumSizeHint,默认的最小尺寸可能为 0,控件被压缩成一条线。建议始终从 palette 取颜色而不是写死 RGB,这样控件能自动跟随系统主题和 QSS 的颜色设置。
2.3 sizePolicy 与刷新区域:不要只靠 double buffer 硬抗
很多人在 layout 里看到控件被压扁,第一反应是给控件设 minimumSize 或 fix 死大小。这能应急但不可维护,正确入口是 sizePolicy。构造函数里写的 QSizePolicy::Minimum 表示 sizeHint 是下限,空间富余时不会主动抢占;如果希望控件随容器均匀拉伸,改成 QSizePolicy::Expanding;如果希望严格保持 120x120 不缩放,则需要把水平和垂直策略都设为 Fixed,并且不设置 stretch factor。常见做法是让外部容器通过布局拉伸因子决定比例,而控件本身只保证最小尺寸合理。
另一个容易被忽略的是 paintEvent 的刷新区域。setProgress 里的 update() 不带参数,意味着整个矩形区重绘。对单个自绘控件,这通常足够;但如果你打算把多个控件放进一个可滚动容器,整块重绘在滚动时会明显吃 CPU。更精细的做法是 update(QRect) 只刷新进度环和中心文本,比如调用 update(ringRect) 代替无参 update。不过这个优化要在确定性能确实受影响后再做,否则过早优化会让代码更复杂。还有一个常见习惯是不要每次拖拽都调用 repaint(),repaint 是同步调用,会阻断事件循环;update 是异步合并,同一帧内多次调用只触发一次 paintEvent,性能好很多。
3. 让控件有交互:Q_PROPERTY、鼠标拖拽与属性动画
3.1 用 Q_PROPERTY 把数值暴露给动画和 QML
Q_PROPERTY 本质上是一个元对象声明,它本身不产生任何逻辑代码,但所有外部工具都会通过它查找属性的 READ、WRITE、NOTIFY。我们头文件里写的:
Q_PROPERTY(int progress READ progress WRITE setProgress NOTIFY progressChanged)意思是:读取进度用 progress(),写入进度用 setProgress(),变化时发 progressChanged 信号。这个声明可以用在三个地方:QPropertyAnimation 按属性名字驱动数值;QML 里可以直接绑定自定义 QWidget 控件的属性;Qt Designer 属性编辑器也能显示一个 Int 类型的 progress。如果将来想把进度范围从 0-100 改成 0-1,只要改 Q_PROPERTY 的 type 和 setter 内部换算,外部接口保持不变,动画和 QML 的绑定代码不需要动。
setter 的正确写法值得再强调:
void RingProgress::setProgress(int value) { int clamped = qBound(0, value, 100); if (m_progress != clamped) { m_progress = clamped; emit progressChanged(clamped); update(); } }这版 setter 比 2.2 里多了 clamp 和判断的逻辑。qBound 是模板,第二个参数是要约束的值,第三个是上限,所以 qBound(0, value, 100) 会把 value 限制在 [0, 100],小于 0 取 0,大于 100 取 100。为什么先判断再赋值?因为 QPropertyAnimation 每帧都会调用 setter,如果动画中途目标值没变甚至停在同一个值,每次调用都 emit + update 会让信号风暴和重绘叠加。用 if (m_progress != clamped) 拦住重复值,是零成本去噪。
3.2 鼠标拖拽改进度:垂直位移比角度计算更好维护
交互设计上,环形控件有两种常见改值手势。一种是按住后绕圆心转,适合旋钮类,但需要做角度归一化和象限判断,代码里充满 atan2 和 pi 的换算,边界情况多。另一种是按住后垂直拖拽,上移增大、下移减小,对仪表盘类控件更直觉,也更容易被非专业用户掌握。我这里选第二种,因为它能把复杂度集中到事件坐标差值,而不是角度数学。
// 需要先在头文件 private 区加一个成员:int m_lastDragY = 0; void RingProgress::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { m_lastDragY = event->pos().y(); setFocus(); event->accept(); } } void RingProgress::mouseMoveEvent(QMouseEvent *event) { if (event->buttons() & Qt::LeftButton) { int deltaY = m_lastDragY - event->pos().y(); // 上移为正,下移为负 setProgress(progress() + deltaY / 2); // 灵敏度:2 像素换 1% m_lastDragY = event->pos().y(); // 基准点跟随,避免累计误差 } } void RingProgress::mouseReleaseEvent(QMouseEvent *event) { Q_UNUSED(event); update(); }mousePressEvent 中如果不 accept,后续 move 事件未必送到这个控件;setFocus 是为了让控件能响应键盘导航,比如按上下键也应该能调进度。mouseMoveEvent 里用 event->buttons() & Qt::LeftButton 判断是否按住左键,而不是依赖进入事件时有没有按下,这点很关键,否则鼠标移入控件时即使没按也会触发。灵敏度通过 divisor 控制,deltaY / 2 表示每移动 2 像素改变 1,数字越大越钝。如果产品要求更精确,可以把 divisor 改成 1,但会让拖拽显得非常敏感,手指一抖就跳好几个点。每次 move 后更新 m_lastDragY,是为了让增量相对上一次位置而不是按下位置计算,否则累计位移会越来越大。
垂直拖拽之外,还可以通过方向键微调。重写 keyPressEvent,判断 Qt::Key_Up / Key_Right 就 setProgress(progress() + 1),Key_Down / Key_Left 就减 1。这 5 行代码能让控件在触屏键盘和无鼠标环境下也可用,是自定义控件完整性的重要一环。
3.3 QPropertyAnimation 让进度平滑过渡
有些场景下用户不关心拖拽,而是希望进度从 0 平滑过渡到目标值。QPropertyAnimation 是最省事的方案:
void animateProgress(RingProgress *ring) { auto *anim = new QPropertyAnimation(ring, "progress", ring->parent()); anim->setDuration(800); anim->setStartValue(ring->progress()); anim->setEndValue(85); anim->setEasingCurve(QEasingCurve::OutCubic); anim->start(QAbstractAnimation::DeleteWhenStopped); }new 出来的动画以 ring->parent() 为父对象,配合 DeleteWhenStopped,动画结束自动 delete,不会泄漏。setDuration 的单位是毫秒,800 毫秒适合仪表盘读数切换;如果做通知提示的进度环,缩短到 400 毫秒紧迫感更强,但低于 200 毫秒就没有平滑感觉了。QPropertyAnimation 的原理是每帧修改 Q_PROPERTY 声明的值,setter 会被反复调用,因此 setter 里必须有 update(),否则界面会一动不动——很多人在这一步翻车,以为动画没生效,其实动画在跑,只是无人通知重绘。Easing 采用 OutCubic 是先快后慢,符合大多数进度环的观感;如果选 OutBounce,进度到 85 会弹跳越过目标再回弹,配合 qBound 夹紧后视觉效果会变成重新落后,所以非必要不要用带回弹的曲线。
属性变化后如果想同步到其他界面,连接 NOTIFY 信号即可:
connect(ring, &RingProgress::progressChanged, this, [](int value) { statusLabel->setText(QStringLiteral("当前进度:%1%").arg(value)); });注意这里没有使用 QSignalBlocker,因为 setter 的 if 判重已经过滤了大部分重复发射。如果你后续要手动把一个进度值一次性写入,又不想触发槽函数,可以在外面用 QSignalBlocker blocker(ring) 包住 setProgress 调用。
4. 让设计师也能拖你的控件:提升法与插件集成
4.1 提升法:不改一行插件代码塞进 Qt Designer
如果你只是一个人做界面,或者项目里只有一个 .ui 文件用到这个控件,提升法是最快路径。打开 Qt Designer,在左侧组件栏拖一个普通的 QWidget 到画布上,保持它被选中,然后右键菜单选 "Promote to..."。弹出对话框中,Promoted class name 填 RingProgress,Header file 填 ring_progress.h,点击 Promote 后设计器里的组件就会显示成 RingProgress。这个操作不编译任何代码,只是在 .ui 文件里记录 class 名字和你指定的头文件。真正编译时,uic 生成的 ui_xxx.h 会执行 #include "ring_progress.h",因此你的头文件必须位于编译器 include 路径中,这就是为什么建议使用相对路径而不是绝对路径。
操作里面有两个容易踩的细节。第一,Base class 必须选准,如果从 QFrame 提升,实际运行时构造的是 QFrame 子类对象,虽然接口上兼容,但构造逻辑可能不同;第二,Header file 如果带子目录,要写相对路径比如 widgets/ring_progress.h,并确保项目 .pro 或 CMake 中把父目录加进 INCLUDEPATH。否则换一台电脑编译会报找不到头文件。提升的好处是零插件依赖,坏处是团队里每个 .ui 文件都要重复做一步提升动作,设计器不会自动出现在组件箱里。
提升法有一个天然限制:在设计器画布上看到的仍然是普通 QWidget,因为 Designer 不会编译你的 C++ 代码,它只知道类名和头文件。如果你需要设计器里直接看到真实圆环效果,就必须走插件方式。所以当你要把控件分享给团队里的 UI 设计者时,下面这条路线才是主选。
4.2 写一个 Designer 插件:让控件出现在组件箱
插件方式的核心是实现 QDesignerCustomWidgetInterface 接口:
// ringprogress_plugin.h #include <QDesignerCustomWidgetInterface> #include <QObject> class RingProgressPlugin : public QObject, public QDesignerCustomWidgetInterface { Q_OBJECT Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QDesignerCustomWidgetInterface") Q_INTERFACES(QDesignerCustomWidgetInterface) public: QString name() const override { return QStringLiteral("RingProgress"); } QString includeFile() const override { return QStringLiteral("ring_progress.h"); } QString toolTip() const override { return QStringLiteral("环形进度控件"); } QString group() const override { return QStringLiteral("Custom Widgets"); } QWidget *createWidget(QWidget *parent) override { return new RingProgress(parent); } QString domXml() const override { return QStringLiteral( "<widget class=\"RingProgress\" name=\"ringProgress\">" "<property name=\"geometry\">" "<rect><x>0</x><y>0</y>" "<width>120</width><height>120</height></rect>" "</property></widget>"); } };name() 返回值就是组件箱里的显示名;includeFile() 返回编译器需要包含的头文件路径;group() 返回组件箱里的分组名。domXml() 返回一段 UI 描述,Designer 在拖入新控件时根据它生成初始几何尺寸,不要在 createWidget 里通过 resize 强行设置,因为 Designer 之后会用 domXml 覆盖。Q_PLUGIN_METADATA 里的 IID 是 Qt 约定的字符串,不能随便改;Q_INTERFACES 告诉 moc 生成接口插件的元数据。
插件编译成动态库后,要放到 Qt 自带插件目录的 designer 子目录里。常见做法是在 .pro 里写:
# ringplugin.pro CONFIG += plugin designer TARGET = ringprogressplugin SOURCES += ring_progress.cpp ringprogress_plugin.cpp HEADERS += ring_progress.h ringprogress_plugin.h编译后把生成的 .so 或 .dll 复制到 Qt 安装目录下的 plugins/designer。重启设计器,如果组件箱没出现,先确认插件用了和 Designer 相同版本的 Qt、相同编译器和相同构建位。一个动态库链接到另一种 Qt 版本时,Designer 通常会直接拒绝加载,日志里不一定打印原因,这也是插件方式比提升法难排查的主要来源。
4.3 与 QSS 协同:让自绘控件吃上样式表
自绘控件一旦不调用 QStyle 绘制背景,样式表就基本报废。这一点在 2.2 的实现里提前做了:paintEvent 开头把 QStyleOption 初始化后交给 style()->drawPrimitive(QStyle::PE_Widget)。有了这一步,QSS 可以直接作用于 RingProgress:
ring->setStyleSheet(QStringLiteral( "RingProgress {" " background-color: #fafafa;" " border: 1px solid #cccccc;" " border-radius: 8px;" "}" "RingProgress[progress=\"100\"] {" " border-color: #2ecc71;" "}" ));QSS 的选择器同样支持属性选择,RingProgress[progress="100"] 会匹配 progress 属性等于 100 的实例。这里有个隐蔽点:属性值匹配发生在 moc 系统中,而 progress 的 NOTIFY 信号会触发样式重新求值,所以如果你忘记声明 NOTIFY,这个选择器可能无法在运行中动态更新。QSS 的背景和边框由 QStyle 内部绘制,我们自己的圆弧只画在 rect 中央的正方形区域,如果边框宽或圆角半径大,它会盖住圆弧的两端,因此建议把绘制区域的 margin 至少留到笔宽的一半,或者把 penWidth 控制在 side 的 0.08-0.12 之间。若想把圆环颜色也纳入 QSS,可以用 palette 间接实现:给 RingProgress 设置 QSS 里的 color,然后 paintEvent 里读取 palette().color(QPalette::Highlight),这样主题切换时不改 C++ 代码。
5. qt 自定义控件避坑:刷新闪烁、坐标错位、样式失效与布局压缩排查
自定义控件踩坑通常是组合式的,单一表现背后往往有多个诱因。下面五条是我在自绘控件上遇到频率最高的,每条按现象到解决完整写一遍,方便直接对照。
5.1 背景发暗发黑,QSS 背景色完全不生效
现象:把 RingProgress 放在一个浅色面板上,控件自身却像贴了一块深色矩形;用 setStyleSheet 设置 background-color 也没反应。
原因:QWidget 默认不透明,并且 QSS 默认不会作用到 QWidget 子类背景上。我们虽然写了 paintEvent,但没有告诉 QStyle 该按样式表绘制背景,于是系统可能先擦成默认深色,再让我们画圆环。这个背景既不是面板色,也不是 QSS 指定的颜色,视觉上就非常突兀。
解决:构造函数中调用 setAttribute(Qt::WA_StyledBackground, true) 打开样式表背景支持;paintEvent 最前面构造 QStyleOption opt,调用 opt.initFrom(this),再调用 style()->drawPrimitive(QStyle::PE_Widget, &opt, &painter, this)。这样 background、border、border-radius 都会由 QStyle 绘制。如果还需要真正透明,比如悬浮气泡,则再设置 Qt::WA_TranslucentBackground,并注意不要在 paintEvent 里画不透明背景。这里最容易被忽略的是顺序:drawPrimitive 必须在绘制圆弧前调用,否则后来者居上,盖住自绘内容。
5.2 窗口 Resize 时控件闪烁、出现残影
现象:快速拖动窗口边框,RingProgress 区域出现白边闪烁,或者上一次绘制的圆弧残影残留在新位置旁边。
原因:QWidget 默认在重绘前先擦除背景,擦除和绘制不在一帧;我们的 paintEvent 只画中央矩形区域,边缘没有覆盖完整矩形,擦除后的空白会在短时间内暴露出来。另一个元凶是某些代码在 resizeEvent 里主动调用了 update(),导致同一轮事件循环里有多个重绘请求被合并,反而扩大了闪烁窗口。
解决:首先给控件加 setAttribute(Qt::WA_OpaquePaintEvent, true),声明"我的绘制会覆盖整个可见区域,系统不需要先擦背景"。但这不是免死金牌,它要求 paintEvent 把所有 rect() 覆盖到,我们用 style()->drawPrimitive 画满背景,所以满足条件。如果控件本身要透明,就不能用 WA_OpaquePaintEvent,改用 Qt::WA_NoSystemBackground 并自行绘制透明背景。其次,不要在 resizeEvent 里盲目 update,Qt 在尺寸变化后会自动调度重绘,手动 update 只会造成重复绘制。如果你已经加了 WA_OpaquePaintEvent 还闪,多半是绘制函数中有些分支提前 return 了,没有绘制完整区域。
5.3 圆弧端点不对:100% 时缺口偏到奇怪方向
现象:调用 setProgress(100) 后,圆环不是预期的一个整圆,缺口朝下或朝左;启动点游离在 3 点或 6 点方向,调试半天以为是值没传对。
原因:QPainter::drawArc 的角度单位是 1/16 度,且 0 度对应 3 点钟方向。大部分人不习惯这个坐标系,直接传入 span = progress * 3.6,忘了乘 16,于是画出来的跨度只有真实角度的 1/16;同时起点用了 0,导致从 3 点方向开始。这两个问题叠加后表现非常混乱。
解决:起点固定用 90 * 16(12 点方向),跨度用 -progress * 3.6 * 16(负数表示逆时针)。换算逻辑建议写成常量:
const qreal kDegreesPerPercent = 360.0 / 100.0; const int kStartAngle = 90 * 16;然后绘制时写 spanAngle = -progress * kDegreesPerPercent * 16。如果产品要求顺时针,把起点改成 -90 * 16,跨度用正数。验证方法很简单:设 progress 为 25,应该刚好是四分之一环;设 100,应该闭合,闭合点正好回到 12 点。凡是出现对接不上,先检查乘以 16 这步,再检查正负号和起始角度。
5.4 属性动画跑着但画面不动,或动画掉帧卡顿
现象:QPropertyAnimation 启动后,qDebug 能连续打印出 progress 的新值,但圆环纹丝不动;动画继续跑,界面整体掉帧,尤其是多个控件同时动画时。
原因:前半句是 setProgress 里忘了 update(),数值在变,没人请求重绘;后半句是每次 update() 都导致整个控件重绘,而 paintEvent 里频繁构造 QPen、QFont,并且在动画每一帧重复进行同样的构造和颜色查找。QPropertyAnimation 默认刷新频率接近显示器帧率,叠加后成为性能热点。
解决:setProgress 中只有值真正变化时才 update(),这是第一步。第二步,把 QPen、QFont、QColor 提取为构造函数里预创建好的成员变量,或者至少在 paintEvent 外部缓存,不要在每帧里 new 和 delete。第三步,如果控件放在可滚动容器中,用 update(ringRect) 限定刷新区域,只重绘圆弧和文本所在的中央方形区域。第四步,检查是否死于信号风暴:如果 setProgress 里 emit progressChanged,而槽函数又反过来调用 setProgress,会造成递归;在 setter 开头用 if 判重就是这道闸门。最后,动画结束后 QPropertyAnimation 会继续占用对象内存,用 DeleteWhenStopped 释放。上述顺序执行完,掉帧通常能明显缓解。
5.5 控件在布局里被压缩成一条线
现象:把 RingProgress 放进 QSplitter 或 QGridLayout,初始显示正常,但拖动分割条后控件被压成一条窄条,圆弧完全看不见。
原因:QWidget 的默认 minimumSizeHint 对自绘控件来说是退化的,它不关心你画了什么,只要没有布局内容,就可能返回 0。我们虽然写了 sizeHint,但布局在空间紧张时优先压缩到 minimumSizeHint,而 minimumSizeHint 没有有效内容,于是控件被压垮。
解决:像 2.2 那样同时重写 sizeHint 和 minimumSizeHint,并让 minimumSizeHint 返回一个实际能容纳圆弧的最小尺寸,比如 40x40。另外在构造函数设置 setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum),表示在宽松环境里取 sizeHint,在紧张环境里取 minimumSizeHint。如果控件是要放在可拉伸区域,可以把策略改成 Expanding,但 minimumSizeHint 必须给一个下界,否则布局系统仍会把它压到 0。调试时可以在构造函数里临时打印 sizeHint 和 minimumSizeHint,确认返回的是预期的 QSize,而不是默认的无效尺寸。
6. 进阶:用 QStyle 接棒自绘,并建立自己的验证清单
当控件要跟随系统主题和 QSS 皮肤切换时,在 paintEvent 里写 if(theme == dark) 会让代码变成一根面条。常见做法是把大部分标准绘制交还给 QStyle:背景、边框、文本阴影可以交给 drawPrimitive 的 PE_Widget 或 PE_Panel,只有真正独特的部分才保留自绘。例如我们的 RingProgress,背景和边框已经由 PE_Widget 负责,圆弧和中心文本保留在 paintEvent 里,这就够了。如果再进一步,实现自己的 QProxyStyle 挂到 QApplication 上,可以集中改写 drawPrimitive 和 drawControl,但多数自定义控件没必要走到这一步。我建议把 QStyle 能画的部分都交给它,形态差异大的再自绘,性价比最高。
我给每个新控件准备的验证清单,可以直接抄进自己的工作文档:
| 测试项 | 操作 | 期望结果 |
|---|---|---|
| 初始渲染 | 在 Designer 或运行窗口中直接显示 | 无锯齿,背景与容器协调 |
| QSS 覆盖 | 设置含 background、border 的样式表 | 背景和圆角生效,圆弧不被覆盖 |
| Resize 闪烁 | 快速拖动窗口边框 | 无残影、无白边闪烁 |
| 属性动画 | 从 0 动画到 100 | 平滑递增,无跳变 |
| 鼠标拖拽 | 按住左键上下拖 | 数值随方向增减,停止拖拽无抖动 |
| DPI 切换 | 在系统显示设置中调整缩放比例 | 线条清晰,文本不模糊 |
每次交付一个自定义控件前,我都会先跑一遍这个清单。不是因为它有多复杂,而是我在真实项目中不止一次因为漏掉 Resize 闪烁,导致一起改界面的同事以为控件坏了。自绘控件返工成本不算高,但一旦埋进复杂表单,排查时要遍历布局、样式表、事件、高 DPI,相当浪费时间。把这几条坑提前写进清单,比 debug 半小时划算。最后留一个小习惯:让 update() 只出现在 setter 中,绘制代码不依赖外部全局状态,每个自定义控件都做到这两点,qt 自定义控件就不是玄学,而是你能稳定复用的能力。希望帮到你。
本文还有配套的精品资源,点击获取