Qt Widgets记账本开发实战:从SQLite到自定义模型的全流程解析
2026/9/8 4:59:44 网站建设 项目流程

简介:一份基于 Qt5.9.9 与 MinGW32 编译环境的个人记账本教学案例,面向 Qt 初学者、计算机专业学生以及需要练习跨平台桌面应用开发的读者,围绕「记账本」这一典型场景,完整演示 Qt 常用控件、信号与槽机制、数据持久化以及增删改查逻辑的工程实现。压缩包共 8 个文件,以 C++ 源文件和头文件为主,配合 Qt 工程配置文件与说明文档,整体仅 17KB,目录结构轻量清晰,便于逐模块阅读和二次练习。案例覆盖用户界面设计、本地数据存储、记账项添加/删除/查询/统计、跨平台适配等关键环节,并兼顾 Windows 10/11 与 Ubuntu 18.04 的兼容处理,可直接用于课程设计或 Qt 教学参考,已有 275 人学习浏览。通过研读主窗口与数据库管理模块的实现,读者能够掌握模型/视图、样式表、布局组织等实用技巧,在完成个人记账功能的同时,建立对完整 Qt 项目结构、工程配置与跨平台开发流程的整体认知。 很多学了Qt基础的朋友都会遇到同一个瓶颈:信号与槽、布局、控件这些知识点单个拎出来都看得懂,但真让自己独立做一个“有完整功能”的程序,就不知道从哪里下手。如果你正处于这个阶段,那我特别建议你把“个人记账本”当成练手项目。它不是一个花哨的应用,但麻雀虽小五脏俱全,SQLite存储、自定义表格模型、增删改查、统计图表、界面布局全都能串起来,做完之后你对Qt的掌控感会完全不一样。

这篇文章我会把我实现 Qt 个人记账本(教学案例)的整套思路、表结构设计、关键代码片段和踩过的坑完整记录下来,内容按照实际开发顺序展开,从工程规划一路写到打包发布。项目本身我是用 Qt Widgets + C++ 做的,数据库选的 SQLite,表格用的 QTableView + 自定义 QAbstractTableModel,统计图用 QCustomPlot。每个模块我都会解释“为什么这么选”,让大家不是只抄代码,而是真的理解背后的设计逻辑。

1. 项目规划与界面框架搭建

1.1 为什么选 Qt Widgets 来做桌面记账本

先解决一个最容易被纠结的问题:用 Qt Widgets 还是 QML。记账本这种工具型软件,界面以表格、表单、按钮为主,数据密集、交互偏传统,Qt Widgets 是天然合适的选择。它成熟稳定,网上资料多,出错时信息也好查。当然 QML 做动画效果确实漂亮,但教学案例的重点是让学生把 C++ 的数据结构和业务逻辑理清楚,Widgets 的模型视图架构本身就很有教学价值。

而且 Qt Widgets 的学习成果是可以直接迁移到工作中的。很多工业软件、内部工具、医疗客户端都还在用 Widgets 维护和开发,把这套东西吃透,比只会在 QML 里摆控件更吃香。我自己在实际项目中,Widgets 的占比仍然很高,所以教学案例我坚决用 Widgets。

1.2 主窗口布局规划

这个记账本界面我不打算做得很复杂,主窗口按照“左右结构 + 上下分区”的思路规划:

  • 左侧是筛选区,放一个“收支类型”下拉框和“分类”下拉框,用来快速过滤记录;
  • 右侧是记录表格,用来展示账目明细;
  • 底部是一个汇总栏,实时显示总收入、总支出和结余;
  • 顶部用 QToolBar 放置“新增”“编辑”“删除”“统计”这几个核心操作按钮。

整体用 QSplitter 把左侧筛选区和右侧表格区分开,用户可以拖动分隔条调整比例。主窗口中央部件我们用一个 QWidget 做容器,最外层用 QVBoxLayout 把筛选区、表格、汇总栏纵向排布。布局时注意给表格设置合理的拉伸因子,保证窗口拉大时表格能撑满空间。

主窗口的代码骨架大致如下:

MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { setWindowTitle(QStringLiteral("个人记账本")); resize(1000, 640); QWidget *central = new QWidget(this); QVBoxLayout *mainLayout = new QVBoxLayout(central); // 顶部按钮栏 QToolBar *toolBar = new QToolBar(this); addToolBar(Qt::TopToolBarArea, toolBar); // 新增、编辑、删除、统计按钮 QAction *actAdd = new QAction(QStringLiteral("新增"), this); QAction *actEdit = new QAction(QStringLiteral("编辑"), this); QAction *actDelete = new QAction(QStringLiteral("删除"), this); QAction *actStat = new QAction(QStringLiteral("统计"), this); toolBar->addAction(actAdd); toolBar->addAction(actEdit); toolBar->addAction(actDelete); toolBar->addSeparator(); toolBar->addAction(actStat); setCentralWidget(central); }

工具栏用 QAction 是推荐做法,因为 QAction 可以同时绑定菜单、按钮和快捷键,逻辑只需写一次。后续新增、编辑、删除的槽函数都连接到这几个 action 上就好了。

1.3 工程文件组织

教学案例最忌讳所有代码堆在 mainwindow.cpp 里,文件又长又难维护。我建议按职责拆分成以下几类文件:

accountbook/ ├── accountbook.pro ├── main.cpp ├── mainwindow.h / mainwindow.cpp // 主窗口、菜单、操作入口 ├── databasehelper.h / databasehelper.cpp // 数据库初始化与通用查询 ├── recordmodel.h / recordmodel.cpp // 自定义表格模型 ├── addrecorddialog.h / addrecorddialog.cpp // 新增/编辑对话框 ├── statisticdialog.h / statisticdialog.cpp // 统计图对话框

.pro 文件里面要注意加上 sql 模块,以及 qcustomplot 的包含路径。如果用的是 Qt Charts 做图表,还需要加 charts 模块:

QT += core gui sql widgets greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11 SOURCES += \ main.cpp \ mainwindow.cpp \ databasehelper.cpp \ recordmodel.cpp \ addrecorddialog.cpp \ statisticdialog.cpp HEADERS += \ mainwindow.h \ databasehelper.h \ recordmodel.h \ addrecorddialog.h \ statisticdialog.h # 如果使用 QCustomPlot,请把 qcustomplot.h 和 qcustomplot.cpp 加入工程 SOURCES += qcustomplot/qcustomplot.cpp HEADERS += qcustomplot/qcustomplot.h

工程结构清晰之后,后续每一个新功能都只需要在对应模块里改,别人接手代码也容易看懂。这一点在项目教学里比功能本身还重要,因为“可维护性”才是工程能力的分水岭。

2. 数据层设计:用 SQLite 把账目存下来

2.1 数据库表结构设计

记账本最核心的就是账目表。我用一张 records 表加一张 category 表来存储数据。优先保证业务简单,但又能体现关系型设计的基本思想。

CREATE TABLE category ( id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER NOT NULL, -- 0 支出,1 收入 name TEXT NOT NULL ); CREATE TABLE record ( id INTEGER PRIMARY KEY AUTOINCREMENT, type INTEGER NOT NULL, -- 0 支出,1 收入 category_id INTEGER NOT NULL, amount_cents INTEGER NOT NULL, -- 金额,以“分”为单位 record_date TEXT NOT NULL, -- 日期,格式 yyyy-MM-dd note TEXT );

这里有一个很关键的设计细节:金额字段用整数amount_cents,而不是浮点数。单据金额如果直接存 double,在做累加、比较时很容易出现 0.1 + 0.2 != 0.3 这类浮点精度问题。存成分,展示的时候再除以 100,这样既精确又不需要引入额外库。

另外日期用 TEXT 类型,格式固定为yyyy-MM-dd,这样既能直接字符串排序,也方便在 SQL 里按月份聚合统计。category 表把收入和支出的分类分开,例如收入有工资、红包、理财,支出有餐饮、交通、购物。下拉框加载时按 type 过滤,不会混在一起。

2.2 数据库初始化与路径处理

数据库文件路径不能写死在代码里,不同系统的可写目录不一样。推荐用QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)拿当前用户的 AppData 目录,再创建我们自己的子目录:

QString DatabaseHelper::getDbPath() { QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); QDir().mkpath(dir); // 目录可能不存在,先创建 return dir + QStringLiteral("/accountbook.db"); }

初始化数据库时要注意,QSqlDatabase::addDatabase的 connectionName 很关键。如果你在多个类里反复调用addDatabase()且不指定连接名,第二次就会报 “duplicate connection name” 错误。更安全的方式是使用全局唯一的连接名,并在打开之后把这个连接传出去:

