JavaFX写的本地通讯录工具,带搜索排序和文本存档功能

📅 2026/7/5 9:45:06 👁️ 阅读次数 📝 编程学习
JavaFX写的本地通讯录工具,带搜索排序和文本存档功能

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

简介:用JavaFX开发的轻量级桌面通讯录程序,界面简洁,支持CSS自定义样式。核心数据结构清晰:Date类处理日期,Person类管理基础信息(姓名、性别、生日),Staff类继承扩展出电话、地址、邮编、邮箱、QQ号及关系类型(如同学、同事)。所有操作通过菜单驱动完成,新增联系人自动保存到Contacts.txt纯文本文件,断电或重启后数据不丢失。查询支持多种方式——姓名或电话精确查找、地址关键词模糊匹配、按关系类别筛选;列表支持按姓名升序排列;修改和删除都可通过姓名或电话快速定位目标记录。项目结构规范,包含src源码、bin编译目录、application启动模块及配置文件,兼容Eclipse等主流Java IDE,导入即运行,适合课程设计、JavaFX入门实践或小型本地信息管理需求。

1. 项目概述:为什么一个“纯文本通讯录”值得认真做一遍?

你可能第一眼看到“JavaFX写的本地通讯录”,会下意识觉得:这不就是个教学Demo?现在谁还用桌面程序管联系人,手机通讯录、微信、钉钉不香吗?但恰恰是这种看似简单的工具,藏着Java桌面开发最扎实的基本功——它不像Web项目有框架兜底,也不像Android开发有系统API封装,它逼着你从零思考:数据怎么存才安全?界面怎么响应才顺滑?用户误操作怎么兜住?文件读写崩溃了怎么办?CSS样式怎么和Java逻辑解耦又不失灵活性?

我带过十几届Java实训课,发现一个规律:能稳稳跑通这个通讯录的同学,后续学Spring Boot做CRUD接口、用JavaFX做工业监控界面、甚至转岗做嵌入式Java应用,上手都特别快。为什么?因为它的麻雀虽小,五脏俱全:对象建模要严谨(Date→Person→Staff的继承链)、IO操作要健壮(Contacts.txt不是随便writeString就能搞定)、UI事件要精准(菜单点击、表格双击、回车搜索的触发时机完全不同)、状态管理要清晰(新增/编辑/查看三种模式如何切换而不混乱)。它不是一个玩具,而是一把解剖Java桌面生态的手术刀。

关键词里提到的“JavaFX通讯录”“人员信息管理”“文本存档工具”,其实指向三个层次的能力:第一层是技术栈落地能力(JavaFX控件怎么用、CSS怎么注入、FXML怎么加载);第二层是业务抽象能力(为什么Staff必须继承Person?为什么关系类别不用String而要用枚举?);第三层是工程鲁棒性(Contacts.txt被其他程序占用时怎么提示?用户输错生日格式,是直接报错还是自动修正?)。这篇文章不会只告诉你“代码怎么写”,而是带你拆开每一个螺丝钉,看看它为什么拧在这里,拧紧多少扭矩才既不滑丝也不崩牙。如果你正卡在JavaFX的TableView绑定不上数据、或者FileWriter一写就乱码、又或者改完信息点保存却没反应——别急,后面每一节都在解决你此刻的真实痛点。

2. 整体架构与设计思路:从一张纸到一个可运行的系统

2.1 核心类设计:为什么非得用三层继承?

先看最常被新手忽略的Date类。很多人直接用java.time.LocalDate,但项目里坚持手写Date类,这不是复古,而是教学深意。LocalDate是不可变对象,一旦创建就不能改年月日;而通讯录里,用户可能录入“1990-02-30”这种错误日期,需要在对象内部做校验和修正。手写Date类,你可以这样设计:

public class Date { private int year, month, day; public Date(int y, int m, int d) { // 关键:这里做闰年、大小月校验,把30号自动转成28号或29号 if (m < 1 || m > 12) m = 1; int maxDay = getMaxDay(y, m); if (d < 1 || d > maxDay) d = 1; // 或者 throw new IllegalArgumentException() this.year = y; this.month = m; this.day = d; } private int getMaxDay(int y, int m) { if (m == 2 && isLeapYear(y)) return 29; int[] days = {31,28,31,30,31,30,31,31,30,31,30,31}; return days[m-1]; } }

这个细节决定了整个系统的容错底线。如果直接用LocalDate.parse("1990-02-30"),程序直接抛DateTimeParseException崩溃,而用户只看到一个空白窗口——这就是真实世界和教科书的区别。

再看PersonStaff的继承关系。有人质疑:“所有字段都塞进一个Staff类不更简单?”不行。因为Person代表的是“人的抽象”,姓名、性别、生日是任何人类实体的共性;而电话、邮箱、地址是“社会角色”的属性。今天你是“同学”,明天可能变成“同事”,关系类别(relationship)是动态的,但你的出生日期永远不会变。这种分层让未来扩展更自然:比如要加一个Student类,它继承Person,扩展学号、专业、年级,而无需动Staff的代码。面向对象不是为了炫技,而是为了让变化发生时,修改范围最小化。

提示:relationship字段用String虽然灵活,但实际项目中强烈建议改为枚举。比如定义enum Relationship { CLASSMATE("同学"), COLLEAGUE("同事"), FRIEND("朋友") }。好处有三:一是防止用户输入“同雪”“盆友”等错别字;二是前端下拉框可以直接遍历枚举值,不用硬编码字符串;三是数据库迁移时,枚举比字符串更容易做约束校验。

2.2 数据持久化策略:为什么选纯文本而非SQLite?

项目用Contacts.txt存数据,不是因为技术落后,而是刻意为之。SQLite当然更强大,但会掩盖两个关键问题:字符编码陷阱并发写入风险

先说编码。Windows记事本默认GBK,Mac/Linux终端默认UTF-8,而JavaFiles.write()默认用系统编码。如果用户在Windows上用记事本改了Contacts.txt,再用IDE运行程序,中文就会变成乱码。解决方案不是“让用户改编码”,而是程序自己扛下来:

// 读取时强制指定UTF-8 List<String> lines = Files.readAllLines(Paths.get("Contacts.txt"), StandardCharsets.UTF_8); // 写入时也强制UTF-8 Files.write(Paths.get("Contacts.txt"), content.getBytes(StandardCharsets.UTF_8));

这个StandardCharsets.UTF_8参数,90%的新手会漏掉,结果调试三天找不到乱码原因。

再说并发。多人同时编辑同一个txt文件?现实中极少,但程序必须考虑。比如用户点了“保存”,后台线程正在写文件,这时用户又点“删除”,另一个线程去读文件——可能读到一半的脏数据。解决方案是加文件锁(FileChannel.lock()),但更务实的做法是:所有写操作串行化,用一个ReentrantLock保护saveToFile()方法。这样即使用户狂点保存按钮,也只会执行最后一次操作,而不是把数据写乱。

注意:不要用FileWriter,它没有编码参数,且append=true时容易覆盖旧内容。必须用Files.write()配合StandardCharsets,这是Java 7+推荐的现代IO方式。

2.3 UI架构:菜单驱动背后的事件流设计

整个界面是典型的“单窗口多视图”模式:主窗口是Stage,中央是TableView<Staff>,顶部是MenuBar,底部可能有状态栏。但关键不在布局,而在事件如何流转

比如“按姓名搜索”功能:用户在搜索框输入“张”,按下回车,触发onAction事件。此时不能直接遍历ObservableList过滤——因为TableView的排序器(SortPolicy)可能已启用,直接改列表会破坏排序。正确做法是:

  1. 获取当前TableViewitems(即ObservableList<Staff>);
  2. 创建一个新的FilteredList<Staff>,用Predicate过滤姓名包含“张”的记录;
  3. FilteredList设为TableView的items
  4. 调用tableView.sort()保持原有排序逻辑。

这个过程涉及JavaFX的响应式编程思想:FilteredList会自动监听原列表变化,当用户新增联系人时,搜索结果实时更新。如果跳过这一步,直接list.removeIf(...),搜索后新增的联系人就永远不出现在表格里了。

3. 核心功能实现详解:从代码到用户体验

3.1 初始化与启动流程:application模块的作用

项目结构里的application目录不是摆设。它通常包含一个MainApp.java,继承自Application,重写start(Stage primaryStage)方法。这里藏着两个易错点:

第一,launch()必须在JavaFX线程调用。很多新手在main()里直接new MainApp().start(new Stage()),结果报IllegalStateException: Not on FX application thread。正确姿势是:

public static void main(String[] args) { launch(args); // 这才是官方入口 }

第二,CSS样式注入位置很关键。不能在start()开头就scene.getStylesheets().add("style.css"),因为此时scene还没关联到Stage。必须在stage.setScene(scene)之后:

@Override public void start(Stage primaryStage) throws Exception { Parent root = FXMLLoader.load(getClass().getResource("/application/main.fxml")); Scene scene = new Scene(root); primaryStage.setScene(scene); // ✅ 此时scene已绑定,可以加CSS scene.getStylesheets().add(getClass().getResource("/application/style.css").toExternalForm()); primaryStage.show(); }

style.css路径中的/application/前缀,表示资源在src/application/目录下,这是Maven标准结构。如果放错位置,CSS加载失败,界面瞬间变丑——而错误日志里只有一行WARNING: Resource not found,新手根本找不到原因。

3.2 表格(TableView)的数据绑定与列配置

TableView<Staff>是核心视图组件,但绑定不是“设置items”就完事。必须为每一列指定cellValueFactory,否则表格显示为空白:

// 姓名列 nameColumn.setCellValueFactory(new PropertyValueFactory<>("name")); // 电话列(Staff类里必须有getPhone()方法) phoneColumn.setCellValueFactory(new PropertyValueFactory<>("phone")); // 关系类别列:如果用枚举,需自定义CellFactory显示中文 relationshipColumn.setCellFactory(col -> new TableCell<Staff, Relationship>() { @Override protected void updateItem(Relationship item, boolean empty) { super.updateItem(item, empty); if (empty || item == null) { setText(null); } else { setText(item.getDisplayName()); // 枚举里定义getDisplayName() } } });

这里暴露一个常见误区:PropertyValueFactory要求Staff类的字段名和getter方法名严格匹配。比如字段叫emailAddress,getter必须是getEmailAddress(),不能是getEmail()。否则绑定失败,控制台无报错,表格就是空的——这是JavaFX最坑的静默失败之一。

另外,列宽自适应是个体验细节。用户拖动列宽后,下次启动程序应该记住这个宽度。JavaFX本身不提供持久化列宽,需要手动保存:

// 保存列宽到配置文件 private void saveColumnWidths() { Properties props = new Properties(); props.setProperty("nameColumn.width", String.valueOf(nameColumn.getWidth())); props.setProperty("phoneColumn.width", String.valueOf(phoneColumn.getWidth())); try (FileOutputStream fos = new FileOutputStream("column_widths.properties")) { props.store(fos, "Column widths"); } catch (IOException e) { e.printStackTrace(); } }

启动时再读取并设置。这个小功能,能让用户感觉“这软件懂我”。

3.3 搜索功能的四种实现方式:精确、模糊、筛选、排序

搜索是用户最高频操作,项目支持四种模式,底层逻辑完全不同:

搜索类型技术实现关键注意事项
姓名/电话精确匹配list.stream().filter(s -> s.getName().equals(input)).collect(Collectors.toList())必须区分大小写!用户搜“zhang”和“Zhang”应视为不同,但实际需求往往是忽略大小写,所以用s.getName().equalsIgnoreCase(input)
地址关键词模糊搜索s.getAddress().contains(input)contains()对中文完全有效,但要注意:如果用户输入空格,"北京市朝阳区".contains(" ")返回true,导致所有记录都被搜出。需提前input = input.trim()
关系类别筛选s.getRelationship() == Relationship.CLASSMATE如果用String,必须用"同学".equals(s.getRelationship()),绝不能s.getRelationship().equals("同学"),避免空指针
按姓名排序FXCollections.sort(list, Comparator.comparing(Staff::getName))排序后必须调用tableView.sort()刷新视图,否则表格显示顺序不变

实操中最大的坑是:搜索后,用户想取消搜索回到全部列表,但“清空搜索框”后表格没反应。这是因为FilteredListsetPredicate(null)才能恢复全部数据,而不是简单地setItems(originalList)。后者会丢失排序状态,而前者会保留。

3.4 修改与删除操作:如何避免“删错人”和“改丢数据”

修改功能最危险的场景是:用户双击表格某行进入编辑窗,改了电话,但没点保存,直接关窗——此时原始数据不能被覆盖。解决方案是采用“副本模式”:

// 编辑窗打开时,创建Staff对象的深拷贝 Staff editingStaff = new Staff(originalStaff); // 构造函数里复制所有字段 // 用户点保存时,才用editingStaff的值更新originalStaff originalStaff.setPhone(editingStaff.getPhone()); originalStaff.setEmail(editingStaff.getEmail()); // ...

这样即使用户关窗,原始对象毫发无损。

删除操作则要防手滑。不能点一下就删,必须弹确认框:

Alert alert = new Alert(Alert.AlertType.CONFIRMATION); alert.setTitle("确认删除"); alert.setHeaderText("确定要删除联系人:" + staff.getName() + "?"); alert.setContentText("此操作无法撤销!"); Optional<ButtonType> result = alert.showAndWait(); if (result.isPresent() && result.get() == ButtonType.OK) { staffList.remove(staff); // 才真正删除 saveToFile(); // 立即持久化 }

这里有个隐藏技巧:AlertshowAndWait()是阻塞调用,但不会卡死UI线程,因为JavaFX的Alert本身就是异步的。很多新手以为要开新线程,反而引入并发问题。

4. 实操避坑指南:那些只有踩过才知道的细节

4.1 文件读写异常的完整处理链

Contacts.txt读写失败是最高频问题,但新手往往只写try-catch(Exception e),结果连具体错在哪都不知道。完整的处理链应该是:

public void loadFromFile() { Path path = Paths.get("Contacts.txt"); try { if (!Files.exists(path)) { // 文件不存在,创建空文件,避免后续读取报NoSuchFileException Files.createFile(path); return; } List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8); staffList.clear(); for (String line : lines) { if (line.trim().isEmpty()) continue; // 跳过空行 Staff staff = parseStaffFromLine(line); // 自定义解析方法 staffList.add(staff); } } catch (AccessDeniedException e) { showError("权限不足", "无法读取Contacts.txt,请检查文件是否被其他程序占用"); } catch (MalformedInputException e) { showError("编码错误", "Contacts.txt包含非法字符,请用UTF-8编码重新保存"); } catch (IOException e) { showError("文件错误", "读取Contacts.txt失败:" + e.getMessage()); } }

关键点:
-AccessDeniedException:文件被记事本、Excel等程序独占锁定;
-MalformedInputException:文件编码不是UTF-8,比如Windows记事本另存为ANSI;
-NoSuchFileException:文件不存在,但Files.exists()已提前判断,所以不会走到这里。

实操心得:在loadFromFile()开头加一行日志System.out.println("Loading from: " + path.toAbsolutePath()),能立刻定位路径问题。很多“找不到文件”错误,其实是相对路径算错了。

4.2 CSS样式调试的黄金三步法

JavaFX的CSS不像网页那样直观,调试靠猜会浪费大量时间。我的三步法:

第一步:确认CSS文件被正确加载
style.css第一行加一句无效规则:* { -fx-base: red; }。如果整个窗口变红,说明CSS加载成功;否则检查路径或toExternalForm()拼写。

第二步:用Scenic View工具实时查看节点树
下载ScenicView,运行程序后,在ScenicView里连接进程,能看到所有控件的CSS类名、伪类状态(:hover,:focused)、实际生效的样式。比如发现TableView的行背景色没变,可能是.table-row-cell选择器权重不够,需要加!important或换更精确的选择器。

第三步:用CSS Analyzer插件
Eclipse的e(fx)clipse插件自带CSS Analyzer,能高亮显示语法错误、未使用的规则、冲突的样式。比如你写了.button { -fx-text-fill: blue; },但按钮文字还是黑色,Analyzer会提示“-fx-text-fill.button:focused的更高优先级规则覆盖”。

4.3 Eclipse导入项目的致命四坑

项目结构里有.gitignore.inscode,说明它可能来自Git仓库。在Eclipse中导入时,90%的问题源于这四个坑:

  1. JRE版本不匹配:项目用Java 11编译,但Eclipse默认用Java 8。右键项目 → Properties → Java Build Path → Libraries → 双击JRE System Library → 选择“Alternate JRE” → Add… → Standard VM → Next → Directory选JDK 11路径。

  2. FXML文件未识别为JavaFX资源.fxml文件在Package Explorer里显示为普通文本。右键 → Properties → Resource → Text file encoding → 改为UTF-8;再右键 → Open With → JavaFX Scene Builder。

  3. CSS路径错误style.csssrc/application/,但MainApp.java里写getClass().getResource("style.css")会找不到。必须写"/application/style.css",前面的/表示从classpath根开始找。

  4. 运行配置缺失VM参数:JavaFX 11+需要显式指定模块。右键项目 → Run As → Run Configurations → Arguments → VM arguments里填:
    --module-path "path/to/javafx-sdk-17/lib" --add-modules javafx.controls,javafx.fxml
    path/to/javafx-sdk-17/lib替换成你本地SDK路径。缺少这个,启动就报java.lang.NoClassDefFoundError: javafx/application/Application

4.4 数据格式校验的实战技巧

用户输入永远比你想象的更“有创意”。比如生日字段,用户可能输:
- “1990/02/30”(斜杠分隔)
- “1990.02.30”(点号分隔)
- “1990-02-30 00:00:00”(带时间)
- “二零二三年一月一日”(中文数字)

与其在Date构造函数里写一堆正则,不如用DateTimeFormatter统一处理:

public static Date parseDate(String input) { if (input == null || input.trim().isEmpty()) return null; String clean = input.trim().replaceAll("[\\u4e00-\\u9fa5\\s]", ""); // 去中文和空格 // 定义多种格式 DateTimeFormatter[] formatters = { DateTimeFormatter.ofPattern("yyyy-MM-dd"), DateTimeFormatter.ofPattern("yyyy/MM/dd"), DateTimeFormatter.ofPattern("yyyy.MM.dd") }; for (DateTimeFormatter f : formatters) { try { LocalDate ld = LocalDate.parse(clean, f); return new Date(ld.getYear(), ld.getMonthValue(), ld.getDayOfMonth()); } catch (DateTimeParseException ignored) {} } return null; // 解析失败 }

这个方法能覆盖95%的用户输入,剩下的5%(比如“去年生日”)就弹窗提示“请输入标准日期格式”。

5. 常见问题速查表与扩展建议

5.1 高频问题排查速查表

问题现象可能原因快速验证方法解决方案
启动黑屏/空白窗口FXML文件路径错误或语法错误检查Console是否有javafx.fxml.LoadException;用Scene Builder打开FXML看是否能渲染确认FXMLLoader.load()路径正确;用Scene Builder检查fx:id是否和Controller里@FXML变量名一致
中文乱码(显示?或方块)文件编码不一致或CSS字体不支持中文style.css里加-fx-font-family: "Microsoft YaHei";;用Notepad++查看Contacts.txt编码所有文件(.java, .fxml, .css, .txt)统一用UTF-8无BOM保存;Java代码中所有IO操作显式指定StandardCharsets.UTF_8
搜索无结果,但数据明明存在字符串比较用了==而非equals();或搜索框绑定的TextField没获取最新值在搜索方法开头加System.out.println("Search input: [" + searchField.getText() + "]");一律用searchField.getText().trim().equalsIgnoreCase(keyword);确保TextFieldgetText()在事件触发时调用
修改后保存,重启程序数据还原saveToFile()没被调用;或文件路径写错,保存到了其他目录saveToFile()开头加System.out.println("Saving to: " + path.toAbsolutePath());检查保存方法是否在所有修改路径(如编辑窗保存按钮、菜单保存项)中被调用;用绝对路径调试,确认文件生成位置
表格双击无反应
(预期:双击进入编辑)TableViewsetOnMouseClicked事件没区分双击;或事件被子控件拦截在事件处理器里加if (event.getClickCount() == 2) { ... };打印event.getTarget()看点击的是TableCell还是TableColumntableView.setOnMouseClicked(e -> { if (e.getClickCount() == 2) { handleDoubleClick(); } });;确保handleDoubleClick()里先tableView.getSelectionModel().getSelectedItem()获取选中行

5.2 从课程设计到生产级的三条升级路径

这个通讯录完全可以作为起点,延伸出更实用的工具:

路径一:增加数据备份与恢复
不是简单复制Contacts.txt,而是实现“每日自动备份”:每次启动时,检查backup/目录下是否存在contacts_20231001.txt,如果没有,则将当前Contacts.txt复制一份并重命名。用户可在菜单里选择“从备份恢复”,程序自动列出所有备份文件供选择。这解决了“误删后无法找回”的核心痛点。

路径二:支持导出为CSV/Excel
用Apache POI库,将staffList导出为Excel文件。关键不是代码,而是用户体验:导出时弹窗让用户选择保存路径和文件名,而不是固定存到程序目录;导出完成后自动用系统默认程序打开文件(Desktop.getDesktop().open(file))。这样用户会觉得“这软件真懂我”。

路径三:添加联系人头像与分组
头像用ImageView控件,图片路径存为相对路径(如images/zhangsan.jpg),这样打包成jar后仍可访问。分组用TreeTableView替代TableView,左侧树形展示“同学”“家人”“同事”等分组,右侧表格显示该分组下的联系人。分组逻辑不是硬编码,而是从Staff.relationship动态生成,新增关系类别自动出现在树中。

最后分享一个小技巧:在Contacts.txt每行末尾加一个时间戳,比如张三,男,1990-01-01,13800138000,...,20231001123025。这样你一眼就能看出哪条记录是昨天改的,哪条是刚新增的。不需要数据库,一行文本就实现了简易版本的“审计日志”。

这个通讯录项目,表面看是JavaFX的练手,内核却是软件工程的缩影:它教会你如何把一个模糊的需求(“做个通讯录”),拆解成可验证的模块(日期校验、文件锁、事件流),再用扎实的代码把它焊死在现实世界的各种意外之上。当你亲手修复第十个NullPointerException,当你第一次看到Contacts.txt里整齐排列的中文联系人,那种“我造出来了”的踏实感,是任何框架文档都给不了的。它不宏大,但足够真实——而这,正是所有伟大软件的起点。

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

简介:用JavaFX开发的轻量级桌面通讯录程序,界面简洁,支持CSS自定义样式。核心数据结构清晰:Date类处理日期,Person类管理基础信息(姓名、性别、生日),Staff类继承扩展出电话、地址、邮编、邮箱、QQ号及关系类型(如同学、同事)。所有操作通过菜单驱动完成,新增联系人自动保存到Contacts.txt纯文本文件,断电或重启后数据不丢失。查询支持多种方式——姓名或电话精确查找、地址关键词模糊匹配、按关系类别筛选;列表支持按姓名升序排列;修改和删除都可通过姓名或电话快速定位目标记录。项目结构规范,包含src源码、bin编译目录、application启动模块及配置文件,兼容Eclipse等主流Java IDE,导入即运行,适合课程设计、JavaFX入门实践或小型本地信息管理需求。


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