第四章中,课程与作业管理系统已经形成 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 中使用自定义控件

最简单的方法是“提升控件”:

  1. 在页面中拖入一个 QWidget;
  2. 设置合适的 objectName,例如 completionRing
  3. 右键选择“提升为”;
  4. 类名填写 CompletionRingWidget
  5. 头文件填写 completionringwidget.h
  6. 将自定义类文件加入 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) {
    // 持续轮询
}

正确方式是连接 finishedreadyReaddownloadProgress 等信号,在状态变化时继续工作。

只有请求完成后还要进行大量 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。

官方参考:QPainterQNetworkAccessManagerQThread线程与 QObject

上一篇:第 4 章:文件、设置与 SQLite 数据闭环 | 下一篇:第 6 章:功能整合与 MVP