☰
Java Swing完整版记事本开发:编码探测、原子保存与查找替换实战
2026/10/15 16:34:50 网站建设 项目流程

简介:这是一份基于Java Swing开发的记事本完整项目,面向正在学习Java GUI编程的初学者,以及需要桌面应用练习题目的开发者。项目整体用一个功能较全的文本编辑器作为切入点,系统展示了如何搭建主窗口、菜单栏、文本编辑区、状态栏等核心界面元素,并串联起文件打开保存、剪切复制粘贴、查找替换、撤销重做、快捷键绑定等常见交互逻辑,对理解事件监听与异常处理尤其有帮助。压缩包共36个文件,其中5个java源文件可直接阅读学习,25个class为编译后的字节码,1个my_notepad.jar可打包运行,另含少量配置和说明文件,整体体积仅103KB,结构紧凑,便于快速浏览和导入开发环境。已有4194人浏览学习,适合作为课程设计、期末项目或自学练手的参考素材。读者既能直接运行体验各项功能,又能对照源码分析JFileChooser、JTextArea、TransferHandler等API的实际用法,学习一个完整Java桌面应用从界面布局到业务逻辑的编码思路与实现细节。

1. Java开发记事本(完整版)是什么:一个值得认真对待的Swing项目

接到一个用Java做记事本工具的需求时,我的第一反应是:这怕不是课程作业搬到我头上了。但把“Java开发记事本(完整版)”这几个字拆开看,要交付的内容远比“能打字、能保存”复杂:不同编码的文件怎么读、退出前有没有未保存提醒、大文本会不会把界面卡死、查找替换高亮会不会错位、打包之后图标和字体为什么丢。真正适合做这个项目的,是给内部运维写一个跨平台文本小工具,或者想把Java GUI基础完整过一遍的开发者。这篇文章按我实际推进的顺序展开,从技术选型、窗口骨架、文件模块到查找替换和避坑,最后给出导出PDF的路线,照着做能拿到一个可交付的桌面应用。

2. 从技术选型到窗口骨架:为什么选Swing以及最小可运行代码

2.1 Swing和JavaFX怎么选:完整版记事本更看重什么

先说结论:单机记事本,我选Swing。原因有三个。第一是依赖,Swing是JDK自带的,目标机器只要能跑Java就能跑;JavaFX从JDK剥离之后,要多带运行时和模块配置,内部分发时每台机器都要处理环境,光这一点就劝退。第二是控件,记事本需求用到的窗口、菜单、文本区、滚动面板、文件选择器,在Swing里都是成熟组件,资料多、踩坑案例也多,遇到问题不至于卡住。第三是维护成本,Swing组件就是普通Java类,事件监听写在代码里一目了然,后接手的人不需要学FXML和控制器生命周期。

但这不代表JavaFX不行。如果你的完整版记事本将来要做代码高亮、富文本、Markdown预览或者深色皮肤,JavaFX的TextFlow和CSS方案反而更省事。我的判断标准很简单:需求只是编辑纯文本、保存、搜索、打印,Swing的开发效率更高;需求里出现“重渲染”场景,再切JavaFX。选型没有绝对答案,只有当前需求下哪个更顺手。对这次的项目目标来说,Swing能把“完整版”三个字做扎实。

2.2 搭建Maven工程与启动类:窗口能弹出的最小代码

习惯上用Maven管理依赖,哪怕项目最终不打第三方包也要保留构建骨架。工程结构我按功能分包,不按图层分包:

  • app:启动类和主窗口
  • editor:文本编辑配置、快捷键和撤销管理
  • fileio:打开、保存、编码探测
  • search:查找替换对话框与高亮逻辑

不要把所有类都塞进一个包,记事本看起来小,功能拆开后类数量很容易超过二十个。窗口能弹出的最小代码是下面这个:

package app; import javax.swing.*; public class NotePadApp { public static void main(String[] args) { SwingUtilities.invokeLater(() -> { JFrame frame = new JFrame("完整版记事本"); frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); frame.setSize(960, 640); frame.setLocationRelativeTo(null); frame.setVisible(true); }); } }

