简介:Qt开发者在编写桌面或嵌入式图形界面时,经常需要进行数据库查询、文件读写等长耗时操作;一旦界面长时间无响应,用户容易误以为程序卡死,让用户体验大打折扣,因此旋转等待动画成为Qt开发中常见的体验优化组件。资源包针对这一场景提供了一套可独立参考的Qt实现,整包共14个文件,包含8个GIF旋转动画素材、3个头文件、2个C++源文件和1个UI界面文件,整体仅206KB,体量小巧、结构清晰,核心代码与视觉素材互相配合,省去从零编写的功夫。GIF素材可适配不同窗口背景,头文件与源文件演示了旋转菊花控件的启动、停止及进度更新逻辑,UI文件则展示了控件在界面中的布置方式,方便快速集成。目前已有272人学习浏览,适合刚接触动画机制的Qt初中级开发者参考借鉴。通过研读这些文件,不仅能掌握QSpinner或自定义QLabel绘制旋转等待效果的方法,还能理解利用QThread或信号槽把耗时任务移出主线程的典型思路,从而为自己的工具类软件平滑添加加载反馈动画。
1. 旋转等待不是转起来就万事大吉
在开发 Qt 工具软件时,最常见的投诉是“点完按钮界面就白屏了”,操作系统标题栏跟着出现“未响应”。此时哪怕你已经在界面上加了一个转轮控件,它也一动不动,因为主线程被耗时程序占住,重绘事件进不了事件循环。旋转等待的功能本质不是动画,而是“事件循环还活着”的信号:它转得流畅,说明 UI 线程没有被卡死;它停摆,说明耗时程序把主线程吃掉了。下面从自定义一个旋转等待控件入手,先讲绘制与定时器驱动,再用多线程把耗时程序挪开,让等待真正转起来,最后给出封装、取消和打包时容易踩到的坑。适合已经在 Qt Widgets 里写过界面、一遇到文件复制或网络请求就不知道怎么组织代码的人。
2. 三种旋转等待方案,先定事件循环是否可用
在开始画转轮之前,先确定要用哪一层方案。常见做法有三种:QProgressDialog 把范围设为 0,0,会显示一个忙碌条;QMovie 加在 QLabel 里,只需要塞一张 GIF;用 QPainter 自定义绘制,没有资源依赖。三者的选择不取决于哪个好看,而是取决于你后续是否要做多线程。忙碌条和 GIF 在界面卡死时一样不动,所以它们并没有魔法。我一般会直接选自定义绘制,因为它与 QTimer 配合后能精确控制帧率,也最容易嵌入到状态栏。
| 方案 | 资源依赖 | 可定制性 | 阻塞主线程时的表现 | 建议场景 |
|---|---|---|---|---|
| QProgressDialog + setRange(0,0) | 无 | 低 | 停止刷新 | 临时给同步任务一个提醒 |
| QMovie + QLabel | GIF/APNG 文件 | 低 | 停止刷新 | 快速原型、动画素材现成 |
| 自定义绘制 + QTimer | 无 | 高 | 停止刷新 | 状态栏、长久运行的工具软件 |
表格里的“停止刷新”并不是缺点,它是结论:任何 UI 动画都要靠事件循环驱动。如果耗时程序不挪出主线程,三种方案都会静止。所以在深入实现前,请先把多线程这件事排进计划。
2.1 用 QPainter 画一个可旋转的圆弧
下面是一个 QWidget 子类的核心绘制代码。它没有使用图片资源,运行时只画 12 个扇形。用 setRenderHint(Antialiasing) 避免边缘锯齿。
void SpinnerIndicator::paintEvent(QPaintEvent *) { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); const int side = qMin(width(), height()); const int margin = lineWidth_ + 2; QRectF outerRect = QRectF((width() - side) / 2.0 + margin, (height() - side) / 2.0 + margin, side - 2.0 * margin, side - 2.0 * margin); const int segments = 12; for (int i = 0; i < segments; ++i) { qreal alpha = 1.0 - qreal(i) / segments; QColor fillColor = color_; fillColor.setAlphaF(alpha); painter.setBrush(fillColor); painter.setPen(Qt::NoPen); qreal startAngle = currentAngle_ - i * 360.0 / segments; painter.drawPie(outerRect, static_cast<int>(startAngle * 16), static_cast<int>(360.0 / segments * 16)); } }这里 drawPie 的第二个参数是起始角度,单位是 1/16 度,所以每个扇形跨度是 360/12*16。currentAngle_ 以一个扇形宽度为单位递增,视觉上就是整组小扇形顺时针旋转。为什么要让 i 越大透明度越低?因为人眼会把“后面拖一条淡尾”的图案识别为旋转方向,而不是单纯闪烁。alpha 的计算用了1.0 - i/segments,最后一个扇形的 alpha 接近 0,相当于看不见,但它仍然占用绘制时间。如果觉得 12 个扇形太密,可以改成 8 个,但这时 360/8=45 度,尾巴会更硬,视觉效果偏卡通。
2.2 QTimer 驱动旋转和启停控制
只有 paintEvent 是不会动的,需要让 currentAngle_ 按固定时间步进。QTimer 的 timeout 连接到一个 lambda,累加角度后调用 update(),触发下一次 paintEvent。
void SpinnerIndicator::start() { if (!timer_) { timer_ = new QTimer(this); timer_->setInterval(80); connect(timer_, &QTimer::timeout, this, [this]() { currentAngle_ += 360.0 / 12.0; if (currentAngle_ >= 360.0) { currentAngle_ -= 360.0; } update(); }); } timer_->start(); } void SpinnerIndicator::stop() { if (timer_) { timer_->stop(); } }start() 里做了惰性初始化,避免在构造函数中启动定时器造成不必要的系统资源占用。interval 为 80 毫秒,12 次更新走完一圈,约 960 毫秒,观感比较从容。如果希望它在“等待”时更急促,可以把 80 改成 50,但 CPU 占用会从约 12 fps 提升到 20 fps。对于长时间驻留状态栏的控件,我建议保持在 80 以上,因为旋转等待不需要像游戏帧率那么高,低帧率反而更省电。currentAngle_ 累加后不会无限增大,超过 360 就减回去,避免浮点精度问题。
2.3 在界面里安放等待控件
把控件放进状态栏是最常见的使用方式。setFixedSize(24,24) 保证绘制区域是正方形,因为 paintEvent 里使用 qMin(width(), height()) 决定半径,宽高不一致时会有不对称空白。statusBar()->addPermanentWidget() 将转轮放到状态栏最右侧,不会被临时信息顶到左边。
spinner_ = new SpinnerIndicator(this); spinner_->setFixedSize(24, 24); spinner_->stop(); statusBar()->addPermanentWidget(spinner_);初始 stop 只是让定时器不跑,控件仍然占据状态栏位置。这种常驻做法让 start/stop 切换时不触发状态栏布局闪动。如果想在空闲时隐藏整个转轮,不建议用 setVisible(false),因为 addPermanentWidget 在反复 show/hide 后偶尔会丢失边距;更稳的办法是状态栏里加一个 QWidget 容器,把转轮放进去,再控制容器的可见性。
3. 耗时程序必须离开主线程,等待才有机会转
旋转等待控件已经能转,但把它接到按钮槽函数之后,你大概率会发现它还是不动。罪魁祸首是耗时程序与 UI 在同一个线程里执行。以 sleep 为例,sleep 期间事件循环没有返回,QTimer 的 timeout 信号不会分发,更不会触发 paintEvent。解决方案只有一条:把耗时程序放到其它线程。下面三种异步写法各有适用场景,我常用 QtConcurrent::run 做一次性任务,用 QThread 子类做需要取消和进度反馈的长期任务。
| 异步方案 | 适合任务 | 取消方式 | 进度回传 | 主要风险 |
|---|---|---|---|---|
| QtConcurrent::run | 一次性计算、无界面依赖 | 不支持立即终止 | QFutureWatcher::progressValueChanged | 长时间阻塞占满线程池 |
| QThread 子类 | 长周期文件/网络操作 | requestInterruption 协作式 | emit 信号 | 对象生命周期管理复杂 |
| QTimer 分片 | 任务可切分,且不阻塞底层库 | stop() | 直接刷新控件 | 代码结构被拆散,难维护 |
如果任务本身只需要几秒,用 QTimer 分片也是可以接受的,但我会避免,因为大部分耗时程序是不能安全拆片的,尤其当外部进程输出量很大时,事件循环频繁进出,错误处理也会分散到多个代码片段,排查难度比直接用 QThread 更大。
3.1 QtConcurrent::run 是最短路径
在 Qt 5 中,QtConcurrent 在默认线程池中执行任务。这里用 QFutureWatcher 监听完成信号,并把转轮停在 finished 槽里。先看代码:
using Result = QString; QFutureWatcher<Result> *watcher = new QFutureWatcher<Result>(this); connect(watcher, &QFutureWatcher<Result>::finished, this, [this, watcher]() { spinner_->stop(); statusBar()->showMessage(watcher->result(), 3000); watcher->deleteLater(); }); spinner_->start(); watcher->setFuture(QtConcurrent::run([this]() -> Result { return runHeavyTask(); }));这里要解释三个细节。第一,watcher->result() 只能在 finished 信号发出后调用,如果在 finished 之前调用,QFuture 的 result 会阻塞等待任务结束,界面又会卡住。第二,runHeavyTask 里不能操作任何 QWidget,因为它运行在工作线程,与主线程的控件亲和不一致,强行访问轻则无效,重则崩溃。第三,watcher 的父对象是 this,任务完成后调用 deleteLater 而不是 delete,因为 deleteLater 会回到事件循环后安全删除,避免在信号回调中直接释放 sender 带来的隐患。
如果任务需要持续回传进度,给 watcher 连接 progressValueChanged 信号即可。注意 QtConcurrent::run 默认不接受 QObject 子类指针作为参数,想让 worker 与界面解耦,最好在 lambda 里返回一个结构体,而不是传 QWidget 进去。
3.2 用 QThread 子类执行耗时程序并控制取消
QtConcurrent 的取消是协作式的,但它只能配合 QFuture 的 cancel,不能精细地控制“取消后正在做的事”。如果耗时程序是文件拷贝,我更推荐 QThread 子类。下面这个 CopyWorker 是一个最小但完整的线程任务:
class CopyWorker : public QThread { Q_OBJECT public: void run() override { for (int i = 0; i < 100; ++i) { if (isInterruptionRequested()) { emit cancelled(); return; } QThread::msleep(20); // 模拟文件块写入 emit progress(i + 1); } emit finished(); } signals: void progress(int percent); void cancelled(); };调用方通过 requestInterruption 请求取消,但 run 内必须主动检查,线程不会因为调用 requestInterruption 而马上停止。我在每个循环块完成之后检查一次,这样最多再多处理一个块。如果想要更细粒度,可以把检查放到一个内层小循环里,代价是检查次数变多,热点路径变慢。emit progress 时携带的 int 是百分比,而不是剩余秒数,因为百分比不依赖任务的总耗时,展示更稳定。
3.3 控件销毁与 QTimer 竞争:一个容易崩溃的点
旋转等待控件在关闭窗口时崩溃,多半是定时器事件与对象析构在竞争。把 QTimer 的父对象设成 SpinnerIndicator 可以规避定时器残留,但仍有另一个问题:QThread 对象可能在 run 还没结束时就被窗口析构。解决办法是在主窗口关闭时至少调用 worker_->wait(),或者把 worker 的 finished 信号连接到 deleteLater,并且确保 worker 父对象是主窗口而不是局部变量。下面是推荐写法:
CopyWorker *worker = new CopyWorker(this); connect(worker, &QThread::finished, worker, &QObject::deleteLater); worker->start();这里 worker 的父对象是 this,窗口析构时 Qt 会删除 worker。如果线程还在跑,对象删除会先请求中断并等待线程退出,但 QThread 析构在很多平台会触发警告QThread: Destroyed while thread is still running。要彻底避免这种崩溃和警告,需要在主窗口的 closeEvent 里显式处理:
void MainWindow::closeEvent(QCloseEvent *event) { if (worker_ && worker_->isRunning()) { worker_->requestInterruption(); if (!worker_->wait(3000)) { worker_->terminate(); // 不应作为常规手段 } } QMainWindow::closeEvent(event); }wait(3000) 是等待线程最多 3 秒退出,超时后调用 terminate 是很粗暴的后手,可能导致资源未释放。我通常会设计成任务每块耗时可控,3 秒足够安全退出,terminate 这一行在实际代码里可以删除。
4. 把旋转等待接入真实业务:文件拷贝与进度回传
控件有了,线程方式也定了,剩下的就是接入真实业务。这里以批量拷贝文件为例,因为文件操作是耗时程序里最典型的阻塞场景,也是新手最容易把状态栏和按钮一起卡住的地方。下面从按钮交互、进度槽和收尾逻辑三个层面展开。
4.1 按钮点击、线程启动、等待控件三者顺序
在主窗口中定义一个 CopyWorker 指针和转轮控件。按钮槽里必须处理重复点击:如果上一次任务仍在运行,这次点击要么忽略,要么请求取消。我采用请求取消的做法,因为用户连点两次说明他已经不耐烦,与其忽略不如帮他停掉正在进行的任务。
void MainWindow::onCopyClicked() { if (worker_ && worker_->isRunning()) { worker_->requestInterruption(); spinner_->stop(); return; } worker_ = new CopyWorker(this); connect(worker_, &CopyWorker::progress, this, &MainWindow::onProgress); connect(worker_, &QThread::finished, worker_, &QObject::deleteLater); connect(worker_, &QThread::finished, this, &MainWindow::onWorkerFinished); copyButton_->setEnabled(false); progressBar_->setValue(0); spinner_->start(); worker_->start(); }这里的顺序有讲究:先 setEnabled(false)、spinner_->start(),最后 worker_->start()。如果先启动线程,再启动转轮,任务可能在转轮 start 之前就发出 finished,界面只闪烁一下就结束,用户会以为按钮没有响应。先 disabled 和 start 能保证“点击后立刻有状态反馈”,这是交互设计上的一个细节。requestInterruption 只对这个 worker 生效;如果你把 worker 指向了新对象,旧对象的取消状态就不会被新任务看到,所以取消分支里要提前把 spinner_ stop 掉。
4.2 进度信号与转轮颜色联动
onProgress 槽负责更新进度条,同时在任务中段改变转轮颜色,把转轮当作二级状态灯用。
void MainWindow::onProgress(int percent) { progressBar_->setVisible(percent > 0); progressBar_->setValue(percent); if (percent > 0 && percent < 100) { spinner_->setColor(QColor(255, 160, 0)); } }颜色变化不是必须的,但它能帮助用户区分“转轮转起来了”和“任务还在跑”。如果转轮是什么颜色都一样,用户只能在完成瞬间看到变化。这里把颜色改成橙色,橙色在亮色和暗色背景下都比默认灰更醒目。注意不要每发一次信号都创建一个新 QColor,这里的 QColor 是值类型,开销不大,但更规范的做法是在 MainWindow 里存一个 memberColor,只在颜色改变时调用 setColor。
4.3 任务结束后的收尾工作
finished 信号触发时,需要恢复按钮、停止转轮并隐藏进度条。我们不能在 onProgress 里判断 percent==100 然后 stop,因为一次任务申请取消时 progress 可能只走到 40,就必须回到 finished 分支里做收尾。下面这个表格总结了任务状态和控制件状态的对应关系,方便对照检查:
| 任务状态 | 按钮 | 转轮 | 进度条 |
|---|---|---|---|
| 未启动 | enabled | stop | 隐藏 |
| 运行中 | disabled | start | 显示 |
| 请求取消 | disabled | start | 显示 |
| 取消完成 | enabled | stop | 隐藏 |
| 正常完成 | enabled | stop | 隐藏 |
对照表格写收尾槽就非常直接:
void MainWindow::onWorkerFinished() { copyButton_->setEnabled(true); spinner_->stop(); progressBar_->setVisible(false); statusBar()->showMessage(tr("任务结束"), 3000); }如果 CopyWorker::run 的返回值告诉我们是取消还是正常结束,那就在 worker 里增加一个成员变量,比如 lastError_,收尾槽读取它并显示不同文案。这里要注意 finished 连接了 worker_ 的 deleteLater,所以 onWorkerFinished 槽执行时 worker_ 对象还活着,可以安全访问它的成员。
4.4 老项目不建议用 processEvents 代替多线程
有些第三方库阻塞型计算不方便挪进线程,常见做法是在循环里调用 QApplication::processEvents(),让转轮动一动。这不是伪多线程,而是强制主线程在计算间隙处理事件,仅适合短任务。它的主要风险是事件重入:processEvents 会分发所有事件,包括按钮点击,用户可能在一个计算还没结束时再次进入同一个槽。如果非要这么做,需要把所有界面按钮先 disable,并且给 processEvents 设置一个最大时间:
for (int i = 0; i < 1000; ++i) { thirdPartyComputeStep(i); QApplication::processEvents(QEventLoop::AllEvents, 10); spinner_->update(); }第二个参数 10 表示最多处理 10 毫秒的事件,之后把控制权交还给计算循环。这样能减少事件太多导致的卡顿,但无法避免用户操作事件嵌套。因此我会把 processEvents 当成“给老项目的止痛药”,新代码一律走 QThread 或 QtConcurrent。我用 Qt 5.15.2 的 msvc2019 环境验证过这段流程,Qt 6 的 QThread 接口完全一致。
5. 用 RAII 封装旋转等待,并验证打包后的控件状态
5.1 用 SpinnerGuard 让转轮自动停
start/stop 散落在各个槽里时,漏停是常见问题。任务路径里有一个提前 return 或抛异常,转轮就会一直转。把转轮的启动和停止收敛到一个作用域对象里,是 C++ 工程师最熟悉的直觉。
class SpinnerGuard { public: explicit SpinnerGuard(SpinnerIndicator *indicator) : indicator_(indicator) { indicator_->start(); } ~SpinnerGuard() { indicator_->stop(); } Q_DISABLE_COPY(SpinnerGuard) private: SpinnerIndicator *indicator_; }; void MainWindow::syncTask() { SpinnerGuard guard(spinner_); runBlockingTask(); // 同步执行耗时程序 }这段代码只适用于同步调用。runBlockingTask 返回时任务一定结束,guard 析构刚好停止转轮。如果 runBlockingTask 内部启动 QtConcurrent 并立刻返回,guard 会过早 stop,转轮闪烁一下就灭。所以异步场景就不要用 RAII,把 stop 放在 QFutureWatcher 的 finished 信号里才正确。
5.2 打包与缩放时检查转轮是否还正常
在部署阶段,还要检查旋转等待控件在 release 打包后是否正常。自定义绘制不依赖图片插件,比 QMovie 少了 gif 插件缺失的问题,但如果你用了 QMovie,打包时需要确保 imageformats 目录里有 qgif.dll。另一种常见问题是界面高 DPI 缩放:在支持缩放的环境里,paintEvent 的坐标不会自动放大,需要为自定义控件设置正确的大小策略和 devicePixelRatio 支持。验证方法很简单:以 150% 缩放启动程序,观察转轮是否有毛边或偏左。如果出现,就在 resizeEvent 里判断 scaleFactor,并重新 setFixedSize。
最后再验证一个交互细节:任务运行期间连续点击按钮。如果你的代码像第 4 章那样把按钮 disabled,第二次点击事件根本不会触发;如果按钮没有 disabled,onCopyClicked 里 isRunning 分支会 requestInterruption,转轮会在取消完成前保持旋转,取消完成后自动停止。这个行为是否符合预期,取决于产品设计,但至少不应该让两个 worker 同时运行。关于打包,我还遇到过状态栏转轮在 Windows 上颜色偏淡的情况,原因是系统主题启用了深色模式,而代码写死了黑色。解决办法是从调色板取色:
spinner_->setColor(palette().highlight().color());这样转轮在浅色和深色主题下都是可辨认的。拿到一台没有安装 Qt 运行库的机器测试一下,如果转轮不转但程序不报错,先看 Qt 插件目录是否被 windeployqt 正确处理。
本文还有配套的精品资源,点击获取