QSqlDatabase DatabaseHelper::getConnection() { QString connName = QStringLiteral("accountbook_conn"); if (QSqlDatabase::contains(connName)) { return QSqlDatabase::database(connName); } QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE", connName); db.setDatabaseName(getDbPath()); db.open(); return db; }

建表语句统一放在初始化函数里,用QSqlQuery::exec执行即可。如果表已经存在,SQLite 不会重复创建,所以我们每次启动都跑一遍建表语句问题不大。教学时可以把建表 SQL 放在一个单独的init.sql文件里,从资源文件加载,逻辑上更干净。

2.3 数据访问接口封装

我不建议在界面代码里直接拼 SQL,那样后期维护是噩梦。更稳妥的做法是给数据库操作封装一个 DAO 层,界面层只关心“插入一条记录”“查询记录列表”这种业务动作。教学案例里我们的 DatabaseHelper 只保留几个核心接口:

  • QVector<Record> queryRecords(int typeFilter, int categoryId, const QString &dateStart, const QString &dateEnd)
  • bool insertRecord(const Record &rec)
  • bool updateRecord(int id, const Record &rec)
  • bool deleteRecord(int id)
  • QVector<QPair<QString, qint64>> querySumByCategory(int type, const QString &month)

这里把 Record 定义成一个简单的结构体,包含 id、type、categoryId、amountCents、date、note。界面层和模型层都操作这个结构体,数据流转非常直观。

这些接口内部统一用QSqlQueryprepare+bindValue做参数绑定,绝对不要用QString::arg直接把用户输入拼进 SQL 字符串。参数绑定一方面能避免 SQL 注入,另一方面也自动处理了特殊字符转义,这属于“少踩一个坑”的典型操作。

3. 记账核心功能实现

3.1 用 QTableView + 自定义 Model 展示账目列表

刚开始写 Qt 的人特别喜欢直接用 QTableWidget,因为 SetItem 就能显示数据,代码短。但一旦数据量大、需要频繁增删改或要与数据库联动,QTableWidget 就会变得非常笨重。教学案例里我特意选了 QTableView + 自定义 QAbstractTableModel,这是模型视图架构里最重要的套路。

自定义 Model 的核心是继承 QAbstractTableModel 并重写这几个虚函数:

class RecordModel : public QAbstractTableModel { Q_OBJECT public: int rowCount(const QModelIndex &parent = QModelIndex()) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role) const override; bool removeRows(int row, int count, const QModelIndex &parent = QModelIndex()) override; void setRecords(const QVector<Record> &records); Record recordAt(int row) const; private: QVector<Record> m_records; };

data()函数中根据role == Qt::DisplayRole返回对应列的内容,注意“金额”列要把 amountCents 换算成元并带两位小数,日期列返回字符串,类型列返回“收入/支出”。常见的难点是表头的显示名称,例如“日期”“分类”“金额”“备注”,不要用英文,这在headerData()里返回中文即可。

数据刷新时有一个初学者必踩的坑:直接清空m_records并调用beginResetModel()/endResetModel()。如果忘记调 reset,界面不会更新,甚至可能越界崩溃。正确写法如下:

void RecordModel::setRecords(const QVector<Record> &records) { beginResetModel(); m_records = records; endResetModel(); }

使用自定义 Model 之后,后续筛选、排序、联动都会顺手很多。还有一个好处:删除记录时我们可以用selectionModel()->selectedRows()拿到选中行,再通过recordAt(row)得到对应记录 id,从数据库删除。

3.2 新增与编辑记录的对话框实现

新增和编辑记录的界面我复用一个 QDialog。顶部放收入/支出类型选择,下面放分类下拉框、金额输入、日期选择、备注输入。复用逻辑很简单:对话框构造时传入一个可选的 Record 指针,有值就回填数据、标题显示“编辑记录”,没值就按新增处理。

这里有个交互细节值得展开:收入和支出对应的分类不同。当用户在对话框里切换收支类型时,分类下拉框的内容要跟着变。实现时我用了 QStandardItemModel 作为 QComboBox 的数据源,切换时重新加载分类数据并清除当前选择。

金额输入框我用 QDoubleSpinBox,设置范围为 0.00 到 10000000.00,小数位数为 2。校验时只要用户点保存,就判断当前金额是否大于 0,同时备注长度限制在 100 个字符内。对话框的按钮用了 QDialogButtonBox,保存和取消的槽函数分别调accept()reject()

保存时把界面控件的数据转换成一个 Record 对象,再交给 DatabaseHelper 执行插入或更新。成功之后主窗口的通过信号通知模型刷新表格。整个流程就是“界面 -> 业务对象 -> 数据库 -> 模型刷新”这样一条链,思路清楚了代码就不会乱。

