1. 项目概述:为什么在2024年还要用Swing处理Excel?
“Swing实现Excel文件的处理”——看到这个标题,不少刚接触Java桌面开发的朋友第一反应可能是:“都2024年了,还用Swing?Excel不是该交给Apache POI、EasyExcel或者Python的pandas干吗?”
这话没错,但恰恰是这种“理所当然”的判断,掩盖了一个真实而普遍的工程现实:大量仍在稳定运行的内部办公系统、质检报表工具、产线数据采集终端、教育机构成绩管理客户端,其GUI层就是Swing,且短期内无法重构。它们不是技术落伍,而是“够用、可控、无依赖、零安装包外扩”的典型代表。某高校教务处使用的课程排考辅助工具,自2016年上线至今,累计处理超12万份Excel格式的班级课表、教师课时统计、教室使用日志,核心逻辑没动过一行,GUI界面也从未升级为JavaFX或Web前端——因为运维老师说:“只要双击就能跑,不连网、不弹安全警告、不卡在Windows Defender里,我们就不用改。”
Swing处理Excel,本质不是“替代POI”,而是“封装POI”。它解决的是最后一公里的交互闭环问题:用户点一个按钮,选一个.xlsx文件,程序读取后在表格组件(JTable)里实时渲染;用户修改几行数据,点“导出”,立刻生成带样式、合并单元格、自动列宽的Excel;甚至支持双击单元格直接编辑、Ctrl+C/V粘贴多行数据、右键菜单插入/删除行——这些操作,在纯POI的命令行脚本里根本不存在,而在Swing界面中,却是用户每天的真实工作流。
关键词“Swing”“Excel文件处理”背后,实际指向三个不可绕开的核心需求:轻量级本地化部署能力、对用户操作意图的即时响应、与传统企业内网环境的零摩擦兼容性。它不追求高并发、不拼云原生,但要求“打开即用、操作即存、出错即知”。我曾参与过某医疗器械公司的生产批次追溯系统客户端开发,所有数据必须离线处理、禁止外传,USB接口直连扫码枪+Swing界面+本地Excel缓存,成了唯一合规方案。当时我们试过把界面换成Electron,结果因杀毒软件拦截Node.js进程导致每日早班启动失败率高达37%;换回Swing后,故障归零——不是技术更先进,而是它足够“透明”,操作系统和安全策略完全不感知它的存在。
所以,这篇内容不是怀旧,而是面向真实场景的务实复盘:如何用Swing构建一个真正可用、可维护、能扛住三年以上业务迭代的Excel处理客户端。它不讲“Swing已淘汰”,只讲“Swing在这里为什么不可替代”;不堆砌API文档,只拆解从点击按钮到生成带样式的Excel文件之间,那几十毫秒里到底发生了什么、哪些坑我踩过三次才记住、哪些参数调错会导致整张表字体变成方块。如果你正在维护一个老系统,或者需要快速交付一个不依赖网络、不折腾用户的桌面工具,那么接下来的内容,就是你明天早上就能抄作业的实操手册。
2. 整体架构设计:Swing不是孤岛,而是POI的指挥中枢
2.1 为什么必须分层?——Swing界面与Excel逻辑的物理隔离
很多初学者一上来就试图在ActionListener里直接写Workbook wb = new XSSFWorkbook();,然后逐行往JTable里塞数据。这看似直白,实则埋下三颗定时炸弹:
- 内存泄漏风险:JTable的数据模型(TableModel)若直接引用Workbook对象,当用户反复打开/关闭文件时,旧Workbook无法被GC回收,10次操作后内存占用飙升200MB;
- 线程阻塞灾难:Excel解析(尤其含图表、公式、上万行数据时)可能耗时数百毫秒,若在EDT(Event Dispatch Thread)中执行,整个界面会“假死”,鼠标悬停按钮无反馈、进度条不转、甚至触发Windows的“程序未响应”提示;
- 维护地狱:当业务方突然要求“导出时增加一列设备校准时间”,你得在界面代码里翻找3个不同位置——读取逻辑、表格渲染逻辑、导出逻辑——改错一处,其他两处就不同步。
因此,第一铁律:Swing只负责“呈现”和“调度”,Excel的所有IO、解析、生成操作,必须下沉到独立的服务层。我们采用经典的三层结构:
┌─────────────────┐ ┌───────────────────────┐ ┌───────────────────────────┐ │ Swing UI层 │───▶│ Excel Service层 │───▶│ Apache POI核心层 │ │ • JFileChooser │ │ • ExcelReader │ │ • XSSFWorkbook / HSSFWorkbook │ │ • JTable + TableModel │ │ • ExcelWriter │ │ • DataFormatter / CellStyle │ │ • JButton监听器 │ │ • DataValidator │ │ • FormulaEvaluator │ └─────────────────┘ └───────────────────────┘ └───────────────────────────┘提示:Service层必须定义为interface(如
ExcelService),而非具体类。这样未来替换POI为EasyExcel(只需重写实现类)或接入云端Excel API(如微软Graph)时,UI层代码一行不改。我在某次系统升级中,仅用2小时就将全部Excel操作切换到EasyExcel,原因就是当初坚持写了ExcelService接口。
2.2 核心组件选型:JTable不是唯一答案,但它是最佳起点
Swing中展示表格数据,选项有JTable、JList、JTree甚至自定义JPanel。为何锁定JTable?
- 原生支持编辑:通过
DefaultTableModel或自定义AbstractTableModel,双击单元格即可输入,无需自己画文本框、处理焦点; - 列控制粒度细:可单独设置某列不可编辑(如ID列)、某列下拉选择(用
TableCellEditor)、某列右对齐(DefaultTableCellRenderer); - 性能经受考验:官方文档明确说明,JTable优化了超过10万行数据的虚拟滚动(viewport rendering),实测在i5-8250U笔记本上,加载5万行×20列的Excel,首次渲染耗时<800ms(开启
setAutoCreateRowSorter(true)后仍稳定)。
但JTable有硬伤:默认不支持合并单元格、不支持冻结首行、不支持列宽自动适应中文。这些不是Bug,而是设计取舍——Swing追求的是“通用性”,而Excel处理需要“专业性”。我们的解法是:不魔改JTable,而是在其之上叠加一层“Excel语义适配器”。
例如,合并单元格:POI中CellRangeAddress定义了合并区域,我们在TableModel中增加getMergedCells()方法,返回List<CellRangeAddress>;再重写JTable的paint()方法,在绘制时遍历该列表,对合并区域内的单元格跳过边框绘制、统一用左上角单元格值渲染。代码量仅47行,却让JTable具备了90%的Excel视觉效果。
注意:绝对不要重写JTable的
getCellRect()或rowAtPoint()等底层方法!我曾见过有开发者为实现“点击合并区域任意位置选中整行”而覆盖这些方法,结果导致鼠标拖拽选择、键盘方向键导航全部失效。正确做法是监听mouseClicked事件,用SwingUtilities.convertPoint()将鼠标坐标转为逻辑行列,再查CellRangeAddress判断是否在合并区内。
2.3 线程模型:Swing的EDT不是枷锁,而是安全护栏
Swing强制要求所有UI更新必须在EDT中执行,这是反直觉的,却是保障线程安全的基石。Excel处理必然涉及耗时IO,我们必须用SwingWorker桥接:
// 用户点击“导入”按钮后的标准流程 private void onImportClick() { JFileChooser fc = new JFileChooser(); if (fc.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { File file = fc.getSelectedFile(); // 启动后台任务,传入文件路径 new ExcelImportWorker(file).execute(); } } // 后台工作者:在非EDT线程执行IO private class ExcelImportWorker extends SwingWorker<List<RowData>, Void> { private final File excelFile; ExcelImportWorker(File file) { this.excelFile = file; } @Override protected List<RowData> doInBackground() throws Exception { // 此处调用ExcelService.read(file),完全脱离EDT return excelService.read(excelFile); } @Override protected void done() { try { // done()方法自动在EDT中执行! List<RowData> data = get(); // 获取doInBackground返回值 tableModel.setData(data); // 安全更新TableModel statusLabel.setText("导入完成,共" + data.size() + "行"); } catch (Exception e) { JOptionPane.showMessageDialog( SwingUtilities.getRoot(this), "导入失败:" + e.getMessage(), "错误", JOptionPane.ERROR_MESSAGE ); } } }这个模式的价值在于:它把“耗时操作”和“UI反馈”彻底解耦。用户点击按钮后,界面立即响应(按钮变灰、显示“处理中…”),后台默默干活,完成后精准刷新——没有闪烁、没有卡顿、没有“等待光标转圈10秒”的焦虑。我在某次给质检员演示时,故意导入一个12MB的Excel(含图表和宏),全程界面流畅,对方惊讶地说:“比我们原来的VB6程序还快。”其实不是快,而是“感觉不到等待”。
3. 核心细节解析:从Excel读取到JTable渲染的完整链路
3.1 Excel读取:为什么不能直接new XSSFWorkbook(file)?
POI的XSSFWorkbook(File)构造方法看似便捷,但它会将整个Excel文件一次性加载进内存。一个10MB的.xlsx文件,解压后XML内容可能膨胀至40MB,再经POI解析成对象树,内存占用常达150MB以上。这对配置4GB内存的老式办公电脑是致命打击——JVM直接OOM。
正确姿势是流式读取(SAX模式),用XSSFReader配合DefaultHandler逐行解析:
public List<RowData> readXlsx(File file) throws Exception { List<RowData> rows = new ArrayList<>(); OPCPackage pkg = OPCPackage.open(file); XSSFReader reader = new XSSFReader(pkg); SharedStringsTable sst = reader.getSharedStringsTable(); // 获取第一个Sheet的InputStream InputStream sheetStream = reader.getSheet("rId1"); // rId1通常指sheet1 XMLReader parser = XMLReaderFactory.createXMLReader(); SheetHandler handler = new SheetHandler(sst, rows); parser.setContentHandler(handler); parser.parse(new InputSource(sheetStream)); pkg.close(); return rows; } // 自定义Handler,只捕获<row>和<cell>标签 private static class SheetHandler extends DefaultHandler { private final SharedStringsTable sst; private final List<RowData> rows; private RowData currentRow; private String currentCellContent; private int currentColIndex; SheetHandler(SharedStringsTable sst, List<RowData> rows) { this.sst = sst; this.rows = rows; } @Override public void startElement(String uri, String localName, String qName, Attributes attributes) { if ("row".equals(qName)) { currentRow = new RowData(); } else if ("c".equals(qName)) { // c标签的r属性如"A1",需解析列号 String ref = attributes.getValue("r"); currentColIndex = parseColumnIndex(ref); } } @Override public void characters(char[] ch, int start, int length) { currentCellContent = new String(ch, start, length).trim(); } @Override public void endElement(String uri, String localName, String qName) { if ("c".equals(qName) && currentRow != null) { // 将字符串索引转为实际值(共享字符串表) String value = resolveCellValue(currentCellContent); currentRow.setCell(currentColIndex, value); } else if ("row".equals(qName) && currentRow != null) { rows.add(currentRow); currentRow = null; } } }实操心得:
parseColumnIndex("AZ123")的实现千万别手写正则!Excel列名是26进制(A=1, Z=26, AA=27),我最初用"AZ".chars().reduce(0, (a,c)->a*26+c-'A'+1),结果发现'A'是65,'A'-65+1=1没错,但'Z'是90,90-65+1=26也没错——可当遇到"AAA"时,手算容易溢出。后来直接抄POI源码里的CellReference.convertColStringToIndex("AZ"),稳如泰山。永远相信成熟库的边界处理,别在轮子上炫技。
3.2 数据建模:RowData不是Map<String,Object>,而是强类型契约
很多教程用List<Map<String, Object>>存储Excel数据,理由是“灵活”。但实际项目中,这会引发灾难:
- 导出时列顺序随机(HashMap无序),用户抱怨“姓名列跑到最后了”;
- 类型丢失:数字“123”和字符串“00123”在Map里都是Object,导出时
CellStyle.setDataFormat()无法区分; - 验证困难:要求“手机号必须11位”,你得先
instanceof String再length()==11,而强类型可直接phone.length()==11。
我们定义RowData为final类,字段与Excel表头严格对应:
public final class RowData { private final String studentId; // 学号(字符串,保留前导零) private final String name; // 姓名 private final Integer score; // 成绩(Integer,null表示空单元格) private final LocalDate examDate; // 考试日期 public RowData(String studentId, String name, Integer score, LocalDate examDate) { this.studentId = studentId; this.name = name; this.score = score; this.examDate = examDate; } // getter方法,全部public,无setter(不可变对象) public String getStudentId() { return studentId; } public String getName() { return name; } public Integer getScore() { return score; } public LocalDate getExamDate() { return examDate; } }注意:
LocalDate的解析需谨慎。Excel中日期本质是浮点数(如44197.0表示2021-01-01),POI提供DateUtil.getJavaDate(double),但必须配合DataFormatter获取原始格式字符串,否则时区转换出错。我们封装了ExcelDateParser工具类,内部用Calendar.getInstance(TimeZone.getTimeZone("GMT"))确保跨时区一致性——这点在跨国工厂的设备日志系统中救了大命。
3.3 JTable渲染:如何让“123.00”显示为“¥123.00”,且导出时仍是数字?
JTable默认把所有数据当字符串渲染,但Excel处理要求“显示格式”与“底层数据”分离。例如:
- 界面显示:
¥123.00(带货币符号、千分位、两位小数) - 底层存储:
Double.valueOf(123.0)(纯数字,便于计算) - 导出Excel:单元格值为123.0,样式为
"¥#,##0.00"
解决方案是双渲染器(Dual Renderer):
TableCellRenderer负责界面显示(调用NumberFormat.getCurrencyInstance().format(value));TableCellEditor负责编辑时显示原始值(用JFormattedTextField,设置NumberFormatter);TableModel.getValueAt()返回原始数字,setValueAt()接收编辑后的数字。
关键代码:
// 货币列渲染器 public class CurrencyRenderer extends DefaultTableCellRenderer { private final NumberFormat currencyFormat; public CurrencyRenderer() { this.currencyFormat = NumberFormat.getCurrencyInstance(Locale.CHINA); setHorizontalAlignment(RIGHT); } @Override public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) { if (value instanceof Number) { setText(currencyFormat.format(((Number) value).doubleValue())); } else { setText(""); } return this; } } // 在JTable初始化时绑定 table.getColumnModel().getColumn(3).setCellRenderer(new CurrencyRenderer()); table.getColumnModel().getColumn(3).setCellEditor(new CurrencyEditor()); // CurrencyEditor继承DefaultCellEditor,核心是JFormattedTextField private static class CurrencyEditor extends DefaultCellEditor { private final JFormattedTextField textField; public CurrencyEditor() { super(new JFormattedTextField( new NumberFormatter(NumberFormat.getNumberInstance()))); this.textField = (JFormattedTextField) getComponent(); textField.setColumns(10); } @Override public Object getCellEditorValue() { try { return textField.getValue(); // 返回Number对象 } catch (Exception e) { return 0.0; } } }实操心得:
JFormattedTextField有个隐藏陷阱——当用户输入“123.456”时,getValue()返回Double,但Double.doubleValue()精度丢失,导出Excel时变成123.45600000000001。解决方案是改用BigDecimal:new JFormattedTextField(new NumberFormatter(new DecimalFormat("#,##0.00"))),并重写getCellEditorValue()返回BigDecimal。我们在某次财务对账中发现差异0.01元,追查三天才发现是这里double精度问题。
4. 实操过程:从零搭建一个可运行的Excel处理客户端
4.1 环境准备:JDK版本与POI依赖的黄金组合
Swing是Java标准库,但POI版本选择直接影响稳定性。截至2024年,最稳妥的组合是JDK 11 + POI 5.2.4:
- JDK 11是长期支持版(LTS),Swing组件在JDK 11中已修复JDK 8遗留的HiDPI缩放bug(4K屏下文字模糊);
- POI 5.2.4是最后一个完全兼容JDK 11且不强制要求模块化(JPMS)的版本。POI 5.3+开始要求
--add-modules java.xml.bind,而Swing应用常被部署在精简JRE上,缺少这些模块会直接启动失败。
Maven依赖配置(pom.xml):
<dependencies> <!-- Swing是JDK内置,无需声明 --> <!-- POI核心 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi</artifactId> <version>5.2.4</version> </dependency> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-ooxml</artifactId> <version>5.2.4</version> </dependency> <!-- 解析Office 2003格式(.xls)必备 --> <dependency> <groupId>org.apache.poi</groupId> <artifactId>poi-scratchpad</artifactId> <version>5.2.4</version> </dependency> <!-- 日志(避免SLF4J绑定冲突) --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-simple</artifactId> <version>2.0.9</version> </dependency> </dependencies>注意:绝对不要引入
poi-ooxml-schemas!POI 5.2.4已内置精简版schemas,额外引入会导致java.lang.NoClassDefFoundError: org/openxmlformats/schemas/spreadsheetml/x2006/main/CTWorkbook。我曾为解决此问题重装JDK三次,最后发现是同事误加了这个依赖。
4.2 创建主窗口:遵循Swing最佳实践的骨架代码
一个健壮的Swing主窗口,必须包含四个要素:
- 正确的线程启动方式:
SwingUtilities.invokeLater(); - 顶层容器选择:
JFrame(非JDialog,因需独立窗口); - 内容面板布局:
BorderLayout(最灵活,North放工具栏,Center放JTable,South放状态栏); - 窗口关闭策略:
setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE),自定义退出逻辑(保存未提交数据)。
完整主类(MainApp.java):
public class MainApp { private JFrame frame; private JTable table; private ExcelTableModel tableModel; private ExcelService excelService; public static void main(String[] args) { // 设置系统外观(Windows风格) try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeel()); } catch (Exception e) { // 降级为Java默认外观 System.err.println("无法设置系统外观:" + e.getMessage()); } // 必须在EDT中创建GUI SwingUtilities.invokeLater(() -> new MainApp().createAndShowGUI()); } private void createAndShowGUI() { frame = new JFrame("Excel处理工具 v1.0"); frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE); frame.addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { onWindowClose(); } }); // 初始化服务层 excelService = new ExcelServiceImpl(); // 创建内容面板 JPanel contentPane = new JPanel(new BorderLayout()); frame.setContentPane(contentPane); // 创建工具栏(JToolBar) JToolBar toolbar = createToolbar(); contentPane.add(toolbar, BorderLayout.NORTH); // 创建表格 tableModel = new ExcelTableModel(); table = new JTable(tableModel); JScrollPane scrollPane = new JScrollPane(table); contentPane.add(scrollPane, BorderLayout.CENTER); // 创建状态栏 JLabel statusLabel = new JLabel("就绪"); contentPane.add(statusLabel, BorderLayout.SOUTH); // 设置窗口大小与居中 frame.setSize(1200, 700); frame.setLocationRelativeTo(null); frame.setVisible(true); } private JToolBar createToolbar() { JToolBar toolbar = new JToolBar("主工具栏"); toolbar.setFloatable(false); JButton btnImport = new JButton("导入Excel"); btnImport.addActionListener(e -> onImportClick()); toolbar.add(btnImport); JButton btnExport = new JButton("导出Excel"); btnExport.addActionListener(e -> onExportClick()); toolbar.add(btnExport); JButton btnClear = new JButton("清空数据"); btnClear.addActionListener(e -> onClearClick()); toolbar.add(btnClear); return toolbar; } private void onWindowClose() { int result = JOptionPane.showConfirmDialog( frame, "确定要退出?未保存的数据将丢失。", "确认退出", JOptionPane.YES_NO_OPTION ); if (result == JOptionPane.YES_OPTION) { frame.dispose(); System.exit(0); } } }实操心得:
frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE)是专业Swing应用的标志。它把“退出控制权”交还给业务逻辑,而不是让系统粗暴终止。某次在车间现场,操作员误点关闭按钮,弹出确认框后,我们悄悄在YES_OPTION分支里加了excelService.backupCurrentData(),自动生成一个backup_20240520_1432.xlsx,救回了当天所有未上传的质检数据。
4.3 表格模型实现:ExcelTableModel的五个关键方法
ExcelTableModel继承AbstractTableModel,必须实现5个抽象方法。重点不在“怎么写”,而在“为什么这么写”:
public class ExcelTableModel extends AbstractTableModel { private List<RowData> data = new ArrayList<>(); private final String[] columnNames = {"学号", "姓名", "成绩", "考试日期", "备注"}; @Override public int getRowCount() { return data.size(); } @Override public int getColumnCount() { return columnNames.length; } @Override public String getColumnName(int columnIndex) { return columnNames[columnIndex]; } @Override public Object getValueAt(int rowIndex, int columnIndex) { if (rowIndex >= data.size()) return ""; RowData row = data.get(rowIndex); switch (columnIndex) { case 0: return row.getStudentId(); case 1: return row.getName(); case 2: return row.getScore(); // 返回Integer,供CurrencyRenderer格式化 case 3: return row.getExamDate(); case 4: return ""; // 备注列初始为空 default: return ""; } } @Override public void setValueAt(Object value, int rowIndex, int columnIndex) { if (rowIndex >= data.size()) return; RowData row = data.get(rowIndex); // 注意:此处必须创建新RowData对象(不可变性) RowData newRow = new RowData( columnIndex == 0 ? (String) value : row.getStudentId(), columnIndex == 1 ? (String) value : row.getName(), columnIndex == 2 ? (Integer) value : row.getScore(), columnIndex == 3 ? (LocalDate) value : row.getExamDate(), columnIndex == 4 ? (String) value : "" ); data.set(rowIndex, newRow); fireTableCellUpdated(rowIndex, columnIndex); // 通知视图更新 } }关键细节:
setValueAt()中创建newRow是强制要求。如果直接row.setXXX(value),会破坏不可变性,导致导出时数据与界面显示不一致(例如用户改了姓名,但导出还是旧姓名)。fireTableCellUpdated()必须精确到单元格,而非fireTableDataChanged()——后者会重绘整个表格,1000行数据时卡顿明显。
4.4 导出功能实现:生成带样式的Excel文件
导出不是简单写数据,而是重建Excel的“视觉契约”。核心三步:
- 创建Workbook和Sheet;
- 写入数据并设置CellStyle;
- 自动调整列宽、冻结首行、设置打印区域。
public void exportToExcel(File file) throws Exception { try (Workbook workbook = new XSSFWorkbook()) { Sheet sheet = workbook.createSheet("数据导出"); // 创建标题行样式(加粗、背景色) CellStyle headerStyle = workbook.createCellStyle(); Font headerFont = workbook.createFont(); headerFont.setBold(true); headerFont.setColor(IndexedColors.WHITE.getIndex()); headerStyle.setFont(headerFont); headerStyle.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex()); headerStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); headerStyle.setBorderBottom(BorderStyle.THIN); headerStyle.setBorderTop(BorderStyle.THIN); headerStyle.setBorderLeft(BorderStyle.THIN); headerStyle.setBorderRight(BorderStyle.THIN); headerStyle.setAlignment(HorizontalAlignment.CENTER); // 创建数据行样式(常规字体) CellStyle dataStyle = workbook.createCellStyle(); dataStyle.setBorderBottom(BorderStyle.THIN); dataStyle.setBorderTop(BorderStyle.THIN); dataStyle.setBorderLeft(BorderStyle.THIN); dataStyle.setBorderRight(BorderStyle.THIN); // 写入标题行 Row headerRow = sheet.createRow(0); for (int i = 0; i < columnNames.length; i++) { Cell cell = headerRow.createCell(i); cell.setCellValue(columnNames[i]); cell.setCellStyle(headerStyle); } // 写入数据行 for (int i = 0; i < data.size(); i++) { Row row = sheet.createRow(i + 1); RowData rowData = data.get(i); row.createCell(0).setCellValue(rowData.getStudentId()); row.createCell(1).setCellValue(rowData.getName()); Cell scoreCell = row.createCell(2); if (rowData.getScore() != null) { scoreCell.setCellValue(rowData.getScore()); // 成绩列设置数字格式:#,##0 CellStyle scoreStyle = workbook.createCellStyle(); scoreStyle.cloneStyleFrom(dataStyle); DataFormat format = workbook.createDataFormat(); scoreStyle.setDataFormat(format.getFormat("#,##0")); scoreCell.setCellStyle(scoreStyle); } Cell dateCell = row.createCell(3); if (rowData.getExamDate() != null) { dateCell.setCellValue(rowData.getExamDate().atStartOfDay()); // 日期列格式:yyyy-mm-dd CellStyle dateStyle = workbook.createCellStyle(); dateStyle.cloneStyleFrom(dataStyle); dateStyle.setDataFormat(format.getFormat("yyyy-mm-dd")); dateCell.setCellStyle(dateStyle); } row.createCell(4).setCellValue(""); // 备注列留空 } // 自动列宽(中文字符按2个英文宽度计算) for (int i = 0; i < columnNames.length; i++) { sheet.autoSizeColumn(i, true); // 手动微调:学号列通常较长,加20% if (i == 0) { sheet.setColumnWidth(i, (int) (sheet.getColumnWidth(i) * 1.2)); } } // 冻结首行 sheet.createFreezePane(0, 1, 0, 1); // 设置打印区域 sheet.setPrintArea(0, 0, columnNames.length - 1, 0, data.size()); // 写入文件 try (FileOutputStream out = new FileOutputStream(file)) { workbook.write(out); } } }实操心得:
autoSizeColumn(i, true)的true参数表示“考虑中文”,但实测对长中文支持不佳。我们最终采用“预估宽度”策略:对每列取前100行数据,计算str.length() * 256(256是Excel单位,1个字符≈256单位),再取最大值设为列宽。代码虽多10行,但导出效果远超autoSizeColumn。
5. 常见问题与排查技巧实录:那些文档里不会写的坑
5.1 问题速查表:高频故障与根因定位
| 现象 | 可能根因 | 排查步骤 | 解决方案 |
|---|---|---|---|
| 导入大文件时JVM内存溢出(OutOfMemoryError) | XSSFWorkbook(File)一次性加载 | 1. 查看堆栈,确认是否在XSSFWorkbook.<init>()抛出2. 用 jstat -gc <pid>监控Eden区增长 | 改用XSSFReader流式解析,或增大JVM参数-Xmx2g |
| 导出Excel打开后提示“文件损坏”,修复后数据乱码 | 字符串编码未指定为UTF-8 | 1. 用file -i exported.xlsx检查文件编码2. 查看POI写入时是否调用 workbook.setEncoding(HSSFCharset.UTF_16) | XSSFWorkbook无需设置编码,但HSSFWorkbook(.xls)必须workbook.setEncoding(HSSFCharset.UTF_16) |
| JTable双击单元格无法编辑,或编辑后按Enter不生效 | TableModel.isCellEditable()返回false | 1. 在ExcelTableModel中添加@Override public boolean isCellEditable(int row, int col) { return true; }2. 检查 JTable.setDefaultEditor(Object.class, new DefaultCellEditor(...))是否被覆盖 | 在ExcelTableModel中显式返回true,并在createAndShowGUI()中调用table.setDefaultEditor(Object.class, new DefaultCellEditor(new JTextField())) |
| 导出的Excel中日期显示为数字(如44197) | 未设置CellStyle.setDataFormat() | 1. 用Excel查看器打开导出文件,右键单元格→“设置单元格格式”→“数字”页签2. 确认是否为“常规”格式 | 对日期列Cell单独创建CellStyle,调用cellStyle.setDataFormat(workbook.createDataFormat().getFormat("yyyy-mm-dd")) |
| Swing界面在高分辨率屏幕(如4K)上文字模糊、按钮过小 | 未启用系统DPI缩放 | 1. 运行java -Dsun.java2d.uiScale=2.0 -jar app.jar测试2. 查看 GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getScaleFactor() | 在main()方法开头添加System.setProperty("sun.java2d.uiScale", "1.0"),或使用JDK 11+的-Dsun.java2d.win.uiScale=1.5 |
5.2 独家避坑技巧:来自三年实战的血泪总结
技巧1:用“影子文件”规避Excel被占用问题
用户导入一个正在被Excel程序打开的文件时,OPCPackage.open(file)会抛IOException: The process cannot access the file...。常规解法是提示“请关闭Excel”,但用户体验极差。我们的方案是:在导入前,将文件复制到临时目录,操作副本。
private File prepareImportFile(File original) throws IOException { File tempDir = Files.createTempDirectory("excel_import_").toFile(); File shadow = new File(tempDir, original.getName()); Files.copy(original.toPath(), shadow.toPath(), StandardCopyOption.REPLACE_EXISTING); return shadow; } // 使用后,在SwingWorker.done()中删除tempDir