优化GUI程序,加入线程池,避免翻页卡死现象发生。

This commit is contained in:
2025-12-03 14:52:37 +08:00
parent 40e90ac7c2
commit 5b31827740
3 changed files with 221 additions and 41 deletions
+4
View File
@@ -515,6 +515,10 @@ class DataManagementApp:
"""窗口关闭事件""" """窗口关闭事件"""
result = messagebox.askyesno("确认", "确定要退出系统吗?") result = messagebox.askyesno("确认", "确定要退出系统吗?")
if result: if result:
# 清理图片加载器资源
from utils.image_loader import cleanup_resources
cleanup_resources()
# 关闭数据库连接 # 关闭数据库连接
self.mysql_manager.close() self.mysql_manager.close()
self.root.destroy() self.root.destroy()
+136 -34
View File
@@ -1,5 +1,10 @@
""" """
中间图片预览面板 中间图片预览面板 - 优化版本
主要优化:
1. SQLite查询移到后台线程
2. 使用线程池管理图片加载
3. 添加取消机制,避免翻页时的线程冲突
4. 正确清理图片对象,避免内存泄漏
""" """
import customtkinter as ctk import customtkinter as ctk
from tkinter import messagebox from tkinter import messagebox
@@ -7,15 +12,17 @@ from typing import List, Dict, Callable, Optional
import threading import threading
import sys import sys
import os import os
from concurrent.futures import ThreadPoolExecutor, Future
from collections import defaultdict
# 添加父目录到路径 # 添加父目录到路径
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from config import GRID_COLUMNS, THUMBNAIL_SIZE, CARD_PADDING, ITEMS_PER_PAGE from config import GRID_COLUMNS, THUMBNAIL_SIZE, CARD_PADDING, ITEMS_PER_PAGE
from utils.image_loader import load_image_from_url, create_placeholder_image from utils.image_loader import load_image_from_url, create_placeholder_image, get_thread_pool
class ImageCard(ctk.CTkFrame): class ImageCard(ctk.CTkFrame):
"""图片卡片组件""" """图片卡片组件 - 优化版本"""
def __init__( def __init__(
self, self,
@@ -36,6 +43,12 @@ class ImageCard(ctk.CTkFrame):
# 勾选状态 # 勾选状态
self.check_var = ctk.BooleanVar(value=False) self.check_var = ctk.BooleanVar(value=False)
# 图片加载Future对象(用于取消)
self.load_future: Optional[Future] = None
# 标记是否已销毁
self._destroyed = False
self._create_widgets() self._create_widgets()
self._load_thumbnail() self._load_thumbnail()
@@ -61,7 +74,7 @@ class ImageCard(ctk.CTkFrame):
self.image_label.pack(pady=5, padx=5) self.image_label.pack(pady=5, padx=5)
self.image_label.bind("<Button-1>", lambda e: self.on_preview(self.data)) self.image_label.bind("<Button-1>", lambda e: self.on_preview(self.data))
# 物品名称加粗 # 物品名称(加粗)
name_label = ctk.CTkLabel( name_label = ctk.CTkLabel(
self, self,
text=self.data['goods_name'], text=self.data['goods_name'],
@@ -111,24 +124,42 @@ class ImageCard(ctk.CTkFrame):
preview_btn.pack(pady=(0, 5), padx=5) preview_btn.pack(pady=(0, 5), padx=5)
def _load_thumbnail(self): def _load_thumbnail(self):
"""异步加载缩略图""" """使用线程池异步加载缩略图"""
def load(): def load():
# 检查是否已销毁
if self._destroyed:
return
image = load_image_from_url( image = load_image_from_url(
self.data['image_url'], self.data['image_url'],
size=(THUMBNAIL_SIZE, THUMBNAIL_SIZE) size=(THUMBNAIL_SIZE, THUMBNAIL_SIZE)
) )
# 在主线程更新UI
if not self._destroyed:
self.after(0, lambda: self._set_image(image))
# 使用全局线程池
thread_pool = get_thread_pool()
self.load_future = thread_pool.submit(load)
def _set_image(self, image):
"""设置图片(在主线程调用)"""
if self._destroyed:
return
try:
if image: if image:
self.image_label.configure(image=image, text="") self.image_label.configure(image=image, text="")
self.image_label.image = image self.image_label.image = image # 保持引用
else: else:
placeholder = create_placeholder_image( placeholder = create_placeholder_image(
size=(THUMBNAIL_SIZE, THUMBNAIL_SIZE) size=(THUMBNAIL_SIZE, THUMBNAIL_SIZE)
) )
self.image_label.configure(image=placeholder, text="") self.image_label.configure(image=placeholder, text="")
self.image_label.image = placeholder self.image_label.image = placeholder
except Exception as e:
threading.Thread(target=load, daemon=True).start() print(f"设置图片失败: {e}")
def _on_check(self): def _on_check(self):
"""勾选框改变""" """勾选框改变"""
@@ -141,10 +172,24 @@ class ImageCard(ctk.CTkFrame):
def set_checked(self, checked: bool): def set_checked(self, checked: bool):
"""设置勾选状态""" """设置勾选状态"""
self.check_var.set(checked) self.check_var.set(checked)
def cancel_loading(self):
"""取消图片加载"""
if self.load_future and not self.load_future.done():
self.load_future.cancel()
def cleanup(self):
"""清理资源"""
self._destroyed = True
self.cancel_loading()
# 清理图片引用
if hasattr(self.image_label, 'image'):
self.image_label.image = None
class PreviewPanel(ctk.CTkFrame): class PreviewPanel(ctk.CTkFrame):
"""中间图片预览面板""" """中间图片预览面板 - 优化版本"""
def __init__( def __init__(
self, self,
@@ -168,6 +213,12 @@ class PreviewPanel(ctk.CTkFrame):
self.current_page = 1 self.current_page = 1
self.selected_ids = set() self.selected_ids = set()
# 下载状态缓存(避免重复查询SQLite)
self.download_status_cache = {}
# 当前的ImageCard列表
self.image_cards: List[ImageCard] = []
self._create_widgets() self._create_widgets()
def _create_widgets(self): def _create_widgets(self):
@@ -192,7 +243,7 @@ class PreviewPanel(ctk.CTkFrame):
) )
self.count_label.pack(side="left", padx=20) self.count_label.pack(side="left", padx=20)
# 图片网格可滚动 # 图片网格(可滚动)
self.scroll_frame = ctk.CTkScrollableFrame(self) self.scroll_frame = ctk.CTkScrollableFrame(self)
self.scroll_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10)) self.scroll_frame.pack(fill="both", expand=True, padx=15, pady=(0, 10))
@@ -297,6 +348,46 @@ class PreviewPanel(ctk.CTkFrame):
self.current_page = page self.current_page = page
self.selected_ids.clear() self.selected_ids.clear()
# 在后台线程查询下载状态
def query_download_status():
try:
# 批量查询下载状态
status_dict = {}
for data in data_list:
is_downloaded = self.sqlite_manager.is_downloaded(
data['datasource'],
data['image_url']
)
status_dict[data['id']] = is_downloaded
# 在主线程更新UI
self.after(0, lambda: self._update_grid_with_status(status_dict))
except Exception as e:
print(f"查询下载状态失败: {e}")
# 失败时使用空字典
self.after(0, lambda: self._update_grid_with_status({}))
# 先显示加载中,然后在后台查询
self._show_loading()
threading.Thread(target=query_download_status, daemon=True).start()
def _show_loading(self):
"""显示加载中状态"""
# 清空现有卡片
self._clear_grid()
loading_label = ctk.CTkLabel(
self.grid_frame,
text="加载中...",
font=("Arial", 14),
text_color="gray"
)
loading_label.grid(row=0, column=0, pady=50, padx=50)
def _update_grid_with_status(self, status_dict: Dict[int, bool]):
"""使用下载状态更新网格"""
self.download_status_cache = status_dict
self._update_grid() self._update_grid()
self._update_stats() self._update_stats()
self._update_pagination() self._update_pagination()
@@ -304,8 +395,7 @@ class PreviewPanel(ctk.CTkFrame):
def _update_grid(self): def _update_grid(self):
"""更新图片网格""" """更新图片网格"""
# 清空现有卡片 # 清空现有卡片
for widget in self.grid_frame.winfo_children(): self._clear_grid()
widget.destroy()
if not self.current_data: if not self.current_data:
no_data_label = ctk.CTkLabel( no_data_label = ctk.CTkLabel(
@@ -322,11 +412,8 @@ class PreviewPanel(ctk.CTkFrame):
row = i // GRID_COLUMNS row = i // GRID_COLUMNS
col = i % GRID_COLUMNS col = i % GRID_COLUMNS
# 检查是否已下载 # 从缓存获取下载状态
is_downloaded = self.sqlite_manager.is_downloaded( is_downloaded = self.download_status_cache.get(data['id'], False)
data['datasource'],
data['image_url']
)
card = ImageCard( card = ImageCard(
self.grid_frame, self.grid_frame,
@@ -342,11 +429,27 @@ class PreviewPanel(ctk.CTkFrame):
pady=CARD_PADDING, pady=CARD_PADDING,
sticky="nsew" sticky="nsew"
) )
# 保存引用
self.image_cards.append(card)
# 配置网格列权重 # 配置网格列权重
for col in range(GRID_COLUMNS): for col in range(GRID_COLUMNS):
self.grid_frame.grid_columnconfigure(col, weight=1) self.grid_frame.grid_columnconfigure(col, weight=1)
def _clear_grid(self):
"""清空网格(正确清理资源)"""
# 先取消所有图片加载
for card in self.image_cards:
card.cleanup()
# 清空列表
self.image_cards.clear()
# 销毁所有子组件
for widget in self.grid_frame.winfo_children():
widget.destroy()
def _update_stats(self): def _update_stats(self):
"""更新统计信息""" """更新统计信息"""
selected_count = len(self.selected_ids) selected_count = len(self.selected_ids)
@@ -378,18 +481,16 @@ class PreviewPanel(ctk.CTkFrame):
def _select_all(self): def _select_all(self):
"""全选""" """全选"""
for widget in self.grid_frame.winfo_children(): for card in self.image_cards:
if isinstance(widget, ImageCard): card.set_checked(True)
widget.set_checked(True) self.selected_ids.add(card.data['id'])
self.selected_ids.add(widget.data['id'])
self._update_stats() self._update_stats()
def _deselect_all(self): def _deselect_all(self):
"""取消全选""" """取消全选"""
for widget in self.grid_frame.winfo_children(): for card in self.image_cards:
if isinstance(widget, ImageCard): card.set_checked(False)
widget.set_checked(False)
self.selected_ids.clear() self.selected_ids.clear()
self._update_stats() self._update_stats()
@@ -422,15 +523,15 @@ class PreviewPanel(ctk.CTkFrame):
if data['id'] in self.selected_ids if data['id'] in self.selected_ids
] ]
# 二次确认(简化版,不显示删除范围选项) # 二次确认
count = len(selected_data) count = len(selected_data)
confirm = messagebox.askyesno( confirm = messagebox.askyesno(
"确认删除", "确认删除",
f"确定要删除选中的 {count} 张图片吗\n\n" f"确定要删除选中的 {count} 张图片吗?\n\n"
"此操作将:\n" "此操作将:\n"
"1. 从MySQL数据库中删除记录\n" "1. 从MySQL数据库中删除记录\n"
"2. 删除已下载的本地文件如有\n\n" "2. 删除已下载的本地文件(如有)\n\n"
"删除后无法恢复", "删除后无法恢复!",
icon='warning' icon='warning'
) )
@@ -471,7 +572,7 @@ class PreviewPanel(ctk.CTkFrame):
print(f"删除记录失败 (ID: {data['id']}): {e}") print(f"删除记录失败 (ID: {data['id']}): {e}")
failed_ids.append(data['id']) failed_ids.append(data['id'])
# 更新UI在主线程 # 更新UI(在主线程)
self.after(0, lambda: self._on_delete_complete(success_count, len(failed_ids))) self.after(0, lambda: self._on_delete_complete(success_count, len(failed_ids)))
except Exception as e: except Exception as e:
@@ -494,7 +595,7 @@ class PreviewPanel(ctk.CTkFrame):
# 清空选中 # 清空选中
self.selected_ids.clear() self.selected_ids.clear()
# 调用删除回调刷新数据 # 调用删除回调,刷新数据
if self.on_delete_callback: if self.on_delete_callback:
self.on_delete_callback() self.on_delete_callback()
@@ -559,20 +660,20 @@ class PreviewPanel(ctk.CTkFrame):
def _first_page(self): def _first_page(self):
"""首页""" """首页"""
if self.current_page > 1: if self.current_page > 1:
# 需要重新查询数据在主应用中处理 # 需要重新查询数据(在主应用中处理)
pass pass
def _prev_page(self): def _prev_page(self):
"""上一页""" """上一页"""
if self.current_page > 1: if self.current_page > 1:
# 需要重新查询数据在主应用中处理 # 需要重新查询数据(在主应用中处理)
pass pass
def _next_page(self): def _next_page(self):
"""下一页""" """下一页"""
total_pages = (self.total_count + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE total_pages = (self.total_count + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE
if self.current_page < total_pages: if self.current_page < total_pages:
# 需要重新查询数据在主应用中处理 # 需要重新查询数据(在主应用中处理)
pass pass
def get_current_page(self) -> int: def get_current_page(self) -> int:
@@ -585,6 +686,7 @@ class PreviewPanel(ctk.CTkFrame):
self.total_count = 0 self.total_count = 0
self.current_page = 1 self.current_page = 1
self.selected_ids.clear() self.selected_ids.clear()
self._update_grid() self.download_status_cache.clear()
self._clear_grid()
self._update_stats() self._update_stats()
self._update_pagination() self._update_pagination()
+81 -7
View File
@@ -1,40 +1,92 @@
""" """
图片加载工具 图片加载工具
用于从URL加载图片并转换为PIL/CTkImage格式 用于从URL加载图片并转换为PIL/CTkImage格式
优化版本:使用连接池、线程池和缓存
""" """
import io import io
import requests import requests
from PIL import Image from PIL import Image
from typing import Optional from typing import Optional
import customtkinter as ctk import customtkinter as ctk
from concurrent.futures import ThreadPoolExecutor
from functools import lru_cache
import threading
# ======================== 全局资源管理 ========================
# HTTP会话(连接池)
_session = None
_session_lock = threading.Lock()
def get_session() -> requests.Session:
"""获取全局HTTP会话(单例模式)"""
global _session
if _session is None:
with _session_lock:
if _session is None:
_session = requests.Session()
# 配置连接池
adapter = requests.adapters.HTTPAdapter(
pool_connections=10, # 连接池大小
pool_maxsize=20, # 最大连接数
max_retries=2, # 重试次数
pool_block=False
)
_session.mount('http://', adapter)
_session.mount('https://', adapter)
return _session
# 线程池(用于图片加载)
_thread_pool = None
_thread_pool_lock = threading.Lock()
def get_thread_pool() -> ThreadPoolExecutor:
"""获取全局线程池(单例模式)"""
global _thread_pool
if _thread_pool is None:
with _thread_pool_lock:
if _thread_pool is None:
# 限制最多同时加载10张图片
_thread_pool = ThreadPoolExecutor(
max_workers=10,
thread_name_prefix="ImageLoader"
)
return _thread_pool
# ======================== 图片加载函数 ========================
def load_image_from_url( def load_image_from_url(
url: str, url: str,
size: tuple = (150, 150), size: tuple = (150, 150),
timeout: int = 10 timeout: int = 5 # 降低超时时间,避免长时间阻塞
) -> Optional[ctk.CTkImage]: ) -> Optional[ctk.CTkImage]:
""" """
从URL加载图片 从URL加载图片(使用连接池)
Args: Args:
url: 图片URL url: 图片URL
size: 目标尺寸 (width, height) size: 目标尺寸 (width, height)
timeout: 超时时间(秒) timeout: 超时时间(秒)
Returns: Returns:
CTkImage对象失败返回None CTkImage对象,失败返回None
""" """
try: try:
# 使用全局session(连接复用)
session = get_session()
# 下载图片 # 下载图片
response = requests.get(url, timeout=timeout) response = session.get(url, timeout=timeout)
response.raise_for_status() response.raise_for_status()
# 转换为PIL Image # 转换为PIL Image
image_data = io.BytesIO(response.content) image_data = io.BytesIO(response.content)
pil_image = Image.open(image_data) pil_image = Image.open(image_data)
# 转换为RGB防止RGBA等格式问题 # 转换为RGB(防止RGBA等格式问题)
if pil_image.mode != 'RGB': if pil_image.mode != 'RGB':
pil_image = pil_image.convert('RGB') pil_image = pil_image.convert('RGB')
@@ -47,17 +99,24 @@ def load_image_from_url(
return ctk_image return ctk_image
except requests.exceptions.Timeout:
print(f"加载图片超时: {url}")
return None
except requests.exceptions.RequestException as e:
print(f"网络请求失败 {url}: {e}")
return None
except Exception as e: except Exception as e:
print(f"加载图片失败 {url}: {e}") print(f"加载图片失败 {url}: {e}")
return None return None
@lru_cache(maxsize=100)
def create_placeholder_image( def create_placeholder_image(
size: tuple = (150, 150), size: tuple = (150, 150),
text: str = "加载失败" text: str = "加载失败"
) -> ctk.CTkImage: ) -> ctk.CTkImage:
""" """
创建占位图片 创建占位图片(带缓存)
Args: Args:
size: 图片尺寸 size: 图片尺寸
@@ -76,3 +135,18 @@ def create_placeholder_image(
) )
return ctk_image return ctk_image
def cleanup_resources():
"""清理全局资源(程序退出时调用)"""
global _session, _thread_pool
# 关闭线程池
if _thread_pool is not None:
_thread_pool.shutdown(wait=False)
_thread_pool = None
# 关闭HTTP会话
if _session is not None:
_session.close()
_session = None