基于C++与MFC的家谱管理系统设计与实现
2026/9/16 1:21:54 网站建设 项目流程

简介:基于C++与MFC构建的图形化家谱管理系统源码包,适合正在学习面向对象编程与Windows界面开发的读者,也可供普通用户直接编译使用,解决家谱成员录入、关系维护与图形化展示等实际问题。整个压缩包共三十九个文件,体积仅一百五十一KB,核心是十三个头文件和十二个C++源文件,分别对应家谱成员类定义、MFC对话框逻辑、文档视图架构及数据读写功能,同时附带工程配置、资源脚本、可执行程序与PDF说明文档,项目结构完整,极易在Visual Studio中打开并运行。目前已有一百八十三人学习下载。通过阅读源码,既能掌握MFC控件使用、消息映射、文件持久化等技术要点,又能基于现有框架进一步扩展树状家谱图、导入导出功能,是兼具教学与实用价值的MFC示例项目。对于想入门MFC项目实战的开发者,这份代码贴近实际、便于二次开发,参考价值较高。

1. 为什么用 C++ 和 MFC 做家谱管理

家谱管理系统看起来只是录入姓名和出生日期,真正花时间的恰恰是那些不会被写进课程报告的部分:成员关系如何在内存里表达,CTreeCtrl怎么和底层数据保持同步,中文姓名和递归绘制在 Unicode 环境下有哪些隐藏坑。这套jiapu资源把这些问题都压在一个基于 C++ 与 MFC 的图形化程序里,对学习 MFC 的开发者来说,比零散刷语法有价值得多,对已经写过几年 C++ 的工程师也是一个观察老派 Windows 桌面程序如何组织数据流的完整样本。下面按数据模型、界面绑定、文件持久化、运行库兼容的顺序拆,关键代码可以直接抄到自己的工程里。

2. 家谱数据模型:从 Person 类到多叉树结构

2.1 为什么不用数据库表,而是用对象数组

小规模家谱通常只有几十到几百人,用 SQLite 或 MySQL 反而要多维护一套 schema 和驱动。更自然的做法是把每个成员映射成一个Person对象,再用一个std::vector<Person>管理全部成员。这里的关键不是“用类”,而是对象之间的关联方式。初学者最容易踩的坑是使用Person*直接指向父亲或孩子,但std::vector在扩容时会移动对象,原来的指针全部失效。所以更好的做法是给每个人一个稳定的m_id,其他字段保存 ID,需要真实对象时再通过FindById查找。

