你是不是觉得,用 Java 写一个带界面的桌面应用,尤其是像记账本这种需要数据持久化的工具,是一件很复杂的事情?需要学 Spring Boot、搞前后端分离、部署数据库服务器?其实,对于个人开发者、学生,或者只是想快速做一个自用工具的人来说,有一个被严重低估的“黄金组合”:Java Swing + SQLite。
这个组合的强大之处在于,它让你用最纯粹的 Java 技术栈,就能构建出一个独立、轻量、无需安装、数据本地的完整桌面应用。Swing 负责提供用户交互的窗口、按钮和表格,而 SQLite 则作为一个嵌入式的数据库,以单个文件的形式存储所有数据,随应用分发,开箱即用。
本文将带你从零开始,手把手实现一个功能完整的可视化本地记账本。你将学到的不只是 Swing 和 SQLite 的 API 调用,更是如何将两者有机结合,构建一个具备增删改查(CRUD)、数据可视化图表和数据导出能力的实战项目。无论你是想巩固 Java 基础、完成课程设计,还是为自己制作一个私密的财务小助手,这篇文章都能提供一条清晰的路径。
我们会先快速搭建起项目骨架,然后逐个击破界面设计、数据库连接、业务逻辑和图表展示这些核心模块。过程中,你会遇到并解决一些典型问题,比如 Swing 的线程安全、SQLite 的并发写入、以及如何将数据库查询结果优雅地展示在界面上。最终,你将获得一个可独立运行的.jar文件,在任何装有 Java 环境的电脑上都能使用。
1. 为什么选择 Swing + SQLite 做桌面记账本?
在开始写代码之前,我们需要明确这个技术选型的优势与边界。这决定了它是否适合你的场景。
核心优势:极致的轻量与便捷
- 零外部依赖:用户只需要安装 JRE(Java 运行环境),你的应用就能运行。不需要额外安装数据库服务器(如 MySQL)、应用服务器(如 Tomcat)或复杂的运行时环境。
- 单文件数据库:SQLite 数据库就是一个
.db或.sqlite文件。你可以把它放在应用目录下,备份、迁移、分发都极其简单——直接复制文件即可。 - 开发简单直接:使用 JDBC 连接 SQLite 与连接其他数据库无异,Swing 的界面编写虽然繁琐但逻辑清晰。整个技术栈都在 Java 标准库或常见扩展包内,学习曲线平滑。
适合谁?
- Java 初学者:想通过一个综合项目将 GUI 编程、事件处理、数据库操作串联起来。
- 课程设计/毕业设计:需要交付一个功能完整、可演示、有数据库的桌面应用。
- 个人工具开发者:希望做一个自用的小工具,注重隐私(数据本地存储)、即开即用。
- 原型验证:快速验证一个桌面应用的创意,后期再考虑用 JavaFX 或 Electron 重构。
它的局限(也是特点)
- 界面“复古”:Swing 的界面风格是经典的跨平台样式,不如现代 Web 界面或 JavaFX 炫酷。
- 不适合高并发:SQLite 在写入时会对整个数据库文件加锁,因此不适合多用户同时高频写入的场景。但对于个人记账本,完全够用。
- 功能需手动实现:分页、高级过滤、复杂的图表交互等,都需要自己编码实现,不像一些现代框架有现成组件。
明确了这些,我们就可以抛开顾虑,专注于用这个经典组合解决实际问题。
2. 项目结构与核心技术栈
在动手之前,我们先规划好项目的目录结构,并引入必要的库。
2.1 项目目录结构
一个清晰的结构有助于管理代码。建议采用 Maven 或 Gradle 项目,这里以 Maven 为例:
personal-finance-ledger/ ├── pom.xml (Maven 配置文件) ├── src/ │ └── main/ │ ├── java/ │ │ └── com/ │ │ └── yourname/ │ │ └── ledger/ │ │ ├── Main.java (程序入口) │ │ ├── model/ │ │ │ ├── Transaction.java (交易实体类) │ │ │ └── Category.java (分类实体类) │ │ ├── dao/ │ │ │ ├── DatabaseConnection.java (数据库连接单例) │ │ │ ├── TransactionDao.java (交易数据访问对象) │ │ │ └── CategoryDao.java (分类数据访问对象) │ │ ├── ui/ │ │ │ ├── MainFrame.java (主窗口) │ │ │ ├── TransactionPanel.java (交易管理面板) │ │ │ ├── ChartPanel.java (图表展示面板) │ │ │ └── component/ │ │ │ └── TransactionTableModel.java (自定义表格模型) │ │ └── util/ │ │ └── DateUtil.java (日期工具类) │ └── resources/ │ ├── ledger.db (SQLite 数据库文件,首次运行生成) │ └── (可存放图标等资源) └── target/ (编译输出目录)2.2 核心技术栈与依赖
我们需要以下核心依赖:
- Java SE 8+:Swing 是 Java 标准库的一部分。
- SQLite JDBC Driver:用于连接和操作 SQLite 数据库。
- JFreeChart(可选但推荐):一个强大的开源图表库,用于生成饼图、柱状图等。
在 Maven 的pom.xml中添加依赖:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.yourname</groupId> <artifactId>personal-finance-ledger</artifactId> <version>1.0-SNAPSHOT</version> <properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> </properties> <dependencies> <!-- SQLite JDBC 驱动 --> <dependency> <groupId>org.xerial</groupId> <artifactId>sqlite-jdbc</artifactId> <version>3.42.0.0</version> <!-- 请使用最新稳定版 --> </dependency> <!-- JFreeChart 用于数据可视化 --> <dependency> <groupId>org.jfree</groupId> <artifactId>jfreechart</artifactId> <version>1.5.3</version> </dependency> </dependencies> </project>3. 数据库设计与初始化
数据是应用的核心。我们先设计简单的表结构。
3.1 实体类定义 (Model)
首先,定义两个核心的实体类,对应数据库中的表。
Transaction.java (交易记录)
package com.yourname.ledger.model; import java.math.BigDecimal; import java.time.LocalDateTime; public class Transaction { private Integer id; // 主键 private LocalDateTime time; // 交易时间 private BigDecimal amount; // 金额,使用BigDecimal避免精度问题 private String type; // 类型:INCOME(收入)/EXPENSE(支出) private Integer categoryId; // 分类ID,外键 private String description; // 描述 // 构造方法、Getter和Setter省略,需自行补充 // 推荐使用Lombok注解 @Data,但为清晰起见,这里展示完整结构 public Transaction() {} public Transaction(LocalDateTime time, BigDecimal amount, String type, Integer categoryId, String description) { this.time = time; this.amount = amount; this.type = type; this.categoryId = categoryId; this.description = description; } // ... Getter and Setter methods }Category.java (分类)
package com.yourname.ledger.model; public class Category { private Integer id; private String name; // 分类名称,如“餐饮”、“交通”、“工资” private String type; // 所属大类型:INCOME/EXPENSE // 构造方法、Getter和Setter省略 }3.2 数据库连接与初始化 (DAO)
我们使用单例模式管理数据库连接,确保全局只有一个连接(对于SQLite桌面应用,这通常是安全且高效的)。
DatabaseConnection.java
package com.yourname.ledger.dao; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.sql.Statement; public class DatabaseConnection { private static final String DB_URL = "jdbc:sqlite:ledger.db"; // 数据库文件位于项目根目录 private static Connection connection = null; // 私有构造方法 private DatabaseConnection() {} public static synchronized Connection getConnection() { if (connection == null) { try { // 加载驱动(SQLite JDBC驱动会自动注册) connection = DriverManager.getConnection(DB_URL); initializeDatabase(); // 首次连接时初始化表 System.out.println("数据库连接成功。"); } catch (SQLException e) { System.err.println("连接数据库失败: " + e.getMessage()); e.printStackTrace(); } } return connection; } private static void initializeDatabase() { String createCategoryTable = """ CREATE TABLE IF NOT EXISTS category ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ('INCOME', 'EXPENSE')) ); """; String createTransactionTable = """ CREATE TABLE IF NOT EXISTS transaction ( id INTEGER PRIMARY KEY AUTOINCREMENT, time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, amount DECIMAL(10, 2) NOT NULL, type TEXT NOT NULL CHECK (type IN ('INCOME', 'EXPENSE')), category_id INTEGER, description TEXT, FOREIGN KEY (category_id) REFERENCES category (id) ); """; // 插入一些默认分类 String insertDefaultCategories = """ INSERT OR IGNORE INTO category (name, type) VALUES ('工资', 'INCOME'), ('兼职', 'INCOME'), ('投资', 'INCOME'), ('餐饮', 'EXPENSE'), ('交通', 'EXPENSE'), ('购物', 'EXPENSE'), ('娱乐', 'EXPENSE'), ('住房', 'EXPENSE'); """; try (Statement stmt = connection.createStatement()) { stmt.execute(createCategoryTable); stmt.execute(createTransactionTable); stmt.execute(insertDefaultCategories); System.out.println("数据库表初始化完成。"); } catch (SQLException e) { System.err.println("初始化数据库失败: " + e.getMessage()); } } public static void closeConnection() { if (connection != null) { try { connection.close(); connection = null; } catch (SQLException e) { e.printStackTrace(); } } } }关键点解析:
jdbc:sqlite:ledger.db:连接字符串。如果ledger.db文件不存在,SQLite 会自动创建。CREATE TABLE IF NOT EXISTS:确保表不存在时才创建,避免重复初始化。CHECK约束:在数据库层保证type字段只能是INCOME或EXPENSE。FOREIGN KEY:建立了transaction表和category表的外键关系。INSERT OR IGNORE:插入默认分类,如果已存在则忽略,防止重复。
4. 数据访问层 (DAO) 实现
DAO 模式将数据库操作封装起来,业务逻辑层无需关心 SQL 细节。
TransactionDao.java
package com.yourname.ledger.dao; import com.yourname.ledger.model.Transaction; import java.math.BigDecimal; import java.sql.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; public class TransactionDao { public boolean addTransaction(Transaction transaction) { String sql = "INSERT INTO transaction(time, amount, type, category_id, description) VALUES(?, ?, ?, ?, ?)"; try (Connection conn = DatabaseConnection.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setTimestamp(1, Timestamp.valueOf(transaction.getTime())); pstmt.setBigDecimal(2, transaction.getAmount()); pstmt.setString(3, transaction.getType()); pstmt.setInt(4, transaction.getCategoryId()); pstmt.setString(5, transaction.getDescription()); return pstmt.executeUpdate() > 0; } catch (SQLException e) { e.printStackTrace(); return false; } } public List<Transaction> getAllTransactions() { List<Transaction> transactions = new ArrayList<>(); String sql = "SELECT t.*, c.name as category_name FROM transaction t LEFT JOIN category c ON t.category_id = c.id ORDER BY t.time DESC"; try (Connection conn = DatabaseConnection.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) { Transaction t = new Transaction(); t.setId(rs.getInt("id")); t.setTime(rs.getTimestamp("time").toLocalDateTime()); t.setAmount(rs.getBigDecimal("amount")); t.setType(rs.getString("type")); t.setCategoryId(rs.getInt("category_id")); t.setDescription(rs.getString("description")); // 可以额外设置分类名称,用于界面显示 // t.setCategoryName(rs.getString("category_name")); transactions.add(t); } } catch (SQLException e) { e.printStackTrace(); } return transactions; } // 实现 updateTransaction, deleteTransaction, queryByDateRange 等方法 public boolean deleteTransaction(int id) { String sql = "DELETE FROM transaction WHERE id = ?"; try (Connection conn = DatabaseConnection.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setInt(1, id); return pstmt.executeUpdate() > 0; } catch (SQLException e) { e.printStackTrace(); return false; } } // 统计某段时间内的总收入/总支出 public BigDecimal getSummary(String type, LocalDateTime start, LocalDateTime end) { String sql = "SELECT SUM(amount) as total FROM transaction WHERE type = ? AND time BETWEEN ? AND ?"; try (Connection conn = DatabaseConnection.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, type); pstmt.setTimestamp(2, Timestamp.valueOf(start)); pstmt.setTimestamp(3, Timestamp.valueOf(end)); ResultSet rs = pstmt.executeQuery(); if (rs.next()) { BigDecimal total = rs.getBigDecimal("total"); return total != null ? total : BigDecimal.ZERO; } } catch (SQLException e) { e.printStackTrace(); } return BigDecimal.ZERO; } }CategoryDao.java的实现类似,主要负责分类的增删改查,这里不再赘述。
5. 用户界面 (Swing) 设计与实现
这是最直观的部分。我们将创建一个主窗口,包含菜单栏、工具栏、一个用于显示交易列表的表格和一个用于显示图表的面板。
5.1 主窗口框架 (MainFrame.java)
package com.yourname.ledger.ui; import javax.swing.*; import java.awt.*; public class MainFrame extends JFrame { private TransactionPanel transactionPanel; private ChartPanel chartPanel; public MainFrame() { setTitle("个人记账本 - Java Swing + SQLite"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(1000, 700); setLocationRelativeTo(null); // 居中显示 initMenuBar(); initMainLayout(); loadData(); } private void initMenuBar() { JMenuBar menuBar = new JMenuBar(); JMenu fileMenu = new JMenu("文件"); JMenuItem exportItem = new JMenuItem("导出数据..."); JMenuItem exitItem = new JMenuItem("退出"); exitItem.addActionListener(e -> System.exit(0)); fileMenu.add(exportItem); fileMenu.addSeparator(); fileMenu.add(exitItem); menuBar.add(fileMenu); setJMenuBar(menuBar); } private void initMainLayout() { // 使用 BorderLayout 作为主布局 setLayout(new BorderLayout()); // 顶部工具栏(快速操作) JToolBar toolBar = new JToolBar(); JButton addButton = new JButton("新增记录"); JButton refreshButton = new JButton("刷新"); toolBar.add(addButton); toolBar.addSeparator(); toolBar.add(refreshButton); add(toolBar, BorderLayout.NORTH); // 中心区域:左侧交易列表,右侧图表 JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT); splitPane.setDividerLocation(600); // 初始分割位置 transactionPanel = new TransactionPanel(); chartPanel = new ChartPanel(); splitPane.setLeftComponent(transactionPanel); splitPane.setRightComponent(chartPanel); add(splitPane, BorderLayout.CENTER); // 底部状态栏 JPanel statusPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); statusPanel.add(new JLabel("就绪")); add(statusPanel, BorderLayout.SOUTH); // 按钮事件绑定 addButton.addActionListener(e -> transactionPanel.showAddTransactionDialog()); refreshButton.addActionListener(e -> refreshAllData()); } private void loadData() { // 启动时加载数据 refreshAllData(); } public void refreshAllData() { transactionPanel.refreshTableData(); chartPanel.refreshChart(); } public static void main(String[] args) { // 确保界面风格与系统一致 try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); } catch (Exception e) { e.printStackTrace(); } // 在事件分发线程中启动GUI SwingUtilities.invokeLater(() -> { MainFrame frame = new MainFrame(); frame.setVisible(true); }); } }5.2 交易管理面板 (TransactionPanel.java)
这个面板是核心,包含一个JTable来展示数据,以及相关的操作按钮。
package com.yourname.ledger.ui; import com.yourname.ledger.dao.TransactionDao; import com.yourname.ledger.model.Transaction; import com.yourname.ledger.ui.component.TransactionTableModel; import javax.swing.*; import javax.swing.table.TableRowSorter; import java.awt.*; import java.math.BigDecimal; import java.time.LocalDateTime; public class TransactionPanel extends JPanel { private JTable transactionTable; private TransactionTableModel tableModel; private TransactionDao transactionDao = new TransactionDao(); public TransactionPanel() { setLayout(new BorderLayout()); initTable(); initButtonPanel(); } private void initTable() { tableModel = new TransactionTableModel(); transactionTable = new JTable(tableModel); // 设置表格排序器 TableRowSorter<TransactionTableModel> sorter = new TableRowSorter<>(tableModel); transactionTable.setRowSorter(sorter); // 设置列宽 transactionTable.getColumnModel().getColumn(0).setPreferredWidth(50); // ID transactionTable.getColumnModel().getColumn(1).setPreferredWidth(150); // 时间 transactionTable.getColumnModel().getColumn(2).setPreferredWidth(80); // 类型 transactionTable.getColumnModel().getColumn(3).setPreferredWidth(100); // 金额 transactionTable.getColumnModel().getColumn(4).setPreferredWidth(100); // 分类 transactionTable.getColumnModel().getColumn(5).setPreferredWidth(200); // 描述 JScrollPane scrollPane = new JScrollPane(transactionTable); add(scrollPane, BorderLayout.CENTER); } private void initButtonPanel() { JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); JButton addButton = new JButton("新增"); JButton editButton = new JButton("编辑"); JButton deleteButton = new JButton("删除"); JButton filterButton = new JButton("筛选"); buttonPanel.add(addButton); buttonPanel.add(editButton); buttonPanel.add(deleteButton); buttonPanel.add(filterButton); add(buttonPanel, BorderLayout.SOUTH); // 事件监听 addButton.addActionListener(e -> showAddTransactionDialog()); deleteButton.addActionListener(e -> deleteSelectedTransaction()); // 编辑和筛选功能需自行实现 } public void showAddTransactionDialog() { // 使用 JDialog 或 JOptionPane 创建一个输入对话框 // 这里简化,使用 JOptionPane 组合多个输入字段 JTextField amountField = new JTextField(10); JComboBox<String> typeCombo = new JComboBox<>(new String[]{"EXPENSE", "INCOME"}); JComboBox<String> categoryCombo = new JComboBox<>(new String[]{"餐饮", "交通", "工资"}); // 应从数据库加载 JTextField descField = new JTextField(20); JPanel panel = new JPanel(new GridLayout(0, 2)); panel.add(new JLabel("金额:")); panel.add(amountField); panel.add(new JLabel("类型:")); panel.add(typeCombo); panel.add(new JLabel("分类:")); panel.add(categoryCombo); panel.add(new JLabel("描述:")); panel.add(descField); int result = JOptionPane.showConfirmDialog(this, panel, "新增交易记录", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); if (result == JOptionPane.OK_OPTION) { try { Transaction t = new Transaction(); t.setTime(LocalDateTime.now()); t.setAmount(new BigDecimal(amountField.getText().trim())); t.setType((String) typeCombo.getSelectedItem()); // 需要根据分类名称获取分类ID,这里简化处理 t.setCategoryId(1); t.setDescription(descField.getText().trim()); if (transactionDao.addTransaction(t)) { JOptionPane.showMessageDialog(this, "添加成功!"); refreshTableData(); } else { JOptionPane.showMessageDialog(this, "添加失败!", "错误", JOptionPane.ERROR_MESSAGE); } } catch (NumberFormatException ex) { JOptionPane.showMessageDialog(this, "金额格式错误!", "输入错误", JOptionPane.ERROR_MESSAGE); } } } private void deleteSelectedTransaction() { int selectedRow = transactionTable.getSelectedRow(); if (selectedRow == -1) { JOptionPane.showMessageDialog(this, "请先选择一条记录!", "提示", JOptionPane.WARNING_MESSAGE); return; } // 转换视图行索引为模型行索引(因为排序后视图索引会变) int modelRow = transactionTable.convertRowIndexToModel(selectedRow); int id = (int) tableModel.getValueAt(modelRow, 0); // 假设第一列是ID int confirm = JOptionPane.showConfirmDialog(this, "确定要删除这条记录吗?", "确认删除", JOptionPane.YES_NO_OPTION); if (confirm == JOptionPane.YES_OPTION) { if (transactionDao.deleteTransaction(id)) { JOptionPane.showMessageDialog(this, "删除成功!"); refreshTableData(); } } } public void refreshTableData() { tableModel.setTransactions(transactionDao.getAllTransactions()); tableModel.fireTableDataChanged(); // 通知表格数据已更新 } }5.3 自定义表格模型 (TransactionTableModel.java)
为了让JTable正确显示我们的Transaction对象列表,需要自定义一个TableModel。
package com.yourname.ledger.ui.component; import com.yourname.ledger.model.Transaction; import javax.swing.table.AbstractTableModel; import java.time.format.DateTimeFormatter; import java.util.List; public class TransactionTableModel extends AbstractTableModel { private List<Transaction> transactions; private final String[] columnNames = {"ID", "时间", "类型", "金额", "分类", "描述"}; private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); public void setTransactions(List<Transaction> transactions) { this.transactions = transactions; } @Override public int getRowCount() { return transactions == null ? 0 : transactions.size(); } @Override public int getColumnCount() { return columnNames.length; } @Override public String getColumnName(int column) { return columnNames[column]; } @Override public Object getValueAt(int rowIndex, int columnIndex) { Transaction t = transactions.get(rowIndex); switch (columnIndex) { case 0: return t.getId(); case 1: return t.getTime().format(formatter); case 2: return t.getType().equals("INCOME") ? "收入" : "支出"; // 本地化显示 case 3: return t.getAmount(); case 4: return "分类名称"; // 这里应通过categoryId查询得到,为简化先写死 case 5: return t.getDescription(); default: return null; } } @Override public Class<?> getColumnClass(int columnIndex) { // 帮助JTable正确渲染不同类型的数据(如数字右对齐) switch (columnIndex) { case 0: return Integer.class; case 3: return java.math.BigDecimal.class; default: return String.class; } } }6. 数据可视化图表实现 (ChartPanel.java)
我们将使用 JFreeChart 来生成支出分类的饼图和月度趋势柱状图。
package com.yourname.ledger.ui; import com.yourname.ledger.dao.TransactionDao; import org.jfree.chart.ChartFactory; import org.jfree.chart.ChartPanel; import org.jfree.chart.JFreeChart; import org.jfree.data.category.DefaultCategoryDataset; import org.jfree.data.general.DefaultPieDataset; import javax.swing.*; import java.awt.*; import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.YearMonth; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class ChartPanel extends JPanel { private TransactionDao transactionDao = new TransactionDao(); private JTabbedPane tabbedPane; public ChartPanel() { setLayout(new BorderLayout()); tabbedPane = new JTabbedPane(); add(tabbedPane, BorderLayout.CENTER); refreshChart(); } public void refreshChart() { tabbedPane.removeAll(); // 添加支出分类饼图 tabbedPane.addTab("支出分类", createPieChartPanel()); // 添加月度趋势图 tabbedPane.addTab("月度趋势", createBarChartPanel()); } private JPanel createPieChartPanel() { // 这里需要从数据库查询各分类的支出总额 // 假设我们有一个方法 getExpenseSummaryByCategory Map<String, BigDecimal> categoryExpense = getExpenseSummaryByCategory(); DefaultPieDataset dataset = new DefaultPieDataset(); categoryExpense.forEach((category, amount) -> { if (amount.compareTo(BigDecimal.ZERO) > 0) { dataset.setValue(category, amount); } }); JFreeChart chart = ChartFactory.createPieChart( "支出分类占比", // 图表标题 dataset, // 数据集 true, // 是否显示图例 true, // 是否生成工具提示 false // 是否生成URL链接 ); return new ChartPanel(chart); } private JPanel createBarChartPanel() { // 查询最近6个月的收入支出趋势 DefaultCategoryDataset dataset = new DefaultCategoryDataset(); LocalDateTime now = LocalDateTime.now(); for (int i = 5; i >= 0; i--) { YearMonth yearMonth = YearMonth.from(now.minusMonths(i)); LocalDateTime start = yearMonth.atDay(1).atStartOfDay(); LocalDateTime end = yearMonth.atEndOfMonth().atTime(23, 59, 59); BigDecimal income = transactionDao.getSummary("INCOME", start, end); BigDecimal expense = transactionDao.getSummary("EXPENSE", start, end); String month = yearMonth.toString(); // 格式如 "2024-03" dataset.addValue(income, "收入", month); dataset.addValue(expense, "支出", month); } JFreeChart chart = ChartFactory.createBarChart( "月度收支趋势", // 图表标题 "月份", // 横轴标签 "金额", // 纵轴标签 dataset // 数据集 ); return new ChartPanel(chart); } // 模拟方法:按分类统计支出 private Map<String, BigDecimal> getExpenseSummaryByCategory() { // 实际应执行SQL: SELECT c.name, SUM(t.amount) FROM transaction t JOIN category c ON t.category_id = c.id WHERE t.type='EXPENSE' GROUP BY c.name // 此处返回模拟数据 return Map.of( "餐饮", new BigDecimal("1500.50"), "交通", new BigDecimal("800.00"), "购物", new BigDecimal("2300.00"), "娱乐", new BigDecimal("500.00") ); } }注意:getExpenseSummaryByCategory方法需要你根据实际的数据库查询来实现。这里为了演示图表生成,使用了模拟数据。
7. 程序入口与运行
最后,我们创建一个简单的入口类来启动应用。
Main.java
package com.yourname.ledger; import com.yourname.ledger.ui.MainFrame; public class Main { public static void main(String[] args) { // 直接调用 MainFrame 的 main 方法,或在此初始化一些全局配置 MainFrame.main(args); } }运行步骤:
- 确保你的项目配置了正确的 Maven 依赖。
- 运行
Main.java或MainFrame.java中的main方法。 - 首次运行会在项目根目录生成
ledger.db数据库文件,并创建表结构及默认分类。 - 通过界面进行新增、删除、刷新等操作。
8. 常见问题与排查思路
在开发和使用过程中,你可能会遇到以下问题:
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
程序启动时报ClassNotFoundException或No suitable driver found | SQLite JDBC 驱动未正确引入 | 检查pom.xml依赖,运行mvn clean compile | 确保依赖已下载,IDE 的类路径配置正确。 |
| 无法新增或删除数据,但无错误提示 | SQL 执行失败但被try-catch静默处理 | 在catch块中打印e.printStackTrace() | 查看控制台输出,定位 SQL 语法错误或约束冲突。 |
| 界面添加记录后,表格不刷新 | TableModel的数据已更新,但未通知视图 | 检查是否调用了fireTableDataChanged()方法 | 在更新transactions列表后,务必调用tableModel.fireTableDataChanged()。 |
| 图表不显示或显示为空白 | JFreeChart 依赖缺失或数据集为空 | 检查 Maven 依赖;在getExpenseSummaryByCategory方法中打印查询结果 | 确保 JFreeChart 依赖已添加;检查数据库查询逻辑,确保返回有效数据。 |
| 在多处操作数据库后,出现“database is locked”错误 | SQLite 默认不支持多线程同时写入 | 检查是否在多个线程中同时持有Connection并执行写操作 | 对于桌面应用,建议使用单例连接,并且将数据库操作封装在同步方法中,或使用连接池(但SQLite轻量场景下单例通常足够)。 |
| 打包成 JAR 后运行,找不到数据库文件 | 数据库文件路径是相对的,在 JAR 中无法写入 | 检查DB_URL,使用绝对路径或用户目录路径 | 将DB_URL改为"jdbc:sqlite:" + System.getProperty("user.home") + "/.ledger/ledger.db",确保有写入权限。 |
9. 项目扩展与最佳实践
至此,一个基础的可视化记账本已经完成。但要让其更健壮、更实用,可以考虑以下扩展方向和实践建议:
数据验证与用户体验:
- 在新增/编辑对话框中对金额、日期进行严格校验。
- 使用
JTable的渲染器 (TableCellRenderer) 对“收入”和“支出”用不同颜色显示。 - 添加“双击编辑”功能。
更强大的查询与筛选:
- 实现按日期范围、分类、金额区间筛选交易记录。
- 在
TransactionDao中增加带参数查询的方法。
数据导入导出:
- 导出:将表格数据导出为 CSV 或 Excel 文件。可以使用 Apache Commons CSV 或 POI 库。
- 导入:提供模板,允许用户批量导入数据。
数据备份与恢复:
- 添加菜单项,允许用户将当前的
ledger.db文件备份到指定位置。 - 提供从备份文件恢复的功能。
- 添加菜单项,允许用户将当前的
生产环境注意事项:
- 连接管理:虽然我们用了单例,但在应用退出时 (
MainFrame的WindowListener中),应调用DatabaseConnection.closeConnection()关闭连接。 - 异常处理:不要仅仅
printStackTrace(),应向用户反馈友好的错误信息。 - 日志记录:引入
SLF4J+Logback记录操作日志和错误日志,便于排查问题。 - 打包分发:使用
Maven Shade Plugin或Launch4j将项目及其所有依赖打包成一个可执行的fat JAR或.exe文件,方便分发。
- 连接管理:虽然我们用了单例,但在应用退出时 (
安全性考虑:
- 这是一个本地单机应用,数据库文件明文存储。如果涉及非常敏感的数据,可以考虑对数据库文件进行加密(SQLite 支持加密扩展,如
SQLCipher),或对特定字段在应用层进行加密。
- 这是一个本地单机应用,数据库文件明文存储。如果涉及非常敏感的数据,可以考虑对数据库文件进行加密(SQLite 支持加密扩展,如
通过这个项目,你不仅实践了 Swing 和 SQLite,更掌握了一个完整桌面应用的开发流程:从需求分析、技术选型、数据库设计、分层架构到界面实现和打包部署。这个模式可以复用到许多其他类型的个人桌面工具开发中,如本地笔记、客户管理、小型库存系统等。