这里有两个地方是有意为之。SwingUtilities.invokeLater保证界面创建和事件分发在EDT线程里做,不在main线程直接new JFrame。原因是Swing组件不是线程安全的,从其他线程碰组件会出现随机性白屏、卡死,这种问题在开发机上复现不出来,换台机器才冒出来,属于典型的“环境相关Bug”。setDefaultCloseOperation(DO_NOTHING_ON_CLOSE)是给后面做退出前未保存检测留位置,如果现在写EXIT_ON_CLOSE,等到要拦截关闭事件时就得回头改。

2.3 JTextArea四个必调参数:换行、自动换行、Tab宽度和撤销栈

正文区放到JScrollPane里,用JTextArea承载。有四个设置我每次都会调:

JTextArea textArea = new JTextArea(); textArea.setLineWrap(true); // 软换行,超过窗口宽度自动折行 textArea.setWrapStyleWord(true); // 按单词换行,避免中文断在字中间 textArea.setTabSize(4); // Tab显示宽度,默认8太占地方 textArea.getDocument().addUndoableEditListener( e -> undoManager.addEdit(e.getEdit()));

setLineWrap只影响显示,不往文本里插入换行符,保存出来的内容不会被折行污染。setWrapStyleWord对中文的影响容易被忽略,默认false时换行可能在一个汉字中间断开,阅读体验很差。最后一行注册撤销监听,把每次文档变化包装成UndoableEdit丢进UndoManager,后面实现Ctrl+Z和Ctrl+Y就靠它,不需要自己保存历史快照。

这里有一个细节:UndoManager默认会把短时间内连续输入的字符合并成一次撤销动作,这是正确行为。如果你希望每次按键都是独立撤销粒度,可以重写UndoManager的editsToBeUndone,但记事本场景没必要。还需要记住,后续加载文件内容时会触发同样的监听器,所以文件打开完成后要手动调用undoManager.discardAllEdits()清空撤销历史,否则用户第一次按撤销,整个文件内容会变成空白。

3. 把文件读写做成可靠模块:编码探测、原子保存与未保存标记

3.1 用FileReader还是NIO.2:字符流读取的两个取舍

很多教程教打开文件用new BufferedReader(new FileReader(file))。这个写法在完整版记事本里不够用。第一,FileReader默认按平台编码读文件,Windows上大概率是GBK,Linux上大概率是UTF-8,同一个文件在不同机器上打开结果不一样,质量没法保证。第二,它不给你控制编码的机会,等用户说“打开文件怎么是乱码”时,代码层面没有补救办法。

我一般用Files.readAllBytes读原始字节,然后自己决定怎么解码。这里要提一个容易误用的API:Files.readAllLines看着方便,内部按指定编码一次性把文件读成List<String>,几MB文本没问题,几百MB日志文件会把内存撑爆。记事本场景有一个折中:文件小于一定阈值时直接读成全String,再大走流式读取分批解码。这个阈值我会做成配置项,默认10MB,而不是写死在代码里。文件打开对话框里也应该显示文件大小,超过阈值时提前提示用户“大文件打开可能会慢”,不要等界面卡住才反应。

3.2 编码探测与BOM清理:UTF-8和GBK混合环境怎么不乱码

读取文本时按这个顺序处理:

public static String readText(byte[] bytes) throws IOException { if (bytes.length >= 3 && (bytes[0] & 0xFF) == 0xEF && (bytes[1] & 0xFF) == 0xBB && (bytes[2] & 0xFF) == 0xBF) { return new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8); } String utf8 = new String(bytes, StandardCharsets.UTF_8); if (!containsReplacementChar(utf8)) { return utf8; } return new String(bytes, Charset.forName("GBK")); }

BOM判断是硬性的:文件头出现EF BB BF就是UTF-8带BOM,直接跳过三个字节,因为BOM不参与内容显示。没有BOM时先按UTF-8解码,如果结果里出现U+FFFD替换字符,说明按UTF-8解出来有非法字节序列,再回退到GBK。这里的顺序不能反,GBK是双字节编码,很多字节序列可以碰巧被UTF-8合法解出来但内容完全不对,先试UTF-8能在绝大多数场景保底。

containsReplacementChar判断解码后的字符串里有没有“�”,不需要自己做复杂的位级校验。但有一个边界要知道:文件本身确实是UTF-8,且内容里包含真正的替换字符时,这个判断会误判成GBK。这种文件很少见,真遇到了就在界面里加一个“用其他编码打开”的入口,比任何自动探测都管用。保存时也建议提供编码选择,默认UTF-8无BOM,兼容性最好。

