优化GUI程序,加入线程池,避免翻页卡死现象发生。
This commit is contained in:
@@ -515,6 +515,10 @@ class DataManagementApp:
|
||||
"""窗口关闭事件"""
|
||||
result = messagebox.askyesno("确认", "确定要退出系统吗?")
|
||||
if result:
|
||||
# 清理图片加载器资源
|
||||
from utils.image_loader import cleanup_resources
|
||||
cleanup_resources()
|
||||
|
||||
# 关闭数据库连接
|
||||
self.mysql_manager.close()
|
||||
self.root.destroy()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
中间图片预览面板
|
||||
中间图片预览面板 - 优化版本
|
||||
主要优化:
|
||||
1. SQLite查询移到后台线程
|
||||
2. 使用线程池管理图片加载
|
||||
3. 添加取消机制,避免翻页时的线程冲突
|
||||
4. 正确清理图片对象,避免内存泄漏
|
||||
"""
|
||||
import customtkinter as ctk
|
||||
from tkinter import messagebox
|
||||
@@ -7,15 +12,17 @@ from typing import List, Dict, Callable, Optional
|
||||
import threading
|
||||
import sys
|
||||
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__))))
|
||||
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):
|
||||
"""图片卡片组件"""
|
||||
"""图片卡片组件 - 优化版本"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -36,6 +43,12 @@ class ImageCard(ctk.CTkFrame):
|
||||
# 勾选状态
|
||||
self.check_var = ctk.BooleanVar(value=False)
|
||||
|
||||
# 图片加载Future对象(用于取消)
|
||||
self.load_future: Optional[Future] = None
|
||||
|
||||
# 标记是否已销毁
|
||||
self._destroyed = False
|
||||
|
||||
self._create_widgets()
|
||||
self._load_thumbnail()
|
||||
|
||||
@@ -61,7 +74,7 @@ class ImageCard(ctk.CTkFrame):
|
||||
self.image_label.pack(pady=5, padx=5)
|
||||
self.image_label.bind("<Button-1>", lambda e: self.on_preview(self.data))
|
||||
|
||||
# 物品名称(加粗)
|
||||
# 物品名称(加粗)
|
||||
name_label = ctk.CTkLabel(
|
||||
self,
|
||||
text=self.data['goods_name'],
|
||||
@@ -111,24 +124,42 @@ class ImageCard(ctk.CTkFrame):
|
||||
preview_btn.pack(pady=(0, 5), padx=5)
|
||||
|
||||
def _load_thumbnail(self):
|
||||
"""异步加载缩略图"""
|
||||
"""使用线程池异步加载缩略图"""
|
||||
def load():
|
||||
# 检查是否已销毁
|
||||
if self._destroyed:
|
||||
return
|
||||
|
||||
image = load_image_from_url(
|
||||
self.data['image_url'],
|
||||
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:
|
||||
self.image_label.configure(image=image, text="")
|
||||
self.image_label.image = image
|
||||
self.image_label.image = image # 保持引用
|
||||
else:
|
||||
placeholder = create_placeholder_image(
|
||||
size=(THUMBNAIL_SIZE, THUMBNAIL_SIZE)
|
||||
)
|
||||
self.image_label.configure(image=placeholder, text="")
|
||||
self.image_label.image = placeholder
|
||||
|
||||
threading.Thread(target=load, daemon=True).start()
|
||||
except Exception as e:
|
||||
print(f"设置图片失败: {e}")
|
||||
|
||||
def _on_check(self):
|
||||
"""勾选框改变"""
|
||||
@@ -142,9 +173,23 @@ class ImageCard(ctk.CTkFrame):
|
||||
"""设置勾选状态"""
|
||||
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):
|
||||
"""中间图片预览面板"""
|
||||
"""中间图片预览面板 - 优化版本"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -168,6 +213,12 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
self.current_page = 1
|
||||
self.selected_ids = set()
|
||||
|
||||
# 下载状态缓存(避免重复查询SQLite)
|
||||
self.download_status_cache = {}
|
||||
|
||||
# 当前的ImageCard列表
|
||||
self.image_cards: List[ImageCard] = []
|
||||
|
||||
self._create_widgets()
|
||||
|
||||
def _create_widgets(self):
|
||||
@@ -192,7 +243,7 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
)
|
||||
self.count_label.pack(side="left", padx=20)
|
||||
|
||||
# 图片网格(可滚动)
|
||||
# 图片网格(可滚动)
|
||||
self.scroll_frame = ctk.CTkScrollableFrame(self)
|
||||
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.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_stats()
|
||||
self._update_pagination()
|
||||
@@ -304,8 +395,7 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
def _update_grid(self):
|
||||
"""更新图片网格"""
|
||||
# 清空现有卡片
|
||||
for widget in self.grid_frame.winfo_children():
|
||||
widget.destroy()
|
||||
self._clear_grid()
|
||||
|
||||
if not self.current_data:
|
||||
no_data_label = ctk.CTkLabel(
|
||||
@@ -322,11 +412,8 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
row = i // GRID_COLUMNS
|
||||
col = i % GRID_COLUMNS
|
||||
|
||||
# 检查是否已下载
|
||||
is_downloaded = self.sqlite_manager.is_downloaded(
|
||||
data['datasource'],
|
||||
data['image_url']
|
||||
)
|
||||
# 从缓存获取下载状态
|
||||
is_downloaded = self.download_status_cache.get(data['id'], False)
|
||||
|
||||
card = ImageCard(
|
||||
self.grid_frame,
|
||||
@@ -343,10 +430,26 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
sticky="nsew"
|
||||
)
|
||||
|
||||
# 保存引用
|
||||
self.image_cards.append(card)
|
||||
|
||||
# 配置网格列权重
|
||||
for col in range(GRID_COLUMNS):
|
||||
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):
|
||||
"""更新统计信息"""
|
||||
selected_count = len(self.selected_ids)
|
||||
@@ -378,18 +481,16 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
|
||||
def _select_all(self):
|
||||
"""全选"""
|
||||
for widget in self.grid_frame.winfo_children():
|
||||
if isinstance(widget, ImageCard):
|
||||
widget.set_checked(True)
|
||||
self.selected_ids.add(widget.data['id'])
|
||||
for card in self.image_cards:
|
||||
card.set_checked(True)
|
||||
self.selected_ids.add(card.data['id'])
|
||||
|
||||
self._update_stats()
|
||||
|
||||
def _deselect_all(self):
|
||||
"""取消全选"""
|
||||
for widget in self.grid_frame.winfo_children():
|
||||
if isinstance(widget, ImageCard):
|
||||
widget.set_checked(False)
|
||||
for card in self.image_cards:
|
||||
card.set_checked(False)
|
||||
|
||||
self.selected_ids.clear()
|
||||
self._update_stats()
|
||||
@@ -422,15 +523,15 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
if data['id'] in self.selected_ids
|
||||
]
|
||||
|
||||
# 二次确认(简化版,不显示删除范围选项)
|
||||
# 二次确认
|
||||
count = len(selected_data)
|
||||
confirm = messagebox.askyesno(
|
||||
"确认删除",
|
||||
f"确定要删除选中的 {count} 张图片吗?\n\n"
|
||||
f"确定要删除选中的 {count} 张图片吗?\n\n"
|
||||
"此操作将:\n"
|
||||
"1. 从MySQL数据库中删除记录\n"
|
||||
"2. 删除已下载的本地文件(如有)\n\n"
|
||||
"删除后无法恢复!",
|
||||
"2. 删除已下载的本地文件(如有)\n\n"
|
||||
"删除后无法恢复!",
|
||||
icon='warning'
|
||||
)
|
||||
|
||||
@@ -471,7 +572,7 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
print(f"删除记录失败 (ID: {data['id']}): {e}")
|
||||
failed_ids.append(data['id'])
|
||||
|
||||
# 更新UI(在主线程)
|
||||
# 更新UI(在主线程)
|
||||
self.after(0, lambda: self._on_delete_complete(success_count, len(failed_ids)))
|
||||
|
||||
except Exception as e:
|
||||
@@ -494,7 +595,7 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
# 清空选中
|
||||
self.selected_ids.clear()
|
||||
|
||||
# 调用删除回调,刷新数据
|
||||
# 调用删除回调,刷新数据
|
||||
if self.on_delete_callback:
|
||||
self.on_delete_callback()
|
||||
|
||||
@@ -559,20 +660,20 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
def _first_page(self):
|
||||
"""首页"""
|
||||
if self.current_page > 1:
|
||||
# 需要重新查询数据(在主应用中处理)
|
||||
# 需要重新查询数据(在主应用中处理)
|
||||
pass
|
||||
|
||||
def _prev_page(self):
|
||||
"""上一页"""
|
||||
if self.current_page > 1:
|
||||
# 需要重新查询数据(在主应用中处理)
|
||||
# 需要重新查询数据(在主应用中处理)
|
||||
pass
|
||||
|
||||
def _next_page(self):
|
||||
"""下一页"""
|
||||
total_pages = (self.total_count + ITEMS_PER_PAGE - 1) // ITEMS_PER_PAGE
|
||||
if self.current_page < total_pages:
|
||||
# 需要重新查询数据(在主应用中处理)
|
||||
# 需要重新查询数据(在主应用中处理)
|
||||
pass
|
||||
|
||||
def get_current_page(self) -> int:
|
||||
@@ -585,6 +686,7 @@ class PreviewPanel(ctk.CTkFrame):
|
||||
self.total_count = 0
|
||||
self.current_page = 1
|
||||
self.selected_ids.clear()
|
||||
self._update_grid()
|
||||
self.download_status_cache.clear()
|
||||
self._clear_grid()
|
||||
self._update_stats()
|
||||
self._update_pagination()
|
||||
|
||||
@@ -1,40 +1,92 @@
|
||||
"""
|
||||
图片加载工具
|
||||
用于从URL加载图片并转换为PIL/CTkImage格式
|
||||
优化版本:使用连接池、线程池和缓存
|
||||
"""
|
||||
import io
|
||||
import requests
|
||||
from PIL import Image
|
||||
from typing import Optional
|
||||
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(
|
||||
url: str,
|
||||
size: tuple = (150, 150),
|
||||
timeout: int = 10
|
||||
timeout: int = 5 # 降低超时时间,避免长时间阻塞
|
||||
) -> Optional[ctk.CTkImage]:
|
||||
"""
|
||||
从URL加载图片
|
||||
从URL加载图片(使用连接池)
|
||||
|
||||
Args:
|
||||
url: 图片URL
|
||||
size: 目标尺寸 (width, height)
|
||||
timeout: 超时时间(秒)
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
CTkImage对象,失败返回None
|
||||
CTkImage对象,失败返回None
|
||||
"""
|
||||
try:
|
||||
# 使用全局session(连接复用)
|
||||
session = get_session()
|
||||
|
||||
# 下载图片
|
||||
response = requests.get(url, timeout=timeout)
|
||||
response = session.get(url, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
# 转换为PIL Image
|
||||
image_data = io.BytesIO(response.content)
|
||||
pil_image = Image.open(image_data)
|
||||
|
||||
# 转换为RGB(防止RGBA等格式问题)
|
||||
# 转换为RGB(防止RGBA等格式问题)
|
||||
if pil_image.mode != 'RGB':
|
||||
pil_image = pil_image.convert('RGB')
|
||||
|
||||
@@ -47,17 +99,24 @@ def load_image_from_url(
|
||||
|
||||
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:
|
||||
print(f"加载图片失败 {url}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=100)
|
||||
def create_placeholder_image(
|
||||
size: tuple = (150, 150),
|
||||
text: str = "加载失败"
|
||||
) -> ctk.CTkImage:
|
||||
"""
|
||||
创建占位图片
|
||||
创建占位图片(带缓存)
|
||||
|
||||
Args:
|
||||
size: 图片尺寸
|
||||
@@ -76,3 +135,18 @@ def create_placeholder_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
|
||||
|
||||
Reference in New Issue
Block a user