MFC 集成 sqlite3 本地存储:静态库配置与 DbHelper 封装 简介面向使用 Visual Studio 2010 进行 Windows 桌面开发的 C 学习者与 MFC 初学者这份示例工程聚焦在 MFC 对话框中集成轻量级嵌入式数据库 SQLite3解决单机应用的数据持久化与增删改查问题。示例覆盖打开数据库、执行 SQL、添加删除修改查询、通过回调函数逐行处理结果、同步与异步查询、错误码与错误信息检查以及关闭连接和释放资源等关键环节可对照代码理解数据库句柄与界面线程的配合方式。压缩包共 38 个文件约 7.01MB包含 C 源码与头文件、VS 解决方案及工程配置、SQLite3 动态库和静态库与命令行工具、示例数据库及 SQL 脚本另有说明文档和少量编译产物便于直接打开工程调试或按模块抽取复用。目前已有 1339 人学习适合作为从建表到查询回调的完整练手样例也能为异步查询和资源释放等易错点提供排错参考。1. MFC 项目里塞进 sqlite3先确认它是不是你的本地存储解一个 MFC 桌面程序要存配置、日志和业务记录装一套数据库服务不现实很多团队会把 sqlite3 直接编进 exe。这份示例工程解决的就是这个场景不依赖外部服务单文件落盘MFC 对话框里完成增删改查。它适合单机工具、设备上位机、内部管理小系统数据量在几十万行以内、写入频率不高的情况。反过来说多进程高频写、需要复杂权限和远程访问的项目不该硬套。资源包给的是可编译的 MFC 工程骨架、DbHelper 封装和建表 SQL拿到后先跑通链接和中文读写再换成自己的表结构。2. 把 sqlite3 接进 MFC 工程静态库、头文件与字符集三件事2.1 源码 amalgamation 和预编译 lib 怎么选MFC 工程接 sqlite3 常见两条路把 sqlite3.c 和 sqlite3.h 直接加进工程或者用预编译好的 sqlite3.lib。源码方式的好处是编译链路透明调试能跟进去裁剪编译选项也方便代价是首次编译慢而且 sqlite3.c 必须按 C 编译混进 C 编译单元容易报一堆类型错误。预编译 lib 接入快工程里只留一个头文件和一个库文件适合业务代码已经稳定、不想在第三方库上花时间的项目。我一般会这样选小工具、需要长期维护的项目走源码方式把 sqlite3.c 放在third_party/sqlite3目录工程里单独设成“编译为 C”临时工具、内部 demo 走预编译 lib先把读写跑通再说。两条路不要混用同一工程里既加 sqlite3.c 又链 sqlite3.lib符号重复会让链接器直接翻车。方式适合场景要注意的点源码 amalgamation需要调试、裁剪、长期维护sqlite3.c 按 C 编译不要重复链接 lib预编译 lib快速接入、业务稳定位数、运行时库、字符集必须和主工程一致提示不管选哪条路工程里只保留一份 sqlite3 实现。源码和 lib 同时存在时先删掉一个再编译。2.2 附加包含目录、库目录与运行时库的对齐预编译方式下工程属性里要配三处附加包含目录指向 sqlite3.h 所在目录附加库目录指向 sqlite3.lib 所在目录附加依赖项填 sqlite3.lib。MFC 项目通常分 Debug 和 Release两个配置要分别检查不能只改 Debug。位数也要对齐主工程是 x64lib 也必须是 x64主工程是 Win32lib 也必须是 Win32。位数不对时链接器报 LNK2019 或 LNK1112看起来像函数没找到实际是二进制格式不匹配。运行时库同样要对齐。主工程用 /MDdlib 却用 /MTd链接阶段可能过运行阶段会出现堆分配和释放跨模块的问题。最稳妥的做法是让 sqlite3.lib 和主工程的“运行库”设置保持一致Debug 对 DebugRelease 对 Release。下面这张表可以直接对着工程属性逐项核对。配置项Debug 建议Release 建议说明附加包含目录$(ProjectDir)third_party\sqlite3同左放 sqlite3.h附加库目录$(ProjectDir)third_party\sqlite3\lib\$(Platform)\Debug对应 Release 目录按平台分目录附加依赖项sqlite3.libsqlite3.lib只填库名运行库与 lib 一致通常 /MDd与 lib 一致通常 /MD不要 Debug 配 /MT字符集UnicodeUnicodeMFC 默认代码里做 UTF-8 转换配完之后不要急着写业务代码先做一次最小链接验证。验证代码只做三件事打开内存库、取版本号、建一张临时表。能编译、能运行、调试输出里能看到版本号说明头文件、lib、位数和运行时库这条链是通的。2.3 用内存库跑通第一次链接验证#include sqlite3.h void CLinkCheckDlg::OnBnClickedCheck() { sqlite3* db nullptr; // :memory: 表示内存库不落盘适合验证链接和 API 是否可用 int rc sqlite3_open(:memory:, db); if (rc ! SQLITE_OK) { TRACE(_T(sqlite3_open failed: %d\n), rc); if (db) sqlite3_close(db); return; } // 版本号来自链接进来的 sqlite3 实现能打印出来说明符号已解析 const char* ver sqlite3_libversion(); TRACE(sqlite3 version: %s\n, ver); char* err nullptr; rc sqlite3_exec(db, CREATE TABLE t_probe(id INTEGER);, nullptr, nullptr, err); if (rc ! SQLITE_OK) { TRACE(exec failed: %s\n, err ? err : unknown); sqlite3_free(err); // err 由 sqlite3 分配必须用它自己的 free } sqlite3_close(db); }这段代码放在对话框的一个按钮响应里Debug 下打开输出窗口就能看到结果。sqlite3_open的第一个参数是数据库路径传:memory:时不会生成文件第二个参数是二级指针函数内部会分配句柄失败时也可能返回一个可关闭的句柄所以失败分支里仍然要判断db是否为空再关闭。sqlite3_libversion返回的是静态字符串不需要释放。sqlite3_exec的最后一个参数用来接收错误文本返回非SQLITE_OK时要把err打印出来再用sqlite3_free释放不能直接delete。如果这里报 LNK2019先查附加依赖项有没有填、库目录有没有指对、平台是不是 x64 对 x64。如果编译报sqlite3.h找不到查附加包含目录是不是只配了 Release。如果运行时报“找不到 sqlite3.dll”说明用的是动态库版本要么把 dll 放到 exe 旁边要么换成静态 lib。验证通过之后再进业务封装后面出问题就容易定位到底是数据库层还是界面层。3. 封装 DbHelper打开、建表、参数化增删改查3.1 为什么绕开拼接 SQL改用 sqlite3_stmt直接拼 SQL 写起来快但边界很薄。姓名里带一个单引号SQL 就断了字段里带%或反斜杠转义规则要自己维护高频插入时每次都要重新解析 SQL性能也不好看。sqlite3 提供的sqlite3_stmt是预编译语句SQL 模板先 prepare参数用 bind 填进去SQL 结构固定值只走参数通道注入和转义问题一起消掉。// 不推荐拼接 SQL姓名里带单引号就会翻车 CString sql; sql.Format(_T(INSERT INTO t_user(name, age) VALUES(%s, %d);), name, age); m_db.Exec(sql);上面这种写法在 demo 里能跑一旦姓名是“ONeil”就会变成语法错误。改成参数绑定后SQL 里只留?占位符姓名作为文本参数传进去单引号由 sqlite3 自己处理。预编译语句还能复用循环插入一万条时prepare 一次、bind 多次、reset 多次比每次拼 SQL 再 exec 快一个量级。下面先定数据结构再写 DbHelper 的骨架。struct User { int id 0; CString name; int age 0; CString createdAt; }; class DbHelper { public: bool Open(const CString dbPath); void Close(); bool CreateTable(); bool InsertUser(const CString name, int age, int newId); bool QueryAll(std::vectorUser out); bool DeleteUser(int id); private: bool Exec(const char* sql); sqlite3* m_db nullptr; };User只放界面需要的字段createdAt用文本存方便直接显示。DbHelper把 sqlite3 句柄藏起来对话框不直接碰sqlite3*后面换表结构时改动面小。Exec只用来跑建表、PRAGMA 这类没有返回值的 SQL带参数的增删改查全部走 prepare/bind/step/finalize 流程。Close在对话框销毁时调用避免程序退出时句柄泄漏。3.2 打开数据库路径、WAL 与 busy_timeout数据库文件不要用相对路径。MFC 程序的工作目录可能是 exe 目录也可能是工程目录调试时和双击运行时经常不一致。我一般用GetModuleFileName取 exe 所在目录再拼data\app.db目录不存在就先建。路径拼好之后CString是 UTF-16sqlite3 的打开接口要 UTF-8中间必须转一次不能直接把CString强转成char*。CString GetExeDir() { TCHAR szPath[MAX_PATH] { 0 }; ::GetModuleFileName(nullptr, szPath, MAX_PATH); CString path(szPath); int pos path.ReverseFind(_T(\\)); if (pos 0) path path.Left(pos); return path; } bool DbHelper::Open(const CString dbPath) { std::string path CStringToUtf8(dbPath); int rc sqlite3_open_v2( path.c_str(), m_db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nullptr); if (rc ! SQLITE_OK) { if (m_db) { sqlite3_close(m_db); m_db nullptr; } return false; } // busy_timeout 让写冲突时先等一会而不是立刻返回 SQLITE_BUSY sqlite3_busy_timeout(m_db, 3000); Exec(PRAGMA foreign_keys ON;); Exec(PRAGMA journal_mode WAL;); return true; }SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE表示文件不存在就创建存在就读写。SQLITE_OPEN_FULLMUTEX让句柄内部串行化单线程界面程序用不上多线程保护但加上它不会错如果确定只在 UI 线程访问也可以换成SQLITE_OPEN_NOMUTEX性能略好。sqlite3_busy_timeout的单位是毫秒3000 表示遇到锁时最多等 3 秒。WAL 模式对读写并发的友好度比默认 rollback journal 好但数据库文件所在目录必须可写放在只读目录里会打开失败。CStringToUtf8的实现放在 3.3 节打开和绑定都要用。Exec里执行 PRAGMA 时没有回调返回结果行会被忽略journal_modeWAL的返回值不需要处理。如果打开失败调试输出里打印sqlite3_errmsg(m_db)比只看返回码有用。数据库文件路径里带中文时UTF-8 转换必须正确否则 sqlite3 会创建一个奇怪名字的文件或者直接报 cannot open。3.3 建表和插入CString 到 UTF-8 的转换边界建表 SQL 直接写成常量字段类型用 SQLite 的亲和类型INTEGER PRIMARY KEY AUTOINCREMENT做主键TEXT存姓名和时间INTEGER存年龄。创建时间用datetime(now,localtime)做默认值插入时不用手动填。IF NOT EXISTS保证重复启动不会报错。bool DbHelper::CreateTable() { const char* sql CREATE TABLE IF NOT EXISTS t_user( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime(now,localtime)) );; return Exec(sql); }这段 SQL 里没有参数用Exec就够。AUTOINCREMENT会额外维护sqlite_sequence表数据量很大时自增 ID 不会复用如果只是普通自增去掉AUTOINCREMENT也能工作但删除后 ID 可能被重新使用。created_at的默认值带括号这是 SQLite 对表达式默认值的语法要求。建表失败时先看sqlite3_errmsg常见原因是数据库文件被另一个进程占用或者目录没有写权限。CString 到 UTF-8 的转换用WideCharToMultiByte不要用CStringA直接赋值。CStringA在 Unicode 工程里做的是当前代码页转换中文会变成问号或乱码。下面这个函数先算需要的字节数再分配std::string最后把转换结果写进去。static std::string CStringToUtf8(const CString s) { int n ::WideCharToMultiByte( CP_UTF8, 0, s, -1, nullptr, 0, nullptr, nullptr); if (n 0) return std::string(); std::string out(n - 1, \0); // n 包含结尾的 \0string 不需要 ::WideCharToMultiByte( CP_UTF8, 0, s, -1, out[0], n, nullptr, nullptr); return out; }WideCharToMultiByte第一次调用传空输出缓冲区返回包含结尾空字符的字节数第二次调用把n个字节写进去std::string的长度取n - 1。CP_UTF8是目标编码s是 UTF-16 源字符串-1表示源字符串以空字符结尾。转换失败返回空串调用方要判断。这个函数只处理 CString 到 UTF-8反向转换在查询时用MultiByteToWideChar逻辑对称。插入用户时SQL 模板只写一次姓名用sqlite3_bind_text绑定年龄用sqlite3_bind_int。绑定文本时长度传utf8.size()最后一个参数传SQLITE_TRANSIENT让 sqlite3 自己复制一份字符串。如果传SQLITE_STATIC字符串生命周期必须覆盖到sqlite3_step执行完局部变量很容易在这里出问题。bool DbHelper::InsertUser(const CString name, int age, int newId) { const char* sql INSERT INTO t_user(name, age) VALUES(?, ?);; sqlite3_stmt* stmt nullptr; if (sqlite3_prepare_v2(m_db, sql, -1, stmt, nullptr) ! SQLITE_OK) return false; std::string utf8Name CStringToUtf8(name); sqlite3_bind_text(stmt, 1, utf8Name.c_str(), (int)utf8Name.size(), SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 2, age); int rc sqlite3_step(stmt); if (rc SQLITE_DONE) { newId (int)sqlite3_last_insert_rowid(m_db); } sqlite3_finalize(stmt); return rc SQLITE_DONE; }sqlite3_prepare_v2的第三个参数-1表示 SQL 以空字符结尾第四个参数接收语句句柄。sqlite3_bind_text的第二个参数是占位符序号从 1 开始第三个参数是 UTF-8 字符串指针第四个参数是字节长度中文一个字符占 3 个字节不能用字符数第五个参数SQLITE_TRANSIENT让 sqlite3 复制数据。sqlite3_step返回SQLITE_DONE表示插入完成sqlite3_last_insert_rowid取刚才生成的自增 ID。无论成功失败sqlite3_finalize都要调用否则语句句柄泄漏后面可能出现 database is locked。3.4 查询与错误信息把 errmsg 落到调试输出查询用sqlite3_step循环取行返回SQLITE_ROW表示还有数据返回SQLITE_DONE表示结束。取文本列用sqlite3_column_text它返回 UTF-8 的const unsigned char*可能为空取之前要判断列类型或指针。转回 CString 用MultiByteToWideChar和写入时的转换对称。static CString Utf8ToCString(const char* p) { if (!p) return CString(); int n ::MultiByteToWideChar(CP_UTF8, 0, p, -1, nullptr, 0); if (n 0) return CString(); CStringW w; ::MultiByteToWideChar(CP_UTF8, 0, p, -1, w.GetBuffer(n), n); w.ReleaseBuffer(n - 1); return CString(w); } bool DbHelper::QueryAll(std::vectorUser out) { const char* sql SELECT id, name, age, created_at FROM t_user ORDER BY id DESC;; sqlite3_stmt* stmt nullptr; if (sqlite3_prepare_v2(m_db, sql, -1, stmt, nullptr) ! SQLITE_OK) return false; while (sqlite3_step(stmt) SQLITE_ROW) { User u; u.id sqlite3_column_int(stmt, 0); u.name Utf8ToCString( reinterpret_castconst char*(sqlite3_column_text(stmt, 1))); u.age sqlite3_column_int(stmt, 2); u.createdAt Utf8ToCString( reinterpret_castconst char*(sqlite3_column_text(stmt, 3))); out.push_back(u); } sqlite3_finalize(stmt); return true; }sqlite3_column_int的列索引从 0 开始和 bind 的 1 起始不一样写的时候容易错一位。sqlite3_column_text返回的指针由语句句柄管理下一次 step 或 finalize 后可能失效所以取到之后要立刻转成 CString不能只保存指针。Utf8ToCString里GetBuffer(n)分配足够空间ReleaseBuffer(n - 1)把长度设成不含结尾空字符的字符数。查询没有结果时循环不进入函数返回 true调用方拿到空 vector 是正常情况。错误信息统一从sqlite3_errmsg取。Exec里用sqlite3_exec的 err 参数prepare/step 失败时用sqlite3_errmsg(m_db)。调试输出只写错误文本不要把整条 SQL 和用户输入拼进去日志里出现个人信息不好。Release 下可以把错误文本写到一个本地日志文件或者弹一个简短提示让用户知道是“写入失败”还是“数据库被占用”。数据库层的错误不要直接吞掉界面层至少要给出可操作的提示。4. MFC 界面读写闭环CListCtrl、编辑框与局部刷新4.1 控件变量和字段映射先定死界面不要一边写代码一边改控件 ID。先把列表控件、姓名编辑框、年龄编辑框、三个按钮的 ID 定下来再在DoDataExchange里绑定控件变量。列表用CListCtrl的报表模式四列ID、姓名、年龄、创建时间。编辑框用CEdit按钮用CButton或者直接走ON_BN_CLICKED不绑定变量也能响应。void CUserDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_LIST_USER, m_list); DDX_Control(pDX, IDC_EDIT_NAME, m_editName); DDX_Control(pDX, IDC_EDIT_AGE, m_editAge); } BEGIN_MESSAGE_MAP(CUserDlg, CDialogEx) ON_BN_CLICKED(IDC_BTN_ADD, CUserDlg::OnBnClickedAdd) ON_BN_CLICKED(IDC_BTN_DEL, CUserDlg::OnBnClickedDel) END_MESSAGE_MAP()DDX_Control把控件 ID 和成员变量关联起来后面用m_list、m_editName直接操作。消息映射里只挂两个按钮编辑和查询可以后续再加。控件 ID 在资源编辑器里改名字时代码里的宏也要同步改否则编译报未定义标识符。列表控件记得在资源属性里把 View 设成 Report本文还有配套的精品资源点击获取