ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CDaoRecordView实战:MFC老项目数据库表单绑定与记录导航解析

CDaoRecordView实战:MFC老项目数据库表单绑定与记录导航解析 前阵子接手一个维护了十几年的MFC桌面程序数据库操作部分用的还是DAO那套组件。一开始我挺抵触——现在做MFC数据库编程大多数人要么直接ADO要么用ODBC配CRecordView谁还碰CDaoRecordView这种老古董但真正把代码翻出来之后我发现自己之前的判断过于草率了。CDaoRecordView这套机制在单表维护、表单绑定、数据导航这类场景下设计得相当精巧而且代码量能压得非常低。这篇文章不是教科书复述而是基于我实际改造和调试DAO程序的经验总结。我会把CDaoRecordView的内部机制、与记录集的协作方式、完整实战代码、还有那些网上几乎搜不到的坑一次性讲透。适合正在维护MFC老项目、或者要在现有工程里加数据库表单功能的开发者参考。1. CDaoRecordView到底替你省了哪些事很多人在MFC里做数据库表单第一反应是拖一堆Edit控件然后手动写SELECT、写手动填充、写上一笔下一笔的导航逻辑。一套下来少说四五百行而且每条记录切换时都要小心翼翼处理控件内容刷新。CDaoRecordView存在的意义就是把这一整套重复劳动封装成框架级能力。1.1 一条记录对应一个表单页的核心价值CDaoRecordView本质上是一个CFormView的派生类它内部持有一个指向CDaoRecordset的指针。它解决的问题非常聚焦你只需要把界面上的Edit、ComboBox、CheckBox等控件和记录集的字段通过DDX机制绑定起来之后不管是记录滚动、字段刷新、还是编辑后写回数据库框架都会自动完成。举个直观例子。传统写法里用户点“下一笔”按钮你要写这些逻辑判断当前记录位置是否到末尾调用记录集的MoveNext遍历每个控件从字段值填充界面处理字段为空的情况处理编辑状态是保存还是放弃用CDaoRecordView之后你的按钮响应函数只需要一行代码OnMove(IDC_RECORD_NEXT)。至于移动到哪条记录、界面怎么刷新、字段空值怎么显示全部由基类的OnMove内部逻辑接管。这就是框架的意义——把高频且容易出错的步骤收敛到一个经过验证的实现里。1.2 与CRecordView、CFormView的界限很多初学者分不清这三个类的关系。简单说CFormView是带滚动支持的表单视图它是基础CDaoRecordView和CRecordView都是在CFormView之上增加数据库记录绑定能力的视图类区别仅在数据访问技术不同。CRecordView绑定的是CRecordset走ODBCCDaoRecordView绑定的是CDaoRecordset走DAO。DAO底层用的是Microsoft Jet数据库引擎对Access的友好程度极高对ODBC数据源也能通过ODBC连接串访问。在我接手那个老项目里数据库就是Access用CDaoRecordView几乎是当时最顺手的方案。还有个实际原因值得提CDaoRecordView的字段映射机制使用DFXDAO Record Field Exchange对Access的字段类型、空值处理、乃至Unicode支持都做得比早期CRecordView更细腻。比如Access里的“是/否”类型DAO能直接映射成BOOL而ODBC那套处理起来就容易出现类型转换边界问题。1.3 模板生成的代码骨架说了什么在用Visual Studio的MFC AppWizard创建程序时如果选择了数据库视图并指定DAO记录集生成的头文件里就能看到这几样东西class CMyDaoView : public CDaoRecordView { public: CMyDaoView(); enum { IDD IDD_MY_FORM }; CDaoRecordset* OnGetRecordset(); protected: virtual void DoDataExchange(CDataExchange* pDX); virtual void OnInitialUpdate(); };这三个东西是CDaoRecordView运行的三根支柱IDD枚举值告诉框架这个视图对应的对话框资源ID视图创建时自动加载表单布局。OnGetRecordset视图需要拿到记录集对象的入口框架在初始化、记录移动后都会通过它获取当前记录集。DoDataExchange负责界面控件和记录集字段之间的数据交换这里面隐藏了DFX机制。很多人在模板生成的代码上直接堆业务却从没想过这三者的调用时机。下一步就深入讲绑定机制。2. 视图和记录集是怎么绑到一起的这一节是整个CDaoRecordView的核心原理。不搞懂它也照样能用但一旦出了问题你就只能瞎猜。我建议花几分钟读明白字段交换的完整路径调试的时候会少走很多弯路。2.1 DoDataExchange里的DDX/DFX双层路由先明确两个缩写DDXDialog Data Exchange处理控件和数据成员之间的交换。这里的“数据成员”通常是CString、int、BOOL这类。DFXDAO Record Field Exchange处理记录集字段数据成员和数据库字段之间的交换。CDaoRecordView的巧妙之处在于把这两条交换链串起来。你在DoDataExchange里写这样一段代码void CUserDaoView::DoDataExchange(CDataExchange* pDX) { CDaoRecordView::DoDataExchange(pDX); DDX_Text(pDX, IDC_EDIT_NAME, m_pSet-m_strName); DDX_Text(pDX, IDC_EDIT_DEPT, m_pSet-m_strDept); DDX_Text(pDX, IDC_EDIT_PHONE, m_pSet-m_strPhone); }这里表面上只有DDX实际上DDX_Text的第四个参数直接引用了记录集里的字段成员数据流动是双向的从数据库到界面CDaoRecordSet打开并读取记录后DFX把表字段值填入记录集成员然后框架触发DoDataExchangeDDX把成员值推送到控件。从界面到数据库用户修改控件内容后调用UpdateData(TRUE)DDX把控件值回写进记录集成员接着框架调用记录集的Update方法时DFX把这些成员值写回数据库表的对应字段。所以整个视图的绑定是两段式管道控件 ⇄ DDX ⇄ 记录集字段成员 ⇄ DFX ⇄ 数据库字段。2.2 OnMove、OnGetRecordset等虚函数的实际职责CDaoRecordView提供了一系列虚函数每个都有明确的使用时机。先看OnMove。用户点击导航按钮时典型响应代码是void CUserDaoView::OnRecordNext() { OnMove(IDC_RECORD_NEXT); }OnMove的职责是先检查当前记录是否处于编辑状态如果是则根据参数决定刷新还是保存然后对记录集执行MoveNext、MovePrev、MoveFirst或MoveLast最后调用UpdateData(FALSE)刷新界面。这个函数是用户导航操作的总调度正确性完全由基类实现保证。再看OnGetRecordsetCDaoRecordset* CUserDaoView::OnGetRecordset() { return m_pSet; }这段代码看起来简单得不像话但它是视图与记录集之间唯一的官方接口。框架在OnInitialUpdate时调用一次后续每次你在界面操作后需要重查记录集时也会用到。如果你试图绕过它直接操作m_pSet虽然程序也能跑但一旦涉及多视图共享记录集的情况就容易乱套。OnInitialUpdate的调用顺序也是必知点先调用基类CDaoRecordView::OnInitialUpdate基类内部会调用OnGetRecordset把视图关联到记录集记录集第一次Open定位到第一条记录然后调用UpdateData(FALSE)把当前记录显示到控件所以你在重写OnInitialUpdate时务必要先调用基类版本再写自己额外的初始化逻辑比如给ComboBox下拉框塞选项。顺序反了你的初始化代码可能在记录集尚未定位时执行ComboBox倒是装好了但第一批数据显示不出来。2.3 游标类型对视图行为的影响CDaoRecordView对记录集有个隐性要求记录集必须是个可滚动的动态集或快照。原因很好理解——视图的上一笔、下一笔导航依赖记录集的绝对定位能力。我在新写代码时见过这样的操作为了查一条数据直接用了dbOpenForwardOnly类型的游标。结果视图初始化能显示第一条记录但一点“下一笔”就抛异常异常信息还不明不白。后来查了DAO文档才知道forward-only游标只支持向前的顺序读取不支持MovePrev和绝对定位CDaoRecordView的OnMove逻辑一调用MovePrev就炸了。所以如果你用AppWizard生成DAO记录集默认的打开类型就是dbOpenDynaset这个别乱改。只有在极特殊的只读单次遍历场景下才考虑切换游标类型但那种场景也不适合用CDaoRecordView承载。3. 实战一个完整的CDaoRecordView应用原理讲完直接上手。下面以一个员工信息维护表单为例走通生成、绑定、增删改查的全流程。Access表结构假设如下字段名类型说明ID自动编号主键长整型Name文本(50)员工姓名Dept文本(50)部门Phone文本(20)电话HireDate日期/时间入职日期Active是/否在职状态3.1 从AppWizard生成基础骨架创建工程的步骤简述如下Visual Studio里新建MFC应用应用程序类型选“单个文档”数据库支持选“不提供文件支持的数据库视图”。数据源选择里指定DAO通过“选择数据库”按钮指向你的Access文件。向导会让你选记录集和表单视图确认后自动生成三个核心类CUserDaoSet记录集、CUserDaoView视图、CUserDaoDoc文档。生成后的记录集类里向导会自动把表字段映射成成员变量。如果没有自动映射也可以手动在DoFieldExchange里补void CUserDaoSet::DoFieldExchange(CDaoFieldExchange* pFX) { pFX-SetFieldType(CDaoFieldExchange::outputColumn); DFX_Long(pFX, _T([ID]), m_lID); DFX_Text(pFX, _T([Name]), m_strName); DFX_Text(pFX, _T([Dept]), m_strDept); DFX_Text(pFX, _T([Phone]), m_strPhone); DFX_DateTime(pFX, _T([HireDate]), m_dateHireDate); DFX_Bool(pFX, _T([Active]), m_bActive); }注意字段名带方括号。这个习惯在Access里很重要因为Name、Date这类词可能是Jet引擎的保留字不加方括号在某些环境会触发SQL解析问题。3.2 界面布局与字段绑定在资源编辑器里设计表单对话框放置6个编辑框、一个复选框ID分别设为IDC_EDIT_ID、IDC_EDIT_NAME、IDC_EDIT_DEPT、IDC_EDIT_PHONE、IDC_EDIT_HIREDATE、IDC_CHECK_ACTIVE。然后重写DoDataExchangevoid CUserDaoView::DoDataExchange(CDataExchange* pDX) { CDaoRecordView::DoDataExchange(pDX); DDX_Text(pDX, IDC_EDIT_ID, m_pSet-m_lID); DDX_Text(pDX, IDC_EDIT_NAME, m_pSet-m_strName); DDX_Text(pDX, IDC_EDIT_DEPT, m_pSet-m_strDept); DDX_Text(pDX, IDC_EDIT_PHONE, m_pSet-m_strPhone); DDX_DateTimeCtrl(pDX, IDC_EDIT_HIREDATE, m_pSet-m_dateHireDate); DDX_Check(pDX, IDC_CHECK_ACTIVE, m_pSet-m_bActive); }日期控件可以用DateTimePicker配合DDX_DateTimeCtrl做绑定比单纯文本控件体验好很多。如果你在Access里存的是纯文本日期那使用DDX_Text绑定字符串即可看你需求定。3.3 增删改查与记录导航MFC表单视图不提供“新增记录”按钮的内置实现需要自己挂一个“新增”按钮。我的实现方式如下void CUserDaoView::OnRecordAdd() { m_pSet-AddNew(); m_pSet-SetFieldNull(NULL, FALSE); m_lPrevID m_pSet-m_lID; // 暂存当前记录ID后面会解释用途 UpdateData(FALSE); GetDlgItem(IDC_EDIT_NAME)-SetFocus(); }这段代码有三个要点AddNew后必须调用SetFieldNull(NULL, FALSE)。原因在于新增记录时记录集所有字段都处于NULL状态而Date类型的NULL值会直接影响DateTimePicker的显示。调用这个函数后字段会被填充为默认值控件才能正常显示。暂存原记录ID是为了“取消新增”时恢复浏览位置。一旦进入新增状态当前记录指针已经变了不暂存的话取消后界面会落在错误的位置。UpdateData(FALSE)保证新增的空白模板立刻反映到界面。保存记录的代码如下void CUserDaoView::OnRecordSave() { if (!UpdateData(TRUE)) return; if (m_pSet-CanUpdate()) { m_pSet-Update(); m_pSet-Requery(); MoveToRecord(m_lPrevID); // 移动回刚才的记录 } }这里的Requery是关键。DAO的Update只是把数据写进表但视图里m_pSet内部的记录集缓存不会自动包含刚刚新增的记录。如果不Requery新增的记录在导航时看不到。Requery后游标回到第一条所以我保存了原记录的ID通过MoveToRecord把它定位回去。删除记录的代码void CUserDaoView::OnRecordDelete() { if (AfxMessageBox(_T(确定要删除当前记录吗), MB_YESNO) ! IDYES) return; m_pSet-Delete(); m_pSet-Requery(); UpdateData(FALSE); }Delete之后Requery界面自动刷新到重查后的第一条记录。记录导航按钮最简单一行命令调用基类void CUserDaoView::OnRecordFirst() { OnMove(IDC_RECORD_FIRST); } void CUserDaoView::OnRecordPrev() { OnMove(IDC_RECORD_PREV); } void CUserDaoView::OnRecordNext() { OnMove(IDC_RECORD_NEXT); } void CUserDaoView::OnRecordLast() { OnMove(IDC_RECORD_LAST); }注意在编辑状态下切换记录OnMove会先帮你处理未保存的数据。如果你想强制用户先保存再切记录需要在OnMove之前做状态检查这一点后面讲坑的章节会展开。3.4 查询功能的组合刷新表单视图不止是维护单条记录经常还要做按部门过滤。CDaoRecordset支持通过m_strFilter实现参数化过滤视图里加个按钮弹出输入框重新设置Filter并Requeryvoid CUserDaoView::OnFilterByDept() { CString strDept; if (GetDlgItemText(IDC_EDIT_FILTER_DEPT, strDept) !strDept.IsEmpty()) { m_pSet-m_strFilter.Format(_T([Dept] %s), strDept); m_pSet-Requery(); UpdateData(FALSE); } }这里有一个安全性和正确性的细节SQL字符串拼接时要警惕单引号转义问题如果部门名称里带单引号这段代码就会出错。稳妥做法是使用参数化查询把过滤值塞进参数集合。CDaoRecordset的m_strFilter虽然能内嵌值但代价是你得自己做转义我一般在生产代码里会写个ReplaceQuote函数把单引号双写能省去大量边界烦恼。全部过滤和查询的核心就一句话改m_strFilter然后Requery。但这句话背后有无数可能踩坏的隐性状态后面细讲。4. 高频坑与排查思路CDaoRecordView最磨人的不是控件绑定而是各种状态联动问题。这一节是我在实际项目里踩过、且最终定位到根因的问题集合。4.1 新增记录后找不到新记录的坑这个坑我见到的次数最多。Bug表现是用户新增并保存了一条记录程序反馈一切正常但关闭重开后这条新记录消失。排查链路是这样的先查保存操作Update执行成功没抛异常。再查表用Access打开数据库记录确实存在。最后定位到新增保存后记录集停在“新增记录”的虚拟位置。此时如果你raw指针在m_pSet上做了Move操作可能会跳错。但更常见的原因是保存路径没做Requery而后续代码又在未刷新状态下调用MoveNext结果DAO内部把刚刚的AddNew缓存附加回了数据库尾部。这其实是DAO组件的一个经典状态问题AddNew后虽然调了Update但记录集内部的缓冲并未同步为最新记录位置。正确的保存序列应该是我在上面写过的三件套AddNew → 填字段 → Update → Requery → 跳回目的记录。任何中间环节缺失都会出现这种“保存成功但视图看不到”的诡异表现。4.2 空值字段显示为0或空串的边界Access允许很多字段为NULL尤其是日期和数字类型。CDaoRecordset刚打开时如果某行记录的HireDate字段为空m_dateHireDate会被标记为空值状态而DDX_DateTimeCtrl绑定控件时控件无法显示这个空状态于是显示成“1899年12月30日”或者干脆就是空白。最省心的处理方式是在记录集打开后、显示到视图之前做空值归一化。方法是在记录集的OnMove或视图的OnInitialUpdate里补一段空值检查逻辑if (m_dateHireDate.GetStatus() COleDateTime::null) { m_dateHireDate COleDateTime::GetCurrentTime(); }但这样会把空值变成当前时间未必符合业务要求。我更建议在界面层处理绑定之前判断空值状态空的话置默认显示文本这样数据库里的NULL不会被误改。另一个典型是“是/否”字段。Access里的“是/否”在DAO里映射成BOOL控件用CheckBox绑定。问题出在NULL上——如果字段还在NULL状态DDX_Check会拿一个不确定值填充三态CheckBox导致界面出现灰度勾选状态。我的做法是记录集打开后统一把Active字段的空值归一化为FALSE反正业务上“未知”和“否”在处理时没有本质区别。4.3 Requery后当前位置丢失的应对Requery是刷新记录的必经之路但它会把游标重置到第一条。如果你的界面上放着“当前是第几条/共几条”的统计信息或者用户的浏览位置在数据中段Requery后界面突然跳到第一条体验很差。我常用的方案是记主键值Requery后定位回原记录。前面保存记录的代码里就用了这个思路。具体做法是void CUserDaoView::MoveToRecord(long lID) { CDaoRecordset* pSet OnGetRecordset(); if (!pSet-IsBOF()) { pSet-MoveFirst(); while (!pSet-IsEOF()) { if (pSet-m_lID lID) break; pSet-MoveNext(); } } UpdateData(FALSE); }如果表很大这种顺序查找不高效。但在维护类小表单场景下几百上千条记录一次遍历毫无压力。如果你要处理几万行的大表不建议用CDaoRecordView这类表单绑定的方式维护直接用列表控件配合SQL定位要好得多。4.4 表结构变更后旧工程崩溃的排查MFC工程在开发中经常面临Access表结构变更加字段、改类型、删列。很多人在表里加完字段后程序一跑就断在DFX相关代码说“字段未找到”。这个问题的根因在于记录集对象的DoFieldExchange是编译期写死的映射列表而Access表结构是运行期读取的。如果映射里写了[Phone]字段但表里已经没有这一列DAO在Open时会直接抛异常。排查思路我推荐三步法先用Access打开数据库拿实际字段列表。对照记录集类的DoFieldExchange映射列表逐字段核对名称和类型。特别注意字段顺序不是问题映射靠名字匹配名字大小写不敏感但空格和隐藏字符要当心。顺带提一个经验改Access表结构时如果程序正在运行Jet引擎可能会因为文件锁定导致DDL操作失败。开发时务必把程序完全退出再改表否则经常白改一场。4.5 缓存过大导致的性能劣化CDaoRecordView的默认模式是打开整个记录集到内存。假设一张表有5万行、每行几百字节用CDaoRecordView打开时内存占用会明显上升导航时肉眼可感知的卡顿。这不是框架bug而是设计边界问题。处理办法是在记录集打开之前通过m_strFilter和m_strSort限制记录集大小。比如员工表可以默认只加载在职员工减少数据量。如果需求是几十万行级别还坚持用表单视图逐条翻那是选型问题不是配置问题建议换数据列表方案。5. 使用边界什么时候不该用CDaoRecordView最后这部分是用血泪换来的选型经验。CDaoRecordView不是万能钥匙它擅长的事情非常明确越界使用只会放大痛苦。5.1 单表单、低并发维护是最佳场景CDaoRecordView是典型的“单记录编辑视图”。一个用户一条一条地维护记录控件直观、导航顺手、绑定自动这种场景下它几乎零成本。特别是Access这种轻量级数据库配合DAO部署时不需要额外配置数据源使用相对简洁。我建议的判断标准是如果你的界面每屏就是一条记录、字段数量在十个以内、用户习惯是顺序翻找修改那CDaoRecordView确实合适。5.2 多表主从结构、事务性修改要三思一个订单头加多个订单明细的主从界面如果试图用两个CDaoRecordView嵌套实现复杂度会指数级上升。原因在于两个记录集的联动头记录切换时明细必须跟着刷新需要手动写同步代码而CDaoRecordView的绑定机制对这种联动没有内建支持。我见过硬写出来的版本代码里到处是“SetViewActive”之类的状态切换维护起来非常痛苦。这种场景正确方向是主表用CDaoRecordView维持编辑形态从表用一个列表控件展示明细通过手动查询刷新列表内容。不要让两个记录集视图嵌套除非你已经做好了写大量胶水代码的心理准备。5.3 多表联查和复杂SQL不是它的菜CDaoRecordView绑定的是“一条记录中的字段”所以记录集本身可以来自多表连接查询。你完全可以写一个带JOIN的SQL把它塞进CDaoRecordset然后绑定到视图上。但问题在于多表联查的结果集往往是只读的。DAO对含JOIN的记录集默认不允许更新如果你界面上放了编辑框用户改了字段保存时DAO会抛“记录集不可更新”异常。除非你设置记录集的锁定类型为乐观并发并且动态集本身具备更新能力否则就会出现“能看不能改”的尴尬。我的经验是多表联查的只读结果集用列表控件展示只有单表或明确可更新的查询才放到表单视图里编辑。5.4 与其他选型对比的结论场景推荐方案理由单表维护、记录导航CDaoRecordView CDaoRecordset绑定自动化、代码量小多表主从、事务联动列表控件 CDaoRecordset/ADO手动可控逻辑清晰几万行以上大表列表控件 分页查询避免一次性载入全量数据只读报表只读记录集 列表/Grid不做编辑绑定性能优先和CRecordView对比CDaoRecordView在Jet/Access场景下的字段类型支持更直接但如果你要接SQL ServerDAO需要额外配置ODBC驱动反而CRecordView或ADO更顺。选型没有绝对优劣关键看数据源类型和更新需求。最后分享一个我在实际项目里的小体会CDaoRecordView这套东西已经被主流开发边缘化了但如果你正在维护的MFC老工程用了它不要急着推倒重写。先把记录集的边界控制好把Requery的位置补对把空值处理写规范它的稳定性其实相当高。我改造过的一个模块重构后代码量缩减了接近一半主要就是靠把散落的导航逻辑收拢到框架自带的OnMove能力里。所以别被“过时”两个字吓到弄清楚边界它依然是个顺手的老伙计。
返回列表