简介:这是一份面向C++/Windows开发者的MFC对话框程序集成SQLite3完整示例,适合刚开始在桌面应用中引入嵌入式数据库的初学者参考。压缩包共38个文件,主要包含8个h头文件与4个cpp源码文件、sqlite3的dll和lib库、exe可执行程序、db数据库文件,以及Visual Studio 2010的工程文件(sln/vcxproj)等,整体大小约7.01MB,目前已有1338人学习下载。示例完整演示了SQLite3在MFC中的增删改查操作,特别展示了两类查询方式:普通同步查询与通过回调函数实现的异步查询,并覆盖sqlite3_open、sqlite3_exec、sqlite3_errmsg、sqlite3_close等关键API的调用顺序。借助回调函数,可以逐行处理查询结果,避免大量数据一次性加载造成界面卡顿,这对MFC下的后台任务处理很有参考价值。压缩包内附x86/x64目录和release构建产物,以及测试数据库文件,既便于直接运行观察效果,也能对照源码理解项目配置、回调机制与资源释放流程,帮助开发者快速在MFC项目中落地SQLite3。
1. MFC使用sqlite3:一个开箱即用的桌面本地数据库接入样例
某公司的内网数据交换系统,每天要往中间库写十几万条设备上报记录。之前在 Access 上折腾,一条 SQL 挂在 ODBC 上动不动就超时;后来换成 MFC 对话框程序内嵌 sqlite3,写读全落在本地文件上,瞬间就利索了。这篇文章要拆的,就是一个把 MFC 和 sqlite3 缝合在一起的最小完整例子:代码不绕、依赖不重,任何熟悉 MFC 对话框模板的开发者,都能在一上午内跑通并改成自己的本地存储层。资源包里带了 sqlite3 合并版源码和封装好的数据库访问类,不装任何数据库服务,用 VS 打开工程直接就能编译运行。适合正在做桌面单机工具、又想摆脱 Excel / XML 存储的开发者。
2. 工程结构与选型:为什么是「MFC 对话框 + sqlite3 C API」
2.1 资源包扫描:拆开看每个文件是干什么的
先把资源包里的可见内容清点一遍,心里有个数再动手。目录按典型 MFC 工程组织,核心内容放在下面这张表里:
| 文件 / 目录 | 类型 | 作用 |
|---|---|---|
| MfcSqliteDemo.sln | 解决方案文件 | VS 直接打开,包含一个对话框工程 |
| MfcSqliteDemoDlg.h / .cpp | 界面代码 | 主对话框:输入框、查询按钮、CListCtrl 列表 |
| CDatabaseHelper.h / .cpp | 数据访问封装 | 封装 sqlite3 的打开、关闭、查询、绑定参数 |
| sqlite3.h / sqlite3.c | 数据库引擎源码 | sqlite3 合并版,无需第三方动态库 |
| sqlite3ext.h | 扩展头文件 | 只有在写自定义函数 / 聚合函数时才用到 |
| sample.db | 示例数据库 | 预置了一张设备上报表,约 200 行测试数据 |
这个结构其实就是典型的「界面层 + 数据访问层 + 引擎层」三层。界面层是 MFC 对话框,数据访问层是 CDatabaseHelper 类,最底层是 sqlite3 的 C API 直接编译进工程,没有经过任何中间件过滤。
个人认为这种结构对于桌面小工具来说是性价比最高的选择。ADO 和 ODBC 从功能上讲也完全能实现,但它们需要系统里装有对应的驱动和组件,换一台机器部署时多一层环境依赖。
sqlite3 不一样,引擎是纯 C 写的,把 sqlite3.c 直接加入工程编译,最后生成出来就是一个独立的 exe,连 dll 都不用带。这是它适合做小工具的关键原因:交付物只有一个文件。
资源包里还有一点值得注意:sqlite3.c 是合并版的,也就是说把整个数据库引擎的源码全部塞进了一个 C 文件,大约二十万行左右。第一次编译的时候会比较慢(release 下可能要十几秒),这是正常的,之后增量编译就很快了。
2.2 选型对比:Access、XML、自研文本存储输在哪里
放到桌面上能选的本地存储方案其实不少,但每种方案都有明显的妥协:
| 方案 | 查询能力 | 部署成本 | 并发表现 | 问题点 |
|---|---|---|---|---|
| Access + ODBC | 支持 SQL | 高,需装驱动 | 差,单文件锁 | 驱动版本、位数冲突很多 |
| XML 文件 | 必须全量加载 | 低 | 差 | 数据量大时内存暴涨 |
| 自研文本格式 | 无 | 低 | 差 | 查询、修改要靠手写逻辑 |
| sqlite3 | 标准 SQL | 很低 | 中等,WAL 后更佳 | 需要了解 C API 约定 |
配合 MFC 时,sqlite3 有一条特别有用的特性:sqlite3 的 C API 是线程安全的,默认编译选项下连接池可以在多线程间共享。MFC 程序里最常见的问题是界面线程和工作线程都要读同一份配置数据,这里用 busy_timeout 加上 WAL 模式,就能比较好地处理读写竞争。
而 Access 在 MFC 里通常要走 ADO 或 ODBC,光是初始化 COM、连接字符串就够写一屏代码,还要处理驱动位数(x86 / x64 不匹配直接连不上)。XML 则是在数据量到了几十万行之后性能完全跟不上,加载一个 50MB 的 XML 文件可能要卡几秒,而 sqlite3 查询同样是 50MB 的库只用几十毫秒。
架构上还要考虑一个问题:这个 Demo 是对话框程序,如果是 SDI / MDI 工程,CDatabaseHelper 通常放在文档类里面,由 Doc 管理生命周期,View 在重绘时间接获取数据。这份样例代码没有纠结到这个层面,但不妨碍二次开发时平移过去。
3. 核心代码拆解:一条 SQL 从输入到 CListCtrl 刷新的完整链路
3.1 数据库连接:Open / Close 的规范写法
拿到资源包后,最快理解这个项目的办法是直接看 CDatabaseHelper.cpp。整个类很小,核心就三个方法:打开、关闭、执行查询。先看连接部分:
bool CDatabaseHelper::Open(const CString& strDbPath) { if (m_pDb) return true; // 已打开则直接复用 // 使用 UTF-8 路径,中文路径不会翻车 int nLen = WideCharToMultiByte(CP_UTF8, 0, strDbPath, -1, NULL, 0, NULL, NULL); std::string strUtf8Path(nLen, ''); WideCharToMultiByte(CP_UTF8, 0, strDbPath, -1, &strUtf8Path[0], nLen, NULL, NULL); int rc = sqlite3_open_v2(strUtf8Path.c_str(), &m_pDb, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL); if (rc != SQLITE_OK) { m_strLastError = sqlite3_errmsg(m_pDb); return false; } // 等待锁的毫秒数,处理多线程读写竞争 sqlite3_busy_timeout(m_pDb, 5000); // 开启 WAL,读不阻塞写 sqlite3_exec(m_pDb, "PRAGMA journal_mode=WAL;", NULL, NULL, NULL); // 外键约束打开(示例里没用到,但属于规范操作) sqlite3_exec(m_pDb, "PRAGMA foreign_keys=ON;", NULL, NULL, NULL); return true; } void CDatabaseHelper::Close() { if (m_pDb) { sqlite3_close_v2(m_pDb); m_pDb = NULL; } }这里有两个关键点。第一个是sqlite3_open_v2的第三个参数:SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE表示文件不存在时自动创建,这对工具类程序非常友好,不用提前准备空库文件。第二个是WideCharToMultiByte转码,MFC 里CString在 Unicode 构建下是 UTF-16,而 sqlite3 的 API 要求 UTF-8 编码的路径,不转换的话一旦路径里有中文就返回SQLITE_CANTOPEN。
sqlite3_busy_timeout是很多人忽略的参数。没有它的时候,如果两个线程同时写库,后写的一方会立刻返回SQLITE_BUSY而不是等待,程序表现为偶尔写入失败。设置成 5000 毫秒后,库会自动等待锁释放再重试,这也是我用 sqlite3 处理多线程问题时的习惯做法。
3.2 带参数查询:防注入又防乱码的 Query 封装
示例里最常用的查询方法是带参数绑定的版本,它同时解决了两个最容易踩的坑:字符串拼接导致的 SQL 注入风险,以及中文字符编码不一致的问题。
bool CDatabaseHelper::Query(const CString& strSql, const std::vector<CString>& vecParams, std::vector<std::vector<CString>>& vecResult) { if (!m_pDb) return false; sqlite3_stmt* pStmt = NULL; int rc = sqlite3_prepare_v2(m_pDb, CT2A(strSql), -1, &pStmt, NULL); if (rc != SQLITE_OK) { m_strLastError = sqlite3_errmsg(m_pDb); return false; } // 按顺序绑定参数,第一个参数 index 从 1 开始 for (size_t i = 0; i < vecParams.size(); i++) { int nLen = WideCharToMultiByte(CP_UTF8, 0, vecParams[i], -1, NULL, 0, NULL, NULL); std::string strUtf8(nLen, ''); WideCharToMultiByte(CP_UTF8, 0, vecParams[i], -1, &strUtf8[0], nLen, NULL, NULL); sqlite3_bind_text(pStmt, (int)(i + 1), strUtf8.c_str(), -1, SQLITE_TRANSIENT); } // 逐行取结果 while ((rc = sqlite3_step(pStmt)) == SQLITE_ROW) { std::vector<CString> vecRow; int nCols = sqlite3_column_count(pStmt); for (int i = 0; i < nCols; i++) { const unsigned char* pText = sqlite3_column_text(pStmt, i); // UTF-8 转回 CString CString strCell = CString(CA2T((const char*)pText)); vecRow.push_back(strCell); } vecResult.push_back(vecRow); } sqlite3_finalize(pStmt); // 释放语句对象,防止句柄泄漏 return (rc == SQLITE_DONE); }调用方式比较简单,WHERE name = ?里的问号就是参数占位符,传入的 vecParams 按顺序对应。绑定字符串时用SQLITE_TRANSIENT告诉 sqlite3 在内部复制一份数据,避免局部字符串变量销毁后出现悬空指针。
需要特别记住的是 sqlite3 的 C API 参数索引从 1 开始,和习惯上 C 数组的 0 下标完全不同。我在这里踩过一次:第一次写循环时从 0 开始 bind,结果第一个参数永远绑定不进去,还没报错,查错查了半天。
3.3 结果集绑定到 CListCtrl:刷新界面的最后一段路
数据从库里查出来之后,剩下的工作是把结果塞进控件的行和列,这段代码在 DemoDlg.cpp 的查询按钮响应函数里:
void CMfcSqliteDemoDlg::OnBnClickedBtnQuery() { // 清空列表,避免上次查询结果残留 m_listResult.DeleteAllItems(); int nColCount = m_listResult.GetHeaderCtrl()->GetItemCount(); for (int i = 0; i < nColCount; i++) m_listResult.DeleteColumn(0); CString strSql = _T("SELECT id, device_id, report_time, status FROM report WHERE device_id = ?"); std::vector<CString> vecParams; vecParams.push_back(m_editDeviceId.GetWindowText()); std::vector<std::vector<CString>> vecResult; if (!m_dbHelper.Query(strSql, vecParams, vecResult)) { AfxMessageBox(m_dbHelper.GetLastError()); return; } // 动态建列 m_listResult.InsertColumn(0, _T("ID"), LVCFMT_LEFT, 60); m_listResult.InsertColumn(1, _T("设备编号"), LVCFMT_LEFT, 120); m_listResult.InsertColumn(2, _T("上报时间"), LVCFMT_LEFT, 150); m_listResult.InsertColumn(3, _T("状态"), LVCFMT_LEFT, 80); // 逐行填数据 for (size_t row = 0; row < vecResult.size(); row++) { int nItem = m_listResult.InsertItem((int)row, vecResult[row][0]); for (size_t col = 1; col < vecResult[row].size(); col++) { m_listResult.SetItemText(nItem, (int)col, vecResult[row][col]); } } // 状态栏显示行数 CString strStatus; strStatus.Format(_T("查询完成,共 %d 行"), (int)vecResult.size()); SetDlgItemText(IDC_STATIC_STATUS, strStatus); }这段链路很短,从按钮点击事件算起,消息进入OnBnClickedBtnQuery,构造带占位符的 SQL,调用封装类查询,取回二维字符串数组,最后逐行插进CListCtrl。整个过程中 C++ 层没有接触任何 sqlite3 的指针类型,即使不太熟悉 C API 的人也能维护。
示例里查询语句是写死的,实际二次开发时可以把表名、列名、查询条件都做成下拉框或编辑框动态拼 SQL。注意表名和列名不能走参数绑定,因为它们不是值,语法上 sqlite3 会把?当字符串字面量处理,所以动态表名必须自己校验白名单。
4. 常见问题排查:编译、中文、性能与数据库锁的四个真实翻车案例
4.1 启动即报:「无法启动此程序,因为计算机中丢失 sqlite3.dll」
现象:编译链接全部通过,F5 运行时弹出这个错误框,程序直接退出。
原因:工程的链接器设置为「使用动态库」,且 sqlite3 是通过 dll + lib 的方式引入的,但生成的 dll 并没有被复制到 exe 所在的输出目录。VS 只会自动复制通过项目引用添加的 dll,手动拖进工程目录的 dll 不会被拷贝。
解决:两条路任选。一是改成源码编译(推荐):把 sqlite3.c 添加进工程,配置里设置「不适用预编译头」,这样整个引擎直接编译进 exe,彻底免除 dll 依赖。二是坚持动态库方式,则在项目属性 → 生成事件 → 后期生成事件里加一条copy /y "$(SolutionDir)dll\sqlite3.dll" "$(OutDir)"。很多新手卡在这里,就是因为那台开发机恰好有 dll,换到别的机器就崩了。
从那以后我每拿到一个 MFC 相关的开源例程,第一件事是先看它是静态链接还是动态链接,再决定是否立刻动手编译,避免折腾半天最后才发现是环境问题,整个流程白费。
4.2 中文全部变成问号或乱码
现象:查询结果显示正常,但插入的数据中文变成「??」,查询条件传中文时什么都查不到。
原因:典型编码约定不一致。sqlite3 的 C APIsqlite3_bind_text和sqlite3_column_text传输的都是 UTF-8 字节流,而 MFC 的CString在 Unicode 构建下是 UTF-16。直接把CString的内存指针传给 sqlite3,它读出来的就是一堆错位的字节。
解决:统一转码边界。写入时用WideCharToMultiByte(CP_UTF8, ...)把 CString 转成 UTF-8 再 bind;读取时列数据先用CA2T转回 CString 再交给界面层。资源包里的封装类已经在 Query 方法内部做好了这两处转换,所以直接使用样例代码不会乱码。自己扩展 SELECT 语句时,记得新加的参数也要走同一个转码函数,不能图省事直接CT2A(它会转成 ANSI 而不是 UTF-8)。
4.3 插入 5000 条记录耗时 30 秒
现象:第一次大批量导入业务数据,进度条走得极慢,估算每秒只有一百多条。
原因:没有显式开启事务。sqlite3 的默认模式是自动提交,每执行一条INSERT都要做一次完整的事务落盘操作,包括日志写入和数据文件同步。机械硬盘下单条插入耗时经常在 10 毫秒以上;如果是普通机械硬盘则会更大,这就会导致批量导入非常慢。
解决:把插入操作包在事务里,一万条数据也能在几百毫秒内完成。
BEGIN; INSERT INTO report(device_id, report_time, status) VALUES('DEV001', '2024-06-01 12:00:00', 'OK'); INSERT INTO report(device_id, report_time, status) VALUES('DEV002', '2024-06-01 12:01:00', 'OK'); COMMIT;在 C++ 代码里执行就是sqlite3_exec(m_pDb, "BEGIN;")开头,循环里反复执行插入,最后sqlite3_exec(m_pDb, "COMMIT;")。需要注意的是事务期间数据库文件被独占写锁,如果有其他线程读同一张表,会阻塞,所以大批量导入在 MFC 里最好放进工作线程,同时界面用sqlite3_busy_timeout兜底。
4.4 打开数据库成功,但是整个库被一个线程锁死
现象:程序运行不报错,但线程 A 执行完写入后不释放,线程 B 的查询就一直卡在SQLITE_BUSY,最终超时。
原因:调用sqlite3_step之后没有调用sqlite3_finalize,或者执行写操作忘了走事务提交。sqlite3 的写锁要靠语句对象清理和提交事务来释放,如果代码里有提前 break / return 的退出路径,很容易跳过清理逻辑。
解决:封装类里统一在函数结尾调用sqlite3_finalize,并加一个对象清理策略,构造时打开、析构时关闭。对于那些「查一条就返回」的场景,务必确认走完sqlite3_step后退出函数前释放了语句句柄。排查时可以从返回值入手:SQLITE_BUSY是 5,SQLITE_LOCKED是 6,这两个码经常伴随出现。另外,Windows 下可以看库文件旁边是不是生成了-journal或-wal文件,如果文件一直存在且体积不断变大,说明连接没有正常关闭。
5. 运行手把手:从拿到资源到看见第一屏数据
5.1 环境准备与工程配置重点
这份例程是基于 VS2019 和 VS2022 的 MFC 对话框工程搭建的,拿到源码后不需要调整平台工具集也能直接编译。如果使用的 VS 版本比较旧,需要注意平台工具集的选择,一般装 VS 时默认带的 MFC 组件即可,工程依赖的库文件不多。
打开工程后先确认两处配置。第一处是在「项目属性 → C/C++ → 预处理器」里,检查是否定义了SQLITE_THREADSAFE=1,这个值表示启用线程安全模式。第二处是「C/C++ → 预编译头」,sqlite3.c 要设置为「不使用预编译头」,否则编译会报一堆莫名其妙的错误。
如果资源包里用的是动态链接方式,还要额外配置一条后期生成命令,但如果是按示例里的源码编译方式,这个步骤就可以省略;拿到的资源包走的是源码编译,直接把 sqlite3.c 放进工程参与编译,从根源上避免 dll 缺失问题。
配置完成后在解决方案上执行「生成解决方案」,输出窗口里看到「已成功生成」就说明第一步过了。
5.2 运行时验证:断点与常见输出检查
按 F5 启动程序,对话框弹出后界面上有一个设备编号输入框、一个「查询」按钮、一个表格控件。什么都不输入直接点查询,列表会显示全部约 200 条测试数据,这说明数据库成功打开、SQL 正确执行、结果集正确映射到控件。
如果想验证查询链路是否真的走通了数据库层,可以在sqlite3_step那一行下断点,然后在输出窗口里查看rc变量的值,正常情况下第一个SQLITE_ROW返回 100,最后一行返回SQLITE_DONE也就是 101。
我一般还会在命令行用 sqlite3 客户端把 sample.db 打开,手动执行一条和按钮触发一样的 SELECT,比对结果集是否一致。这不是必须步骤,但在排查「代码看起来对但数据不对」的玄学问题时有奇效,可以快速确认是 SQL 本身写错还是代码逻辑写错。
5.3 从 Demo 走向实际使用的三步检查清单
跑通之后建议做一轮部署前检查,这几点直接影响换机后的表现:
| 检查项 | 做法 | 说明 |
|---|---|---|
| 位数一致性 | 确认 exe 是 x86 还是 x64 | sqlite3.c 直接编译进 exe,位数由 exe 决定,无额外风险 |
| 数据库路径 | 不要放在 Program Files | 放置到 %APPDATA% 某子目录下,避免权限问题 |
| 多线程策略 | 确认工作线程如何访问 helper | 建议全部走同一 helper 实例,配合 busy_timeout |
数据库路径这个坑,在 MFC 开发里出现的频率很高。开发时路径随便写也许没问题,但正式使用时用户往往会遇到「打开失败」,原因就是当前用户没有该目录的写权限。比较稳妥的做法是程序启动时SHGetFolderPath(CSIDL_APPDATA)拿到用户目录,再CreateDirectory建子目录,把数据库文件放进去,这也就是常见的规范做法。经过这一步改造后,即使安装在 C 盘 Program Files 下也不会出现权限拒绝异常。
6. 二次开发:把 Demo 改成带自动备份的本地配置库
拿到了能跑的 Demo,接下来有意义的事情是把它改造成可以直接落地的模块。我通常会在两个方向动刀:一是把 CDatabaseHelper 改成 RAII 风格,构造时自动打开、析构自动关闭,避免业务代码里到处Open/Close导致泄漏;二是给数据库加上在线备份能力,防止单文件库里积累了几个月的数据后损坏,到时再想到补救就真的来不及了。
sqlite3 自带一个叫 backup API 的机制,可以在数据库连接打开的状态下在线备份。核心代码其实只有几行:
sqlite3* pBackupDb = NULL; sqlite3_open("backup.db", &pBackupDb); sqlite3_backup* pBackup = sqlite3_backup_init(pBackupDb, "main", m_pDb, "main"); if (pBackup) { sqlite3_backup_step(pBackup, -1); // -1 表示一次全部拷贝 sqlite3_backup_finish(pBackup); // 完成并释放资源 } sqlite3_close(pBackupDb);这段代码放在哪里比较合适?我的做法是放在工作线程里,每次程序退出前自动备份一次,同时每周做一次全量备份。备份期间主库可以继续读写,这就是 backup API 相比直接copy文件最大的优势——直接拷贝正在写入的库文件,几乎必然会得到一份损坏的结果。
接入业务时还会补一个查询方法的重载,支持直接传std::string格式的 SQL。调试阶段我经常把查询语句打印到输出窗口,对比实际执行文本和预期文本,遇到复杂的动态条件拼接逻辑时能省不少力气。至于真正的业务限制,比如同时最多允许多少个写入线程,这要在架构设计时定好上限,sqlite3 本身虽然线程安全,但过度并发只会让锁等待时间线性增长。
从那以后我每次接手带 sqlite3 的桌面项目,都会先检查三件事:数据库文件放在哪、有没有开 WAL、有没有备份策略。如果没有,就先把这三样补齐再动业务代码。这个习惯救过我好几回,希望也能帮到你。
本文还有配套的精品资源,点击获取