3.3 删除、汇总和筛选

删除操作要处理好“多选”场景。我支持按住 Ctrl 多选表格行,然后一次删除。删除前记得弹确认框,避免误操作:

QModelIndexList selected = ui->tableView->selectionModel()->selectedRows(); if (selected.isEmpty()) { QMessageBox::information(this, QStringLiteral("提示"), QStringLiteral("请先选择要删除的记录")); return; }

批量删除时最好开事务,SQLite 默认每条语句一个事务,逐条删除多条记录会很慢。这里我用QSqlDatabase::transaction()把多条 delete 放在同一个事务里,速度更快,而且任何一条失败都可以回滚,不会出现删了一半的脏数据。

汇总栏的“总收入、总支出、结余”,我选择在模型刷新后,用 SQL 的 SUM 聚合函数重新统计,而不是在界面上做减法。这样哪怕数据库被其他地方改了,界面也不会出错。按条件筛选时,我把类型、分类、日期范围三个条件组装成 SQL 的 WHERE 子句,再走 prepare + bindValue。这里有一个技巧:日期范围的两个 QDateEdit 需要允许“未选中”状态才能表示不限日期,否则就只能限定精确区间。实际项目中我加了两个复选框,勾选后才启用日期过滤条件,交互上更自然。

4. 统计图表与体验细节优化

4.1 用 QCustomPlot 画支出分类占比饼图

图表部分是这个记账本最出彩的地方。统计对话框里放两个图:“本月支出分类占比”饼图和“近半年收支趋势”折线图。

饼图我用的 QCustomPlot,这个第三方库直接把 qcustomplot.h 和 qcustomplot.cpp 加进工程就能用,比 Qt Charts 更轻,而且文档齐全。需要注意,新版 QCustomPlot(2.1.0 之后)才内置 QCPPieChart,老版本没有,需要自己用 QCPBars 或者外部类来实现,所以大家尽量用新版,或者干脆选择 Qt Charts 的 QPieSeries,效果一样好。我在项目里为了减少第三方依赖,最终选了 Qt Charts 作为备选方案,但如果你希望保留 QCustomPlot 的定制度,那 2.x 版本完全够用。

画饼图的核心是准备分类汇总数据。我先用 query 按分类分组统计本月的支出金额,再把结果填充进饼图:

QPieSeries *series = new QPieSeries(); for (auto item : sumList) { QPieSlice *slice = series->append(item.categoryName, item.totalCents); slice->setLabel(QStringLiteral("%1 %2%").arg(item.categoryName) .arg(item.totalCents * 100 / totalCents)); }

饼图的块颜色可以手动指定,用一组预设的 QColor 循环分配,避免相邻块颜色太接近。显示百分比时注意除零保护——如果本月没有支出,直接提示“本月暂无支出数据”,而不是让绘图代码去处理空数据。

4.2 近半年收支趋势折线图

折线图的横轴是月份,纵轴是金额。为了让坐标轴显示“2025-03”这样的文字标签,我用 QCPAxisTickerDateTime 设置时间格式:

QSharedPointer<QCPAxisTickerDateTime> ticker(new QCPAxisTickerDateTime); ticker->setDateTimeFormat("yyyy-MM"); xAxis->setTicker(ticker);

数据按自然月分组聚合,从当前月份往回推 6 个月,每个月分别算收入、支出 2 个点,生成两条 QCPGraph。画线前把上个月的日期时间戳用QDateTime::currentSecsSinceEpoch()和月份计算函数转成秒数,注意 QCustomPlot 的时间单位是 Unix 秒,别把 QDate 直接塞进去。

图表绘制完成后,给折线加上范围拖拽和缩放功能,这样用户可以用鼠标框选查看某一段时间的明细趋势。对教学项目来说,这个交互细节会明显提升“作品感”。

4.3 空状态、快捷键和其他体验细节

一个软件好不好用,细节决定体验。记账本最少要处理下面几个细节:

  • 表格为空时,不要显示一张白板,配上一条“暂无记账记录,点击上方新增开始记账”的提示标签。
  • 快捷键支持:Ctrl+N 新增、Ctrl+E 编辑、Delete 删除、Ctrl+S 打开统计窗口。菜单栏里把快捷键文字标出来,用户一看就会。
  • 状态栏实时刷新汇总金额,操作完任何一笔账都能立刻看到结余变化。
  • 表格列宽需要根据内容自适应:金额、日期列固定宽度,备注列拉伸填满剩余空间。
  • 窗口关闭时如果存在未保存的编辑对话框,要给出提示,避免用户误关。

