简介:本资源是一份面向Qt初学者与C++桌面开发入门者的教学实践案例,聚焦个人记账本功能实现,帮助开发者在真实项目中掌握跨平台GUI应用开发全流程。压缩包共8个文件(3个cpp源文件、2个h头文件、1个pro工程配置、1个user用户设置及1个README说明文档),总大小仅17KB,轻量易读,涵盖主窗口逻辑、数据库管理模块、工程构建配置等核心结构,便于逐层理解信号槽机制、模型/视图编程、本地数据持久化及多平台编译适配要点。目前已有277人学习下载,适合希望从零构建可运行Qt项目的开发者——不仅能直接编译运行于Windows 10/11与Ubuntu 18.04环境,还可通过源码反向梳理界面布局设计、事件响应逻辑与跨系统路径处理等实战细节,是巩固Qt基础并衔接实际开发的高性价比学习材料。
1. Qt 实现个人记账本:为什么一个“小桌面工具”反而成了新手跨过 C++ GUI 门槛的最优跳板?
你可能已经试过用 Python 写个记账脚本,也跑通过 Flask 做个网页版记账页——但当真正想把「数据存本地、界面不卡顿、双击就开、关机也不丢账」这五件事稳稳落地时,90% 的初学者会卡在「怎么让按钮点下去真能写进文件」「为什么 QDateTimeEdit 选的时间总比系统慢8小时」「SQLite 插入后查不到,刷新界面却突然冒出来」这些看似琐碎、实则直指 GUI 编程底层逻辑的节点上。这个 Qt 实现个人记账本的教学案例,不是教你怎么堆控件,而是用「记一笔收入/支出→自动算余额→按月汇总→导出 CSV」这一条真实动线,把信号与槽的绑定时机、模型/视图的刷新边界、QSqlTableModel 的隐式提交陷阱、QSettings 的跨平台路径兼容性等关键断点,全部暴露在可调试、可打断点、可单步跟踪的桌面环境里。它适合两类人:刚学完 C++ 基础语法、对着 QWidget 文档发懵的在校学生;以及从 Web 或嵌入式转来、需要快速建立「事件驱动 + 数据持久化 + 界面响应」闭环认知的转岗工程师。别小看这个记账本——它没用任何云服务、不依赖浏览器、不碰网络请求,但恰恰是这种「纯本地、强交互、弱后台」的形态,最能逼你搞懂 Qt 的灵魂:对象树生命周期、事件循环调度、模型数据同步的三重时序关系。
2. 从零搭起主窗口骨架:用 Qt Designer 快速生成 UI,再用 C++ 补齐业务逻辑
Qt 的优势不在“全手写 UI”,而在“设计即代码、修改即生效”的双向工作流。我们不跳过 Designer,因为它是理解 Qt 对象命名规则、布局管理器行为、信号自动连接机制的第一现场。
2.1 用 Qt Designer 拉出核心界面组件(含命名规范)
打开 Qt Creator → 新建 Qt Widgets Application → 选择MainWindow模板 → 进入 Designer 视图。按以下结构拖放控件,并严格按此命名(后续 C++ 代码将直接通过ui->xxx访问):
- 顶部水平布局:
dateEdit(QDateEdit,设calendarPopup: true)、typeCombo(QComboBox,填入“收入”“支出”)、amountEdit(QDoubleSpinBox,范围-999999.99 ~ 999999.99,精度2)、descEdit(QLineEdit) - 中间按钮区:
addBtn(QPushButton,文字“添加记录”)、delBtn(QPushButton,文字“删除选中”) - 下方表格:
tableView(QTableView,注意:不要用 QTableWidget,为后续接入 QSqlTableModel 预留接口) - 底部状态栏:
statusLabel(QLabel,用于显示当前总余额)
提示:所有控件必须放入
QVBoxLayout或QHBoxLayout布局中,禁止绝对定位。Qt 的布局管理器是响应式的基础,手动 setGeometry 在高 DPI 屏或缩放设置下必然翻车。
2.2 手动编写AccountModel类:封装数据库操作与数据映射
Designer 只管界面,数据层必须自己写。我们不直接操作QSqlQuery,而是封装一个继承自QSqlTableModel的AccountModel,让它承担三件事:自动建表、字段映射、余额计算缓存。
// accountmodel.h #ifndef ACCOUNTMODEL_H #define ACCOUNTMODEL_H #include <QSqlTableModel> #include <QSqlDatabase> #include <QVariant> class AccountModel : public QSqlTableModel { Q_OBJECT public: explicit AccountModel(QObject *parent = nullptr, QSqlDatabase db = QSqlDatabase()); // 重载 data() 以支持余额列动态计算 QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; // 提供余额查询接口(非模型列,独立计算) double getTotalBalance() const; protected: void initDatabase(); // 创建表、插入初始数据(如无) }; #endif // ACCOUNTMODEL_H// accountmodel.cpp #include "accountmodel.h" #include <QSqlQuery> #include <QSqlError> #include <QDebug> AccountModel::AccountModel(QObject *parent, QSqlDatabase db) : QSqlTableModel(parent, db) { setTable("records"); setEditStrategy(QSqlTableModel::OnFieldChange); // 关键!实时提交,避免手动 submitAll() initDatabase(); select(); // 加载数据 } void AccountModel::initDatabase() { QSqlQuery query(database()); if (!query.exec(R"( CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('收入', '支出')), amount REAL NOT NULL, description TEXT ) )")) { qWarning() << "创建表失败:" << query.lastError(); } } QVariant AccountModel::data(const QModelIndex &index, int role) const { if (!index.isValid()) return QVariant(); // 第5列(索引4)为“余额”,不存于数据库,动态计算 if (index.column() == 4 && role == Qt::DisplayRole) { double balance = 0.0; for (int i = 0; i <= index.row(); ++i) { QModelIndex amtIdx = this->index(i, 3); // amount 列 double amt = data(amtIdx, Qt::DisplayRole).toDouble(); QString type = data(this->index(i, 2), Qt::DisplayRole).toString(); balance += (type == "收入") ? amt : -amt; } return QString::number(balance, 'f', 2); } return QSqlTableModel::data(index, role); } double AccountModel::getTotalBalance() const { double total = 0.0; for (int i = 0; i < rowCount(); ++i) { QModelIndex amtIdx = this->index(i, 3); double amt = data(amtIdx, Qt::DisplayRole).toDouble(); QString type = data(this->index(i, 2), Qt::DisplayRole).toString(); total += (type == "收入") ? amt : -amt; } return total; }参数说明与逻辑重点:
setEditStrategy(QSqlTableModel::OnFieldChange)是血泪经验:若用OnRowChange,用户改完一行后焦点移走才提交,期间若程序崩溃或误操作,数据就丢了;OnFieldChange保证每次单元格失焦即写库,虽略增 IO,但对记账这类强一致性场景是刚需。data()重载中第 5 列(余额)不查库,而是从第 0 行累加到当前行——这是为支持「按日期排序后仍能正确显示每笔余额」而做的必要妥协。真实项目中可预计算并缓存,但教学案例优先保证逻辑透明。getTotalBalance()是对外提供的只读接口,供状态栏调用,不触发视图刷新,避免循环更新。
3. 绑定信号与槽:让按钮点击真正驱动数据写入与界面刷新
UI 和模型搭好了,但它们还是两座孤岛。Qt 的核心价值,就体现在如何用最少的胶水代码,把用户动作(点击)、数据变更(插入)、界面响应(刷新表格+更新余额)三者串成一条无损流水线。
3.1 主窗口构造函数中完成模型与视图绑定
// mainwindow.cpp(构造函数片段) #include "accountmodel.h" MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) , ui(new Ui::MainWindow) { ui->setupUi(this); // 1. 初始化数据库连接(单例模式,避免多连接冲突) QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE"); db.setDatabaseName("account.db"); // 同目录下生成 if (!db.open()) { qCritical() << "无法打开数据库:" << db.lastError(); QMessageBox::critical(this, "错误", "数据库初始化失败,请检查权限"); return; } // 2. 创建模型并绑定到 tableView model = new AccountModel(this, db); ui->tableView->setModel(model); ui->tableView->setSelectionBehavior(QAbstractItemView::SelectRows); ui->tableView->setSelectionMode(QAbstractItemView::SingleSelection); // 3. 隐藏 ID 列(技术主键,用户无需看到) ui->tableView->hideColumn(0); // 4. 设置列宽自适应(但保留手动调整能力) ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); ui->tableView->horizontalHeader()->setSectionResizeMode(1, QHeaderView::ResizeToContents); // date 列适配内容 ui->tableView->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); // type 列 ui->tableView->horizontalHeader()->setSectionResizeMode(3, QHeaderView::ResizeToContents); // amount 列 // 5. 初始化状态栏余额显示 updateStatusBar(); }关键点解析:
QSqlDatabase::addDatabase()必须在new AccountModel之前调用,且db.setDatabaseName()的路径应为相对路径(如"account.db"),Qt 会自动解析为可执行文件所在目录。若写成绝对路径(如"/home/user/account.db"),打包发布后在其他机器上必然报错。setSelectionBehavior和setSelectionMode是用户体验底线:用户点一行,整行高亮;一次只允许选中一行,避免误删多条。hideColumn(0)不是可选项——ID 是数据库内部标识,暴露给用户只会引发困惑(“为什么我不能改这个数字?”)。
3.2 实现添加按钮逻辑:校验 → 插入 → 刷新 → 清空 → 聚焦
// mainwindow.cpp(槽函数) void MainWindow::on_addBtn_clicked() { // 1. 基础校验:金额不能为0,描述不能为空(可放宽) double amount = ui->amountEdit->value(); if (qFuzzyCompare(amount, 0.0)) { QMessageBox::warning(this, "输入错误", "金额不能为零"); return; } QString desc = ui->descEdit->text().trimmed(); if (desc.isEmpty()) { QMessageBox::warning(this, "输入错误", "请填写备注"); return; } // 2. 构造新记录(注意:dateEdit 返回 QDate,需转为字符串存储) QString dateStr = ui->dateEdit->date().toString("yyyy-MM-dd"); QString type = ui->typeCombo->currentText(); // 3. 使用模型插入(非原始 SQL),确保触发模型内部机制 QSqlRecord record = model->record(); record.setValue("date", dateStr); record.setValue("type", type); record.setValue("amount", amount); record.setValue("description", desc); if (!model->insertRecord(-1, record)) { // -1 表示追加到末尾 QMessageBox::critical(this, "写入失败", "数据库插入异常:" + model->lastError().text()); return; } // 4. 强制刷新模型(关键!否则 tableView 不更新) model->select(); // 5. 更新状态栏余额 updateStatusBar(); // 6. 清空输入框,光标回到金额框(提升操作流) ui->amountEdit->clear(); ui->descEdit->clear(); ui->amountEdit->setFocus(); }为什么必须调model->select()?QSqlTableModel::insertRecord()并不会自动触发视图刷新。它的作用只是把记录写入数据库并更新模型缓存,但QTableView的显示依赖model->rowCount()和model->data()的返回值。select()会重新执行SELECT * FROM records,强制模型重载全部数据并发出dataChanged信号,这才是视图更新的真正开关。漏掉这句,你会看到“点了添加,表格没变,但数据库里确实多了”。
3.3 实现删除按钮逻辑:确认 → 获取选中行 → 删除 → 刷新
void MainWindow::on_delBtn_clicked() { QModelIndexList selected = ui->tableView->selectionModel()->selectedRows(); if (selected.isEmpty()) { QMessageBox::information(this, "提示", "请先选中要删除的记录"); return; } if (QMessageBox::question(this, "确认删除", QString("确定删除 %1 条记录?").arg(selected.size()), QMessageBox::Yes | QMessageBox::No) != QMessageBox::Yes) { return; } // 逆序删除(避免索引偏移) for (int i = selected.size() - 1; i >= 0; --i) { model->removeRow(selected[i].row()); } // 提交更改(OnFieldChange 策略下,removeRow 已触发提交,但 select 仍需调用) model->select(); updateStatusBar(); }逆序删除是硬性要求:selectedRows()返回的是按点击顺序的 QModelIndex 列表,但removeRow(0)删除第一行后,原第 1 行变成新第 0 行。若正序删,第二次removeRow(1)就会跳过实际的第二行,导致漏删。逆序则始终安全。
4. 避坑指南:5 个新手必踩、但文档绝口不提的 Qt 记账本陷阱
Qt 官方文档写得极好,但它默认你已理解“事件循环”“对象父子关系”“模型缓存机制”这些隐性知识。而记账本这类小项目,恰恰最容易在这些地方静默失败。以下是我在带某高校实训班时,学生集中暴雷的 5 个点,附现象、根因与解法。
4.1 现象:程序关闭后,再次启动,表格为空,但account.db文件里有数据
原因:QSqlDatabase连接未显式关闭,且QSqlTableModel析构时未调用database().close()。Qt 的数据库连接池在进程退出时未必能可靠释放,尤其 Windows 下易出现文件锁残留,导致下次db.open()失败但无报错,model->select()返回空结果。
解决:在MainWindow析构函数中显式关闭:
MainWindow::~MainWindow() { delete ui; if (model && model->database().isOpen()) { model->database().close(); } }4.2 现象:在tableView中双击编辑金额,输完回车,数值变回原样
原因:QSqlTableModel默认只允许编辑QSqlRelationalTableModel关联的外键列,普通列需重载flags()并添加Qt::ItemIsEditable。
解决:在AccountModel中添加:
Qt::ItemFlags AccountModel::flags(const QModelIndex &index) const { Qt::ItemFlags flags = QSqlTableModel::flags(index); if (index.column() == 3) { // amount 列可编辑 flags |= Qt::ItemIsEditable; } return flags; }4.3 现象:dateEdit选了 2024-05-20,但数据库里存的是2024-05-19
原因:QDateEdit::date()返回的是本地时区QDate,但 SQLite 存储TEXT时若未指定格式,Qt 可能按 UTC 解析。更常见的是:QDate::toString("yyyy-MM-dd")在某些 locale 下输出2024/05/20,被 SQLite 当作非法日期忽略。
解决:强制使用Qt::ISODate格式,且在initDatabase()中为date字段加NOT NULL约束,让插入失败时立刻报错:
record.setValue("date", ui->dateEdit->date().toString(Qt::ISODate)); // 输出 2024-05-204.4 现象:添加多条记录后,getTotalBalance()返回值比手动计算器少 0.01 元
原因:double类型浮点精度误差累积。QDoubleSpinBox显示100.01,底层存储可能是100.00999999999999,累加后四舍五入偏差放大。
解决:金额字段统一用int存分(如100.01存10001),显示时再除以 100。修改建表语句:
amount INTEGER NOT NULL -- 单位:分并在data()和insertRecord中做*100//100.0转换。这是金融类应用的铁律,不容玄学。
4.5 现象:打包成.exe后,在其他电脑运行,提示QSQLITE driver not loaded
原因:QSqlDatabase::addDatabase("QSQLITE")需要qsqlite.dll插件,该文件不在可执行文件同目录,Qt 无法自动找到。
解决:使用windeployqt工具(Qt 安装目录下)部署:
windeployqt --no-translations --no-system-d3d-11 --no-opengl-sw your_app.exe它会自动拷贝sqldrivers/qsqlite.dll到sqldrivers/子目录。切勿手动复制,版本不匹配会导致静默失败。
5. 进阶技巧:用 QSettings 保存最后使用的日期和窗口尺寸,让记账本真正“记住你”
一个合格的桌面工具,不该每次启动都回到“今天”和“默认大小”。QSettings是 Qt 提供的跨平台配置存储方案,它自动处理 Windows 注册表、macOS plist、Linux ini 文件的差异,且线程安全。我们用它存两样东西:用户上次选的记账日期、主窗口的几何尺寸。
5.1 在MainWindow构造函数中读取设置
// mainwindow.cpp(构造函数末尾添加) QSettings settings; // 恢复日期 QDate lastDate = settings.value("lastDate", QDate::currentDate()).toDate(); ui->dateEdit->setDate(lastDate); // 恢复窗口尺寸(仅当有效时) QByteArray geometry = settings.value("geometry").toByteArray(); if (!geometry.isEmpty()) { restoreGeometry(geometry); }5.2 在closeEvent中保存设置
void MainWindow::closeEvent(QCloseEvent *event) { QSettings settings; settings.setValue("lastDate", ui->dateEdit->date()); settings.setValue("geometry", saveGeometry()); // 注意:不是 geometry(),是 saveGeometry() event->accept(); }关键区别:saveGeometry()返回的是包含位置、大小、状态(最大化/最小化)的完整 QByteArray,而geometry()只返回 QRect。若只存geometry(),窗口最大化后关闭,下次启动会以“最大化前的尺寸”弹出,体验极差。
5.3 用 QTranslator 实现简体中文界面(零成本国际化)
Qt 自带中文翻译文件,只需两行代码即可切换:
// main.cpp 开头添加 #include <QTranslator> #include <QLocale> int main(int argc, char *argv[]) { QApplication a(argc, argv); // 加载 Qt 自带中文翻译 QTranslator qtTranslator; qtTranslator.load("qt_zh_CN", QLibraryInfo::location(QLibraryInfo::TranslationsPath)); a.installTranslator(&qtTranslator); // 加载应用级翻译(可选,此处暂不实现 .ts 文件) // QTranslator appTranslator; // appTranslator.load(":/translations/myapp_zh.qm"); // a.installTranslator(&appTranslator); MainWindow w; w.show(); return a.exec(); }注意:
QLibraryInfo::location(QLibraryInfo::TranslationsPath)在 Qt 6 中已废弃,应改为QLibraryInfo::path(QLibraryInfo::TranslationsPath)。但教学案例建议用 Qt 5.15 LTS 版本,稳定性更高,且qt_zh_CN.qm文件在安装包中默认存在。
6. 验证你的记账本是否“生产可用”:一份可执行的自查清单
写完代码不等于项目完成。我给自己定了一条铁律:任何桌面工具,在交付前必须通过这 7 项本地验证。它们不涉及性能压测,但直指真实用户每天会遇到的“小故障”。
| 验证项 | 操作步骤 | 期望结果 | 不通过意味着 |
|---|---|---|---|
| 1. 断电模拟 | 添加 3 条记录 → 突然结束进程(任务管理器杀进程)→ 重启程序 | 表格显示全部 3 条,余额正确 | 数据未实时提交,OnFieldChange未生效或数据库连接异常 |
| 2. 高 DPI 适配 | Windows 设置 → 缩放设为 125% → 启动程序 | 所有文字清晰,按钮不重叠,表格列宽自适应 | 未启用QApplication::setAttribute(Qt::AA_EnableHighDpiScaling) |
| 3. 中文路径测试 | 把account.db所在文件夹重命名为记账本_2024→ 运行程序 | 正常读写,无乱码报错 | QSqlDatabase::setDatabaseName()传入了QString,但底层 SQLite 驱动未正确处理 UTF-8 路径(Qt 5.14+ 已修复,旧版本需用QDir::toNativeSeparators()) |
| 4. 时区切换 | 系统时间改为 UTC+0 → 添加记录 → 改回本地时区 → 重启 | dateEdit显示原日期,数据库存的仍是2024-05-20字符串 | 日期存储未用ISODate,导致时区转换污染字符串值 |
| 5. 多实例互斥 | 启动两个程序实例 → 在实例 A 添加记录 → 切换到实例 B 点击select() | 实例 B 表格立即更新(需手动触发,Qt 不自动监听外部 DB 变更) | 正常,Qt 无自动监听机制;若期望自动刷新,需用QFileSystemWatcher监控 DB 文件修改时间,再调select() |
| 6. 导出防崩 | 表格选中 1000 行 → 点击“导出 CSV”(需自行实现)→ 取消导出对话框 | 程序不卡死,可继续操作 | CSV 导出未用QFile流式写入,而是拼接大字符串导致内存爆满 |
| 7. 键盘流操作 | Tab切到amountEdit→ 输入100.5→Tab→ 输入饭钱→Enter | 记录添加成功,光标回到amountEdit | QShortcut或QKeySequence绑定错误,或on_addBtn_clicked()未调setFocus() |
最后一句掏心窝子的话:别急着加图表、云同步、密码保护。先把这 7 项跑通,你就已经甩开 80% 的“玩具项目”。因为真正的工程能力,不在于功能多炫,而在于——当用户在凌晨两点记完最后一笔加班餐费,关掉电脑睡觉时,他不需要担心明天打开,那 12 块 5 毛会不会消失。希望帮到你。
本文还有配套的精品资源,点击获取