引言:为什么选择Tkinter开发个人记账本
Python Tkinter是Python标准库中内置的GUI开发工具,它不需要额外安装,非常适合初学者快速构建桌面应用程序。个人记账本是一个非常实用的项目,它涵盖了GUI编程的核心概念:界面布局、事件处理、数据存储、文件操作等。通过这个项目,你不仅能掌握Tkinter的基本用法,还能学习如何解决实际开发中常见的性能问题和数据安全问题。
在本教程中,我们将从零开始,逐步构建一个功能齐全的个人记账本应用。这个应用将具备以下功能:
- 收入和支出记录的添加、删除和修改
- 数据持久化存储(使用SQLite数据库)
- 数据统计和可视化(图表展示)
- 界面优化,避免卡顿
- 数据备份和恢复机制,防止数据丢失
让我们开始吧!
第一部分:环境准备和项目结构设计
1.1 环境准备
确保你的电脑上安装了Python 3.x版本。Tkinter通常随Python一起安装,你可以通过以下命令检查是否已安装:
import tkinter
print(tkinter.TkVersion)
如果未安装,可以通过包管理器安装(例如在Ubuntu上:sudo apt-get install python3-tk)。
1.2 项目结构设计
为了保持代码的清晰和可维护性,我们将项目分为以下几个模块:
main.py:主程序入口,负责创建主窗口和启动应用ui.py:包含所有GUI界面的代码,如主窗口、按钮、输入框等database.py:负责数据库操作,包括数据的增删改查utils.py:工具函数,如数据验证、备份恢复等config.py:配置文件,定义常量如数据库路径、备份路径等
这种模块化设计有助于分离关注点,便于后期维护和扩展。
第二部分:构建基础GUI界面
2.1 创建主窗口
首先,我们创建一个主窗口,并设置基本的标题和大小。在main.py中编写以下代码:
import tkinter as tk
from ui import MainWindow
def main():
root = tk.Tk()
root.title("个人记账本")
root.geometry("800x600") # 设置窗口大小
app = MainWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
2.2 设计主界面布局
在ui.py中,我们定义MainWindow类。主界面采用经典的布局:顶部是菜单栏,左侧是导航栏,右侧是主要内容区域(使用Frame切换)。我们使用grid布局管理器来精确控制组件位置。
import tkinter as tk
from tkinter import ttk, messagebox
import database as db
from utils import validate_amount, backup_data, restore_data
class MainWindow:
def __init__(self, master):
self.master = master
self.create_menu()
self.create_sidebar()
self.create_main_area()
self.current_frame = None
self.show_home() # 默认显示首页
def create_menu(self):
menu_bar = tk.Menu(self.master)
# 文件菜单
file_menu = tk.Menu(menu_bar, tearoff=0)
file_menu.add_command(label="备份数据", command=self.backup_data)
file_menu.add_command(label="恢复数据", command=self.restore_data)
file_menu.add_separator()
file_menu.add_command(label="退出", command=self.master.quit)
menu_bar.add_cascade(label="文件", menu=file_menu)
self.master.config(menu=menu_bar)
def create_sidebar(self):
sidebar = tk.Frame(self.master, width=150, bg="lightgray")
sidebar.grid(row=1, column=0, sticky="nswe", padx=5, pady=5)
btn_home = tk.Button(sidebar, text="首页", command=self.show_home, width=15)
btn_home.grid(row=0, column=0, pady=5)
btn_add = tk.Button(sidebar, text="添加记录", command=self.show_add, width=15)
btn_add.grid(row=1, column=0, pady=5)
btn_view = tk.Button(sidebar, text="查看记录", command=self.show_view, width=15)
btn_view.grid(row=2, column=0, pady=5)
btn_stats = tk.Button(sidebar, text="统计图表", command=self.show_stats, width=15)
btn_stats.grid(row=3, column=0, pady=5)
def create_main_area(self):
self.main_area = tk.Frame(self.master)
self.main_area.grid(row=1, column=1, sticky="nswe", padx=5, pady=5)
# 配置列权重,使主区域可伸缩
self.master.columnconfigure(1, weight=1)
self.master.rowconfigure(1, weight=1)
def show_home(self):
self.clear_main_area()
self.current_frame = HomeFrame(self.main_area)
def show_add(self):
self.clear_main_area()
self.current_frame = AddRecordFrame(self.main_area)
def show_view(self):
self.clear_main_area()
self.current_frame = ViewRecordsFrame(self.main_area)
def show_stats(self):
self.clear_main_area()
self.current_frame = StatsFrame(self.main_area)
def clear_main_area(self):
for widget in self.main_area.winfo_children():
widget.destroy()
def backup_data(self):
if backup_data():
messagebox.showinfo("成功", "数据备份成功!")
else:
messagebox.showerror("错误", "数据备份失败!")
def restore_data(self):
if restore_data():
messagebox.showinfo("成功", "数据恢复成功!")
# 刷新当前视图
if self.current_frame:
self.current_frame.refresh()
else:
messagebox.showerror("错误", "数据恢复失败!")
2.3 首页界面
首页显示一些基本统计信息,如总收入、总支出和余额。在ui.py中添加HomeFrame类:
class HomeFrame(tk.Frame):
def __init__(self, master):
super().__init__(master)
self.pack(fill="both", expand=True)
self.create_widgets()
def create_widgets(self):
# 获取统计数据
total_income = db.get_total("income")
total_expense = db.get_total("expense")
balance = total_income - total_expense
# 显示标签
tk.Label(self, text="欢迎使用个人记账本", font=("Arial", 16, "bold")).pack(pady=10)
stats_frame = tk.Frame(self)
stats_frame.pack(pady=20)
tk.Label(stats_frame, text=f"总收入: {total_income:.2f}", font=("Arial", 12), fg="green").grid(row=0, column=0, padx=10, pady=5)
tk.Label(stats_frame, text=f"总支出: {total_expense:.2f}", font=("Arial", 12), fg="red").grid(row=0, column=1, padx=10, pady=5)
tk.Label(stats_frame, text=f"当前余额: {balance:.2f}", font=("Arial", 12), fg="blue").grid(row=0, column=2, padx=10, pady=5)
def refresh(self):
# 清空并重新创建组件以更新数据
for widget in self.winfo_children():
widget.destroy()
self.create_widgets()
2.4 添加记录界面
添加记录界面包括输入框用于输入日期、金额、类型(收入/支出)和备注。我们添加验证确保金额为数字。在ui.py中添加AddRecordFrame类:
class AddRecordFrame(tk.Frame):
def __init__(self, master):
super().__init__(master)
self.pack(fill="both", expand=True)
self.create_widgets()
def create_widgets(self):
tk.Label(self, text="添加新记录", font=("Arial", 14, "bold")).pack(pady=10)
form_frame = tk.Frame(self)
form_frame.pack(pady=10)
# 日期输入
tk.Label(form_frame, text="日期 (YYYY-MM-DD):").grid(row=0, column=0, padx=5, pady=5, sticky="e")
self.date_entry = tk.Entry(form_frame, width=20)
self.date_entry.grid(row=0, column=1, padx=5, pady=5)
# 默认当前日期
from datetime import datetime
self.date_entry.insert(0, datetime.now().strftime("%Y-%m-%d"))
# 类型选择
tk.Label(form_frame, text="类型:").grid(row=1, column=0, padx=5, pady=5, sticky="e")
self.type_var = tk.StringVar(value="expense")
tk.Radiobutton(form_frame, text="收入", variable=self.type_var, value="income").grid(row=1, column=1, padx=5, pady=5, sticky="w")
tk.Radiobutton(form_frame, text="支出", variable=self.type_var, value="expense").grid(row=1, column=2, padx=5, pady=5, sticky="w")
# 金额输入
tk.Label(form_frame, text="金额:").grid(row=2, column=0, padx=5, pady=5, sticky="e")
self.amount_entry = tk.Entry(form_frame, width=20)
self.amount_entry.grid(row=2, column=1, padx=5, pady=5)
# 备注输入
tk.Label(form_frame, text="备注:").grid(row=3, column=0, padx=5, pady=5, sticky="e")
self.note_entry = tk.Entry(form_frame, width=20)
self.note_entry.grid(row=3, column=1, padx=5, pady=5)
# 按钮
btn_frame = tk.Frame(self)
btn_frame.pack(pady=10)
tk.Button(btn_frame, text="保存", command=self.save_record, width=10).pack(side="left", padx=5)
tk.Button(btn_frame, text="清空", command=self.clear_fields, width=10).pack(side="left", padx=5)
def save_record(self):
date = self.date_entry.get()
record_type = self.type_var.get()
amount_str = self.amount_entry.get()
note = self.note_entry.get()
# 验证输入
if not date or not amount_str:
messagebox.showerror("错误", "日期和金额不能为空!")
return
if not validate_amount(amount_str):
messagebox.showerror("错误", "金额必须为正数!")
return
amount = float(amount_str)
# 保存到数据库
try:
db.add_record(date, record_type, amount, note)
messagebox.showinfo("成功", "记录保存成功!")
self.clear_fields()
except Exception as e:
messagebox.showerror("错误", f"保存失败: {e}")
def clear_fields(self):
self.date_entry.delete(0, tk.END)
self.date_entry.insert(0, datetime.now().strftime("%Y-%m-%d"))
self.amount_entry.delete(0, tk.END)
self.note_entry.delete(0, tk.END)
self.type_var.set("expense")
def refresh(self):
pass # 此页面不需要刷新
2.5 查看记录界面
查看记录界面使用Treeview显示所有记录,支持分页和搜索。在ui.py中添加ViewRecordsFrame类:
class ViewRecordsFrame(tk.Frame):
def __init__(self, master):
super().__init__(master)
self.pack(fill="both", expand=True)
self.page = 1
self.page_size = 10
self.search_query = ""
self.create_widgets()
def create_widgets(self):
# 搜索框
search_frame = tk.Frame(self)
search_frame.pack(fill="x", pady=5)
tk.Label(search_frame, text="搜索备注:").pack(side="left", padx=5)
self.search_entry = tk.Entry(search_frame, width=20)
self.search_entry.pack(side="left", padx=5)
tk.Button(search_frame, text="搜索", command=self.search_records).pack(side="left", padx=5)
tk.Button(search_frame, text="刷新", command=self.refresh).pack(side="left", padx=5)
# Treeview显示记录
columns = ("ID", "日期", "类型", "金额", "备注")
self.tree = ttk.Treeview(self, columns=columns, show="headings")
for col in columns:
self.tree.heading(col, text=col)
self.tree.column(col, width=100)
self.tree.pack(fill="both", expand=True, pady=5)
# 分页控件
page_frame = tk.Frame(self)
page_frame.pack(fill="x", pady=5)
tk.Button(page_frame, text="上一页", command=self.prev_page).pack(side="left", padx=5)
self.page_label = tk.Label(page_frame, text="第 1 页")
self.page_label.pack(side="left", padx=5)
tk.Button(page_frame, text="下一页", command=self.next_page).pack(side="left", padx=5)
# 操作按钮
btn_frame = tk.Frame(self)
btn_frame.pack(pady=5)
tk.Button(btn_frame, text="删除选中", command=self.delete_selected, fg="red").pack(side="left", padx=5)
tk.Button(btn_frame, text="编辑选中", command=self.edit_selected).pack(side="left", padx=5)
self.load_data()
def load_data(self):
# 清空Treeview
for item in self.tree.get_children():
self.tree.delete(item)
# 从数据库获取数据,支持分页和搜索
offset = (self.page - 1) * self.page_size
records = db.get_records(limit=self.page_size, offset=offset, search=self.search_query)
for record in records:
self.tree.insert("", "end", values=(record[0], record[1], record[2], record[3], record[4]))
# 更新页码标签
total_records = db.get_total_records(self.search_query)
total_pages = (total_records + self.page_size - 1) // self.page_size
self.page_label.config(text=f"第 {self.page} / {total_pages} 页")
def search_records(self):
self.search_query = self.search_entry.get().strip()
self.page = 1
self.load_data()
def prev_page(self):
if self.page > 1:
self.page -= 1
self.load_data()
def next_page(self):
total_records = db.get_total_records(self.search_query)
total_pages = (total_records + self.page_size - 1) // self.page_size
if self.page < total_pages:
self.page += 1
self.load_data()
def delete_selected(self):
selected = self.tree.selection()
if not selected:
messagebox.showwarning("警告", "请先选择一条记录!")
return
if messagebox.askyesno("确认", "确定要删除选中的记录吗?"):
record_id = self.tree.item(selected[0])['values'][0]
db.delete_record(record_id)
self.refresh()
def edit_selected(self):
selected = self.tree.selection()
if not selected:
messagebox.showwarning("警告", "请先选择一条记录!")
return
record_id = self.tree.item(selected[0])['values'][0]
# 这里可以弹出一个编辑对话框,为简化,我们直接调用添加界面并预填充数据
# 实际项目中应创建单独的编辑界面
messagebox.showinfo("提示", "编辑功能可通过类似添加界面的对话框实现。")
def refresh(self):
self.load_data()
2.6 统计图表界面
统计界面使用matplotlib绘制简单的柱状图。首先,确保安装matplotlib:pip install matplotlib。在ui.py中添加StatsFrame类:
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
class StatsFrame(tk.Frame):
def __init__(self, master):
super().__init__(master)
self.pack(fill="both", expand=True)
self.create_widgets()
def create_widgets(self):
tk.Label(self, text="收支统计图", font=("Arial", 14, "bold")).pack(pady=10)
# 获取数据
data = db.get_stats_data() # 返回按日期分组的收入和支出
if not data:
tk.Label(self, text="暂无数据").pack(pady=20)
return
dates = [item[0] for item in data]
incomes = [item[1] for item in data]
expenses = [item[2] for item in data]
# 创建matplotlib图形
fig, ax = plt.subplots(figsize=(8, 4))
x = range(len(dates))
width = 0.35
ax.bar([i - width/2 for i in x], incomes, width, label='收入', color='green')
ax.bar([i + width/2 for i in x], expenses, width, label='支出', color='red')
ax.set_xlabel('日期')
ax.set_ylabel('金额')
ax.set_title('每日收支统计')
ax.set_xticks(x)
ax.set_xticklabels(dates, rotation=45)
ax.legend()
# 嵌入到Tkinter
canvas = FigureCanvasTkAgg(fig, master=self)
canvas.draw()
canvas.get_tk_widget().pack(fill="both", expand=True, pady=10)
def refresh(self):
for widget in self.winfo_children():
widget.destroy()
self.create_widgets()
第三部分:数据库操作(数据持久化)
3.1 数据库设计
我们使用SQLite数据库来存储记录。数据库表结构如下:
- 表名:records
- 字段:id (INTEGER PRIMARY KEY), date (TEXT), type (TEXT), amount (REAL), note (TEXT)
在database.py中编写代码:
import sqlite3
import os
from config import DB_PATH
def init_db():
"""初始化数据库,如果不存在则创建"""
if not os.path.exists(os.path.dirname(DB_PATH)):
os.makedirs(os.path.dirname(DB_PATH))
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
type TEXT NOT NULL,
amount REAL NOT NULL,
note TEXT
)
''')
conn.commit()
conn.close()
def add_record(date, record_type, amount, note):
"""添加记录"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO records (date, type, amount, note) VALUES (?, ?, ?, ?)
''', (date, record_type, amount, note))
conn.commit()
conn.close()
def get_records(limit=10, offset=0, search=""):
"""获取记录列表,支持分页和搜索"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
query = "SELECT * FROM records"
params = []
if search:
query += " WHERE note LIKE ?"
params.append(f"%{search}%")
query += " ORDER BY date DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
cursor.execute(query, params)
records = cursor.fetchall()
conn.close()
return records
def get_total(record_type=None):
"""获取总收入或总支出"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
if record_type:
cursor.execute("SELECT SUM(amount) FROM records WHERE type = ?", (record_type,))
else:
cursor.execute("SELECT SUM(amount) FROM records")
result = cursor.fetchone()[0] or 0.0
conn.close()
return result
def delete_record(record_id):
"""删除记录"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute("DELETE FROM records WHERE id = ?", (record_id,))
conn.commit()
conn.close()
def get_total_records(search=""):
"""获取总记录数"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
if search:
cursor.execute("SELECT COUNT(*) FROM records WHERE note LIKE ?", (f"%{search}%",))
else:
cursor.execute("SELECT COUNT(*) FROM records")
result = cursor.fetchone()[0]
conn.close()
return result
def get_stats_data():
"""获取统计数据,按日期分组"""
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
cursor.execute('''
SELECT date,
SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END) as income,
SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END) as expense
FROM records
GROUP BY date
ORDER BY date
LIMIT 30 -- 限制显示最近30天的数据
''')
data = cursor.fetchall()
conn.close()
return data
# 初始化数据库
init_db()
在config.py中定义数据库路径:
import os
DB_PATH = os.path.join(os.path.expanduser("~"), "accounting_book", "records.db")
BACKUP_PATH = os.path.join(os.path.expanduser("~"), "accounting_book", "backup")
第四部分:解决常见问题
4.1 解决界面卡顿问题
在GUI应用中,如果执行耗时操作(如大量数据查询或复杂计算),会导致界面卡顿。Tkinter是单线程的,主线程负责界面更新。解决方法是使用多线程或异步处理。
4.1.1 使用多线程优化数据加载
修改ViewRecordsFrame的load_data方法,使用线程在后台加载数据,完成后更新界面。引入threading模块。
在ui.py中导入threading和queue:
import threading
import queue
修改ViewRecordsFrame:
class ViewRecordsFrame(tk.Frame):
def __init__(self, master):
super().__init__(master)
self.pack(fill="both", expand=True)
self.page = 1
self.page_size = 10
self.search_query = ""
self.data_queue = queue.Queue() # 用于线程间通信
self.create_widgets()
self.check_queue() # 启动队列检查
def create_widgets(self):
# ...(同上,省略重复代码)...
# 添加加载指示器
self.loading_label = tk.Label(self, text="", fg="blue")
self.loading_label.pack(pady=2)
def load_data(self):
# 显示加载提示
self.loading_label.config(text="正在加载数据...")
# 启动线程加载数据
thread = threading.Thread(target=self._load_data_thread, daemon=True)
thread.start()
def _load_data_thread(self):
try:
offset = (self.page - 1) * self.page_size
records = db.get_records(limit=self.page_size, offset=offset, search=self.search_query)
total_records = db.get_total_records(self.search_query)
self.data_queue.put((records, total_records))
except Exception as e:
self.data_queue.put(e)
def check_queue(self):
try:
data = self.data_queue.get_nowait()
if isinstance(data, Exception):
messagebox.showerror("错误", f"加载数据失败: {data}")
else:
records, total_records = data
# 更新Treeview
for item in self.tree.get_children():
self.tree.delete(item)
for record in records:
self.tree.insert("", "end", values=(record[0], record[1], record[2], record[3], record[4]))
# 更新页码
total_pages = (total_records + self.page_size - 1) // self.page_size
self.page_label.config(text=f"第 {self.page} / {total_pages} 页")
self.loading_label.config(text="")
except queue.Empty:
pass
finally:
self.master.after(100, self.check_queue) # 每100ms检查一次队列
# 其他方法保持不变...
4.1.2 使用after方法避免阻塞
对于简单的操作,可以使用root.after()来分批处理。例如,在统计图表生成时,如果数据量大,可以分批计算。但在我们的例子中,数据量不大,多线程已足够。
4.2 解决数据丢失问题
数据丢失可能由于程序崩溃、误操作或硬件故障导致。我们通过以下方式解决:
- 自动备份:定期或在关键操作后备份数据库。
- 恢复机制:提供手动恢复备份的功能。
- 事务处理:确保数据库操作的原子性。
4.2.1 数据备份和恢复
在utils.py中实现备份和恢复功能:
import shutil
import os
from config import DB_PATH, BACKUP_PATH
def backup_data():
"""备份数据库到指定目录"""
try:
if not os.path.exists(BACKUP_PATH):
os.makedirs(BACKUP_PATH)
backup_file = os.path.join(BACKUP_PATH, f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db")
shutil.copy2(DB_PATH, backup_file)
# 保留最近5个备份
backups = sorted([f for f in os.listdir(BACKUP_PATH) if f.startswith("backup_")])
for old_backup in backups[:-5]:
os.remove(os.path.join(BACKUP_PATH, old_backup))
return True
except Exception as e:
print(f"备份失败: {e}")
return False
def restore_data():
"""从最新备份恢复数据"""
try:
if not os.path.exists(BACKUP_PATH):
return False
backups = [f for f in os.listdir(BACKUP_PATH) if f.startswith("backup_")]
if not backups:
return False
latest_backup = os.path.join(BACKUP_PATH, sorted(backups)[-1])
shutil.copy2(latest_backup, DB_PATH)
return True
except Exception as e:
print(f"恢复失败: {e}")
return False
在MainWindow的菜单中,我们已经添加了备份和恢复命令。此外,可以在添加记录后自动备份:
# 在AddRecordFrame的save_record方法中,保存成功后调用backup_data()
# 但为避免频繁备份,可以设置条件,如每10条记录备份一次,或使用定时器。
4.2.2 事务处理
在数据库操作中,使用事务确保一致性。例如,在批量删除时:
def delete_multiple_records(ids):
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
cursor.execute("BEGIN TRANSACTION")
for record_id in ids:
cursor.execute("DELETE FROM records WHERE id = ?", (record_id,))
conn.commit()
except:
conn.rollback()
raise
finally:
conn.close()
在我们的应用中,单条删除已足够,但扩展时可以考虑。
第五部分:完整代码整合和运行
5.1 完整代码结构
现在,我们将所有部分整合。确保你的项目目录如下:
accounting_book/
├── main.py
├── ui.py
├── database.py
├── utils.py
└── config.py
5.2 运行应用
在终端运行:
python main.py
应用将启动,你可以测试添加记录、查看、统计、备份和恢复功能。
5.3 测试和调试
- 测试添加记录:输入各种数据,检查验证是否生效。
- 测试界面卡顿:添加大量数据(例如1000条),然后快速切换页面,观察是否卡顿。如果卡顿,调整线程优先级或使用
concurrent.futures。 - 测试数据丢失:模拟崩溃(强制关闭程序),然后重启应用,检查数据是否还在(因为SQLite是事务性的)。然后测试备份恢复。
第六部分:扩展和优化建议
6.1 功能扩展
- 用户认证:添加登录界面,使用
tkinter的Toplevel窗口。 - 数据导出:将记录导出为CSV或Excel文件,使用
pandas库。 - 提醒功能:使用
schedule库实现定期提醒记账。
6.2 性能优化
- 数据库索引:在
date和type字段添加索引,提高查询速度:cursor.execute("CREATE INDEX IF NOT EXISTS idx_date ON records(date)") - 界面优化:使用
ttk主题使界面更美观,避免使用过多的嵌套Frame。 - 内存管理:在统计图表界面,关闭matplotlib图形以释放内存:
plt.close(fig)。
6.3 常见问题总结
- 界面卡顿:始终将耗时操作放入线程,使用队列通信。
- 数据丢失:定期备份,使用数据库事务,避免直接操作文件。
- 跨平台兼容:Tkinter在Windows、macOS和Linux上表现良好,但路径处理使用
os.path确保兼容。
通过本教程,你应该能独立完成一个功能齐全的个人记账本。如果遇到问题,可以参考Python官方文档或Tkinter教程。继续实践,你会更熟练地掌握GUI编程!