状态栏汇总的计算不一定非要实时查库。我是把 3.3 节提到的汇总逻辑单独抽了一个函数,在模型刷新后调用一次,填充 QLabel 文本。Realtime 刷新不仅开销小,还让界面交互更跟手。

5. 常见问题与排查技巧实录

5.1 平台插件启动报错

运行程序时如果弹窗提示类似 “windows no qt platform plugin could be initialized” 的错误,多半是插件拷贝不全。新手最容易犯的错误是直接从 Qt 安装目录把 exe 发给别人,对方机器上没有 Qt 环境,当然跑不起来。正确做法是用官方打包工具:

windeployqt accountbook.exe

它会自动把 exe 依赖的 Qt 运行库和插件复制到 exe 当前目录。但注意 windeployqt 版本必须和编译器的位数一致。你用 MSVC2019 64 位编译,就要用对应的 windeployqt,千万别拿 MinGW 版去拷贝 MSVC 的程序,那样会缺一堆运行库。

如果打包之后还报同样的错误,检查 exe 同级目录下有没有 platforms 文件夹,里面有没有 qwindows.dll。没有的话,手动把 Qt 安装目录plugins/platforms整个文件夹复制到 exe 目录。另外某些精简版 Qt 安装时没有勾选这一层的插件,重装时把所有模块都勾上可以避免后续麻烦。

5.2 表格数据刷新后显示错乱

一个很典型的症状:点击筛选后表格行数不对,或者某列显示的内容和数据库里不一致。这通常是因为模型刷新时没有正确调用 reset。QAbstractTableModel 当中插入或删除行数据时,必须先调用beginInsertRows/endInsertRows(插入),或beginResetModel/endResetModel(全量刷新)。我自己的经验是,如果数据是整批重新查询并替换,就用 reset 没问题;如果只是局部修改,则尽量改行数较少的接口。

另一个常见问题是data()函数里对非法索引没有做保护。在模型里拿到一个空 index 或者行号越界会直接导致崩溃,排查半天。统一在 data() 开头加一个 if (!index.isValid()) return QVariant 的防御判断,能省下很多调试时间。

5.3 日期和时间相关的坑

记账本和日期打交道非常多,日期也是容易出问题的地方。SQL 里的日期字符串要统一格式,我在插入时强制用QDate::toString("yyyy-MM-dd"),绝不在界面里直接显示“2025年3月15日”这种格式后存库,否则排序和比较都会乱套。统计“本月”支出时,SQL 语句里用strftime('%Y-%m', record_date) = strftime('%Y-%m', 'now')来过滤,能自动适配不同月份的边界,不用在程序里算上个月是哪天。

5.4 中文乱码与编码问题

Qt 5 的时代,中文字符串直接写在源码里,编译器编码和源码编码不一致就会乱码。我给大家的建议是,在项目中统一使用 UTF-8 编码,MSVC 环境下在 .pro 文件或编译选项里加一句解决:

msvc { QMAKE_CXXFLAGS += /utf-8 }

如果你在用 Qt 6,源码默认就是 UTF-8,基本不用操心乱码问题。如果界面上某处中文还是显示成问号,优先检查那行的字符串是不是误用了本地编码的字节流。数据库里读取的中文正常显示,界面里手工输入的中文却乱码,那基本就是上面这个编译选项的问题。

写在最后的一个建议

这个记账本项目我前前后后带过好几轮学生,每次做完大家的最大收获并不是“学会了 Qt”,而是真正理解了一个软件是怎么从空白界面一步步长成完整程序的。如果你照着本文的模块划分自己动手写,我建议不要一次性全部照抄,先搭好数据库和表格,再逐步加编辑、删除、统计,每加一个功能就编译运行一次,这样每一步的成就感都能清晰感知。

整个项目过程中,最值得花时间深入研究的是自定义 Model 和信号槽之间的关系,这不光是为了写记账本,以后做任何数据密集型软件都会用上。如果你写完记账本之后还觉得不过瘾,可以继续给它加导入导出 CSV、月预算提醒、多账本切换这些扩展功能。个人记账本这类项目的天花板很高,而起点恰好又很友好,非常适合作为第一个完整 Qt 项目认真打磨。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询