☰
Java+JavaFX家谱管理系统:从数据模型到打包全实战
2026/10/20 5:30:13 网站建设 项目流程

简介:基于Java与JavaFX技术栈实现的家谱管理系统完整项目源码,适合正在学习Java桌面应用开发、需要图形界面实战项目的开发者参考。项目围绕家庭树的建立与管理展开,包含Person、Family等核心数据类的设计,TreeView树形视图展示成员层级关系,以及结合JDBC与SQLite的数据持久化方案,覆盖从对象建模、界面交互到数据存储的完整开发链路。压缩包共239个文件,以Java源码、class编译文件、FXML布局文件为主,附有jar可运行包、项目说明文档及配置信息,整体3.45MB,目录层级清晰,便于按模块对照学习。目前已有1620人学习下载,对于希望掌握JavaFX界面编写、树形组件应用和桌面数据库嵌入的开发者具有较高参考价值,也可作为课程设计或毕业设计的起始模板。

1. 为什么要自己写一套家谱管理系统:Excel 存不出三代人的关系

家族树只要超过三代,Excel 就开始变味。你可以给每个亲属单独建一行,写上“父亲”“母亲”“配偶”“子女”,但真要回答“我舅公的孙子和我是什么关系”这种跨支系提问,就得靠人肉搜索。这正好是 Java + JavaFX 家谱管理系统的价值所在:它用数据模型替代备注本,把“谁是谁的谁”变成可查询、可校验、可导出的结构化数据,而不是一份越写越乱的清单。如果你的诉求是整理族谱、交课程设计或者想找一个能真正跑起来的 JavaFX 完整项目,这份源码包可以省掉不少从零摸索的时间。

2. 数据模型与树形展示:关系先设计对,界面才不会越写越乱

2.1 家谱是一张有向图,不是一棵树:双亲 id 比邻接表更顺手

先把一个容易想岔的点说透:家谱不是一棵树。树结构默认每个节点只有一个父节点,但家谱里每个人有两个父母,还要维护配偶、兄弟姐妹、子女。一旦设计成树,就会出现“祖父同时是某棵子树的枢纽”这种尴尬——他既要作为儿子挂在曾祖父下面,又要作为父亲挂一棵新子树,在 TreeView 里根本没法表达。

常见做法是直接给每个人存 fatherId、motherId、spouseId,这也叫双亲表示法。对家谱来说,它比通用邻接表更好维护,因为家谱的查询频率远大于修改频率,而每个节点最多只有父、母、配偶三个固定关系,没必要为这点数据量引入一张中间表。

// Person.java:家谱中最小的数据单元,同时承担显示与检索两个职责 public class Person implements Serializable { private static final long serialVersionUID = 1L; private int id; // 全局唯一,数据落盘后不可再变 private String name; // 显示名 private String gender; // "男" / "女" private String birthDate; // 以字符串存储,避开日期格式跨平台差异 private String deathDate; // 为空表示在世 private Integer fatherId; // 指向另一条 Person.id,null 表示未知 private Integer motherId; // 同上 private Integer spouseId; // 配偶 id,只存一个,界面按配偶对展示 }

注意,这个类不要顺手加一个 children 子节点列表。子女列表是查询结果,不是存储字段。每次需要子女时按 fatherId / motherId 从成员表中过滤,代码更好维护,也不会出现同一个孩子被挂到两处、修改一处另一处不同步的问题。

字段里 birthDate 用 String 而不是 LocalDate,是很务实的选择。家谱录入的数据经常不完整,有人只记得农历年份,有人填“上世纪四十年代”,用 LocalDate 反而要在序列化和校验上多写很多容错逻辑。字符串虽然松散,但不挑数据。

2.2 TreeView 虚拟根:让所有成员都出现在同一棵树下