class Person { public: Person() : m_id(0), m_gender(0), m_parentId(0), m_spouseId(0) {} int m_id; CString m_name; int m_gender; // 0 未知,1 男,2 女 CString m_birthDate; // 统一保存为字符串,便于直接显示 CString m_deathDate; // 为空表示仍健在 int m_parentId; // 血缘父/母 ID,根为 0 int m_spouseId; // 配偶 ID,无配偶为 0 std::vector<int> m_childrenIds; // 孩子 ID,按长幼顺序存放 };

这样设计有两点考虑。m_parentId虽然只有一条链,但通过m_spouseId可以找到再婚配偶及其子女,不需要把关系做成图。孩子列表用std::vector<int>而不是定长数组,因为不同家庭的孩子数量差别很大,动态数组可以避免浪费,也方便保持长幼顺序。CString在 MFC 环境下能自动处理 Unicode 和 ANSI 转换,比std::string在界面交互上省心。

FamilyTree类负责所有查找和增删操作,核心是线性扫描。几百人时线性查找完全够用,没必要引入哈希表,但每次从文件加载后都要重建m_childrenIds,这个会在第四章详细讲。需要特别说明的是,FindById返回Person*有一个隐含条件:外部不能在持有指针期间再向m_personspush_back 新成员,否则指针地址可能变化。出于安全,所有可能扩容的操作都放在一轮刷新之前完成,界面在响应按钮事件时不要立刻使用旧指针。

2.2 深度优先遍历与增删成员

向家谱添加成员的常规步骤是:先创建Person并分配 ID,然后把它挂到指定父亲的m_childrenIds尾部。删除成员要复杂一些,因为不能只删自己的记录,还要把它从父亲的m_childrenIds里移除,同时它的孩子如何处理也需要约定。这个项目采用的约定是:被删除节点的孩子自动上移成被删除节点的父亲的孩子,避免出现整支断代。

遍历家谱最常见的操作是“按辈分列出所有成员”。递归深度优先遍历是教科书级别的方法,但放到这里有一个细节:必须用depth参数控制层级显示,不能用递归层数本身代替辈分,因为同一个人在不同分支可能属于不同代际。代码可以这样写:

void FamilyTree::Traverse(int id, int depth, std::vector<std::pair<int,int>>& out) { Person* p = FindById(id); if (!p) return; out.push_back(std::make_pair(p->m_id, depth)); for (int childId : p->m_childrenIds) { Traverse(childId, depth + 1, out); } }

这里depth表示相对根的代数,根的深度为 0。输出到out后,界面可以直接用第二项决定缩进或绘制位置。参数说明:id是当前成员 ID,depth用于累计辈分,out是结果数组。需要特别注意的是FindById有可能返回空指针,比如序列化数据损坏时父节点 ID 指向了不存在的记录,所以递归开头必须有if (!p) return;,否则后续访问p->m_childrenIds就是未定义行为。

插入成员时,如果允许把近亲改成夫妻这种操作,需要额外校验。常见做法是在AddChild里禁止把某人的后代设置为他的父节点,否则递归遍历会死循环。判断方法就是先用IsAncestor检查,检查通过后再修改m_parentIdm_childrenIds

2.3 祖先判断与最近公共祖先 LCA

“谁是谁的祖先”是家谱关系里最高频的问题,实现方式可以直接从当前 ID 沿着m_parentId向上走。最简单的写法是维护一个std::set<int>

bool FamilyTree::IsAncestor(int ancestorId, int descendantId) { int cur = descendantId; while (cur != 0) { if (cur == ancestorId) return true; Person* p = FindById(cur); cur = p ? p->m_parentId : 0; } return false; }

这个函数的时间复杂度是O(树高),人员数量不大时没有问题。IsAncestor的返回逻辑需要验证:如果descendantId是自己本身,也会返回true,因为循环第一次就会命中cur == ancestorId。如果业务上要区分“祖先”和“本人”,就把开始条件改成cur = FindById(descendantId)->m_parentId

求两个人的最近公共祖先可以用另一个思路:先把 A 到根之间所有 ID 放进集合,再从 B 向上走,遇到的第一个在集合中的 ID 就是 LCA。

int FamilyTree::LowestCommonAncestor(int a, int b) { std::set<int> path; int cur = a; while (cur != 0) { path.insert(cur); Person* p = FindById(cur); cur = p ? p->m_parentId : 0; } cur = b; while (cur != 0) { if (path.count(cur)) return cur; Person* p = FindById(cur); cur = p ? p->m_parentId : 0; } return 0; }

这里的边界条件是:如果 A 和 B 不在同一棵树里,比如分别是两个家庭的根,返回 0,界面显示“无血缘关系”。如果家谱存在过继情况,m_parentId表示的是法律上的抚养关系还是血缘关系,必须在数据模型里明确区分。常见做法是增加一个m_adoptiveParentId字段,血缘查询走m_parentId,法律上的父辈关系走m_adoptiveParentId,两个字段独立存,避免混淆。下面是两种建模的对比:

模型类型血缘关系表示过继/收养实现成本
单 parentIdm_parentId直接指向生父/生母不支持,会把养父母和生父母混在一起最低
双父链m_parentId+m_adoptiveParentId分别保存生父母和养父母高,界面要区分

这个表格也解释了为什么很多家谱项目最后都会向“图 + 关系表”演进:只要允许再婚、过继、入赘这些场景,纯树结构就不够用了。不过作为课程设计或者入门级工具,单 parentId 已经能覆盖绝大多数用户需求,把边界说清楚比直接上复杂模型更有工程意义。

3. MFC 图形化界面:从对话框到树形控件

3.1 程序骨架:基于对话框还是文档/视图

MFC 新建项目时有“基于对话框”和“单文档”两种常见选择。家谱管理需要同时展示树、编辑区、按钮,用单文档加切分窗口更合适。但很多人图省事用对话框,结果后续要处理菜单、工具栏、拖拽文件时非常被动。这个项目采用主窗口加左右分栏,左边CTreeCtrl显示家谱树,右边CListView或普通CView显示详情。关键代码在InitInstance,手动创建主窗口:

BOOL CMyApp::InitInstance() { CWinApp::InitInstance(); CMainFrame* pFrame = new CMainFrame(); if (!pFrame->LoadFrame(IDR_MAINFRAME)) { return FALSE; } m_pMainWnd = pFrame; pFrame->ShowWindow(m_nCmdShow); pFrame->UpdateWindow(); return TRUE; }

LoadFrame负责从资源加载菜单、快捷键和窗口类名,如果资源 ID 写错,这里会返回FALSE。注意ShowWindowm_nCmdShow是系统传来的启动参数,比如最小化启动或普通启动,不能写死成SW_SHOW。如果后续要调整切分条位置,在视图类里调用GetParentFrame()->RecalcLayout()刷新布局。理解这段是排查窗口加载失败的基础。

3.2 用 CTreeCtrl 显示并保证数据指针安全

树控件是家谱系统的第一交互入口。CTreeCtrl::InsertItem只负责显示文本,要让它和Person对应,最稳妥的方法是用SetItemData保存成员 ID,而不是保存Person*。因为m_personsstd::vector,一旦push_back导致内存块重新分配,任何Person*都会失效,下次点击树节点再取指针就崩溃了。

HTREEITEM AddPersonToTree(CTreeCtrl& tree, FamilyTree& family, int id, HTREEITEM hParent) { Person* p = family.FindById(id); if (!p) return nullptr; CString text = p->m_name; HTREEITEM hItem = tree.InsertItem(text, hParent, TVI_LAST); tree.SetItemData(hItem, (DWORD_PTR)id); for (int childId : p->m_childrenIds) { AddPersonToTree(tree, family, childId, hItem); } return hItem; }

这里TVI_LAST表示插入到兄弟节点末尾,保证孩子顺序与m_childrenIds一致。SetItemData保存(DWORD_PTR)id,在 32 位和 64 位程序里都不会截断。当用户点击树节点时,用GetItemData取回 ID,再通过FindById得到当前成员。整个过程中不存在跨vector扩容保存的指针,安全性提高很多。注意在删除成员后,m_persons中元素会被erase,之后继续遍历m_childrenIds时如果删除了当前元素,必须使用迭代器风格循环,不能使用基于范围的循环同时删除。

大量添加节点时,树控件每插入一个节点都会触发重绘,效率很低。习惯用法是先在批量插入前调用SetRedraw(FALSE),全部完成后调用SetRedraw(TRUE)Invalidate()。这样既能加快加载速度,也能避免用户看到一棵树从空到满的闪烁过程。SetItemData的另一个好处是,当需要按关系跳转时,不用每次做字符串匹配,直接拿 ID 去FamilyTree里查就行。

3.3 在主视图里绘制血缘关系图

树控件适合浏览结构,但要表达“谁和谁是兄弟”“谁和谁隔了几代”,一张关系图比控件更直观。MFC 的CView::OnDraw提供了CDC,可以直接在上面画矩形和连线。下面是简化后的自动布局函数,核心是递归计算每个子树的宽度,然后回推父节点位置:

int DrawFamilyGraph(CDC* pDC, int x, int y, int id, FamilyTree& family) { Person* p = family.FindById(id); if (!p) return 0; int boxW = 110, boxH = 30, vGap = 50; CString name = p->m_name; pDC->Rectangle(x, y, x + boxW, y + boxH); pDC->DrawText(name, CRect(x, y, x + boxW, y + boxH), DT_CENTER | DT_VCENTER | DT_SINGLELINE); int subX = x; int subY = y + boxH + vGap; int totalW = 0; std::vector<int> childStartX; for (int childId : p->m_childrenIds) { int childW = DrawFamilyGraph(pDC, subX, subY, childId, family); childStartX.push_back(subX); subX += childW + 20; totalW += childW + 20; } // 从父节点底部中心画线到每个孩子顶部中心 int parentCenterX = x + boxW / 2; int parentBottomY = y + boxH; for (size_t i = 0; i < p->m_childrenIds.size(); i++) { int childCenterX = childStartX[i] + boxW / 2; pDC->MoveTo(parentCenterX, parentBottomY); pDC->LineTo(childCenterX, subY); } return max(totalW, boxW); }

上面代码里用childStartX记录每个孩子的起始横坐标,否则遍历第二个循环时subX已经移动到末尾,画不出连接线。DrawText用的CRect是矩形文本区,DT_VCENTER只对单行文本有效,所以必须配合DT_SINGLELINE。实际项目中,如果家谱层级很深,这个递归函数会在窗口右边界被截断,需要把整个图包在ScrollWindowCMFCSmartScroll里。另外还要设置pDC->SetBkMode(TRANSPARENT),否则文字背景是实心的,会盖住矩形边框。下面列出本场景常用 MFC 控件的用途:

控件类在本项目中的用途关键设置
CTreeCtrl展示成员层级,响应节点选中SetItemData保存 ID
CListCtrl展示某个成员的孩子和配偶列表报表视图,列头用InsertColumn
CEdit编辑姓名、出生日期使用CString变量绑定,自动处理 Unicode
CButton添加、删除、保存按钮BN_CLICKED事件绑定处理函数

3.4 编辑成员信息时的控件联动

当用户在树控件选择某个成员后,右边的编辑框应当立即显示该成员的姓名、出生日期和配偶。常见错误是在OnSelchangedTree里逐个SetWindowText,但用户点完树马上按回车,焦点可能还在树控件,编辑框数据没有刷新。所以还需要在OnUpdate里判断当前选中项是否发生了变化。控制器里的更新逻辑使用UpdateData(FALSE)m_xxx变量刷新到控件,然后在OnOKUpdateData(TRUE)读回。这个流程用熟了以后,MFC 的 DDX 机制会显得非常顺手。

4. 数据持久化:家谱文件格式与序列化方案

4.1 为什么不用 CArchive,而用自描述文本

MFC 自带的CArchive序列化写起来简单,但生成的二进制文件字段顺序完全依赖类定义,一旦后续版本增加字段,旧文件就无法读取。家谱数据要长期保存,更适合用自描述文本格式。这个项目选择最简单的制表符分隔格式,每行代表一个成员。字段含义如下:

字段类型说明
IDint全局唯一,加载时重建索引
姓名CString支持中文,不能包含制表符
性别int0 未知,1 男,2 女
出生日期CStringYYYY-MM-DD,空串表示未知
死亡日期CStringYYYY-MM-DD,空串表示健在
父IDint0 表示根节点
配偶IDint0 表示未填写

CHILD关系不直接写在人物行里,而是在加载完所有人物后,扫描每个人的m_parentId填入对应父亲的m_childrenIds尾部。这样可以避免源文件中父亲先出现还是孩子先出现的问题。性别用1/2/0而不是男/女,主要是避免编码问题。出生日期用YYYY-MM-DD存储,显示时再格式化,这个选择让排序和比较年龄都很方便。

4.2 保存与加载的具体实现

保存可以用CStdioFile一行一行写。注意 MFC 的CString::Format在 Unicode 工程里要使用%s配合CString::GetString(),不要直接传CString对象。下面是一个安全版本:

bool SaveTree(const CString& filepath, const FamilyTree& family) { CStdioFile file; if (!file.Open(filepath, CFile::modeCreate | CFile::modeWrite | CFile::typeText)) { AfxMessageBox(_T("无法创建文件")); return false; } file.WriteString(_T("# family tree data\n")); for (size_t i = 0; i < family.m_persons.size(); i++) { const Person& p = family.m_persons[i]; CString line; line.Format(_T("%d\t%s\t%d\t%s\t%s\t%d\t%d\n"), p.m_id, p.m_name.GetString(), p.m_gender, p.m_birthDate.GetString(), p.m_deathDate.GetString(), p.m_parentId, p.m_spouseId); file.WriteString(line); } file.Close(); return true; }

这里typeText表示以文本模式打开,WriteString会自动把换行符转成本地格式。参数表里的p.m_name.GetString()在 Unicode 工程里是const wchar_t*,配合%s没有问题。注意line.Format%s只能用于 MFC 的CString,如果混用std::string就必须先转成CStringA,否则编译器不报错但运行结果错误。

加载函数使用Tokenize按制表符切分,比swscanf_s更容易处理字段缺失:

bool LoadTree(const CString& filepath, FamilyTree& family) { CStdioFile file; if (!file.Open(filepath, CFile::modeRead | CFile::typeText)) { AfxMessageBox(_T("无法打开文件")); return false; } CString line; while (file.ReadString(line)) { if (line.IsEmpty() || line[0] == _T('#')) continue; Person p; int start = 0; CString token = line.Tokenize(_T("\t"), start); if (token.IsEmpty()) continue; p.m_id = _ttoi(token); token = line.Tokenize(_T("\t"), start); p.m_name = token; token = line.Tokenize(_T("\t"), start); p.m_gender = _ttoi(token); token = line.Tokenize(_T("\t"), start); p.m_birthDate = token; token = line.Tokenize(_T("\t"), start); p.m_deathDate = token; token = line.Tokenize(_T("\t"), start); p.m_parentId = _ttoi(token); token = line.Tokenize(_T("\t"), start); p.m_spouseId = _ttoi(token); family.m_persons.push_back(p); } family.RebuildRelations(); file.Close(); return true; }

Tokenizestart参数会持续记录当前扫描位置,调用下一轮时从上一个分隔符后继续找。_ttoi负责把字符串转成整数,空串转换成 0,不会抛异常。加载完成后调用RebuildRelations,这一步很关键:先清空所有人的m_childrenIds,再遍历所有成员,如果m_parentId不为 0 且能找到对应父亲,就把该成员 ID 追加到父亲的m_childrenIds尾部。注意追加顺序依赖成员在文件中的顺序,如果想要严格的长幼顺序,应该在文件里额外保存顺序字段。

4.3 数据一致性校验

家谱文件最容易出现的脏数据是“孩子指向了不存在的父亲”“配偶关系不对称”“根节点不止一个”。加载完成后最好做一次一致性扫描,把问题汇总成字符串显示出来:

void FamilyTree::CheckConsistency() { for (size_t i = 0; i < m_persons.size(); i++) { Person& p = m_persons[i]; if (p.m_parentId != 0 && FindById(p.m_parentId) == nullptr) { TRACE(_T("成员 %s 的父亲 %d 不存在\n"), p.m_name, p.m_parentId); } if (p.m_spouseId != 0) { Person* spouse = FindById(p.m_spouseId); if (spouse && spouse->m_spouseId != p.m_id) { TRACE(_T("配偶关系不对称:%s 与 %s\n"), p.m_name, spouse->m_name); } } } }

这里的TRACE只在 Debug 版本输出,发布版可以通过OutputDebugString查看。但更好的做法是把问题收集到CStringArray里,由界面决定是弹窗还是写入日志。对于课程设计,用TRACE加断点检查已经能应付 90% 的情况。注意m_persons内部成员地址在push_back后可能变化,所以这个CheckConsistency要放在加载完成、不再追加新成员之后调用。

4.4 中文编码:从 GBK 到 UTF-8

旧工程可能用CStdioFile直接写 TEXT,在简体中文 Windows 上默认是 GBK。如果编写的jiapu文件要在不同系统或新版 Windows 间共享,建议内部统一使用 UTF-8。MFC 的CStdioFile不能直接写 UTF-8 BOM,常见做法是在文件首行手工写入\xEF\xBB\xBF,后续内容用CW2A转换成 UTF-8 后写入。注意CStringWCStringA的转换需要指定代码页,推荐用CW2A(CP_UTF8)而不是默认 ANSI。如果只是课程设计,保持 GBK 也能运行,但在 Visual Studio 2019 及以上版本里,字符集设置是 Unicode,必须显式转换。

5. 进阶:关系判定、布局优化与常见崩溃排查

5.1 用自检函数避免递归死循环

AddChild前调用IsAncestor(childId, parentId)可以防止成环。具体写法是在修改m_parentId之前检查一下:

if (IsAncestor(childId, parentId)) { AfxMessageBox(_T("不能把后代设为父节点")); return false; }

这一步避免了加载不合法数据后绘制函数无限递归。Debug 模式下还可以用ASSERT把它加严,让问题在测试阶段就暴露出来。

5.2 树控件与视图双缓冲刷新

树节点批量插入时闪烁,先用SetRedraw(FALSE),插完再SetRedraw(TRUE)。对于OnDraw里的关系图,使用内存 DC 双缓冲,避免每次鼠标移动都闪屏:

void CMyView::OnDraw(CDC* pDC) { CRect rc; GetClientRect(&rc); CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rc.Width(), rc.Height()); CBitmap* pOld = memDC.SelectObject(&bmp); // 在 memDC 上画图,最后 BitBlt 到 pDC pDC->BitBlt(0, 0, rc.Width(), rc.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); }

这里把绘制命令全部发到内存画布,只有最后一次BitBlt真正更新屏幕。参数说明:CreateCompatibleBitmap需要和窗口 DC 同尺寸,否则边缘显示不全;BitBltSRCCOPY是直接覆盖,不需要考虑背景色。

5.3 发布时缺 DLL 的问题

MFC 程序发布时依赖动态链接的mfc140u.dll以及运行库。如果目标机器没有安装对应版本的 Visual C++ Redistributable,启动时会直接弹“缺少 VCRUNTIME140.dll”或“无法定位程序输入点”。最容易的排查方式是用dumpbin /dependents查看生成的可执行文件依赖了哪些 DLL,确认后再决定安装哪个版本运行库。注意 Debug 版不可分发,默认连接的是mfc140ud.dll,普通用户机器上不会有调试版运行库。如果要在没有安装开发环境的机器上跑,把编译配置切到 Release,并在工程设置里把“代码生成”的“运行库”改成“多线程 DLL (/MD)”,再带上官方 VC++ 运行库安装包即可。

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

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

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

立即咨询