在第四章中,课程与作业管理系统已经形成 SQLite 数据闭环。本章进入项目专题阶段:从绘图、网络和多线程中选择真正服务于需求的能力,而不是为了展示技术把所有模块堆进项目。
默认项目将实现一个“作业完成率圆环”,学习 QPainter 和自定义控件。网络与多线程作为可选路线:只有项目确实需要外部数据或耗时任务时才加入。
1. 本章目标
完成本章后,应当能够:
- 根据项目需求选择专题模块;
- 理解 QWidget 的绘制流程;
- 使用 QPainter 编写自定义统计控件;
- 从数据库聚合统计数据;
- 使用 QNetworkAccessManager 发起异步请求;
- 判断何时不需要额外线程;
- 使用 worker object 模式执行耗时任务;
- 正确处理进度、取消、错误和对象释放。
2. 先选择,再实现
可以按照下面的标准判断:
| 项目需求 | 推荐模块 | 不建议的做法 |
|---|---|---|
| 自定义统计图、绘图板 | QPainter | 只为一张静态图片引入复杂绘图库 |
| HTTP API、文件下载 | Qt Network | 为异步 HTTP 请求额外创建线程 |
| TCP 聊天、局域网通信 | QTcpSocket、QTcpServer | 用阻塞循环等待消息 |
| 大量文件处理、复杂计算 | QThread、QtConcurrent | 把 GUI 控件移动到工作线程 |
| 普通 SQLite 增删改查 | 通常保持主线程 | 每次简单查询都开新线程 |
本章最多选择一个主要亮点和一个辅助能力。功能越多,测试、错误处理和答辩解释的成本也越高。
3. QWidget 的绘制流程
当控件需要重绘时,Qt 会投递绘制事件并调用 paintEvent()。我们在这个函数中创建 QPainter:
数据或尺寸发生变化
→ 调用 update()
→ Qt 合并重绘请求
→ 触发 paintEvent()
→ QPainter 在控件上绘制
不要在 paintEvent() 中修改数据库、发网络请求或改变业务状态。绘制函数应读取当前状态并完成显示,尽量保持快速和可重复。
update() 会安排一次稍后的重绘;repaint() 通常会立即绘制。普通界面更新优先使用 update()。
4. 自定义作业完成率圆环
创建 completionringwidget.h:
#pragma once
#include <QWidget>
class CompletionRingWidget final : public QWidget {
Q_OBJECT
Q_PROPERTY(qreal progress READ progress WRITE setProgress
NOTIFY progressChanged)
public:
explicit CompletionRingWidget(QWidget *parent = nullptr);
qreal progress() const;
void setProgress(qreal value);
signals:
void progressChanged(qreal value);
protected:
void paintEvent(QPaintEvent *event) override;
private:
qreal progress_ = 0.0;
};
实现 completionringwidget.cpp:
#include "completionringwidget.h"
#include <QPainter>
#include <QPaintEvent>
#include <QPen>
#include <QtMath>
CompletionRingWidget::CompletionRingWidget(QWidget *parent)
: QWidget(parent) {
setMinimumSize(140, 140);
}
qreal CompletionRingWidget::progress() const {
return progress_;
}
void CompletionRingWidget::setProgress(qreal value) {
const qreal bounded = qBound(0.0, value, 1.0);
if (qFuzzyCompare(progress_, bounded)) {
return;
}
progress_ = bounded;
update();
emit progressChanged(progress_);
}
void CompletionRingWidget::paintEvent(QPaintEvent *event) {
Q_UNUSED(event)
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing);
const int side = qMin(width(), height());
const qreal penWidth = qMax(10.0, side * 0.08);
const qreal margin = penWidth / 2.0 + 4.0;
const QRectF ringRect(
(width() - side) / 2.0 + margin,
(height() - side) / 2.0 + margin,
side - 2.0 * margin,
side - 2.0 * margin);
QPen backgroundPen(QColor("#d8e2ef"));
backgroundPen.setWidthF(penWidth);
backgroundPen.setCapStyle(Qt::RoundCap);
painter.setPen(backgroundPen);
painter.drawArc(ringRect, 0, 360 * 16);
QPen progressPen(QColor("#2f80ed"));
progressPen.setWidthF(penWidth);
progressPen.setCapStyle(Qt::RoundCap);
painter.setPen(progressPen);
painter.drawArc(
ringRect,
90 * 16,
-qRound(progress_ * 360.0 * 16.0));
painter.setPen(palette().color(QPalette::Text));
const QString percent = QStringLiteral("%1%")
.arg(qRound(progress_ * 100.0));
painter.drawText(rect(), Qt::AlignCenter, percent);
}
这个控件把进度限制在 0~1,并根据控件尺寸动态计算圆环。Q_PROPERTY 让属性可以被 Designer、属性系统或动画框架使用。
5. 在 Designer 中使用自定义控件
最简单的方法是“提升控件”:
- 在页面中拖入一个 QWidget;
- 设置合适的 objectName,例如
completionRing; - 右键选择“提升为”;
- 类名填写
CompletionRingWidget; - 头文件填写
completionringwidget.h; - 将自定义类文件加入 CMake 目标。
Designer 只保存提升信息,真正的对象仍在构建和运行时由 C++ 类创建。
6. 从数据库计算完成率
统计值应由数据库或 Service 计算,而不是遍历表格单元格:
struct AssignmentStatistics {
int total = 0;
int completed = 0;
qreal completionRate() const {
return total == 0
? 0.0
: static_cast<qreal>(completed) / total;
}
};
查询语句:
SELECT
COUNT(*) AS total,
COALESCE(SUM(completed), 0) AS completed
FROM assignments;
刷新总览页:
const AssignmentStatistics statistics =
assignmentService_->statistics();
ui->completionRing->setProgress(
statistics.completionRate());
ui->totalLabel->setText(QString::number(statistics.total));
ui->completedLabel->setText(
QString::number(statistics.completed));
每次新增、删除或更新作业后发出统一的 assignmentsChanged 信号,列表和统计页都监听它,而不是在多个槽函数中分别手工刷新。
7. 可选路线:异步 HTTP 请求
如果项目需要从公开 API 获取数据,可以链接 Network 模块:
find_package(Qt6 REQUIRED COMPONENTS Widgets Sql Network)
target_link_libraries(course_manager PRIVATE
Qt6::Widgets
Qt6::Sql
Qt6::Network
)
QNetworkAccessManager 本身提供异步接口。一个应用通常复用一个 manager:
networkManager_ = new QNetworkAccessManager(this);
发送请求并处理响应:
#include <QScopeGuard>
QNetworkRequest request(
QUrl("https://example.com/api/semester"));
request.setHeader(
QNetworkRequest::UserAgentHeader,
QStringLiteral("CourseManager/0.1"));
QNetworkReply *reply = networkManager_->get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply] {
const auto cleanup = qScopeGuard([reply] {
reply->deleteLater();
});
if (reply->error() != QNetworkReply::NoError) {
showNetworkError(reply->errorString());
return;
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(
reply->readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError) {
showNetworkError(tr("服务器返回了无效 JSON"));
return;
}
applySemesterData(document.object());
});
如果不使用 qScopeGuard,则要保证每条返回路径都调用 reply->deleteLater()。还应处理超时、取消、HTTP 状态码、JSON 字段缺失和重复请求。
不要通过忽略 sslErrors 来“解决”证书问题。应修复证书、系统时间或服务器配置。
8. 网络请求为什么通常不需要 QThread
QNetworkAccessManager、QTcpSocket 和 QTimer 都是事件驱动的。发起请求后,它们把等待交还给事件循环,不会像阻塞式读取那样占住主线程。
下面这种写法会冻结界面:
// 错误思路:在主线程循环等待网络结果。
while (!finished) {
// 持续轮询
}
正确方式是连接 finished、readyRead、downloadProgress 等信号,在状态变化时继续工作。
只有请求完成后还要进行大量 CPU 计算时,才考虑把那部分计算移到工作线程。
9. 可选路线:worker object 多线程模式
假设导入文件非常大,需要逐行解析并提供进度,可以创建 worker:
class ImportWorker final : public QObject {
Q_OBJECT
public:
explicit ImportWorker(QString filePath);
public slots:
void run();
signals:
void progressChanged(int percent);
void succeeded(QVector<Assignment> assignments);
void failed(QString message);
void finished();
private:
QString filePath_;
};
创建线程并移动 worker:
auto *thread = new QThread(this);
auto *worker = new ImportWorker(filePath);
worker->moveToThread(thread);
connect(thread, &QThread::started,
worker, &ImportWorker::run);
connect(worker, &ImportWorker::progressChanged,
this, &MainWindow::updateImportProgress);
connect(worker, &ImportWorker::succeeded,
this, &MainWindow::applyImportedData);
connect(worker, &ImportWorker::failed,
this, &MainWindow::showImportError);
connect(worker, &ImportWorker::finished,
thread, &QThread::quit);
connect(worker, &ImportWorker::finished,
worker, &QObject::deleteLater);
connect(thread, &QThread::finished,
thread, &QObject::deleteLater);
thread->start();
需要特别注意:
- worker 中不能访问 QWidget;
- 通过信号发送值或不可变结果;
- 取消操作应采用协作式检查;
- 线程结束前清理属于该线程的 QObject;
- 每个线程中的数据库连接应在该线程创建和使用。
不要把槽函数直接写进 QThread 子类并假设它会在新线程执行。QThread 对象本身通常仍属于创建它的线程。
10. 进度、取消和错误状态
耗时操作至少需要四种状态:
Idle → Running → Succeeded
├── Failed
└── Cancelled
运行期间:
- 禁用会重复启动任务的按钮;
- 显示进度或“不确定进度”的忙碌状态;
- 保留取消入口;
- 结束后恢复界面;
- 不论成功、失败还是取消,都释放资源。
不要只处理成功信号。异常路径才是课程项目现场演示时最容易暴露的问题。
11. 七个练习步骤
| 顺序 | 学习与实践 |
|---|---|
| 第 1 步 | 根据需求选择一个主要特色功能 |
| 第 2 步 | 理解 paintEvent、QPainter 和 update() |
| 第 3 步 | 实现可缩放的完成率圆环 |
| 第 4 步 | 从 SQLite 聚合统计并刷新总览页 |
| 第 5 步 | 按需练习一个异步 HTTP 请求 |
| 第 6 步 | 按需实现带进度的 worker object |
| 第 7 步 | 测试成功、失败、取消和空数据状态 |
默认课程项目完成前四步即可。只有需求确实涉及网络或大文件时,才继续对应路线。
12. 常见问题
自定义控件更新数据后没有重绘
状态变化后调用 update(),并确认 paintEvent() 读取的是最新成员变量。
绘制内容在窗口缩放后偏移
根据 width()、height() 和 rect() 动态计算,而不是写死坐标。使用 qMin(width(), height()) 保持圆形比例。
网络请求完成后程序偶尔崩溃
检查 QNetworkReply 生命周期,不要在回调外保留已经 deleteLater() 的裸指针,也不要重复删除。
开了线程后界面仍然卡顿
确认耗时函数真的在 worker 所在线程执行,并检查主线程是否在等待 wait()、锁或大数据复制。
工作线程修改 QLabel 后崩溃
GUI 只能在主线程访问。worker 发出进度信号,由主窗口的槽更新 QLabel。
13. 本章验收清单
- 特色功能与项目需求直接相关;
- 自定义控件能够随尺寸正确重绘;
paintEvent()不修改业务数据;- 完成率能够处理零条数据;
- 数据变化后统计和列表统一刷新;
- HTTP 请求使用异步信号而不是轮询;
- 网络错误和 JSON 解析错误有明确处理;
- 只有真实耗时任务才使用线程;
- worker 不访问 GUI 对象;
- 线程能够正常结束并释放对象;
- 成功、失败、取消和空数据状态都已测试。
完成特色功能后,应当停止继续堆叠模块。下一章将冻结需求、整理架构和错误处理,把已有功能组合成可以从头到尾稳定演示的 MVP。
官方参考:QPainter | QNetworkAccessManager | QThread | 线程与 QObject
上一篇:第 4 章:文件、设置与 SQLite 数据闭环 | 下一篇:第 6 章:功能整合与 MVP