From 5b3182774065c541bc38bda435ad19744b8e7111 Mon Sep 17 00:00:00 2001 From: zhangpu <1250681871@qq.com> Date: Wed, 3 Dec 2025 14:52:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96GUI=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=EF=BC=8C=E5=8A=A0=E5=85=A5=E7=BA=BF=E7=A8=8B=E6=B1=A0=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E7=BF=BB=E9=A1=B5=E5=8D=A1=E6=AD=BB=E7=8E=B0?= =?UTF-8?q?=E8=B1=A1=E5=8F=91=E7=94=9F=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data_management/app.py | 4 + data_management/ui/preview_panel.py | 170 ++++++++++++++++++++------ data_management/utils/image_loader.py | 88 +++++++++++-- 3 files changed, 221 insertions(+), 41 deletions(-) diff --git a/data_management/app.py b/data_management/app.py index 42d7979..5db42d0 100644 --- a/data_management/app.py +++ b/data_management/app.py @@ -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() diff --git a/data_management/ui/preview_panel.py b/data_management/ui/preview_panel.py index 10e07df..fbd6ad2 100644 --- a/data_management/ui/preview_panel.py +++ b/data_management/ui/preview_panel.py @@ -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("", 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): """勾选框改变""" @@ -141,10 +172,24 @@ class ImageCard(ctk.CTkFrame): def set_checked(self, checked: bool): """设置勾选状态""" 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, @@ -342,11 +429,27 @@ class PreviewPanel(ctk.CTkFrame): pady=CARD_PADDING, 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() diff --git a/data_management/utils/image_loader.py b/data_management/utils/image_loader.py index fe40c6c..1504b0b 100644 --- a/data_management/utils/image_loader.py +++ b/data_management/utils/image_loader.py @@ -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