简介:这是一套基于JavaFX开发的家谱管理系统实战项目,面向Java初学者及GUI应用开发学习者,解决家族成员信息结构化管理与可视化展示问题。项目以直观树形界面呈现亲属关系,涵盖数据建模、UI交互、SQLite持久化存储及CSS样式美化等完整开发链路,适合课程设计、毕业设计或JavaFX进阶实践。压缩包共239个文件,包含24个核心Java源码、34个编译后class文件、12个FXML界面定义文件、10个XML配置与数据文件,以及docx说明文档、jar可执行文件和多类资源文件,整体大小为3.45MB,结构清晰,模块职责分明。已有1620人学习下载,读者可直接运行体验完整功能,获取可复用的Person/Family类设计、TreeView动态构建逻辑、JDBC数据库操作模板及JavaFX动画与样式定制方案,具备良好的扩展性与教学参考价值。
1. 家谱不是静态 PDF:这个 Java + JavaFX 家谱管理系统,能拖拽增删节点、实时渲染三代血缘图、导出带关系线的 PNG,适合想拿来做课程设计或轻量级家族数据管理的开发者
你手头有一份祖辈手写的家谱草稿,或者刚从长辈那儿录完十几口人的生卒年、配偶关系、子女排序——但把它敲进 Excel?很快你会发现:改一个曾祖父的配偶信息,得手动同步更新他所有子女的“母亲”字段;想看某人往上三代+往下两代的关系图?得反复筛选、复制粘贴、画箭头。这不是数据管理,是体力活。而这个Family-Tree.zip项目,用纯 Java + JavaFX 实现了一个真正可交互的家谱可视化系统:它不只存数据,而是把“父子”“夫妻”“兄弟”这些关系变成可点击、可拖拽、可折叠的图形节点;新增一个人,系统自动计算他在树中的层级和连接线;点开任意节点,右侧立刻显示完整履历+照片占位区;导出的 PNG 不是截图,而是 JavaFX SceneBuilder 渲染的真实矢量关系图。它没上 Spring Boot,没连 MySQL,核心逻辑全在src/main/java/familytree/下几十个类里,编译即跑,改两行就能换主题色或加字段。如果你正卡在“怎么让家谱动起来”这一步,而不是纠结“要不要学数据库”,那它就是你现在最该解压运行的资源。
2. 架构与模块拆解:从 Person 模型到 TreeView 渲染,看清数据如何流经每一层
2.1 核心数据模型:Person 类不是简单 POJO,而是自带血缘计算能力的实体
Person.java是整个系统的基石,但它远不止是name,birthDate,gender的容器。它的关键设计在于关系内聚和计算延迟:
public class Person { private String id; // UUID 生成,确保全局唯一 private String name; private LocalDate birthDate; private Gender gender; // 关系引用(非数据库外键,是内存对象引用) private Person father; private Person mother; private List<Person> children; // 双向关联:child.setFather(this) 时自动 add private Person spouse; // 血缘深度缓存(避免递归重复计算) private Integer generationCache; // null 表示未计算 public int getGeneration() { if (generationCache != null) return generationCache; int gen = 1; // 根节点为第 1 代 if (father != null || mother != null) { int parentGen = Math.max( father != null ? father.getGeneration() : 0, mother != null ? mother.getGeneration() : 0 ); gen = parentGen + 1; } generationCache = gen; return gen; } }提示:
getGeneration()的缓存机制是性能关键。家谱树可能有上百节点,若每次渲染都递归计算深度,UI 会明显卡顿。这里用Integer generationCache做懒加载缓存,首次调用计算并存储,后续直接返回——这是 JavaFX 场景下典型的“用空间换时间”策略。
Person还封装了基础校验逻辑:
addChild(Person child)方法内部会检查child.father == null && child.mother == null才允许绑定,防止关系错乱;setSpouse(Person other)会双向设置other.spouse = this,并校验双方性别是否符合传统设定(可配置关闭);- 所有 setter 都触发
PropertyChangeSupport事件,为后续 UI 绑定埋下伏笔。
2.2 关系图谱引擎:GraphLayout 类如何把 Person 列表转成可拖拽的力导向布局
家谱不是严格的二叉树(比如再婚、收养会导致多父多母),所以项目没用 TreeView 的标准父子嵌套,而是引入了轻量级力导向算法(Force-Directed Layout)。核心在GraphLayout.java:
public class GraphLayout { private final List<Person> persons; private final Map<Person, Point2D> positions; // 当前坐标缓存 private final double REPULSION_FORCE = 100.0; // 排斥力系数 private final double ATTRACTION_FORCE = 5.0; // 吸引力系数 public void calculateLayout() { // 初始化:随机散布节点(避开边缘) for (Person p : persons) { positions.put(p, new Point2D( 100 + Math.random() * 600, 100 + Math.random() * 400 )); } // 迭代 100 次优化位置(实际 30 次已收敛) for (int iter = 0; iter < 100; iter++) { Map<Person, Point2D> deltas = new HashMap<>(); // 步骤1:计算所有节点间的排斥力(类似电荷) for (Person a : persons) { for (Person b : persons) { if (a == b) continue; Point2D delta = calculateRepulsion(a, b); deltas.merge(a, delta, Point2D::add); } } // 步骤2:计算直系关系的吸引力(父子、夫妻线) for (Person p : persons) { if (p.getFather() != null) { Point2D delta = calculateAttraction(p, p.getFather()); deltas.merge(p, delta, Point2D::add); deltas.merge(p.getFather(), delta.multiply(-1), Point2D::add); } if (p.getSpouse() != null) { Point2D delta = calculateAttraction(p, p.getSpouse()); deltas.merge(p, delta, Point2D::add); deltas.merge(p.getSpouse(), delta.multiply(-1), Point2D::add); } } // 步骤3:应用位移(带阻尼,防止震荡) for (Person p : persons) { Point2D pos = positions.get(p); Point2D delta = deltas.getOrDefault(p, new Point2D(0, 0)); double damping = 0.95 - (iter * 0.0005); // 迭代越深,移动越小 positions.put(p, pos.add(delta.multiply(damping))); } } } private Point2D calculateAttraction(Person a, Person b) { Point2D posA = positions.get(a); Point2D posB = positions.get(b); double dx = posB.getX() - posA.getX(); double dy = posB.getY() - posA.getY(); double distance = Math.sqrt(dx*dx + dy*dy); if (distance < 10) distance = 10; // 防止除零和过近爆炸 double force = ATTRACTION_FORCE * (distance / 100.0); return new Point2D(dx * force / distance, dy * force / distance); } }这段代码实现了可预测的视觉稳定性:
- 排斥力保证节点不重叠,吸引力把直系亲属拉近,形成自然簇群;
damping随迭代衰减,让布局过程像弹簧一样平滑收敛,而非抖动;distance < 10的保护避免父子节点被吸成一个点——这是我在调试时踩过的坑:初始位置太近导致力计算失真,UI 卡死。
最终positions映射被传给FamilyTreePane,驱动每个PersonNode的setLayoutX/Y。
2.3 UI 层:JavaFX 的 Pane + Group 如何承载动态关系线与节点
FamilyTreePane extends Pane是渲染主容器,它不继承TreeView或ListView,因为家谱需要自由定位和关系线重绘。其核心是两个子组:
public class FamilyTreePane extends Pane { private final Group connectionGroup; // 存放所有 Line(关系线) private final Group nodeGroup; // 存放所有 PersonNode(自定义控件) public FamilyTreePane() { this.connectionGroup = new Group(); this.nodeGroup = new Group(); getChildren().addAll(connectionGroup, nodeGroup); } public void updateConnections(List<Person> persons, Map<Person, Point2D> positions) { connectionGroup.getChildren().clear(); for (Person p : persons) { // 绘制父子连线(实线) if (p.getFather() != null) { drawConnection(p, p.getFather(), Color.BLACK, 1.5); } if (p.getMother() != null) { drawConnection(p, p.getMother(), Color.PINK, 1.5); } // 绘制夫妻连线(虚线) if (p.getSpouse() != null && p.getId().compareTo(p.getSpouse().getId()) < 0) { // 避免重复绘制:只画 id 小的一方到大的一方 drawConnection(p, p.getSpouse(), Color.GRAY, 1.0, true); } } } private void drawConnection(Person from, Person to, Color color, double width, boolean dashed) { Point2D fromPos = positions.get(from); Point2D toPos = positions.get(to); Line line = new Line(fromPos.getX(), fromPos.getY(), toPos.getX(), toPos.getY()); line.setStroke(color); line.setStrokeWidth(width); if (dashed) { line.getStrokeDashArray().addAll(5d, 5d); // 虚线模式 } connectionGroup.getChildren().add(line); } }注意:
drawConnection中的p.getId().compareTo(p.getSpouse().getId()) < 0是关键去重逻辑。若不加此判断,夫妻 A-B 会被绘制两次(A→B 和 B→A),导致线条变粗、UI 混乱。这是 JavaFX 渲染中典型的“状态一致性”陷阱——数据无向,但绘制必须有向。
PersonNode是自定义StackPane,内部包含:
- 头像圆形
Circle(支持拖拽) - 姓名
Label(双击可编辑) - 生卒年
Text(小号字体) - 右上角删除按钮(
Button,悬停变红)
所有PersonNode的onMouseDragged事件监听器,会实时更新positions映射,并触发updateConnections()重绘——这就是“拖拽即生效”的底层机制。
3. 运行与调试:从 JDK 版本到 JavaFX 模块路径,绕过所有新手第一道墙
3.1 环境准备:JDK 11 是底线,JavaFX SDK 必须显式声明
这个项目基于 Java 11 编写(pom.xml中<java.version>11</java.version>),不兼容 JDK 8。原因很实在:JavaFX 自 JDK 11 起已剥离为独立 SDK,而项目使用了javafx.controls中的DatePicker(用于生日输入)和Dialog(用于确认删除),这些在 JDK 8 的内置 JavaFX 中存在,但 API 已变更。
正确配置步骤:
- 下载 Gluon JavaFX SDK (推荐 17.0.2 版本,与 JDK 17 兼容性最好);
- 解压后记下路径,例如
C:\javafx-sdk-17.0.2; - 编译命令需显式添加模块路径:
javac --module-path "C:\javafx-sdk-17.0.2\lib" --add-modules javafx.controls,javafx.fxml,javafx.graphics,javafx.base src/main/java/familytree/*.java - 运行命令同样要加模块参数:
java --module-path "C:\javafx-sdk-17.0.2\lib" --add-modules javafx.controls,javafx.fxml,javafx.graphics,javafx.base familytree.MainApp
提示:如果用 IntelliJ IDEA,需在
Project Structure → Project Settings → Project中设置 Project SDK 为 JDK 11+,并在Modules → Dependencies中点击+ → JARs or directories添加javafx-sdk-17.0.2\lib目录。IDEA 会自动将--add-modules参数注入运行配置。
3.2 启动失败排查:ClassNotFoundException 与 NoClassDefFoundError 的本质区别
新手常遇到两类报错,表面相似,根源不同:
| 现象 | 原因 | 解决方案 |
|---|---|---|
Exception in thread "main" java.lang.NoClassDefFoundError: javafx/application/Application | JavaFX 类在运行时不可见(--add-modules缺失或错误) | 检查java命令是否带--add-modules,且模块名拼写正确(javafx.controls不是javafx.control) |
Caused by: java.lang.ClassNotFoundException: familytree.MainApp | 编译后的.class文件未在 classpath 中(javac输出目录未被java命令识别) | 确保javac编译时指定-d out/输出目录,java命令用-cp out/指向该目录 |
我第一次跑通时,在java命令里漏写了-cp .,结果报NoClassDefFoundError: familytree/MainApp(注意斜杠),以为是 JavaFX 问题,折腾半小时才发现是 classpath 没设——这种低级错误,建议把编译+运行命令写成build.bat脚本固化。
3.3 数据初始化:sample-data.json 是唯一入口,修改它即可加载自定义家谱
项目默认不连数据库,所有数据来自src/main/resources/sample-data.json。这是一个标准 JSON 数组,每个对象代表一个Person:
[ { "id": "p1", "name": "张大山", "birthDate": "1945-03-12", "gender": "MALE", "father": null, "mother": null, "spouse": "p2", "children": ["p3", "p4"] }, { "id": "p2", "name": "李秀英", "birthDate": "1948-07-22", "gender": "FEMALE", "spouse": "p1", "children": ["p3", "p4"] } ]关键规则:
id字段必须全局唯一,且所有关系字段(father,mother,spouse,children)都引用其他id;children是字符串数组,不是嵌套对象——这是为了解耦数据加载逻辑,JsonLoader类负责根据id查找对应Person实例并建立引用;null值表示无此关系(如father: null),不能写成"father": "",否则解析会抛JsonParseException。
修改后,重启程序即可看到新家谱。这是比写 SQL 更快的原型验证方式。
4. 常见问题与避坑指南:那些让开发者抓狂却文档里找不到的答案
4.1 现象:拖拽节点后,关系线位置错乱,甚至连接到空白处
原因:FamilyTreePane.updateConnections()方法在positions更新后未被及时调用。常见于两种场景:
- 在
PersonNode.onMouseDragged中只更新了positions,但忘记调用familyTreePane.updateConnections(persons, positions); positions是Map<Person, Point2D>,但拖拽时误操作了副本(如new HashMap<>(positions)),原映射未更新。
解决:在PersonNode的拖拽事件处理器末尾,强制调用getParent().lookup("#treePane").updateConnections(...)(假设 pane 的 fx:id 为treePane),或通过EventBus发布LayoutUpdatedEvent由 pane 订阅。
4.2 现象:双击编辑姓名后,按回车没保存,焦点丢失
原因:PersonNode内部的TextField编辑框,其onAction事件未绑定。项目默认只实现了onMouseClicked触发编辑,但未处理回车提交。
解决:在PersonNode的构造方法中,为TextField添加:
nameField.setOnAction(e -> { person.setName(nameField.getText().trim()); nameField.setVisible(false); nameLabel.setText(person.getName()); // 同步 Label 显示 nameLabel.setVisible(true); });4.3 现象:导出 PNG 时,图片只有节点没有连线,或连线断裂
原因:JavaFX 的SnapshotParameters默认不捕获Group的子元素,而connectionGroup和nodeGroup都是Group。若直接对FamilyTreePane调用snapshot(),部分子Group可能被忽略。
解决:导出逻辑应改为对getChildren()中每个Group分别快照,再合成:
// 正确做法:先 snapshot connectionGroup,再 snapshot nodeGroup,最后叠加 WritableImage connImg = connectionGroup.snapshot(new SnapshotParameters(), null); WritableImage nodeImg = nodeGroup.snapshot(new SnapshotParameters(), null); // 使用 GraphicsContext 合成两张图...4.4 现象:添加新成员后,“三代视图”(仅显示当前人上下三代)不刷新
原因:“三代视图”功能在FamilyTreeController.java中由showThreeGenerations(Person root)方法实现,但它只在初始化时调用一次,未监听Person关系变更事件。
解决:在Person类中增加addRelationChangeListener(ChangeListener),当setFather()等方法被调用时触发事件;在控制器中注册监听器,事件回调里重新执行showThreeGenerations(currentRoot)。
4.5 现象:Windows 上中文路径读取sample-data.json报MalformedInputException
原因:Java 默认用系统编码(GBK)读取文件,但 JSON 文件是 UTF-8 编码。Files.readString(Paths.get("sample-data.json"))会失败。
解决:显式指定编码:
String json = Files.readString(Paths.get("sample-data.json"), StandardCharsets.UTF_8);5. 二次开发实战:给家谱加“收养关系”字段,并在 UI 上用蓝色虚线标识
家谱常涉及收养(如“张三收养李四”),但原项目只支持血缘(父子/母子)和婚姻(夫妻)关系。现在我们要扩展它,让收养关系在图中以蓝色虚线 + “ADOPTED” 标签呈现,且数据可存入 JSON。
5.1 数据层改造:在 Person 类中添加 adoptedChildren 列表
原Person类已有children字段,但它是血缘子嗣。我们新增adoptedChildren作为独立列表,避免混淆:
// 在 Person.java 中添加 private List<Person> adoptedChildren; // 收养的子女(非血缘) public void addAdoptedChild(Person child) { if (adoptedChildren == null) adoptedChildren = new ArrayList<>(); if (!adoptedChildren.contains(child)) { adoptedChildren.add(child); child.setAdoptiveParent(this); // 新增方法,见下文 } } // 新增 getter,供 UI 和 JSON 序列化使用 public List<Person> getAdoptedChildren() { return adoptedChildren != null ? Collections.unmodifiableList(adoptedChildren) : Collections.emptyList(); }同时,为Person添加adoptiveParent字段(单向,因收养通常是一对一):
private Person adoptiveParent; // 收养我的人(可能与 biological parent 不同) public void setAdoptiveParent(Person parent) { this.adoptiveParent = parent; // 可选:触发事件通知 UI 更新 firePropertyChange("adoptiveParent", null, parent); }5.2 JSON 序列化适配:修改 Gson 配置,支持 adoptedChildren 字段
项目用 Gson 解析sample-data.json,需为其注册自定义序列化器。在JsonLoader.java的loadData()方法中:
Gson gson = new GsonBuilder() .registerTypeAdapter(Person.class, new PersonDeserializer()) // 已有 .registerTypeAdapter(Person.class, new PersonSerializer()) // 新增序列化器 .create(); // PersonSerializer.java public class PersonSerializer implements JsonSerializer<Person> { @Override public JsonElement serialize(Person src, Type typeOfSrc, JsonSerializationContext context) { JsonObject obj = new JsonObject(); obj.addProperty("id", src.getId()); obj.addProperty("name", src.getName()); // ... 其他原有字段 if (src.getAdoptedChildren() != null && !src.getAdoptedChildren().isEmpty()) { JsonArray adoptedArray = new JsonArray(); for (Person child : src.getAdoptedChildren()) { adoptedArray.add(child.getId()); } obj.add("adoptedChildren", adoptedArray); } return obj; } }这样,sample-data.json就可以写:
{ "id": "p100", "name": "王建国", "adoptedChildren": ["p101"] }5.3 UI 层渲染:在 FamilyTreePane 中绘制收养连线
修改updateConnections()方法,加入收养关系绘制逻辑:
public void updateConnections(List<Person> persons, Map<Person, Point2D> positions) { connectionGroup.getChildren().clear(); // ... 原有父子、夫妻连线代码 // 新增:收养连线(蓝色虚线 + 标签) for (Person p : persons) { if (p.getAdoptedChildren() != null) { for (Person child : p.getAdoptedChildren()) { drawConnection(p, child, Color.BLUE, 1.2, true); // 蓝色虚线 // 添加 "ADOPTED" 标签(居中显示在线条上方) Point2D midPoint = new Point2D( (positions.get(p).getX() + positions.get(child).getX()) / 2, (positions.get(p).getY() + positions.get(child).getY()) / 2 - 10 ); Text label = new Text("ADOPTED"); label.setFill(Color.BLUE); label.setFont(Font.font("System", FontWeight.BOLD, 10)); label.setX(midPoint.getX() - label.getBoundsInLocal().getWidth() / 2); label.setY(midPoint.getY()); connectionGroup.getChildren().add(label); } } } }5.4 验证与测试:用最小数据集快速验证改动
创建test-adopt.json:
[ { "id": "adopter", "name": "陈国强", "gender": "MALE", "adoptedChildren": ["adopted"] }, { "id": "adopted", "name": "赵小雨", "gender": "FEMALE" } ]替换sample-data.json,启动程序。预期效果:
- 图中出现一条蓝色虚线,从“陈国强”指向“赵小雨”;
- 线条中点上方有蓝色加粗 “ADOPTED” 标签;
- 导出 PNG 后,标签清晰可见;
- 修改
adopted的name,标签文字不变(因标签是静态文本,非绑定)——若需动态,可改用Label并绑定textProperty()。
从那以后我每次扩展关系类型,都强制走一遍“数据模型 → JSON 序列化 → UI 渲染 → 导出验证”四步闭环,哪怕只是加个字段。因为家谱系统里,一个关系的缺失,可能让三代人查不到归属——这种责任,比任何技术难点都重。希望帮到你。
本文还有配套的精品资源,点击获取