3.3 原子保存与未保存标记:内容不被写丢的两种手段

保存文件最怕两件事:写到一半断电、用户没保存就关闭。前者用临时文件加原子移动解决,后者靠DocumentListener维护脏标记。原子保存的核心代码:

public static void atomicSave(File file, String content) throws IOException { Path targetPath = file.toPath(); Path tempPath = Files.createTempFile( targetPath.getParent(), targetPath.getFileName().toString() + ".tmp", ".tmp"); Files.write(tempPath, content.getBytes(StandardCharsets.UTF_8)); try { Files.move(tempPath, targetPath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (AtomicMoveNotSupportedException e) { Files.move(tempPath, targetPath, StandardCopyOption.REPLACE_EXISTING); } }

createTempFile在目标文件同一个目录里建临时文件,保证move是同一个文件系统内部操作。ATOMIC_MOVE让操作系统直接替换目标文件,不会出现“目标文件被截断但新内容没写完”的半成品状态。如果文件系统不支持原子移动,会抛AtomicMoveNotSupportedException,回退成普通replace。注意不要在catch里只打一行日志就完事,要确认这个异常发生时用户没有丢数据,因为普通replace在跨文件系统时也可能失败。

未保存标记靠DocumentListener实现:

Document doc = textArea.getDocument(); doc.addDocumentListener(new DocumentListener() { @Override public void insertUpdate(DocumentEvent e) { setDirty(true); } @Override public void removeUpdate(DocumentEvent e) { setDirty(true); } @Override public void changedUpdate(DocumentEvent e) { setDirty(true); } });

insertUpdate和removeUpdate覆盖了绝大多数字符输入和删除场景,changedUpdate在纯文本编辑器里几乎不会触发,但保留能防止将来换成富文本Document时漏掉样式变化。setDirty(true)里除了改布尔值,还要同步更新窗口标题,比如在窗口标题后面加星号。有一个每次都会踩的坑:程序自己调用setText清空内容或加载文件时也会触发DocumentListener,所以加载完成后要手动把dirty重置为false,否则刚打开的文件立刻提示未保存。

4. 菜单、快捷键与查找替换:完整版记事本的交互细节

4.1 菜单栏与Command模式:让MenuItem和快捷键走同一个Action

菜单项和快捷键最容易出现的问题是:Ctrl+S能保存,点菜单也能保存,但两套逻辑不一致,后面改一处漏一处。我全部用AbstractAction来做,同一个Action实例既丢进JMenuItem,又绑定到InputMap。代码是这样:

AbstractAction saveAction = new AbstractAction("保存") { @Override public void actionPerformed(ActionEvent e) { performSave(); } }; saveAction.putValue(Action.ACCELERATOR_KEY, KeyStroke.getKeyStroke(KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK)); JMenuItem saveItem = new JMenuItem(saveAction); JRootPane rootPane = frame.getRootPane(); rootPane.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW) .put(KeyStroke.getKeyStroke(KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK), "save"); rootPane.getActionMap().put("save", saveAction);

两个KeyStroke要完全一致,否则菜单栏获得焦点时快捷键响应,编辑区获得焦点时不响应,反过来也一样。常见错误是构造KeyStroke时把CTRL_DOWN_MASK和别的修饰键写混,导致Ctrl+S绑定到别的键位上。InputMap绑在JRootPane上而不是JTextArea上,这样即使焦点在菜单或者其他子控件上,快捷键依然生效。当查找对话框打开时,这个全局快捷键仍然会触发保存,这是正常行为,用户习惯在任何时候Ctrl+S都能存盘。

4.2 查找替换对话框:高亮、光标移动和文本修改的同步

查找功能看起来简单,做完整版时要管理三个状态:高亮区间、选中区间、当前搜索位置。先看最核心的查找动作:

private void highlightMatch(JTextArea area, String keyword) { Highlighter highlighter = area.getHighlighter(); highlighter.removeAllHighlights(); if (keyword == null || keyword.isEmpty()) { return; } String text = area.getText(); int index = text.indexOf(keyword, searchStartPos); if (index < 0) { searchStartPos = 0; index = text.indexOf(keyword); if (index < 0) { statusBar.showMessage("没有找到:" + keyword); return; } } try { Highlighter.HighlightPainter painter = new DefaultHighlighter.DefaultHighlightPainter(new Color(255, 255, 0)); highlighter.addHighlight(index, index + keyword.length(), painter); area.setCaretPosition(index + keyword.length()); area.select(index, index + keyword.length()); searchStartPos = index + keyword.length(); } catch (BadLocationException e) { // 文本被并发修改时可能触发,理论上不常见 } }

searchStartPos是查找功能的核心状态,记录从哪个位置出发往下找,每执行一次“查找下一个”就往后移。到结尾找不到就回绕到0再找一遍,这是系统记事本的默认行为。addHighlight和select要区分:高亮只是视觉标记,不改变选中内容;如果用户想按删除键删掉搜索到的文本,必须调用select。每次查找都removeAllHighlights再重新高亮唯一匹配,避免页面上出现一堆黄色块看不清。

替换文本时会遇到一个翻车点:替换后文本长度变化,之前的高亮区间全部失准,而且DocumentListener会把dirty标记置为true。我的做法是执行replaceRange时同步把searchStartPos调整成“替换文本结束的位置”,然后清除所有高亮,提醒用户重新点“查找下一个”。不要尝试替换后手动恢复旧高亮,文本一旦变化,所有下标都不可信。

4.3 状态栏与行号:右下角信息怎么刷才不卡

完整版记事本的右下角一般显示行、列、字符数。最naive的写法是给JTextArea的每次键盘事件加监听,立即更新JLabel。长文档里按住方向键移动时,每次击键都重新计算行号和列号,每秒钟触发几十次,界面能明显感到延迟。

我改成用Swing Timer节流,每200毫秒刷一次:

Timer timer = new Timer(200, e -> updateStatusBar()); timer.start(); private void updateStatusBar() { int caret = textArea.getCaretPosition(); Element root = textArea.getDocument().getDefaultRootElement(); int line = root.getElementIndex(caret) + 1; int column = caret - root.getElement(line - 1).getStartOffset() + 1; long charCount = textArea.getDocument().getLength(); statusLabel.setText(String.format("第%d行,第%d列 字符数:%d", line, column, charCount)); }

getElementIndex按Document的Element树找光标所在行,比用getLineOfOffset少一层异常处理。200毫秒这个值在拖动滚动条时足够流畅,也不会让CPU空转。如果要做行号显示,不要在状态栏里做,而是在JScrollPane的行头放一个可滚动的组件,只绘制可视区域的行号。这个组件要自己处理滚动偏移,我会在滚动事件触发的repaint里重算可见行区间,避免对整个文档做完整渲染。

5. 记事本避坑手册:编码乱码、内容丢失、界面卡死四个翻车现场

5.1 打开GBK文件全是乱码

现象:用户在Windows上用系统记事本存了一个TXT文件,用这个项目打开后满屏问号,重新保存后原文件也损坏了。

原因:读取时没有做编码探测,默认用FileReader按平台编码读。Windows简体中文环境下平台编码是GBK,单独跑没问题;同一套代码在Linux服务器上一跑,同样的文件按UTF-8解读,中文全变乱码,再保存就把乱码写回文件了。这不是玄学,是跨平台分发最容易踩的乱码来源。

解决:文件读取改成第3章的流程:先读字节、判断BOM、按UTF-8试探、失败回退GBK。另外要注意,文件选择对话框如果按扩展名过滤,不要对.txt之外的扩展名直接套UTF-8。很多工具导出的CSV是带BOM的UTF-8,有些是GBK,必须走同一套探测逻辑。我还会提供一个“另存为编码”下拉框,让用户在极端情况下手动指定编码,这是最后的后悔药。

5.2 刚打开文件就弹出“内容已修改”

现象:打开一个文件后什么都还没碰,点关闭窗口就提示是否保存。用户被吓一跳,以为文件被改过。

原因:加载文本内容时调用textArea.setText(...)会触发DocumentListener,把dirty标记置为true。这个“加载即修改”的问题还会影响撤销栈:UndoManager中保存了一条初始文本记录,用户第一次按Ctrl+Z,整个文件内容直接变成空白。

解决:加载完成后手动重置状态,顺序不能乱:

textArea.setText(content); undoManager.discardAllEdits(); setDirty(false);

discardAllEdits清掉加载过程产生的撤销记录,setDirty(false)把标题栏星号去掉。这个顺序里setText必须在discardAllEdits之前,否则setText触发的事件还会往被清空的撤销栈里塞新记录。每次做文件加载都要核对这三行,少一个就是定时炸弹。

5.3 打开几十MB的文件界面像死机

现象:双击一个50MB的日志文件,窗口先弹出来但一直白屏,几十秒后才显示内容,期间鼠标转圈。

原因:主线程直接做文件读取和字符串解码,I/O操作把EDT线程堵住了。Swing的界面绘制和事件分发都在EDT线程上跑,文件读取占用线程多久,界面就停多久。

解决:用SwingWorker把读文件放到后台线程,完成后切回EDT更新组件:

SwingWorker<String, Void> worker = new SwingWorker<>() { @Override protected String doInBackground() throws Exception { byte[] bytes = Files.readAllBytes(file.toPath()); return readText(bytes); } @Override protected void done() { try { textArea.setText(get()); resetEditorState(); } catch (Exception e) { statusBar.showMessage("打开失败:" + e.getMessage()); } } }; worker.execute();

SwingWorker是一次性的,每次打开文件都new新实例并execute。界面在加载过程中要禁用“保存”按钮,等done里恢复,防止用户点保存把半截文本写回文件。还有一个隐藏问题:用户连续双击两个文件,前一个worker没跑完,后一个又启动,最终结果取决于谁后执行。我会给每代worker编号,用AtomicInteger保证只有最新一代能写文本区,避免旧线程覆盖新结果。

5.4 打包成JAR后图标和字体丢失

现象:开发环境里菜单图标、窗口图标显示正常,打成可执行JAR发给别人后所有图标消失,有时候加载自定义字体还会抛异常。

原因:开发环境运行时通过new File("icons/save.png")能读到资源,因为当前工作目录里就有icons文件夹。打成JAR后文件被压缩进包,不再是文件系统里的真实路径,File方式自然找不到。

解决:统一用ClassLoader拿资源:

ImageIcon icon = new ImageIcon( NotePadApp.class.getResource("/icons/save.png"));

getResource返回URL,对JAR包内路径也能解析。注意资源路径以/开头,并且放在Maven工程的src/main/resources/icons/目录下,构建时才不会被过滤掉。如果用getResourceAsStream读取自定义字体或配置,返回的是InputStream,用完必须关闭,否则JAR在Windows上会被占用,重新打包提示文件不可写。打包后要在一台没有开发环境的机器上验证一遍,不能只在自己电脑上测。

6. 在完整版基础上多做一步:导出PDF的三种可行路线

6.1 三条路线选哪条:打印、第三方库和Java2D

导出PDF几乎是记事本完整版的默认加分项。常见做法有三条,各有边界:

路线依赖输出质量适合场景
Java Print Service调用虚拟PDF打印机JDK自带依赖用户机器安装PDF虚拟打印机内网工具快速分发
OpenPDF/iText直接生成PDF第三方库完全控制页边距、字体、分页需要自动导出、批量生成
Java2D绘制到PDF Canvas第三方库适合带行号、带高亮的扩展展示想复用现有打印逻辑

我的建议是直接走第二条路线,用Paragraph写文本换行,控制页边距和字体:

Document pdfDoc = new Document(PageSize.A4); PdfWriter.getInstance(pdfDoc, new FileOutputStream(target)); pdfDoc.open(); for (String line : textArea.getText().split("\n")) { pdfDoc.add(new Paragraph(line, font)); } pdfDoc.close();

这里的关键参数是PageSize.A4和字体。中文字体必须显式指定支持中文的Font对象,否则PDF里会出现方块字。分页逻辑不要自己数行数,第三方库的Paragraph会根据页面高度自动分页,你要处理的是超长单行,提前按可视宽度做折行。

6.2 验证导出结果的一个习惯

导出功能做完后,我会用一个固定测试文本做回归:里面包含中文、英文、数字、Tab符、空行、超长URL。每次改完代码都导出一次,用PDF阅读器翻一遍,重点看三处:中文是否正常显示、超长URL是否超出页面边界、空行是否导致前几页大量留白。这个方法比写自动化断言直观,能抓住大部分排版问题。

做这个项目时我最大的体会是:先把打开、保存、脏标记这条数据链路测稳,再往上面堆导出、打印、皮肤这些外围功能。如果基础链路不稳,每加一个功能都会多出一个“拿到半成品内容”的新Bug。导出PDF不是核心,但它能把完整版的完成度撑起来。希望帮到你。

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

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

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

立即咨询