资讯动态

MFC对话框集成SQLite:从配置到调优的完整实践

发布时间:2026/9/25 5:34:42 来源:尧图企业网站定制
简介针对MFC开发者这份示例工程演示了在VS2010对话框应用中集成SQLite3数据库的完整流程涵盖添加、删除、修改与查询操作其中特别展示了基于回调函数的查询方式及同步/异步处理思路适合初学者快速上手。压缩包共38个文件总大小7.01MB包含C源码h/cpp、可执行程序exe、SQLite3运行库dll/lib、示例数据库db及工程配置sln/vcxproj等源码与运行库分离便于直接编译同时附有可直接运行的程序及配套dll无需安装即可运行验证db文件提供预置测试数据方便对比增删改查效果。目前已有1337人学习下载。通过该示例读者可直观理解sqlite3_open、sqlite3_exec等API调用流程掌握CRUD操作、错误处理与资源释放等关键细节同时学习如何用回调函数逐行处理查询结果为在Windows桌面端开发轻量级数据库应用打下基础并能有效规避常见集成陷阱。1. 把 SQLite 塞进 MFC 对话框程序sqlite3 的引入比想象中多两道坎MFC 程序需要本地存储时我第一反应基本就是 sqlite3。它不用安装数据库服务一个 DLL 加一个文件就能跑特别适合对话框程序里的配置、记录、缓存这类轻量数据。不过把 sqlite3 接进 MFC 工程并不是“下载一个 dll写两句 SQL”这么简单。平台匹配、链接配置、字符编码、句柄释放每一步都可能让程序在运行时突然翻车。这篇笔记按一个能跑的 MFC 对话框程序串下来先给出最小引入路径再讲列表展示、参数调优和常见坑最后教你怎么自检。适合正在用 MFC 写工具类程序、又不想引入重量级数据库的工程师照着重现。2. 引入 sqlite3 与项目配置三条决定成败的路径2.1 选型预编译 DLL 比源码编译省事sqlite3 官方在 sqlite.org 下载页同时提供源码包和 Windows 预编译二进制包。对绝大多数 MFC 工程我建议用预编译 DLL下载解压后一般能得到 sqlite3.dll、sqlite3.lib、sqlite3.h 三个文件分别负责运行时、链接导入、头文件声明。把 dll 放到 exe 输出目录在工程里链接 sqlite3.lib就能开始写代码。有人会问为什么不把 sqlite3.c 直接编译进工程。常见做法是它确实可行但代价是你要自己处理 C 文件与 MFC 工程的运行库配置。预编译 DLL 的 sqlite3 已经按标准 Windows 运行库编好和 MFC 的 /MD 模式兼容性最好。自己编源码时一旦工程是静态链接 MFC又要调整 SQLITE_THREADSAFE、预处理器定义很容易在一个本来不用改的项目配置里折腾半天。对起步阶段直接上官方 DLL 是最可靠的选择。如果你打算给 sqlite3 加定制编译选项比如启用某些扩展、调整页面大小默认值再考虑换成源码编译。MFC 项目里这不是高频需求等真需要时再迁移也不算晚。选型的关键是目标是把业务跑起来不要在引入环节埋雷。2.2 包含目录、库目录与平台一致性拿到 sqlite3 三件套后最简单的方式是在 stdafx.h 或使用它的 cpp 文件顶部写两行#include sqlite3.h #pragma comment(lib, sqlite3.lib)pragma comment 比到“链接器-输入-附加依赖项”里点鼠标更直观也不会因为工程换机器后丢失配置。头文件所在目录如果不在工程默认包含路径里需要到项目属性 - VC 目录 - 包含目录里追加或者直接把 sqlite3.h 复制到工程目录。我一般倾向于把 sqlite3.h 放一个公共的 third_party 目录然后只配包含目录避免头文件散落。真正容易踩坑的是平台一致性。Visual Studio 打开一个老 MFC 工程时解决方案平台经常默认是 Win32而官网下载的 sqlite 预编译包通常是 x64 目录下。你把 x64 的 sqlite3.lib 给 32 位工程链接编译阶段会报一堆 LNK2019无法解析的外部符号 sqlite3_open。如果你强行忽略 lib 版别dllexe 运行时会直接提示加载失败。反过来64 位工程链 32 位 lib 也会同样翻车。所以配置目录前先看两处项目属性 - 链接器 - 常规里的“目标平台”以及 Visual Studio 工具栏上的解决方案平台。再对照下载的 sqlite 包是 x86 还是 x64。官方包文件名一般写得很清楚比如 sqlite-dll-win64-x64 之类的命名按需取用。复制 dll 时也要复制到对应平台的输出目录Debug 和 Release 的 OutDir 不同不要只复制一次就觉得完事。2.3 最小例子建表、插入、查询、释放接好库之后先写一个最小的自检函数能跑通就说明整条链路没问题。下面这个例子是一个控制台或对话框按钮里都能直接调用的完整流程#include sqlite3.h #pragma comment(lib, sqlite3.lib) void RunSqlite3Demo() { sqlite3* db nullptr; // 打开或创建数据库文件 int rc sqlite3_open_v2(demo.db, db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr); if (rc ! SQLITE_OK) { if (db) sqlite3_close(db); AfxMessageBox(_T(无法打开数据库)); return; } // 建表IF NOT EXISTS 防止重复执行 rc sqlite3_exec(db, CREATE TABLE IF NOT EXISTS t_user( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, score REAL DEFAULT 0);, nullptr, nullptr, nullptr); if (rc ! SQLITE_OK) { AfxMessageBox(CString(sqlite3_errmsg(db))); sqlite3_close(db); return; } // 插入准备语句 绑定参数 sqlite3_stmt* stmt nullptr; rc sqlite3_prepare_v2(db, INSERT INTO t_user(name, score) VALUES(?, ?);, -1, stmt, nullptr); if (rc SQLITE_OK) { sqlite3_bind_text(stmt, 1, zhangsan, -1, SQLITE_TRANSIENT); sqlite3_bind_double(stmt, 2, 98.5); if (sqlite3_step(stmt) ! SQLITE_DONE) { AfxMessageBox(CString(sqlite3_errmsg(db))); } sqlite3_finalize(stmt); } // 查询 rc sqlite3_prepare_v2(db, SELECT id, name, score FROM t_user ORDER BY id;, -1, stmt, nullptr); if (rc SQLITE_OK) { while (sqlite3_step(stmt) SQLITE_ROW) { int id sqlite3_column_int(stmt, 0); const char* name (const char*)sqlite3_column_text(stmt, 1); double score sqlite3_column_double(stmt, 2); TRACE(id%d name%s score%.1f\n, id, name, score); } sqlite3_finalize(stmt); } sqlite3_close(db); }这段代码覆盖了 sqlite3 基本操作里最核心的五个动作打开、建表、插入、查询、关闭。sqlite3_open_v2 比老接口 sqlite3_open 多一个 flags 参数推荐新代码都用它。SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE 表示文件不存在时自动创建如果只读现有库去掉 CREATE 即可。prepare_v2 里的 -1 表示按字符串结尾判断 SQL 长度常规 SQL 这么写没问题。绑定参数的下标从 1 开始不是 0这是新手经常写错的地方。SQLITE_TRANSIENT 告诉 sqlite3 内部拷贝一份文本这样即使传入的字符串在后续被释放也不会影响语句执行。每次 prepare 出来的 stmt 必须 finalize否则会占用连接资源。TRACE 只在调试输出里打印Release 下看不到真要看数据需要配合界面展示。2.4 打开与关闭的生命周期数据库连接的打开和关闭不要由某一个按钮函数单独负责。常见做法是在对话框类里放一个成员sqlite3* m_db;在 OnInitDialog 里打开并 busy_timeout在 OnDestroy 里关闭。这样能保证整个窗口存活期间只有一个连接避免每个按钮函数临时开库、忘关句柄。有人在 sqlite3_exec 里执行完一条 SQL 后没有关闭 stmt然后反复打开新连接程序在 Debug 下看起来正常实际数据库文件一直被占用。这个问题在 MFC 程序里尤其常见因为对话框关闭后进程不一定退出dll 里残留的句柄让数据库文件无法删除。先养成一个习惯所有打开路径必须有配套的关闭所有 prepare 必须有配套的 finalize。想再稳妥一点可以用一个简单的 RAII 包装后面第 6 章会给骨架。3. MFC 对话框查询与界面刷新把 sqlite3 结果装进 CListCtrl3.1 先解决显示CListCtrl 从查询结果填充sqlite3 查询结果是一行行读出来的MFC 里最自然的展示控件是 CListCtrl。先给对话框加一个 List Control把 View 设为 Report在 OnInitDialog 里设置列头// 设置整行选中、显示网格线 m_list.SetExtendedStyle(m_list.GetExtendedStyle() | LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES); m_list.InsertColumn(0, _T(ID), LVCFMT_RIGHT, 60); m_list.InsertColumn(1, _T(姓名), LVCFMT_LEFT, 120); m_list.InsertColumn(2, _T(成绩), LVCFMT_RIGHT, 80);之后写一个统一的加载函数。每次刷新前先 DeleteAllItems再遍历查询结果插入数据。注意 sqlite3_column_text 返回的是 const unsigned char*需要转成 const char* 处理如果直接用 CString 接收非 Unicode 工程还好Unicode 工程下会出现乱码后面专门讲转换。void CMfcSqliteDlg::ReloadUserList() { m_list.DeleteAllItems(); sqlite3_stmt* stmt nullptr; const char* sql SELECT id, name, score FROM t_user ORDER BY id;; if (sqlite3_prepare_v2(m_db, sql, -1, stmt, nullptr) ! SQLITE_OK) { AfxMessageBox(CString(sqlite3_errmsg(m_db))); return; } int row 0; while (sqlite3_step(stmt) SQLITE_ROW) { CString idText; idText.Format(_T(%d), sqlite3_column_int(stmt, 0)); const char* nameUtf8 (const char*)sqlite3_column_text(stmt, 1); CString nameText Utf8ToTchar(nameUtf8); CString scoreText; scoreText.Format(_T(%.1f), sqlite3_column_double(stmt, 2)); m_list.InsertItem(row, idText); m_list.SetItemText(row, 1, nameText); m_list.SetItemText(row, 2, scoreText); row; } sqlite3_finalize(stmt); }这里每插入一行要调用一次 SetItemText行的索引从 0 开始递增和数据库里的 id 无关。如果表里有被删除的历史数据id 会有空洞不要用行号去映射 id查询语句里带上主键即可。数据量不大时这种全量刷新完全够用等上万行之后再考虑增量更新。3.2 中文不乱码TcharToUtf8 与 Utf8ToTcharSQLite 的 TEXT 字段按 UTF-8 存储而 MFC 的 CString 在 Unicode 工程里是 UTF-16。直接把 sqlite3_column_text 返回的字节串塞给 CString等于把 UTF-8 当 UTF-16 用结果必然乱码。正确的做法是写入前把宽字符转成 UTF-8读出后把 UTF-8 转回宽字符。下面两个函数是整个 MFCsqlite3 方案里最值得复用的部分std::string TcharToUtf8(LPCTSTR text) { if (!text) return std::string(); int len WideCharToMultiByte(CP_UTF8, 0, text, -1, nullptr, 0, nullptr, nullptr); if (len 0) return std::string(); std::string out(len - 1, \0); WideCharToMultiByte(CP_UTF8, 0, text, -1, out[0], len, nullptr, nullptr); return out; } CString Utf8ToTchar(const char* text) { if (!text) return CString(); int len MultiByteToWideChar(CP_UTF8, 0, text, -1, nullptr, 0); if (len 0) return CString(); CString out; wchar_t* buf out.GetBuffer(len); MultiByteToWideChar(CP_UTF8, 0, text, -1, buf, len); out.ReleaseBuffer(); return out; }这段代码按 Unicode 工程处理适用于 Visual Studio 默认的 MFC 项目设置。如果你的工程还停留在多字节字符集TcharToUtf8 要改成先 GBK 转 Unicode再由 Unicode 转 UTF-8多一道中间步骤。转换函数的入参尽量用 LPCTSTR 而不是 CString这样能直接接受字符串字面量和控件内容调用处不用写一堆强制转换。3.3 增删改查按钮的标准写法在对话框上放两个编辑框和一个“添加”按钮点击后读取输入绑定参数插入数据库。这个流程是 MFC 对话框程序工程代码里最常见的形态void CMfcSqliteDlg::OnBnClickedBtnAdd() { CString name, scoreText; GetDlgItemText(IDC_EDIT_NAME, name); GetDlgItemText(IDC_EDIT_SCORE, scoreText); if (name.IsEmpty()) { AfxMessageBox(_T(姓名不能为空)); return; } double score _ttof(scoreText); std::string utf8Name TcharToUtf8(name); sqlite3_stmt* stmt nullptr; const char* sql INSERT INTO t_user(name, score) VALUES(?, ?);; if (sqlite3_prepare_v2(m_db, sql, -1, stmt, nullptr) SQLITE_OK) { sqlite3_bind_text(stmt, 1, utf8Name.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_double(stmt, 2, score); if (sqlite3_step(stmt) ! SQLITE_DONE) { AfxMessageBox(CString(sqlite3_errmsg(m_db))); } sqlite3_finalize(stmt); } ReloadUserList(); }要点是 SQL 里的值一律用 ? 占位通过 bind 函数传进去。不要用 CString::Format 拼 SQL 字符串MFC 的 CString 拼 SQL 时单引号转义、百分号处理都是麻烦事而且拼出来的语句容易被特殊字符注入。删除和修改同理WHERE 条件走绑定参数。更新语句绑定了一个 id 和一个 score常见错误是绑定的顺序和 SQL 里 ? 出现顺序不一致。比如UPDATE t_user SET score? WHERE id?这两个占位符bind 时第一个参数下标 1 表示 score下标 2 表示 id。如果反过来绑定程序不报错但数据就更新错了。3.4 刷新时的闪烁控制数据量变大后列表每次刷新会闪。原因很简单DeleteAllItems 和每个 InsertItem 都会触发控件重绘。解决办法是在刷新前后关掉重绘m_list.SetRedraw(FALSE); ReloadUserList(); m_list.SetRedraw(TRUE); m_list.Invalidate();SetRedraw(FALSE) 期间控件不会重画所有插入操作只在内存里更新最后 SetRedraw(TRUE) 配合 Invalidate 一次性重绘。这个方法比 LockWindowUpdate 安全LockWindowUpdate 在某些情况下会拦截鼠标消息导致拖动滚动条时出现诡异行为。几百行数据场景下这个改动能让界面观感完全不一样。4. 参数调优sqlite3 不是配好就能扛住4.1 busy_timeout处理 database is lockedMFC 程序里出现“database is locked”报错绝大多数原因是 sqlite3 默认 busy_timeout 为 0。也就是说一旦有其他连接暂时持有锁sqlite3 会立刻返回 SQLITE_BUSY而不会等对方释放。单进程单连接的程序也会撞上杀毒软件扫描数据库文件、备份工具读取、另一个调试实例打开了同一个库都会造成短时间锁冲突。解决办法是在打开数据库后立刻设置等待超时sqlite3_busy_timeout(m_db, 3000);推荐值在 3000 到 5000 毫秒之间。太短用户点击按钮时偶尔弹出锁错误太长真出现死锁时界面会卡住。busy_timeout 只对数据库忙有效不能解决死锁。两个连接互相等对方释放资源时超时到了照样报错。多线程程序里需要额外注意锁顺序这个不是 dll 能替你解决的。4.2 journal_modeWAL并发读写的缓冲垫如果程序只有一个连接默认的 rollback journal 模式已经够用。但很多 MFC 工具程序不止自己一个进程在访问数据库测试脚本要读库、同事用 sqlite3.exe 查数据、程序内部又开了 worker 线程写日志。这种场景下把 journal 模式切换成 WAL 会更宽容sqlite3_exec(m_db, PRAGMA journal_modeWAL;, nullptr, nullptr, nullptr); sqlite3_exec(m_db, PRAGMA synchronousNORMAL;, nullptr, nullptr, nullptr);WAL 模式下写操作先追加到 -wal 文件读操作还能读旧快照读写并发能力比默认模式好很多。代价是数据库目录下会多出 .wal 和 .shm 两个文件备份时不能只拷贝主文件。还有一点WAL 在网络共享盘上的表现不稳定如果程序的目标环境是 U 盘或公司共享目录建议保留默认模式否则可能出现无法预料的锁异常。synchronous 配 NORMAL 是 WAL 下的常见组合。它降低了每次提交时强制落盘的强度换来更高写入吞吐。对崩溃安全要求极高的场景保持 FULL 更稳但 MFC 本地工具程序通常没有这个必要。4.3 批量提交纪律一万行插入别一条一条 commitsqlite3 默认每条 INSERT 语句都是自动提交每写一行就要做一次磁盘同步。循环一万行就是一万次磁盘写入耗时完全是线性上涨。正确做法是手动包一个事务sqlite3_exec(m_db, BEGIN;, nullptr, nullptr, nullptr); for (int i 0; i 10000; i) { sqlite3_stmt* stmt nullptr; sqlite3_prepare_v2(m_db, INSERT INTO t_user(name, score) VALUES(?, ?);, -1, stmt, nullptr); sqlite3_bind_text(stmt, 1, name, -1, SQLITE_TRANSIENT); sqlite3_bind_double(stmt, 2, i * 0.1); sqlite3_step(stmt); sqlite3_finalize(stmt); } sqlite3_exec(m_db, COMMIT;, nullptr, nullptr, nullptr);事务中途失败要执行 ROLLBACK否则连接还挂着未提交事务其他连接无法写入。批处理期间不要做界面刷新把 UI 更新放到 COMMIT 之后。很多“导入卡死”的现场其实不是 sqlite3 慢是自动提交模式下磁盘 IO 太多加上 UI 线程又被循环堵住界面自然假死。4.4 参数对照表sqlite3 常用配置参数按 MFC 工程的实际影响整理如下参数默认值建议值影响busy_timeout03000~5000决定锁冲突时等待多久0 表示立刻报错journal_modedelete保持默认或 WAL决定写事务落盘方式WAL 适合读写并发synchronousFULLWAL 配 NORMAL控制同步策略越高越安全也越慢cache_size-20008000 左右页缓存大小提高重复查询性能temp_storefilememory临时表和排序是否走内存数据量大才明显这些参数之间会互相影响。开了 WAL 再调 synchronousNORMAL 是常规组合如果 journal 保持默认却把 synchronous 改成 OFF数据库崩溃后损坏概率会明显上升。MFC 工具类程序要的是稳定可维护追求极限写入速度不如把代码写清楚。5. MFC 工程里 sqlite3 最常翻车的五个场景5.1 打开数据库失败加载 DLL 报“应用程序无法正常启动”现象编译链接都通过一运行就报错对话框都弹不出来。原因基本只有两个缺 sqlite3.dll或 dll 的位数和 exe 不匹配。32 位 exe 加载 64 位 dllWindows 会直接拒绝执行反过来也一样。文件缺失时错误提示通常是“找不到 sqlite3.dll”。两种问题都发生在输出目录里而不是源码目录。解决先确认解决方案平台是 x86 还是 x64然后到 exe 输出目录看 dll 是否存在。如果 dll 文件名正确但位数不对用 Visual Studio 自带的 dumpbin 工具查dumpbin /headers sqlite3.dll | findstr machine输出里 14C 表示 x868664 表示 x64。查到不匹配去官网下载对应位数的预编译包替换 dll 和 lib。这个坑在 MFC 教程里讲得少因为教程通常默认你复制对了文件但实际项目里几乎一半的引入失败都发生在这。5.2 中文乱码三个源头现象插入数据库后用 sqlite3.exe 命令行查询显示正常程序界面上却是乱码。或者反过来程序里显示正常命令行工具看到的是问号。原因有三个。第一个是 sqlite3_column_text 返回 UTF-8直接赋给 MFC 的 CString在 Unicode 工程下必然乱。第二个是用 CString 拼 SQL 时宽字符被转成默认 ANSI 再送进 sqlite3编码就变了。第三个是数据库文件本身被某个外部工具用非 UTF-8 编码写过程序读到旧数据无法还原。解决统一走第 3 章的 TcharToUtf8 和 Utf8ToTchar所有写入和读取都经过转换函数。对于已经被写乱的库只能先确认源数据是什么编码再做一次全表转码。这类问题排查起来比较烦最好的办法是在第一天就定好“程序进出口必须 UTF-8”的规矩。5.3 多线程共用同一个连接黑匣子式崩溃现象程序在 Debug 下跑一整天没事换 Release 之后偶发崩溃或者加了后台线程后打开对话框时偶尔卡死。原因sqlite3 编译时默认支持多线程但它的线程安全是有条件的。同一连接同时被两个线程调用 prepare/step属于未定义行为崩溃点和崩溃时机都无法预测。解决每线程一个连接或者给唯一连接的访问加锁。我一般倾向每线程一个连接sqlite3 打开连接的成本很低数据库文件路径相同线程内自己 open、自己 close互不干扰。真出现两个线程同时写同一张表交给 busy_timeout 和 WAL 处理锁等待。给唯一连接包一个 CCriticalSection 也能救但锁的范围要覆盖整个 prepare、bind、step、finalize 周期少一步都可能漏保护。5.4 句柄泄漏对话框一关文件还是被占用现象程序关闭后数据库文件删不掉提示被另一个程序占用。原因某处 prepare 了 stmt 没有 finalize或者 sqlite3_open 成功之后中间某个 return 分支跳过了 sqlite3_close。sqlite3_close 会释放连接的资源但它也只能释放你自己没 finalize 的语句不会帮你调用根本没执行的 close。解决所有 sqlite3_open 的后续分支检查失败时也要先 close 再 return。所有 sqlite3_prepare_v2 成功之后无论 sqlite3_step 是否执行成功都要 sqlite3_finalize。在对话框 OnDestroy 里统一写sqlite3_close(m_db); m_db nullptr;。Debug 模式下可以用 sqlite3_memory_used 和 _CrtDumpMemoryLeaks 配合确认泄漏源。这个问题隐蔽在现象上程序不报错只是文件锁着不放慢慢才发现每次调试完都得强杀进程。5.5 在 UI 线程批量写库导致界面假死现象点击“导入”“同步”按钮后窗口标题显示“未响应”过几秒或几十秒才恢复。原因循环插入加上自动提交全部跑在 UI 线程里消息泵没有机会处理重绘和鼠标点击。解决把耗时操作放进 worker 线程完成后 PostMessage 给主线程刷新列表。最朴素的写法是 AfxBeginThread 跑一个写库函数函数结束前向对话框窗口发自定义消息在主线程里调 ReloadUserList。注意 worker 线程里不要直接操作 MFC 控件控件没有跨线程访问的保护。数据量小时这一条无所谓一旦有了批量导入需求这个坑几乎人人都踩。6. 进阶验证库文件与一个不绕圈的封装6.1 用 sqlite3.exe 命令行做健康检查官方 tools 包里有一个 sqlite3.exe把它和数据库放到同一目录在命令行里就能做基础体检sqlite3 myapp.db PRAGMA integrity_check; sqlite3 myapp.db .tables sqlite3 myapp.db SELECT count(*) FROM t_user;integrity_check 如果返回 ok说明数据库物理结构没损坏。如果看到 database disk image is malformed第一件事是立刻备份整个目录包括 wal 和 shm 文件再考虑恢复。这一步操作简单价值很大比写一堆检测代码靠谱。6.2 一个比裸句柄更稳的封装思路裸 sqlite3* 的生命周期问题用一个很薄的类就能解决class CSqlite3Db { public: CSqlite3Db() : db_(nullptr) {} ~CSqlite3Db() { Close(); } bool Open(LPCTSTR path) { std::string pathUtf8 TcharToUtf8(path); return sqlite3_open_v2(pathUtf8.c_str(), db_, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr) SQLITE_OK; } void Close() { if (db_) { sqlite3_close(db_); db_ nullptr; } } sqlite3* Get() const { return db_; } private: sqlite3* db_; };这个封装不做什么高级处理核心价值是让析构替人兜底。对话框成员里放一个 CSqlite3Db 对象窗口销毁时自动关库漏关闭的隐患能消掉一大半。至于 prepare 和 finalize 也可以封装成 RAII 样式但建议先熟练裸 API出了问题能定位到具体调用链。我个人的习惯是每次写完一批 sqlite3 相关改动先跑一遍 PRAGMA integrity_check再执行一次“插入-查询-删除”冒烟测试最后关程序确认数据库文件能被正常重命名。这三步里有任何一步不对优先怀疑没 finalize、没 close、编码没转换。这三个原因占了 MFC 工程里 sqlite3 问题的大半。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