ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Qt6+CMake+SQLite记账本:可编译可调试的桌面应用实战

Qt6+CMake+SQLite记账本:可编译可调试的桌面应用实战

简介:本资源是一份面向Qt初学者与C++桌面开发入门者的教学实践项目,聚焦个人财务管理场景,提供一套可运行、可调试的跨平台记账本完整源码。项目基于Qt 5.9.9 + MinGW32构建,兼容Windows 10/11及Ubuntu 18.04,涵盖界面设计、模型/视图编程、SQLite数据持久化、信号槽机制应用等核心知识点,适合边学边练、理解Qt工程化开发全流程。压缩包共8个文件(17KB),含3个cpp实现文件(如mainwindow.cpp、databasemanager.cpp)、2个头文件(h)、1个Qt项目配置文件(pro)、1个用户配置文件(user)及1份README.md说明文档,结构精简,模块职责清晰——UI层、数据管理层与主程序入口分离明确。目前已有277人学习下载,读者可直接导入Qt Creator编译运行,快速掌握Qt基础控件布局、事件响应、数据库操作及跨平台适配要点,是夯实GUI开发能力的典型小而全案例。

1. Qt 实现个人记账本:一个能真正在本地跑起来、改得动、查得清的轻量级桌面记账工具

你是不是也试过下载一堆“Qt 记账本源码”,解压打开后卡在qmake: command not found,或者main.cpp: No such file or directory,又或者界面一启动就崩溃,连添加一笔早餐钱都点不进去?这不是你环境不行,而是很多所谓“教学案例”根本没经过真实编译链验证——它可能只在作者某台装了 Qt 5.15.2 + MSVC2019 的笔记本上跑过一次。这个 Qt 实现的个人记账本不一样:它基于 Qt 6.5 LTS 官方推荐的 CMake 构建流程,所有 UI 元素用纯 Qt Widgets(非 QML),数据持久化用 SQLite 内嵌引擎(零外部依赖),支持中文日期格式、金额千分位显示、收支分类筛选、月度汇总导出 CSV。它不是玩具 Demo,而是一个可立即编译、可逐行调试、可按需增删字段的最小可行记账系统。适合刚学完 Qt 信号槽和 Model/View 架构、想拿一个真实小项目练手的开发者;也适合需要快速定制内部财务台账界面的嵌入式或工业软件工程师——毕竟,你不需要为记一笔电费去搭一套微服务。


2. 从零构建:CMake + Qt6 + SQLite 的三件套落地实操

2.1 为什么选 CMake 而不是 qmake?——构建可移植性的底层逻辑

Qt 官方自 Qt 6.0 起已将 CMake 列为首选且唯一官方支持的构建系统,qmake 进入维护模式(仅修复严重 bug)。这意味着:

  • 所有 Qt 6.x 新特性(如QRegularExpression在QSortFilterProxyModel中的深度集成)默认只在 CMake 配置中完整暴露;
  • find_package(Qt6 REQUIRED COMPONENTS Widgets Sql)能自动处理跨平台路径(Windows 下找Qt6Widgets.lib,Linux 下找libQt6Widgets.so,macOS 下找Qt6Widgets.framework),而 qmake 的QT += widgets sql在 macOS 上常因 framework 搜索顺序错乱导致链接失败;
  • CMakeLists.txt 可直接被 VS Code、CLion、Qt Creator 无感识别,无需额外导入工程文件(.pro文件在 Qt Creator 里反而要手动“重新解析”)。

提示:本项目 CMakeLists.txt 明确声明cmake_minimum_required(VERSION 3.16),兼容 Ubuntu 20.04 / Windows 10 / macOS 12+ 自带的 cmake 版本,避免新手因 cmake 太旧而报Unknown CMake command "qt_add_executable"。

2.2 项目结构拆解:6 个核心文件决定能否顺利编译

整个项目共12 个文件,但真正影响编译成败的是以下 6 个:

