ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Qt旋转等待控件:从绘制到多线程的完整实践

Qt旋转等待控件:从绘制到多线程的完整实践 简介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 QLabelGIF/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_castint(startAngle * 16), static_castint(360.0 / segments * 16)); } }这里 drawPie 的第二个参数是起始角度单位是 1/16 度所以每个扇形跨度是 360/12*16。currentAngle_ 以一个扇形宽度为单位递增视觉上就是整组小扇形顺时针旋转。为什么要让 i 越大透明度越低因为人眼会把“后面拖一条淡尾”的图案识别为旋转方向而不是单纯闪烁。alpha 的计算用了1.0 - i/segments最后一个扇形的 alpha 接近 0相当于看不见但它仍然占用绘制时间。如果觉得 12 个扇形太密可以改成 8 个但这时 360/845 度尾巴会更硬视觉效果偏卡通。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; QFutureWatcherResult *watcher new QFutureWatcherResult(this); connect(watcher, QFutureWatcherResult::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 里判断 percent100 然后 stop因为一次任务申请取消时 progress 可能只走到 40就必须回到 finished 分支里做收尾。下面这个表格总结了任务状态和控制件状态的对应关系方便对照检查任务状态按钮转轮进度条未启动enabledstop隐藏运行中disabledstart显示请求取消disabledstart显示取消完成enabledstop隐藏正常完成enabledstop隐藏对照表格写收尾槽就非常直接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第二次点击事件根本不会触发如果按钮没有 disabledonCopyClicked 里 isRunning 分支会 requestInterruption转轮会在取消完成前保持旋转取消完成后自动停止。这个行为是否符合预期取决于产品设计但至少不应该让两个 worker 同时运行。关于打包我还遇到过状态栏转轮在 Windows 上颜色偏淡的情况原因是系统主题启用了深色模式而代码写死了黑色。解决办法是从调色板取色spinner_-setColor(palette().highlight().color());这样转轮在浅色和深色主题下都是可辨认的。拿到一台没有安装 Qt 运行库的机器测试一下如果转轮不转但程序不报错先看 Qt 插件目录是否被 windeployqt 正确处理。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进