在第三章中,我们通过自定义 Model/View 完成了内存中的作业管理流程。本章将把数据保存到 SQLite,同时用 QSettings 保存界面偏好,用 JSON 完成可读的导入导出。
本章结束后,用户关闭程序再重新打开,课程和作业数据仍然存在。输入、校验、写入、查询、显示、修改和删除将形成完整闭环。
1. 本章目标
完成本章后,应当能够:
- 为不同数据选择合适的持久化方式;
- 使用 QStandardPaths 确定应用数据目录;
- 使用 QFile、QSaveFile 和 JSON;
- 使用 QSettings 保存窗口与用户偏好;
- 使用 Qt SQL 连接 SQLite;
- 创建数据库表、索引和外键;
- 使用预处理语句完成增删改查;
- 使用事务保证多步操作的一致性;
- 把 Repository 与界面逻辑分离。
2. 三种存储方式各司其职
| 数据 | 推荐方式 | 示例 |
|---|---|---|
| 用户偏好 | QSettings | 窗口大小、上次页面、主题 |
| 交换或备份文件 | JSON、CSV | 导入导出、调试样例 |
| 结构化业务数据 | SQLite | 课程、作业、状态、日期 |
不要把全部数据都塞进 QSettings,也不要为了保存几个偏好而建立数据库表。存储方式应该与数据的结构、规模和查询需求匹配。
3. 确定应用数据目录
运行目录在开发、打包和安装后可能不同,因此数据库不应依赖当前工作目录。可以使用 QStandardPaths:
#include <QDir>
#include <QStandardPaths>
QString applicationDataDirectory() {
const QString path = QStandardPaths::writableLocation(
QStandardPaths::AppDataLocation);
QDir directory;
if (!directory.mkpath(path)) {
return {};
}
return path;
}
在 main() 创建 QApplication 后,先设置组织名和应用名:
QCoreApplication::setOrganizationName("ListenSnowCourse");
QCoreApplication::setApplicationName("CourseManager");
它们会影响 QSettings 和部分标准路径。名称一旦用于正式项目,就不要随意修改,否则程序可能找不到旧配置。
4. 使用 QSettings 保存界面状态
保存主窗口几何信息和上次打开页面:
void MainWindow::saveSettings() {
QSettings settings;
settings.setValue("window/geometry", saveGeometry());
settings.setValue("navigation/page",
ui->pageStack->currentIndex());
settings.setValue("assignment/filter",
ui->searchEdit->text());
}
程序启动时恢复:
void MainWindow::restoreSettings() {
QSettings settings;
restoreGeometry(
settings.value("window/geometry").toByteArray());
const int page = settings.value(
"navigation/page", 0).toInt();
if (page >= 0 && page < ui->pageStack->count()) {
ui->pageStack->setCurrentIndex(page);
}
ui->searchEdit->setText(
settings.value("assignment/filter").toString());
}
恢复来自旧版本的设置时要检查范围。不能假设配置值永远合法。
5. 为 SQLite 增加 Qt SQL 模块
修改 CMake:
find_package(Qt6 REQUIRED COMPONENTS Widgets Sql)
target_link_libraries(course_manager PRIVATE
Qt6::Widgets
Qt6::Sql
)
Qt SQL 使用驱动插件访问具体数据库。SQLite 驱动名为 QSQLITE。开发环境中可以检查:
qInfo() << "SQL drivers:" << QSqlDatabase::drivers();
如果列表中没有 QSQLITE,应检查 Qt 安装组件或 Kit,而不是立即重写数据库代码。
6. 封装数据库初始化
databasemanager.h:
#pragma once
#include <QSqlDatabase>
#include <QString>
class DatabaseManager final {
public:
bool open(QString *errorMessage = nullptr);
QSqlDatabase database() const;
private:
bool createSchema(QString *errorMessage);
QSqlDatabase database_;
};
核心实现:
#include "databasemanager.h"
#include <QDir>
#include <QSqlError>
#include <QSqlQuery>
#include <QStandardPaths>
bool DatabaseManager::open(QString *errorMessage) {
const QString dataDirectory =
QStandardPaths::writableLocation(
QStandardPaths::AppDataLocation);
if (!QDir().mkpath(dataDirectory)) {
if (errorMessage) {
*errorMessage = QObject::tr("无法创建数据目录");
}
return false;
}
database_ = QSqlDatabase::addDatabase("QSQLITE");
database_.setDatabaseName(
dataDirectory + "/course-manager.db");
if (!database_.open()) {
if (errorMessage) {
*errorMessage = database_.lastError().text();
}
return false;
}
QSqlQuery pragma(database_);
if (!pragma.exec("PRAGMA foreign_keys = ON")) {
if (errorMessage) {
*errorMessage = pragma.lastError().text();
}
return false;
}
return createSchema(errorMessage);
}
QSqlDatabase DatabaseManager::database() const {
return database_;
}
7. 设计数据库结构
课程与作业之间是一对多关系:
courses
┌────────────┬──────────────┐
│ id │ INTEGER PK │
│ name │ TEXT UNIQUE │
└────────────┴──────────────┘
1
│
N
assignments
┌────────────┬──────────────┐
│ id │ INTEGER PK │
│ course_id │ INTEGER FK │
│ title │ TEXT │
│ due_date │ TEXT │
│ completed │ INTEGER │
└────────────┴──────────────┘
创建表时每次 exec() 只执行一条 SQL:
bool DatabaseManager::createSchema(QString *errorMessage) {
const QStringList statements = {
QStringLiteral(R"SQL(
CREATE TABLE IF NOT EXISTS courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE
)
)SQL"),
QStringLiteral(R"SQL(
CREATE TABLE IF NOT EXISTS assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
course_id INTEGER NOT NULL,
title TEXT NOT NULL,
due_date TEXT NOT NULL,
completed INTEGER NOT NULL DEFAULT 0
CHECK (completed IN (0, 1)),
UNIQUE (course_id, title),
FOREIGN KEY (course_id)
REFERENCES courses(id)
ON DELETE CASCADE
)
)SQL"),
QStringLiteral(R"SQL(
CREATE INDEX IF NOT EXISTS
idx_assignments_due_date
ON assignments(due_date)
)SQL")
};
for (const QString &statement : statements) {
QSqlQuery query(database_);
if (!query.exec(statement)) {
if (errorMessage) {
*errorMessage = query.lastError().text();
}
return false;
}
}
return true;
}
日期以 yyyy-MM-dd 保存为文本时,可以正确进行字典序排序。更复杂的时间场景还要明确时区和精度。
8. 使用 Repository 执行参数化查询
界面不应拼接 SQL。可以建立 AssignmentRepository,由它接收 QSqlDatabase 并执行查询。
新增作业:
bool AssignmentRepository::add(
qint64 courseId,
const QString &title,
const QDate &deadline,
QString *errorMessage) {
QSqlQuery query(database_);
query.prepare(R"SQL(
INSERT INTO assignments
(course_id, title, due_date, completed)
VALUES
(:course_id, :title, :due_date, :completed)
)SQL");
query.bindValue(":course_id", courseId);
query.bindValue(":title", title.trimmed());
query.bindValue(":due_date",
deadline.toString("yyyy-MM-dd"));
query.bindValue(":completed", false);
if (!query.exec()) {
if (errorMessage) {
*errorMessage = query.lastError().text();
}
return false;
}
return true;
}
永远不要这样拼接用户输入:
// 错误示例:既容易出错,也存在 SQL 注入风险。
query.exec("INSERT INTO assignments(title) VALUES('" + title + "')");
查询列表时使用 JOIN 获取课程名称:
SELECT
assignments.id,
courses.name,
assignments.title,
assignments.due_date,
assignments.completed
FROM assignments
JOIN courses ON courses.id = assignments.course_id
ORDER BY assignments.due_date, assignments.id;
Repository 把查询结果转换为 QVector<Assignment>,再交给上一章的 AssignmentTableModel。这样 View 不需要知道数据来自内存、SQLite 还是其他来源。
9. 更新与删除
修改完成状态:
QSqlQuery query(database_);
query.prepare(R"SQL(
UPDATE assignments
SET completed = :completed
WHERE id = :id
)SQL");
query.bindValue(":completed", completed);
query.bindValue(":id", assignmentId);
if (!query.exec() || query.numRowsAffected() != 1) {
// 记录 query.lastError(),并向调用方返回失败。
}
删除时使用稳定的数据库 ID,而不是表格行号:
QSqlQuery query(database_);
query.prepare("DELETE FROM assignments WHERE id = :id");
query.bindValue(":id", assignmentId);
排序和过滤会改变显示行号,但不会改变数据库 ID。业务层始终使用 ID 定位实体。
10. 使用事务保护多步操作
例如导入一个课程及其多条作业时,应当全部成功或全部失败:
if (!database_.transaction()) {
return false;
}
bool success = insertCourse(course);
for (const Assignment &assignment : assignments) {
if (!success || !insertAssignment(assignment)) {
success = false;
break;
}
}
if (success) {
success = database_.commit();
} else {
database_.rollback();
}
事务开始后再创建和执行相关查询。发生错误时保留原始数据库状态,并把具体错误记录到日志。
11. 使用 JSON 导出备份
JSON 适合可读备份和数据交换。使用 QSaveFile 可以避免写到一半留下损坏文件:
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QSaveFile>
bool exportAssignments(const QString &filePath,
const QVector<Assignment> &items) {
QJsonArray array;
for (const Assignment &item : items) {
array.append(QJsonObject{
{"id", item.id},
{"course", item.courseName},
{"title", item.title},
{"deadline", item.deadline.toString(Qt::ISODate)},
{"completed", item.completed}
});
}
const QJsonObject root{
{"formatVersion", 1},
{"assignments", array}
};
QSaveFile file(filePath);
if (!file.open(QIODevice::WriteOnly)) {
return false;
}
file.write(QJsonDocument(root).toJson(
QJsonDocument::Indented));
return file.commit();
}
导入时要检查:
- JSON 是否能成功解析;
- 根对象和字段是否存在;
formatVersion是否受支持;- 日期和布尔值类型是否正确;
- 重复数据采用跳过、覆盖还是报错策略;
- 导入失败时是否回滚事务。
不能因为文件扩展名是 .json 就信任其内容。
12. 刷新模型的统一流程
一次新增操作可以组织为:
AssignmentDialog 获取输入
→ AssignmentService 校验业务规则
→ AssignmentRepository 写入 SQLite
→ Repository 重新查询列表
→ AssignmentTableModel::setAssignments()
→ QTableView 自动刷新
数据库写入失败时不要先更新界面。只有持久化成功后,才刷新模型并显示成功状态。
13. 七个练习步骤
| 顺序 | 学习与实践 |
|---|---|
| 第 1 步 | 设置应用名称并确定 AppDataLocation |
| 第 2 步 | 使用 QSettings 保存和恢复界面状态 |
| 第 3 步 | 链接 Qt SQL 并检查 QSQLITE 驱动 |
| 第 4 步 | 创建课程表、作业表、外键和索引 |
| 第 5 步 | 实现 Repository 的查询和增删改操作 |
| 第 6 步 | 将数据库查询结果接入 TableModel |
| 第 7 步 | 完成 JSON 导入导出与重启测试 |
14. 常见问题
提示 QSQLITE driver not loaded
先检查 QSqlDatabase::drivers()。开发环境缺少驱动时检查 Qt 安装和 Kit;发布环境则检查 sqldrivers/qsqlite.dll 是否随程序部署。
数据库能打开,但没有创建到预期位置
输出 database_.databaseName() 和 QStandardPaths 的结果确认实际路径,不要根据当前工作目录猜测。
外键没有生效
SQLite 连接打开后执行 PRAGMA foreign_keys = ON,并检查执行结果。每个数据库连接都需要正确配置。
中文或引号导致 SQL 报错
不要拼接 SQL 字符串。使用 prepare()、bindValue() 和参数占位符。
表格显示旧数据
数据库成功写入后重新查询 Repository,并通过模型通知接口更新数据。不要同时维护多个互不知情的缓存副本。
多线程访问数据库出现随机问题
QSqlDatabase 连接具有线程归属。不要把主线程创建的连接直接拿到工作线程使用;需要时在目标线程建立独立连接。
15. 本章验收清单
- 数据库位于可写的应用数据目录;
- 首次启动可以自动创建表和索引;
QSQLITE驱动可用;- 所有用户输入通过绑定参数写入 SQL;
- 课程和作业具有正确外键关系;
- 新增、查询、修改、删除均能刷新表格;
- 程序重启后数据仍然存在;
- 多步导入失败时可以回滚;
- QSettings 能保存并恢复界面偏好;
- JSON 导出文件包含格式版本;
- 数据库错误能够记录并显示为用户可理解的信息。
完成这一章后,课程项目已经具备可靠的数据闭环。下一章将根据需求选择绘图、网络和多线程能力,并为默认项目实现一个真正有辨识度的特色功能。
官方参考:QSettings | Qt JSON | QSqlDatabase | QSqlQuery
上一篇:第 3 章:事件、输入校验与 Model/View | 下一篇:第 5 章:绘图、网络与多线程的取舍