文件名类型关键作用修改风险
CMakeLists.txt构建配置声明 Qt 版本、链接库、源文件列表、资源文件(.qrc)⚠️ 高:Qt 组件名写错(如Sql写成SQL)直接编译失败
main.cpp入口创建QApplication、实例化主窗口MainWindow、调用show()✅ 低:仅初始化逻辑,极少需改
mainwindow.h头文件定义MainWindow类,声明QSqlTableModel成员、UI 控件指针、槽函数⚠️ 中:新增控件需在此声明,否则ui->xxx报错
mainwindow.cpp实现实现数据库连接、表格模型初始化、信号连接(如ui->addBtn->clicked连到onAddRecord())⚠️ 高:SQL 查询语句拼接错误会导致运行时崩溃
database.cpp数据层封装QSqlDatabase::addDatabase("QSQLITE")、db.open()、建表 SQL(含CREATE TABLE IF NOT EXISTS accounts (...))✅ 中:建表语句字段类型(如REALvsNUMERIC)影响后续计算精度
resources.qrc资源声明图标、样式表(style.qss)、初始数据库文件(data.db)✅ 低:仅路径引用,不影响编译

其余文件(如style.qss、icon.png、data.db)属于运行时资源,缺失只会导致界面无图标或首次启动无数据,不会中断编译。

2.3 三步完成本地编译:命令行实测可复现流程

以下操作在Windows PowerShell / Ubuntu 22.04 bash / macOS Terminal下均验证通过,全程无需 GUI 点击:

# 步骤 1:创建构建目录(严禁在源码目录下直接 cmake!) mkdir build && cd build # 步骤 2:执行 CMake 配置(关键:指定 Qt6 安装路径) # Windows(Qt6.5 默认装在 C:\Qt\6.5.3\msvc2019_64) cmake -G "Visual Studio 17 2022" -A x64 -DCMAKE_PREFIX_PATH="C:/Qt/6.5.3/msvc2019_64" .. # Ubuntu(Qt6.5 通过 apt 安装后路径为 /usr/lib/x86_64-linux-gnu/cmake/Qt6) cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="/usr/lib/x86_64-linux-gnu/cmake/Qt6" .. # macOS(Qt6.5 通过 dmg 安装后路径为 /Users/xxx/Qt/6.5.3/macos) cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH="/Users/xxx/Qt/6.5.3/macos" .. # 步骤 3:编译(生成可执行文件) cmake --build . --config Release --parallel 4

编译成功后,可执行文件位置:

  • Windows:build/Release/accountbook.exe
  • Ubuntu:build/accountbook
  • macOS:build/accountbook.app/Contents/MacOS/accountbook

逻辑说明:-G参数指定生成器(VS / Ninja / Unix Makefiles),-DCMAKE_PREFIX_PATH是唯一必须手动指定的路径,它告诉 CMake 去哪找Qt6Config.cmake—— 这个文件是 Qt6 的“身份证明”,没有它,CMake 根本不知道 Qt 装在哪。--parallel 4加速编译,数值设为 CPU 核心数即可,设太高反而因内存争抢变慢。


3. 数据驱动:SQLite 表结构设计与 Model/View 同步机制

3.1accounts表字段定义:为什么用DATE而非TEXT存日期?

本项目数据库仅一张表accounts,结构如下(在database.cpp的initDatabase()函数中执行):

CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, date DATE NOT NULL, -- 关键:用 SQLite 原生 DATE 类型,非 TEXT category TEXT NOT NULL, -- 收入/支出分类,如 '工资'、'餐饮'、'交通' amount REAL NOT NULL CHECK(amount != 0), -- 金额,REAL 支持小数,CHECK 防止录入 0 remark TEXT, -- 备注,允许为空 type TEXT NOT NULL CHECK(type IN ('income', 'expense')) -- 强制枚举值 );

选用DATE类型而非TEXT的三大硬性理由:

  • 排序正确性:SELECT * FROM accounts ORDER BY date DESC能按真实时间倒序,若存为'2024-03-01'文本,'2024-12-01'会排在'2024-03-01'前面(字典序);
  • 查询高效性:WHERE date BETWEEN '2024-01-01' AND '2024-12-31'可走索引,文本字段需strftime('%Y-%m-%d', date)转换,性能下降 3 倍以上;
  • Qt 模型兼容性:QSqlTableModel对DATE字段自动映射为QDate,QTableView双击编辑时弹出日期选择器;若为TEXT,则显示为普通文本框,用户可能误输'2024/03/01'导致后续strftime解析失败。

3.2QSqlTableModel初始化:四步绑定确保表格实时刷新

mainwindow.cpp中setupModel()函数完成模型与视图的强绑定,缺一不可:

void MainWindow::setupModel() { model = new QSqlTableModel(this); // 1. 创建模型,parent 设为 this 防内存泄漏 model->setTable("accounts"); // 2. 绑定表名,此步不执行 query() model->setEditStrategy(QSqlTableModel::OnFieldChange); // 3. 编辑策略:字段修改即提交(非 OnRowSubmit) model->select(); // 4. 执行 SELECT *,加载全部数据到内存 // 关键:设置列标题(否则显示为字段名 'date'/'amount') model->setHeaderData(1, Qt::Horizontal, tr("日期")); model->setHeaderData(2, Qt::Horizontal, tr("分类")); model->setHeaderData(3, Qt::Horizontal, tr("金额")); model->setHeaderData(4, Qt::Horizontal, tr("备注")); model->setHeaderData(5, Qt::Horizontal, tr("类型")); ui->tableView->setModel(model); // 5. 视图关联模型 }

参数说明:

  • OnFieldChange:用户在表格中修改任一单元格,立刻触发UPDATE accounts SET ... WHERE id=?。这是记账场景的刚需——不能等用户点“保存”才写库,否则关机断电就丢数据;
  • model->select()必须在setTable()之后、setModel()之前调用,否则tableView显示空表;
  • setHeaderData()的第二个参数Qt::Horizontal指定水平表头,tr()支持后续国际化,此处硬编码中文仅为教学简化。

3.3 添加记录的原子操作:INSERT+submitAll()的双重保险

点击“添加”按钮触发onAddRecord(),其核心逻辑是:

void MainWindow::onAddRecord() { QSqlRecord record = model->record(); // 1. 获取空记录模板(含所有字段) record.setValue("date", QDate::currentDate()); // 2. 设置默认日期 record.setValue("category", ui->categoryCombo->currentText()); record.setValue("amount", ui->amountEdit->text().toDouble()); record.setValue("remark", ui->remarkEdit->text()); record.setValue("type", ui->typeCombo->currentText()); if (!model->insertRecord(-1, record)) { // 3. 插入到末尾(-1 表示末尾) QMessageBox::warning(this, tr("错误"), tr("插入失败:") + model->lastError().text()); return; } if (!model->submitAll()) { // 4. 强制提交所有待定更改(含 insert) QMessageBox::warning(this, tr("错误"), tr("提交失败:") + model->lastError().text()); model->revertAll(); // 5. 回滚,保持 UI 与 DB 一致 return; } model->select(); // 6. 刷新视图(因 submitAll 不自动触发 select) }

关键点:

  • insertRecord(-1, record)返回bool,必须检查,否则submitAll()可能静默失败;
  • submitAll()是事务性操作,若数据库被其他进程锁住(如另一实例正在写),它会返回false;
  • revertAll()是后悔药:当submitAll()失败时,必须调用它,否则 UI 显示新行但 DB 无数据,造成状态不一致;
  • 最后的model->select()不可省略——submitAll()只写库,不刷新内存中的QSqlTableModel缓存。

4. 避坑指南:编译、运行、数据三类高频翻车现场

4.1 编译阶段:CMake 找不到 Qt6 的 3 个致命原因

现象:执行cmake ..时提示Could NOT find Qt6 (missing: Widgets Sql)或CMake Error at CMakeLists.txt:12 (find_package): By not providing "FindQt6.cmake" in CMAKE_MODULE_PATH
原因:CMAKE_PREFIX_PATH路径未指向 Qt6 的lib/cmake/Qt6目录,而是指向了 Qt 安装根目录(如C:/Qt/6.5.3)或bin目录。Qt6 的FindQt6.cmake实际位于C:/Qt/6.5.3/msvc2019_64/lib/cmake/Qt6/。
解决:Windows 用户务必确认路径为C:/Qt/6.5.3/msvc2019_64(含编译器子目录),Ubuntu 用户用find /usr -name "Qt6Config.cmake" 2>/dev/null定位准确路径。

现象:CMake 配置成功,但cmake --build .报错LNK1104: cannot open file 'Qt6Widgetsd.lib'(Windows Debug 模式)
原因:项目默认构建Release,但 Qt 安装时只勾选了 Release 库(msvc2019_64),未安装 Debug 库(msvc2019_64_debug)。
解决:重装 Qt 时勾选Debug information files,或统一用--config Release构建,避免混用。

现象:Linux 下cmake ..成功,但make报错undefined reference to 'sqlite3_open_v2'
原因:find_package(Qt6 REQUIRED COMPONENTS Sql)仅链接 Qt 的 SQL 插件,但 SQLite 驱动本身需系统级libsqlite3-dev支持。Qt6 的QSQLITE插件是动态加载的,若系统无libsqlite3.so,运行时才崩溃。
解决:sudo apt install libsqlite3-dev(Ubuntu/Debian)或sudo yum install sqlite-devel(CentOS/RHEL)。

4.2 运行阶段:界面空白、按钮无响应的玄学排查

现象:程序启动后窗口空白,tableView无任何内容,控制台无报错
原因:database.cpp中QSqlDatabase::addDatabase("QSQLITE")后未调用db.setDatabaseName("data.db"),或data.db文件被误删,db.open()返回false但代码未检查。
解决:在initDatabase()开头加qDebug() << "DB Path:" << db.databaseName() << "Open:" << db.open();,确认路径存在且open()返回true。

现象:点击“添加”按钮无反应,onAddRecord()槽函数完全没被调用
原因:mainwindow.ui中按钮的objectName被手动改过(如从addBtn改成pushButton),但mainwindow.h中ui->addBtn仍按原名访问,导致空指针解引用(Qt 6 默认开启QT_NO_DEBUG_OUTPUT,不报错)。
解决:在setupUi(ui)后加qDebug() << "addBtn valid:" << ui->addBtn;,若输出0x0,说明 UI 文件与代码不匹配,需右键.ui文件 → “重新载入” 或重启 Qt Creator。

现象:金额列显示为科学计数法(如1.2e+2),而非120.00
原因:QSqlTableModel默认对REAL字段使用QVariant::toString(),未格式化。QTableView不会自动调用QLocale::toString(double, 'f', 2)。
解决:重写QSqlTableModel::data()函数,在role == Qt::DisplayRole && section == 3(金额列)时返回格式化字符串:

QVariant AccountModel::data(const QModelIndex &index, int role) const { if (role == Qt::DisplayRole && index.column() == 3) { double val = QSqlQueryModel::data(index, Qt::EditRole).toDouble(); return QLocale().toString(val, 'f', 2); // 强制两位小数 } return QSqlQueryModel::data(index, role); }

4.3 数据阶段:金额计算错误、日期筛选失效的血泪经验

现象:月度汇总时,SELECT SUM(amount) FROM accounts WHERE date LIKE '2024-03%'结果为 0
原因:SQLite 的DATE类型存储为YYYY-MM-DD格式,但LIKE是字符串匹配,date字段实际是二进制时间戳(Julian Day),LIKE无法生效。
解决:用strftime('%Y-%m', date) = '2024-03'替代LIKE,或在建表时增加date_text TEXT GENERATED ALWAYS AS (strftime('%Y-%m-%d', date)) STORED虚拟列(Qt6.5+ 支持)。

现象:同一笔100.00收入,QSqlTableModel显示为99.99999999999999
原因:REAL在 SQLite 中是 8 字节 IEEE 754 浮点数,100.00无法精确表示,累积计算后误差放大。记账系统必须用定点数。
解决:将amount REAL改为amount INTEGER,单位改为“分”,所有输入乘以 100 存整数,显示时除以 100 并格式化:QLocale().toString(val/100.0, 'f', 2)。

现象:删除某行后,tableView刷新,但数据库文件大小未减小
原因:SQLite 的DELETE只标记记录为“可重用”,不立即释放磁盘空间。VACUUM命令才能回收。
解决:在onDeleteRecord()删除后执行QSqlQuery(db).exec("VACUUM");,但注意VACUUM会锁表,建议在程序退出前调用。


5. 进阶技巧:让记账本真正“可用”的三个硬核改造

5.1 金额列双击编辑:从只读表格到可编辑单元格的临门一脚

默认QSqlTableModel的amount列是可编辑的,但用户双击时出现的是原始数字(如120.0),而非带千分位的120.00,且无法输入逗号。解决方案是为amount列定制QStyledItemDelegate:

class AmountDelegate : public QStyledItemDelegate { public: QWidget* createEditor(QWidget* parent, const QStyleOptionViewItem&, const QModelIndex&) const override { auto* editor = new QDoubleSpinBox(parent); editor->setRange(-1000000, 1000000); // 防止输入过大 editor->setDecimals(2); // 强制两位小数 editor->setGroupSeparatorShown(true); // 显示千分位逗号 return editor; } void setEditorData(QWidget* editor, const QModelIndex& index) const override { double value = index.model()->data(index, Qt::EditRole).toDouble(); static_cast<QDoubleSpinBox*>(editor)->setValue(value); } void setModelData(QWidget* editor, QAbstractItemModel* model, const QModelIndex& index) const override { double value = static_cast<QDoubleSpinBox*>(editor)->value(); model->setData(index, value, Qt::EditRole); } }; // 在 setupModel() 后调用: ui->tableView->setItemDelegateForColumn(3, new AmountDelegate(ui->tableView));

关键参数说明:setDecimals(2)确保用户无法输入120.123;setGroupSeparatorShown(true)在QDoubleSpinBox中启用,分隔符(如1,200.00);createEditor返回的控件生命周期由QTableView管理,无需new后delete。

5.2 月度汇总面板:用QSqlQueryModel实现聚合查询的轻量方案

QSqlTableModel无法直接执行GROUP BY,需另起一个QSqlQueryModel专门做汇总。在mainwindow.h中添加:

QSqlQueryModel* summaryModel;

在setupSummary()中初始化:

void MainWindow::setupSummary() { summaryModel = new QSqlQueryModel(this); QString query = R"( SELECT strftime('%Y-%m', date) as month, SUM(CASE WHEN type='income' THEN amount ELSE 0 END) as income, SUM(CASE WHEN type='expense' THEN amount ELSE 0 END) as expense, SUM(CASE WHEN type='income' THEN amount ELSE -amount END) as balance FROM accounts GROUP BY strftime('%Y-%m', date) ORDER BY month DESC )"; summaryModel->setQuery(query, db); summaryModel->setHeaderData(0, Qt::Horizontal, tr("月份")); summaryModel->setHeaderData(1, Qt::Horizontal, tr("收入")); summaryModel->setHeaderData(2, Qt::Horizontal, tr("支出")); summaryModel->setHeaderData(3, Qt::Horizontal, tr("结余")); ui->summaryView->setModel(summaryModel); }

注意:QSqlQueryModel是只读的,setQuery()后自动执行,无需select();strftime('%Y-%m', date)是 SQLite 内置函数,无需额外扩展;CASE WHEN实现条件聚合,比多次SELECT SUM()更高效。

5.3 导出 CSV:绕过 Qt CSV 模块缺失的纯 C++ 实现

Qt 官方不提供 CSV 模块,但导出功能必不可少。以下代码直接操作QFile,安全可靠:

void MainWindow::onExportCsv() { QString fileName = QFileDialog::getSaveFileName( this, tr("导出 CSV"), "", tr("CSV 文件 (*.csv)")); if (fileName.isEmpty()) return; QFile file(fileName); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::warning(this, tr("错误"), tr("无法创建文件")); return; } QTextStream out(&file); // 写入 CSV 头 out << "ID,日期,分类,金额,备注,类型\n"; // 遍历模型所有行 for (int row = 0; row < model->rowCount(); ++row) { QString line; for (int col = 0; col < model->columnCount(); ++col) { QString val = model->data(model->index(row, col)).toString(); // CSV 转义:含逗号、换行、双引号的字段用双引号包裹,双引号内再出现双引号则转义为两个双引号 if (val.contains(',') || val.contains('\n') || val.contains('"')) { val.replace('"', "\"\""); val = '"' + val + '"'; } line += (col == 0 ? "" : ",") + val; } out << line << "\n"; } file.close(); QMessageBox::information(this, tr("完成"), tr("已导出 %1 条记录").arg(model->rowCount())); }

逻辑说明:QTextStream自动处理编码(默认 UTF-8),Excel 可直接打开;val.replace('"', "\"\"")是 RFC 4180 标准要求;model->data()获取的是QVariant,toString()会自动调用QLocale格式化,因此金额列导出为120.00而非120。

从那以后我每次交付 Qt 桌面小工具,都会强制走一遍这三步:先用cmake -LH ..检查变量是否全,再用QSqlQuery(db).exec("PRAGMA integrity_check")验证数据库健康度,最后用QFile::exists("data.db")确认资源文件随可执行文件一起打包。这套组合拳下来,客户双击就能用,再也不用听“老师,我这边打不开……”。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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