简介:基于Java Swing开发的完整记事本项目,面向Java初学者、自学者以及需要课程设计参考的开发者,可作为入门Java桌面应用开发的练手项目。项目中使用了JFileChooser、JTextArea、JMenuBar、JOptionPane等典型Swing组件,覆盖菜单栏、快捷键、状态栏、查找替换、撤销重做等实用功能,能够帮助理解GUI布局、事件监听、文件读写与异常处理等核心知识点。资源包共36个文件,包括5个java源码、25个class编译文件、1个可运行的jar包及说明文档等,压缩包整体仅103KB,结构精炼便于直接运行和学习。目前已有4192人学习/下载,适合用于巩固Java桌面编程基础或作为毕业设计/课程设计的基础模板。项目源码对菜单事件绑定、文件读写、异常捕获均有清晰实现,先运行jar包体验整体功能,再对照源码理解Swing组件与Document接口配合使用的方式,并学习如何设计完整应用的菜单体系与编辑操作历史栈,是一份兼具教学与实用价值的入门资料。 大概每个用过电脑的人,都跟记事本打过交道。启动快、没多余功能、不用管什么格式,扔进去一段文本就能编辑。但你要是用Java自己动手写一个像样的记事本,从零开始搭界面、接文件读写、处理编码、做查找替换,真不是把JTextArea一拖就完事的事——里面牵扯到的细节够你记满一整页笔记。
这篇教程贴如果你想照着敲一遍,适合已经有Java语法基础、但还没系统做过GUI项目的人,也适合正在憋课程设计、想交一个"完整版"作业的同学。我会把整个记事本从界面到文件操作、再到编辑功能的实现思路拆开讲,最后附上一份能直接运行的完整代码,以及我在实际写这个项目时踩过、后来也帮不少朋友解决过的坑。
1. 开发前的环境准备与整体设计决策
1.1 为什么选 Swing 而不是 JavaFX
这个项目最核心的UI技术选型,我的建议是直接上Swing,不用纠结。
虽然JavaFX在官方定位上是Swing的"继任者",但拿来做记事本这种体量的工具,Swing有非常现实的优势:JDK内置,不需要额外配JavaFX SDK和Maven插件,任何一台装了JDK的机器都能直接编译运行;资料存量巨大,碰到问题搜一下基本都有现成答案;而且AWT/Swing的事件模型、布局管理器和组件体系,学完之后再看JavaFX的属性和绑定模型,理解成本会低很多。
有人可能会问,为什么不用JavaFX里的TextArea?性能更流畅、支持CSS样式,听着更"现代"。但对一个记事本应用来说,Swing和JavaFX的文本编辑体验几乎没差别,反而Swing在系统托盘、全局快捷键、原生对话框的集成上更省事。我给学生的建议一向是:能用内置库解决的项目,就别给自己增加配置层面的变量。
JDK版本我建议用JDK 8或JDK 11,不用太新。关键是稳定,而且市面上绝大多数的Swing教程和示例都基于这两个版本,遇到问题对照起来方便。当然你用JDK 17甚至21也没问题,Swing的API这十几年几乎没变过。
1.2 记事本项目的功能边界与包结构
动手写代码之前,先把"完整版"三个字具象化——到底哪些功能必须要有?
我的划分如下:文件操作(新建、打开、保存、另存为、退出)、编辑操作(撤销、重做、剪切、复制、粘贴、全选、查找、替换)、格式操作(字体类型、字体大小、自动换行开关)、帮助(关于窗口)。这四类覆盖了Windows经典记事本90%的日常功能,又不至于让代码膨胀到失控。
项目结构上,不用搞得太"企业级",一个包就够:
notepad/ ├── Notepad.java // 主类,负责组装窗口和事件 ├── MenuBarHelper.java // 菜单栏构建和快捷键绑定 ├── FileOperation.java // 打开保存等文件操作 ├── EditOperation.java // 剪贴板、查找替换、撤销重做 └── AboutDialog.java // 关于窗口为什么要把菜单构建从主类里抽出来?因为Swing的菜单初始化代码非常啰嗦,如果全部堆在构造方法里,主类会变成几百行的"上帝类",后面排查问题时你会在滚动条里迷失方向。按职责分包后,每个类只做一件事,哪个功能出问题就打开哪个文件,这就是最简单的单一职责原则。
2. 主窗口与菜单栏的构建:从零搭起一个可运行的界面
2.1 文本区与滚动面板的搭配逻辑
记事本的文本编辑区我用的是JTextArea,但直接把它丢进窗口里是不行的——文本一旦超过窗口高度,你会发现在没有滚动条的情况下根本翻不到后面的内容。正确做法是用JScrollPane把JTextArea包一层,再把滚动面板加到窗口的CONTENT_PANE中间区域。
这里有个初学者最容易忽略的点:JTextArea默认是可以横向无限扩展的,也就是说它不自动换行。这个问题不处理,打开一篇长文时,你会看到文本往右一路延伸,滚动条被拉得老长,阅读体验非常差。解决方案就是设置自动换行:
textArea.setLineWrap(true); textArea.setWrapStyleWord(true);setLineWrap(true)是开启换行,setWrapStyleWord(true)是让换行发生在单词或标点之后而不是字符中间。两个方法配合使用,才算是真正解决了换行问题。
窗口布局我采用BorderLayout——这也是记事本类应用的标准布局方案。CENTER区域放JScrollPane,因为BorderLayout的CENTER区域会占据所有剩余空间,窗口拉伸时文本区能自动适应。如果你改用FlowLayout,文本区只会获取到它的"理想大小",窗口放多大它都只占中间一小块,那看起来就是个半成品。
主窗口本身设置如下:
frame.setTitle("Java记事本"); frame.setSize(900, 650); frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);setDefaultCloseOperation要特别注意——我故意设置成DO_NOTHING_ON_CLOSE,这样点击右上角X时,程序不会直接退出,而是进入我注册的WindowListener回调,由代码决定"是否提示保存未保存的更改"。这一点在后面的避坑章节里会详细展开。
2.2 菜单栏和快捷键的一次性注册
菜单栏的结构是JMenuBar里放若干个JMenu(文件、编辑、格式、帮助),每个菜单里再放JMenuItem。代码上我用了一个辅助类集中构建,因为这部分逻辑量大但模式统一,适合批量处理。
以"文件"菜单为例:
JMenu fileMenu = new JMenu("文件"); JMenuItem newItem = new JMenuItem("新建"); newItem.setAccelerator( KeyStroke.getKeyStroke(KeyEvent.VK_N, InputEvent.CTRL_DOWN_MASK)); newItem.addActionListener(e -> newFile()); fileMenu.add(newItem); JMenuItem openItem = new JMenuItem("打开..."); openItem.setAccelerator( KeyStroke.getKeyStroke(KeyEvent.VK_O, InputEvent.CTRL_DOWN_MASK)); openItem.addActionListener(e -> openFile()); fileMenu.add(openItem);setAccelerator用来绑定快捷键,KeyStroke.getKeyStroke的第一个参数是按键码,第二个参数是修饰键掩码。CTRL_DOWN_MASK是InputEvent里的常量,注意不要用它早年废弃的CTRL_MASK,后者虽然也能用,但编辑器里会给你画一道删除线,看着也别扭。
菜单构建完成之后,我用一个循环把四个菜单统一加入菜单栏,顺便把菜单栏设置到窗口。菜单栏在Swing里的设置方式是frame.setJMenuBar(menuBar),不是加进CONTENT_PANE,这个顺序关系要理清楚。
3. 文件读写功能的实现与编码处理
文件操作是整个记事本项目里最有含金量的部分。所谓"用Java做记事本",表面上是界面程序,骨子里是文本文件的读写、编码转换和异常处理。一个记事本能不能用,看它的打开和保存代码就够。
3.1 打开文件:JFileChooser + 编码探测
打开文件的入口是JFileChooser。这个组件能调起系统的文件选择对话框,选中文件后返回一个文件对象,我们的活就是把文件内容读进JTextArea。
完整流程拆开是三步:
JFileChooser chooser = new JFileChooser(); chooser.setDialogTitle("打开文件"); int result = chooser.showOpenDialog(frame); if (result != JFileChooser.APPROVE_OPTION) { return; } File file = chooser.getSelectedFile();如果用户点了"打开",result就等于APPROVE_OPTION,此时拿到选中的文件,开始读取。读取环节有个重要决策:用FileReader还是用FileInputStream加字节读取?
我的建议是不要用FileReader。FileReader默认使用平台字符编码(中文Windows上是GBK,macOS/Linux上通常是UTF-8),同一份UTF-8文件在中文Windows上打开就会乱码。原因在FileReader的文档里写得很清楚——它"使用平台默认字符集",这就意味着程序的可移植性被机器环境绑架了。
正确的做法是用InputStreamReader显式指定编码。当然,文本文件的编码本身五花八门,更稳妥的方案是先探测再读取。我用的是:先用字节流读取前几个字节,检查是否有UTF-8的BOM头(EF BB BF),有则按UTF-8读取,没有则尝试UTF-8解码,解码失败再回退到GBK。
public String readFile(File file) throws IOException { byte[] bytes = java.nio.file.Files.readAllBytes(file.toPath()); String content; if (bytes.length >= 3 && (bytes[0] & 0xFF) == 0xEF && (bytes[1] & 0xFF) == 0xBB && (bytes[2] & 0xFF) == 0xBF) { content = new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8); } else { try { content = new String(bytes, StandardCharsets.UTF_8); } catch (Exception e) { content = new String(bytes, Charset.forName("GBK")); } } textArea.setText(content); currentFile = file; return content; }这里有个操作细节:如果检测到BOM头,我要从第3个字节开始截取,不能把BOM字符本身也读进文本框,否则文本开头会多出一个不可见字符"?"或和空格,保存回去还会污染文件。
3.2 保存与另存为:文件覆盖与编码一致性
保存功能分两类:有当前文件路径时直接覆盖保存,没有路径时走"另存为"对话框。
public void saveFile(File file) throws IOException { String content = textArea.getText(); try (BufferedWriter writer = new BufferedWriter( new OutputStreamWriter( new FileOutputStream(file), StandardCharsets.UTF_8))) { writer.write(content); } }这里用try-with-resources自动关闭流,比手动close()稳妥得多——如果写入过程中抛异常,流也能被正确释放。
保存时同样要选择编码。我统一按UTF-8保存,因为现在跨平台的文本编辑场景里,UTF-8已经成了事实标准。如果你用GBK保存,拿到macOS或Linux上打开就是乱码。当然,如果你明确知道目标环境是中文Windows且要和系统自带记事本互相打开,那GBK反而更兼容。这个取舍要看使用场景:我做的项目面向跨平台分发,所以默认UTF-8。
3.3 打开后窗口标题的联动
有个细节容易被忽略但体验感很强:打开文件后,窗口标题应该从"Java记事本"变成"文件名 - Java记事本"。这样用户在多窗口切换时才能一眼看出当前编辑的是哪个文件。
frame.setTitle(file.getName() + " - Java记事本");保存新文件时也是同理——用户第一次点保存,其实触发的是"另存为",保存成功后要把新路径设置成currentFile,同时更新标题。如果保存失败,比如路径无权限,要给用户弹出错误对话框,不能静默失败。
4. 编辑功能:剪贴板、查找替换与字体设置
4.1 剪贴板操作和撤销重做的实现思路
剪贴板操作(剪切、复制、粘贴)在Swing里不是必须自己手动调ClipboardAPI的。JTextArea内置了cut()、copy()、paste()方法,菜单项的监听器里直接调用对应方法即可。但如果你的菜单点击后没有效果,大概率是因为JTextArea没有获得焦点——焦点不在文本框上时,粘贴操作没有接收对象。所以我一般会在paste()之前先调用textArea.requestFocusInWindow()。
撤销和重做是另外一个话题。JTextArea本身没有撤销功能,官方做法是使用UndoManager配合DocumentListener。实现逻辑是:
UndoManager undoManager = new UndoManager(); textArea.getDocument().addUndoableEditListener(e -> undoManager.addEdit(e.getEdit())); JMenuItem undoItem = new JMenuItem("撤销"); undoItem.addActionListener(e -> { if (undoManager.canUndo()) { undoManager.undo(); } });这里有两点要注意:一是UndoManager默认对"输入一个字符"和"粘贴一段文字"的粒度处理不同,连续输入会被合并成一次编辑操作,粘贴则算一次独立操作,这是正常的,不是Bug。二是必须调用undoManager.setLimit(100)之类的上限设置,否则用户编辑量太大时,UndoManager会无限保留编辑历史,撑爆内存。
4.2 查找与替换:文本高亮与动态跳转
查找功能的实现核心是textArea.getText()配合indexOf逐次定位,然后用Highlighter高亮当前匹配项。
public void findNext(String keyword) { String text = textArea.getText(); int index = text.indexOf(keyword, lastSearchIndex); if (index >= 0) { textArea.setCaretPosition(index); textArea.moveCaretPosition(index + keyword.length()); // 高亮选中区域 try { textArea.getHighlighter().addHighlight( index, index + keyword.length(), DefaultHighlighter.DefaultPainter); } catch (BadLocationException e) { e.printStackTrace(); } lastSearchIndex = index + keyword.length(); } }这里关键的地方是:查找完成后要把高亮的起点从lastSearchIndex更新,这样下次再点"查找下一个"时才会从上次找到的位置往后继续找,而不是每次都从头找同一个词。如果indexOf返回-1,说明已经找到结尾,应该弹提示"已到达文末"并重置lastSearchIndex=0,方便用户循环查找。
替换的实现思路是:先走查找逻辑定位关键词,然后截取关键词前后的文本,拼接替换后的新文本,再setText回文本框。记得替换后要重新计算光标的偏移量,否则连续替换时位置会乱。
4.3 格式菜单:字体大小与自动换行的即时生效
格式菜单里的"自动换行"本质就是切换textArea的setLineWrap属性。这个操作不需要重建文本区,直接setLineWrap(!textArea.getLineWrap())即可,文本不会丢失。
字体设置我做成二级菜单:一个字号子菜单里放12、16、20、24几个档位,点击后用Font对象直接覆盖textArea的字体。
Font newFont = new Font( textArea.getFont().getFamily(), textArea.getFont().getStyle(), fontSize ); textArea.setFont(newFont);这里有个容易踩的坑:直接用new Font("宋体", Font.PLAIN, 16)会丢失原字体风格信息,而且跨平台时"宋体"在macOS上不存在,系统会用默认字体替换。所以正确做法是拿到当前字体的getFamily()和getStyle(),只改变字号,其余保留。
4.4 关于窗口:Dialog vs 普通窗体
"帮助 -> 关于"窗口我用的是JDialog而不是新建一个JFrame。区别在于:JDialog有父窗口参数,弹出时会自动居中于父窗口,而且关闭JDialog不会影响主窗口;如果误用JFrame,用户关了主窗口发现"关于"窗口还挂在屏幕上,进程都不退出,体验就很诡异。
JDialog aboutDialog = new JDialog(frame, "关于", true); aboutDialog.setSize(300, 180); aboutDialog.setLocationRelativeTo(frame);setLocationRelativeTo(frame)这行很关键——不设置的话对话框会落在屏幕左上角。第三个参数传true表示模态,模态窗口会阻塞主窗口交互,用户必须先关闭关于窗口才能回到正文编辑,这是标准的对话框行为。
5. 完整代码与避坑指南
5.1 主类完整代码
为了这部分内容可复现,我贴出精简过的完整主类代码。你可以直接建一个Notepad.java,把所有内容复制进去,JDK 8以上环境都能编译运行:
import javax.swing.*; import javax.swing.undo.UndoManager; import java.awt.*; import java.awt.event.*; import java.io.*; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; public class Notepad { private JFrame frame; private JTextArea textArea; private File currentFile; private UndoManager undoManager = new UndoManager(); private int lastSearchIndex = 0; public static void main(String[] args) { SwingUtilities.invokeLater(() -> new Notepad().initUI()); } private void initUI() { frame = new JFrame("Java记事本"); frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); frame.setSize(900, 650); textArea = new JTextArea(); textArea.setFont(new Font("Dialog", Font.PLAIN, 16)); textArea.setLineWrap(true); textArea.setWrapStyleWord(true); textArea.getDocument().addUndoableEditListener(e -> undoManager.addEdit(e.getEdit())); JScrollPane scrollPane = new JScrollPane(textArea); frame.add(scrollPane, BorderLayout.CENTER); // 菜单栏 JMenuBar menuBar = new JMenuBar(); // 文件菜单 JMenu fileMenu = new JMenu("文件"); JMenuItem newItem = new JMenuItem("新建"); newItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_N, InputEvent.CTRL_DOWN_MASK)); newItem.addActionListener(e -> newFile()); fileMenu.add(newItem); JMenuItem openItem = new JMenuItem("打开..."); openItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_O, InputEvent.CTRL_DOWN_MASK)); openItem.addActionListener(e -> openFile()); fileMenu.add(openItem); JMenuItem saveItem = new JMenuItem("保存"); saveItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_S, InputEvent.CTRL_DOWN_MASK)); saveItem.addActionListener(e -> saveFile()); fileMenu.add(saveItem); JMenuItem saveAsItem = new JMenuItem("另存为..."); saveAsItem.addActionListener(e -> saveAsFile()); fileMenu.add(saveAsItem); fileMenu.addSeparator(); JMenuItem exitItem = new JMenuItem("退出"); exitItem.addActionListener(e -> exitApp()); fileMenu.add(exitItem); menuBar.add(fileMenu); // 编辑菜单 JMenu editMenu = new JMenu("编辑"); JMenuItem undoItem = new JMenuItem("撤销"); undoItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Z, InputEvent.CTRL_DOWN_MASK)); undoItem.addActionListener(e -> { if (undoManager.canUndo()) undoManager.undo(); }); editMenu.add(undoItem); JMenuItem redoItem = new JMenuItem("重做"); redoItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Y, InputEvent.CTRL_DOWN_MASK)); redoItem.addActionListener(e -> { if (undoManager.canRedo()) undoManager.redo(); }); editMenu.add(redoItem); editMenu.addSeparator(); JMenuItem cutItem = new JMenuItem("剪切"); cutItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_X, InputEvent.CTRL_DOWN_MASK)); cutItem.addActionListener(e -> textArea.cut()); editMenu.add(cutItem); JMenuItem copyItem = new JMenuItem("复制"); copyItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_C, InputEvent.CTRL_DOWN_MASK)); copyItem.addActionListener(e -> textArea.copy()); editMenu.add(copyItem); JMenuItem pasteItem = new JMenuItem("粘贴"); pasteItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_V, InputEvent.CTRL_DOWN_MASK)); pasteItem.addActionListener(e -> { textArea.requestFocusInWindow(); textArea.paste(); }); editMenu.add(pasteItem); JMenuItem selectAllItem = new JMenuItem("全选"); selectAllItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_A, InputEvent.CTRL_DOWN_MASK)); selectAllItem.addActionListener(e -> textArea.selectAll()); editMenu.add(selectAllItem); editMenu.addSeparator(); JMenuItem findItem = new JMenuItem("查找..."); findItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F, InputEvent.CTRL_DOWN_MASK)); findItem.addActionListener(e -> showFindDialog()); editMenu.add(findItem); JMenuItem replaceItem = new JMenuItem("替换..."); replaceItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_R, InputEvent.CTRL_DOWN_MASK)); replaceItem.addActionListener(e -> showReplaceDialog()); editMenu.add(replaceItem); menuBar.add(editMenu); // 格式菜单 JMenu formatMenu = new JMenu("格式"); JCheckBoxMenuItem wrapItem = new JCheckBoxMenuItem("自动换行", true); wrapItem.addActionListener(e -> textArea.setLineWrap(wrapItem.isSelected())); formatMenu.add(wrapItem); JMenu fontMenu = new JMenu("字号"); int[] sizes = {12, 16, 20, 24, 32}; for (int size : sizes) { JMenuItem sizeItem = new JMenuItem(String.valueOf(size)); sizeItem.addActionListener(e -> { Font oldFont = textArea.getFont(); textArea.setFont(new Font(oldFont.getFamily(), oldFont.getStyle(), size)); }); fontMenu.add(sizeItem); } formatMenu.add(fontMenu); menuBar.add(formatMenu); // 帮助菜单 JMenu helpMenu = new JMenu("帮助"); JMenuItem aboutItem = new JMenuItem("关于"); aboutItem.addActionListener(e -> showAboutDialog()); helpMenu.add(aboutItem); menuBar.add(helpMenu); frame.setJMenuBar(menuBar); frame.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { exitApp(); } }); frame.setVisible(true); } private void newFile() { if (confirmGivingUpUnsavedChanges()) { textArea.setText(""); currentFile = null; frame.setTitle("Java记事本"); } } private void openFile() { JFileChooser chooser = new JFileChooser(); int result = chooser.showOpenDialog(frame); if (result != JFileChooser.APPROVE_OPTION) return; File file = chooser.getSelectedFile(); try { byte[] bytes = Files.readAllBytes(file.toPath()); String content; if (bytes.length >= 3 && (bytes[0] & 0xFF) == 0xEF && (bytes[1] & 0xFF) == 0xBB && (bytes[2] & 0xFF) == 0xBF) { content = new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8); } else { try { content = new String(bytes, StandardCharsets.UTF_8); } catch (Exception ex) { content = new String(bytes, Charset.forName("GBK")); } } textArea.setText(content); currentFile = file; frame.setTitle(file.getName() + " - Java记事本"); } catch (IOException ex) { JOptionPane.showMessageDialog(frame, "打开文件失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); } } private void saveFile() { if (currentFile == null) { saveAsFile(); return; } try (BufferedWriter writer = new BufferedWriter(new OutputStreamWriter( new FileOutputStream(currentFile), StandardCharsets.UTF_8))) { writer.write(textArea.getText()); } catch (IOException ex) { JOptionPane.showMessageDialog(frame, "保存失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); } } private void saveAsFile() { JFileChooser chooser = new JFileChooser(); int result = chooser.showSaveDialog(frame); if (result != JFileChooser.APPROVE_OPTION) return; File file = chooser.getSelectedFile(); if (file.exists()) { int confirm = JOptionPane.showConfirmDialog(frame, "文件已存在,是否覆盖?", "确认", JOptionPane.YES_NO_OPTION); if (confirm != JOptionPane.YES_OPTION) return; } currentFile = file; saveFile(); frame.setTitle(file.getName() + " - Java记事本"); } private void exitApp() { if (confirmGivingUpUnsavedChanges()) { System.exit(0); } } private boolean confirmGivingUpUnsavedChanges() { int result = JOptionPane.showConfirmDialog(frame, "文件尚未保存,确定放弃更改吗?", "提示", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); return result == JOptionPane.YES_OPTION; } private void showFindDialog() { String keyword = JOptionPane.showInputDialog(frame, "查找内容:"); if (keyword == null || keyword.isEmpty()) return; lastSearchIndex = 0; findNext(keyword); } private void findNext(String keyword) { String text = textArea.getText(); int index = text.indexOf(keyword, lastSearchIndex); if (index >= 0) { textArea.setCaretPosition(index); textArea.moveCaretPosition(index + keyword.length()); textArea.getHighlighter().removeAllHighlights(); try { textArea.getHighlighter().addHighlight(index, index + keyword.length(), new DefaultHighlighter.DefaultPainter()); } catch (Exception ignored) {} lastSearchIndex = index + keyword.length(); } else { JOptionPane.showMessageDialog(frame, "已到达文档末尾,继续从头查找。", "查找", JOptionPane.INFORMATION_MESSAGE); lastSearchIndex = 0; } } private void showReplaceDialog() { String keyword = JOptionPane.showInputDialog(frame, "查找内容:"); if (keyword == null || keyword.isEmpty()) return; String replacement = JOptionPane.showInputDialog(frame, "替换为:"); if (replacement == null) return; String text = textArea.getText(); String newText = text.replace(keyword, replacement); textArea.setText(newText); } private void showAboutDialog() { JDialog dialog = new JDialog(frame, "关于", true); dialog.setSize(300, 160); dialog.setLocationRelativeTo(frame); JLabel label = new JLabel("<html><h2>Java记事本</h2><p>版本 1.0</p><p>基于 Swing 开发</p></html>", SwingConstants.CENTER); dialog.add(label); dialog.setVisible(true); } }5.2 我在实践中踩过的几个坑
第一是关闭窗口资源未释放,进程退不干净。DO_NOTHING_ON_CLOSE配合WindowListener这套组合,测试时容易遇到"关闭窗口后进程还在后台跑"的现象。这是因为JVM中有非守护线程存活,Swing的事件分发线程Event Dispatch Thread没被结束。解决方式是确保退出路径里调用了System.exit(0),而不是只dispose()窗口。
第二是乱码问题。我在一次实际交付中遇到的情况是:用户在中文Windows系统上用系统自带记事本写好文件,我的程序打开后全是"锟斤拷"。排查发现,系统记事本在Win10以下默认保存ANSI(GBK),我程序里全部按UTF-8解码,自然乱码。加上编码探测回退逻辑之后才解决。按我的经验,处理文本文件编码的通用策略就是:优先BOM判断,其次UTF-8尝试,失败回退系统默认码表。没有这个回退逻辑,就别跟用户说"跨平台"。
第三是快捷键绑定时焦点冲突。Ctrl+C复制在文本框有选中内容时行为正常,但如果你用InputMap和ActionMap给JTextArea重新绑定了Ctrl+C,会发现复制功能失效。因为JTextArea默认的复制行为是复制选中文本,但你自定义Action覆盖后,getSelectedText()可能为空,于是什么都没复制。解决方案是编辑操作全部使用文本框内置方法cut()/copy()/paste(),不要自己从Clipboard里读写。
第四是打开大文件时的界面卡顿。有人试过用textArea.read(new FileReader(file), null)一口气读入几十MB的TXT,结果界面卡死几十秒。原因是没有用SwingWorker做异步读取,文件IO直接阻塞了EDT线程。对记事本这种体量来说,我建议在readAllBytes读取文件时就放在SwingWorker.doInBackground()里,完成后在done()回调里更新文本区,能显著提升体验。
5.3 从记事本到更深一层的扩展方向
写完这个版本之后,你可以做的扩展方向非常多。比如给文件菜单加"最近打开"列表,用Properties持久化到本地配置文件;比如给查找对话框增加"区分大小写"和"向下/向上查找"选项;再比如把保存格式扩展成支持ANSI/UTF-8/UTF-8 BOM切换,做成一个编码下拉框——这个功能连很多商业编辑器都做得不好,但你用已经写好的编码探测逻辑做起来非常自然。
还有一个很值得做的方向:把撤销重做从UndoManager升级成自定义的EditAction命令队列,这样以后你想加入"格式刷"或者"批量缩进"功能,就能把每一次操作都封装成可撤销对象,而不像现在这样只能依靠底层UndoableEdit。
我个人写这个项目的最大体会是:Swing应用看着不难,但要做好、做"完整",关键在于处理边界情况——文件不存在、编码不匹配、用户取消对话框、未保存就关闭、超大文件加载,每一类边界情况都是一次真实的用户伤害。你的代码在理想路径上跑通,只是完成了30%;剩下70%的功夫,全在你对"用户会怎么乱点"的预判和兜底里。这个思维习惯,比记事本本身值钱得多。
本文还有配套的精品资源,点击获取