界面侧,JavaFX 自带的 TreeView 控件天然适合展示层级关系。但直接把某个祖先设成根,等于默认家谱只有一个起点,实际情况往往是从中间某个人开始录的。更稳的做法是引入虚拟根,把所有“没人认领作父母”的人挂在虚拟根下面:

// FamilyTreeViewBuilder.java:构建 TreeView 并填充成员 TreeItem<Person> virtualRoot = new TreeItem<>(null); // 虚拟根,不承载数据 Set<Integer> visited = new HashSet<>(); for (Person p : service.getAllPersonList()) { boolean orphan = p.getFatherId() == null && p.getMotherId() == null; if (orphan && visited.add(p.getId())) { virtualRoot.getChildren().add(buildSubTree(p, visited)); } } treeView.setRoot(virtualRoot); treeView.setShowRoot(false); // 关键:虚拟根不能显示出来

buildSubTree 递归往下走:先通过 spouseId 找到配偶,再把所有“父亲或母亲 id 指向当前节点”的人作为孩子继续展开。每挂进一个节点就放进 visited,避免双向配偶关系导致重复展开。

TreeItem 的 value 直接放 Person 对象即可,虚拟根的 value 放 null。界面渲染由 cellFactory 控制,离世的人加个后缀,一眼能区分:

treeView.setCellFactory(view -> new TreeCell<>() { @Override protected void updateItem(Person person, boolean empty) { super.updateItem(person, empty); if (empty || person == null) { setText(null); return; } // 离世人员附加“已故”标记,样式可交给 CSS String suffix = person.getDeathDate() == null ? "" : "(已故)"; setText(person.getName() + suffix); } });

这个地方容易晕的点:TreeItem 的 value 类型一旦确定,整个树都要保持一致。如果不小心一个分支塞 String、一个分支塞 Person,运行期会直接 ClassCastException,而且报错位置在 JavaFX 内部的 updateItem 里,不好排查。

2.3 FXML 与 Controller 拆分:一个人写也要有边界

JavaFX 的界面文件不要把布局和逻辑写在一起。这个源码包里通常是 MainView.fxml 定义骨架,MainController.java 处理事件。FXML 大概长这样:

<!-- MainView.fxml:左侧 TreeView,右侧详情面板 --> <SplitPane dividerPositions="0.35"> <TreeView fx:id="familyTree"/> <ScrollPane> <VBox> <Label fx:id="detailName" styleClass="detail-name"/> <Label fx:id="detailBirth"/> <Label fx:id="detailSpouse"/> <Button text="添加成员" onAction="#onAddMember"/> </VBox> </ScrollPane> </SplitPane>

Controller 里只做三件事:初始化数据、刷新树、响应按钮。这意味着想换数据源,只要替换 service 实现,Controller 不用大改。

// MainController.java:页面加载后被 FXMLLoader 自动调用 @FXML private TreeView<Person> familyTree; @FXML private void initialize() { familyTree.setRoot(service.buildVirtualRoot()); familyTree.getSelectionModel().selectedItemProperty() .addListener((obs, oldNode, newNode) -> showDetail(newNode)); } private void showDetail(TreeItem<Person> node) { if (node == null || node.getValue() == null) { detailName.setText("未选择"); return; } Person p = node.getValue(); detailName.setText(p.getName()); detailBirth.setText("出生:" + p.getBirthDate()); }

这里最容易翻车的是 initialize 被调用过早:如果 service 还没初始化就去 setRoot,树会一直是空的。稳妥做法是把 service 的初始化放在构造器或 static 代码块里,不依赖 FXML 加载顺序。家谱这种场景,几十到几千人,用遍历过滤完全够,不需要引入图数据库;真用上图数据库反而把模型复杂化。

3. 把 Family-Tree.zip 跑起来:依赖、启动与打包三件事

3.1 pom.xml 与 module-info.java:JavaFX 项目的正确打开方式

拿到这个包的第一件事,先看 pom.xml。JavaFX 在 JDK 8 之后不再是 JDK 自带组件,依赖必须显式声明。项目里配好了 javafx-maven-plugin,所以多数情况下直接mvn clean javafx:run就能起来。

<!-- pom.xml:JavaFX 依赖,版本由 properties 统一管理 --> <properties> <javafx.version>17.0.2</javafx.version> <maven.compiler.source>17</maven.compiler.source> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> <version>${javafx.version}</version> </dependency> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-fxml</artifactId> <version>${javafx.version}</version> </dependency>

javafx-controls 提供 TreeView、Button、Label 这些控件;javafx-fxml 提供 FXMLLoader。只引 controls 不引 fxml,加载 MainView.fxml 时会 NoClassDefFoundError。

同时注意 module-info.java,又一个高频故障点:

// module-info.java:模块化 Java 应用必写 module familytree { requires javafx.controls; requires javafx.fxml; // FXMLLoader 反射需要运行期访问控制器所在包 opens com.example.familytree to javafx.fxml; exports com.example.familytree; }

exports 是给别人用的,opens 是给 FXMLLoader 做反射用的。漏掉 opens 的话,FXML 加载阶段会抛 IllegalAccessError。实际排查中,相当一部分 JavaFX 启动失败都和这里有关。

3.2 三种启动方式怎么选

启动方式典型命令或配置适合场景
Maven 插件mvn javafx:run开发阶段最快,自动处理模块路径
IDEA 手动配置VM options 里加--module-path和--add-modules项目没配插件时的备选
jpackage 打包见 3.4 节分发给别人,脱离 IDE 运行

mvn javafx:run会把 JavaFX 依赖放到 module path 上再启动类,所以不需要手动写--module-path。如果不用插件,在 IDEA 运行配置里补:

--module-path $PATH_TO_JAVAFX --add-modules javafx.controls,javafx.fxml

两条路径本质一样:给 JVM 指明模块位置。唯一差别是 Maven 插件会自动在本地仓库里定位 JavaFX 包,省去手填路径。启动失败后先别急着怀疑代码,八成是启动方式没匹配上模块化配置。

3.3 Dao 与数据文件:存到用户目录比存到项目目录靠谱

数据存取在这个包里统一走一个 Dao 类,默认把整个家谱存成 JSON 文件。为什么不是数据库?几十个人的家谱用数据库太重;为什么不是 Java 序列化?改字段就废,后面第 5 章会专门讲。JSON 的好处是暴力又可读,文件被改坏了还能肉眼抢救一半。

// FamilyDao.java:基于文件的存储实现 public final class FamilyDao { private static final Path DATA_DIR = Path.of( System.getProperty("user.home"), ".familytree" ); private static final Path DATA_FILE = DATA_DIR.resolve("family.json"); public void save(FamilyTree tree) { try { Files.createDirectories(DATA_DIR); Files.writeString(DATA_FILE, gson.toJson(tree), StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("保存失败,检查 " + DATA_DIR, e); } } }

存到用户主目录比存到当前工作目录稳,因为双击启动、命令行启动的工作目录经常不一样。每次 save 前把旧文件复制一份成 family.json.bak,成本很低,关键时刻能救命。建议把这个备份逻辑直接放进 save 里,别指望用户手动备份。

3.4 打包成可执行安装包:jlink 与 jpackage 组合

代码跑起来之后,下一步是打包发给不懂 Java 的人。JavaFX 依赖平台相关的 native 库,普通mvn package生成的 jar 不会带上这些库,换台机器一跑就崩。我习惯先用 jlink 生成精简运行时,再用 jpackage 打成安装文件:

mvn clean package jlink --module-path target/classes:target/dependency \ --add-modules familytree,javafx.controls,javafx.fxml \ --output target/runtime jpackage --name FamilyTree --runtime-image target/runtime \ --module familytree/com.example.familytree.Launcher \ --type msi --dest target/installer

关键参数是--type:Windows 上打 msi 或 exe,macOS 上打 dmg。jpackage 不是万能的,Windows 打 msi 还依赖外部工具链,第一次失败很正常,不用怀疑代码。

为了让打包过程顺畅,常见做法是单独拆一个 Launcher,而不是直接在主类里写 main:

// Launcher.java:普通 main 入口,转发给 JavaFX 应用 public class Launcher { public static void main(String[] args) { FamilyTreeApplication.main(args); } }

拆开的目的只有一个:打包工具扫描 main 类时,需要一个不带extends Application的普通类作为静态入口,避免 JavaFX 启动路径和打包工具冲突。

4. 核心操作流程:录入、辈分计算与关系校验如何串联

4.1 成员录入表单:Dialog 内置模态最省心

添加成员走“添加成员”按钮,弹出一个 Dialog。JavaFX 的 Dialog 自带模态窗口,事件处理在同一条 UI 线程上走完,不用自己协调关闭、更新主界面的时序。这个包里的核心写法:

// MemberDialog.java:弹出录入窗口 Dialog<Person> dialog = new Dialog<>(); dialog.setTitle("添加家庭成员"); dialog.setHeaderText("填写成员信息,带 * 为必填"); TextField nameField = new TextField(); nameField.setPromptText("* 姓名"); ChoiceBox<String> genderBox = new ChoiceBox<>(); genderBox.getItems().addAll("男", "女"); ButtonType ok = new ButtonType("保存", ButtonData.OK_DONE); dialog.getDialogPane().getButtonTypes().addAll(ok, new ButtonType("取消", ButtonData.CANCEL_CLOSE)); dialog.getDialogPane().setContent( new VBox(8, new Label("姓名"), nameField, new Label("性别"), genderBox)); dialog.setResultConverter(button -> { if (button == ok && !nameField.getText().isBlank()) { Person p = new Person(); p.setName(nameField.getText().trim()); p.setGender(genderBox.getValue()); return p; } return null; }); Optional<Person> result = dialog.showAndWait(); result.ifPresent(p -> service.addMember(p));

这里有两个参数选择值得留意。第一,性别用 ChoiceBox 而不是 TextField,从源头保证数据只落“男/女”两个值,后面做 GEDCOM 导出映射 M/F 时不用做容错。第二,resultConverter 返回的 Person 会直接传给 showAndWait 的调用方,校验逻辑统一放在 service 层,Controller 不掺和。

4.2 辈分计算:BFS 不适合零散家谱

家谱里的“第几代”不是简单的树深度,因为它可能从中间某个人开始录入,顶层祖先缺失。常规 BFS 要求先有明确的根集合,一旦录入到一半,根集合会漏。这里用迭代计算更稳:所有无父母的人先标记为 1 代,反复扫描直到辈分不再变化。

// GenerationCalculator.java:迭代计算,收敛即止 int[] gen = new int[size]; for (Person p : persons) { if (p.getFatherId() == null && p.getMotherId() == null) { gen[p.getId()] = 1; } } boolean changed; do { changed = false; for (Person p : persons) { int father = p.getFatherId() == null ? 0 : gen[p.getFatherId()]; int mother = p.getMotherId() == null ? 0 : gen[p.getMotherId()]; int expected = Math.max(father, mother) + 1; if (expected > 1 && gen[p.getId()] != expected) { gen[p.getId()] = expected; changed = true; } } } while (changed);

在血缘关系无环的情况下,这个迭代一定收敛。父或母为 0 时,另一支有效就按另一支算,正好处理“父亲未知、母亲有辈分”的残缺数据。注意 spouseId 不参与辈分传播:配偶一般是同一代人,但跨代婚姻并不少见,诚实记录每个人的独立辈分,比强行统一配偶辈分更好维护。

排行的计算则限定在“同一对父母”下,按出生日期升序:

// 同一父 + 同一母的成员按出生日期排序得到排行 List<Person> siblings = persons.stream() .filter(p -> fatherId.equals(p.getFatherId()) && motherId.equals(p.getMotherId())) .sorted(Comparator.comparing(p -> p.getBirthDate())) .toList();

不要把堂兄弟排进去,那是两个家庭的事,不属于同一对父母下的排行。这个规则写进注释比写进文档更能提醒后人。

4.3 关系校验:防止自己成为自己的祖先

录入错误是家谱系统最大的质量风险:把父亲 id 填成自己,或把儿子设成爷爷的爸爸,都会让辈分计算死循环或算出负数。因此写一个祖先检测,每次更新前校验一次:

// RelationValidator.java:校验是否形成祖先环 public boolean isAncestor(Person candidate, Person target, Set<Integer> visited) { if (candidate == null || target == null) return false; if (candidate.getId() == target.getId()) return true; // 沿 target 的父亲往上传,递归前查 visited 防死循环 if (target.getFatherId() != null) { Person father = findPerson(target.getFatherId()); if (father != null && visited.add(father.getId()) && isAncestor(candidate, father, visited)) { return true; } } // 再沿母亲往上找,逻辑相同 if (target.getMotherId() != null) { Person mother = findPerson(target.getMotherId()); if (mother != null && visited.add(mother.getId()) && isAncestor(candidate, mother, visited)) { return true; } } return false; }

使用方式是在 addChild 和 setSpouse 之前调用:如果 isAncestor(child, parent) 为真,说明父母链上已经有这个孩子,保存会让谱系成环。visited 集合的作用是防死循环——没有它,夫妻互指或兄弟互相设为父亲时,递归会无限跑下去。加了这个集合,每个节点最多被访问两次,复杂度稳定在 O(n)。

这层校验写进 service 层、数据进入持久化之前,比在 Controller 里临时判断可靠得多。UI 做得再漂亮,关系错了才是真正的大事故。

5. 常见坑排查:乱码、模块缺失、树不刷新与序列化版本冲突

5.1 FXML 中文变成乱码:先查文件编码,再查编译器编码

现象:窗口上的按钮和 Label 中文全部变成“锟斤拷”或“?”。原因有两层。第一层:.fxml 文件被编辑器以 GBK 保存,而 JavaFX 读取 XML 时按 UTF-8 解析。第二层:Maven 编译源码时未指定编码,运行时 class 里的中文字符串走了平台默认字符集。

解决:pom.xml 里显式声明 UTF-8:

<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.encoding>UTF-8</maven.compiler.encoding> </properties>

再把 IDE 的文件编码统一改成 UTF-8,FXML 文件里<?xml version="1.0" encoding="UTF-8"?>也保持住。排查顺序是先看文件实际字节,再谈代码。

5.2 报错“JavaFX runtime components are missing”

现象:右键直接运行某个继承 Application 的类,程序一闪就退,控制台出现这句英文。原因:JDK 11 之后运行时不再打包 JavaFX,模块化应用必须在 module path 上显式添加 javafx.controls,IDE 没把项目识别为模块工程时,编译后的 class 路径里根本没有 JavaFX 模块。

按顺序试三招:第一,检查 module-info.java 是否在 src/main/java 根目录,requires 是否齐全。第二,在 IDEA 运行配置里补 VM options,--module-path指向本地 javafx 依赖,后面加--add-modules javafx.controls,javafx.fxml。第三,写一个 Launcher 转发 main,交给 javafx-maven-plugin 执行。第三种最省心,插件会自动构造完整的模块化启动参数。

5.3 修改人物信息后,TreeView 上还是旧名字

现象:数据层里 name 已经改成“某某”,树节点上的文本没变化。原因:TreeItem 的 getChildren 结构没有变化时,JavaFX 不会自动重绘,它监听的是 TreeItem 自身,监听不到 Person 的字段变更。

解决:最土但有效的办法是调一次 refresh:

familyTree.refresh(); // 强制重建所有 TreeCell

refresh 是全量重建,几千人的树会卡一下,对家谱系统来说基本无感。也可以做局部更新:只对变化节点执行 setValue(p)。局部更新更优雅,但会引入很多状态同步代码,实际工程里我优先用 refresh,简单直接不出错。

5.4 修改实体类后读取旧存档报 InvalidClassException

现象:老版本保存的 family.dat 文件,更新代码后重新打开,直接弹出 InvalidClassException。原因:默认 serialVersionUID 会随类结构变化而改变,新代码无法反序列化旧数据。解决:给 Person 和 FamilyData 统一显式声明:

private static final long serialVersionUID = 1L;

但要注意,这只能让版本不再报错,字段迁移逻辑依然得靠代码处理。如果项目还处于原型期,更建议把存储从 Java 序列化换到 JSON,Gson 或 Jackson 都行。序列化适合快速原型,JSON 适合长期维护——数据结构调整时,JSON 会优雅地丢字段,而不是整体罢工。

5.5 资源图片路径:工作目录一换就黑屏

现象:项目拷贝到别的电脑后,图标加载不出来,或窗口变成空壳。原因:代码里用了相对路径src/main/resources/icon.png,在 IDE 里运行没问题,打包后目录结构变了,资源找不到。解决:所有图片统一走 classpath:

Image img = new Image(App.class.getResourceAsStream("/icon/logo.png"));

classpath 资源比文件路径稳定。这个坑想让新手感知到,最快的方式就是打包一次、换一台没有 IDE 的机器跑一次。跑通了,路径问题才算真解决。

6. 进阶:导出 GEDCOM 做交叉验证,别让截图毁掉整棵关系树

家谱数据最怕“自嗨”——界面显示正确,导出却没人敢接。GEDCOM 5.5.1 是通用的家谱数据交换格式,把树里的成员导出去再导回来,等于给关系模型做一次第三方审计。导出核心代码:

// GedcomExporter.java:导出 GEDCOM 5.5.1 try (BufferedWriter out = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { out.write("0 HEAD\n1 GEDC\n2 VERS 5.5.1\n"); for (Person p : persons) { out.write("0 @I" + p.getId() + "@ INDI\n"); out.write("1 NAME " + p.getName() + "\n"); out.write("1 SEX " + (p.getGender().equals("男") ? "M" : "F") + "\n"); if (p.getFatherId() != null || p.getMotherId() != null) { out.write("1 FAMC @F" + p.getId() + "@\n"); } if (p.getSpouseId() != null) { out.write("1 FAMS @F" + p.getId() + "@\n"); } } out.write("0 TRLR\n"); }

这里有两个关键标识符:@Ix@代表个人,@Fx@代表家庭。FAMC 表示“我作为孩子属于哪个家庭”,FAMS 表示“我作为配偶在哪个家庭”。导出后拿去第三方软件导入,如果关系链断裂,基本可以定位是哪个人的父或母 id 写错了。

导出时还有个隐藏问题:日期字段。家谱里常有人只填“1937”或“约民 26 年”,直接写进 GEDCOM 的 DATE 字段,部分软件会拒收。我的处理是统一整理成“约 1937 年”并存到备注,保持 DATE 字段干净。

整棵树截图导出同样有坑。直接对 TreeView 执行 snapshot,长树会被截断。我通常先临时调大 TreeView 高度,调 layout(),再截图,最后恢复原高度。这个操作放在“导出”菜单下比放在每次保存时更合适,因为大树重新布局确实会卡一下。

这个项目最后真正卡住我的不是算法,而是把整棵家族树截图导出后,发现中间缺了两支——那两兄弟没有填父亲,作为虚拟根下的独立子树存在。从那以后,我每次做完家谱项目,都会强制把“无父无母节点列表”拉出来过一遍,确认每一个都是真实的始祖,而不是漏录。希望帮到你。

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

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

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

立即咨询