57047eb9 by 柴进

:zap:️ 历史记录列表改增量渲染 + Model/View 视口懒加载

阶段 1: 生成完成不再 refresh_history() 全量重建 360+ 个 widget,
改走 prepend_history_item() O(1) 增量插入.

阶段 2 Step 1: QListWidget -> QListView + HistoryListModel:
- Model 仅持有 list[str] timestamps + OrderedDict LRU 缓存
- 视口可见行才触发 data() 加载缩略图,与历史总条数解耦
- delete/clear 走 model 增量,不再触发全量刷新

修复 macOS 上长时间运行被 jetsam SIGKILL 的崩溃路径
(2026-04-24 单日 14 次闪退,无 Traceback / faulthandler 栈).
1 parent f848e929
...@@ -9,10 +9,10 @@ from PySide6.QtWidgets import ( ...@@ -9,10 +9,10 @@ from PySide6.QtWidgets import (
9 QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout, 9 QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout,
10 QLabel, QLineEdit, QPushButton, QCheckBox, QTextEdit, 10 QLabel, QLineEdit, QPushButton, QCheckBox, QTextEdit,
11 QComboBox, QScrollArea, QGroupBox, QFileDialog, QMessageBox, 11 QComboBox, QScrollArea, QGroupBox, QFileDialog, QMessageBox,
12 QListWidget, QListWidgetItem, QTabWidget, QSplitter, 12 QListWidget, QListWidgetItem, QListView, QTabWidget, QSplitter,
13 QMenu, QProgressBar, QInputDialog 13 QMenu, QProgressBar, QInputDialog
14 ) 14 )
15 from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QMimeData 15 from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QMimeData, QAbstractListModel, QModelIndex
16 from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices, QAction, QImage, QDragEnterEvent, QDropEvent, QDrag, QMouseEvent 16 from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices, QAction, QImage, QDragEnterEvent, QDropEvent, QDrag, QMouseEvent
17 from PySide6.QtCore import QUrl 17 from PySide6.QtCore import QUrl
18 18
...@@ -472,6 +472,169 @@ class HistoryItem: ...@@ -472,6 +472,169 @@ class HistoryItem:
472 ) 472 )
473 473
474 474
475 class HistoryListModel(QAbstractListModel):
476 """历史记录列表的 Qt Model(路线 A 阶段 2)。
477
478 数据本体只持有 list[str] 时间戳;渲染所需的 HistoryItem / 缩略图 QIcon 走
479 LRU 缓存按需懒加载。配合 QListView IconMode,Qt 只会对视口可见 + 邻近
480 几行调用 data(),从而实现"历史记录涨到 1000+ 也不会一次性建 N 个 widget"。
481
482 Step 1:data(DecorationRole) 内同步加载 thumbnail 并缓存为 QIcon。
483 Step 2 后会改为 QPixmapCache + QRunnable 异步加载。
484 """
485
486 _CACHE_LIMIT = 300
487
488 def __init__(self, history_manager, build_placeholder_icon, logger, parent=None):
489 super().__init__(parent)
490 self._history_manager = history_manager
491 self._build_placeholder_icon = build_placeholder_icon
492 self._logger = logger
493 self._timestamps: List[str] = []
494 # OrderedDict 维护 LRU:timestamp -> dict(item / icon / display_text / tooltip)
495 from collections import OrderedDict
496 self._cache: 'OrderedDict[str, Dict[str, Any]]' = OrderedDict()
497
498 # ---- Qt model interface -------------------------------------------------
499
500 def rowCount(self, parent=QModelIndex()) -> int:
501 if parent.isValid():
502 return 0
503 return len(self._timestamps)
504
505 def flags(self, index: QModelIndex):
506 if not index.isValid():
507 return Qt.NoItemFlags
508 return Qt.ItemIsEnabled | Qt.ItemIsSelectable
509
510 def data(self, index: QModelIndex, role: int = Qt.DisplayRole):
511 if not index.isValid():
512 return None
513 row = index.row()
514 if row < 0 or row >= len(self._timestamps):
515 return None
516 timestamp = self._timestamps[row]
517
518 if role == Qt.UserRole:
519 return timestamp
520
521 cached = self._get_or_build(timestamp)
522 if cached is None:
523 return None
524 if role == Qt.DisplayRole:
525 return cached.get('display_text', '')
526 if role == Qt.ToolTipRole:
527 return cached.get('tooltip', '')
528 if role == Qt.DecorationRole:
529 return cached.get('icon')
530 return None
531
532 # ---- 增量操作 -----------------------------------------------------------
533
534 def reset_timestamps(self, timestamps: List[str]):
535 """重置整个列表(用于刷新按钮 / 首次加载 / 清空)。"""
536 self.beginResetModel()
537 self._timestamps = list(timestamps)
538 self._cache.clear()
539 self.endResetModel()
540
541 def prepend_timestamp(self, timestamp: str):
542 """把新 timestamp 插到最前。"""
543 self.beginInsertRows(QModelIndex(), 0, 0)
544 self._timestamps.insert(0, timestamp)
545 self.endInsertRows()
546
547 def remove_timestamp(self, timestamp: str) -> bool:
548 """按 timestamp 删除一行。"""
549 try:
550 row = self._timestamps.index(timestamp)
551 except ValueError:
552 return False
553 self.beginRemoveRows(QModelIndex(), row, row)
554 del self._timestamps[row]
555 self._cache.pop(timestamp, None)
556 self.endRemoveRows()
557 return True
558
559 def row_of(self, timestamp: str) -> int:
560 try:
561 return self._timestamps.index(timestamp)
562 except ValueError:
563 return -1
564
565 def invalidate_cache(self, timestamp: Optional[str] = None):
566 """业务操作(路径修正、缩略图重建等)后让缓存失效。"""
567 if timestamp is None:
568 self._cache.clear()
569 else:
570 self._cache.pop(timestamp, None)
571
572 # ---- 内部 ---------------------------------------------------------------
573
574 def _get_or_build(self, timestamp: str) -> Optional[Dict[str, Any]]:
575 """按需构建并 LRU 缓存渲染数据。"""
576 cached = self._cache.get(timestamp)
577 if cached is not None:
578 self._cache.move_to_end(timestamp)
579 return cached
580 try:
581 item = self._history_manager.load_history_item_fast(timestamp)
582 except Exception as e:
583 self._logger.warning(f"[HistoryListModel] load 失败 {timestamp}: {e}")
584 item = None
585 if item is None:
586 placeholder = {
587 'item': None,
588 'icon': self._build_placeholder_icon("已删除"),
589 'display_text': f"{timestamp}\n(已删除)",
590 'tooltip': f"时间戳: {timestamp}\n记录已不存在",
591 }
592 self._put_cache(timestamp, placeholder)
593 return placeholder
594
595 prompt_preview = item.prompt[:20] + "..." if len(item.prompt) > 20 else item.prompt
596 display_text = f"{item.timestamp}\n{prompt_preview}"
597 tooltip = (
598 f"时间: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
599 f"提示词: {item.prompt}\n"
600 f"宽高比: {item.aspect_ratio}\n"
601 f"尺寸: {item.image_size}"
602 )
603
604 icon = self._load_thumbnail_icon(item)
605 rendered = {
606 'item': item,
607 'icon': icon,
608 'display_text': display_text,
609 'tooltip': tooltip,
610 }
611 self._put_cache(timestamp, rendered)
612 return rendered
613
614 def _load_thumbnail_icon(self, item: HistoryItem) -> QIcon:
615 """同步加载缩略图为 QIcon(Step 2 改异步)。"""
616 if not item.generated_image_path.exists():
617 return self._build_placeholder_icon("图片\n不存在")
618 try:
619 thumb_path = self._history_manager.get_or_create_thumbnail(item.generated_image_path)
620 if thumb_path is None:
621 return self._build_placeholder_icon("缩略图\n失败")
622 pixmap = QPixmap(str(thumb_path))
623 if pixmap.isNull():
624 return self._build_placeholder_icon("图片\n加载失败")
625 scaled = pixmap.scaled(120, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation)
626 return QIcon(scaled)
627 except Exception as e:
628 self._logger.error(f"[HistoryListModel] 缩略图异常 {item.timestamp}: {e}")
629 return self._build_placeholder_icon("图片\n错误")
630
631 def _put_cache(self, timestamp: str, value: Dict[str, Any]):
632 self._cache[timestamp] = value
633 self._cache.move_to_end(timestamp)
634 while len(self._cache) > self._CACHE_LIMIT:
635 self._cache.popitem(last=False)
636
637
475 def _migrate_data_from_app_bundle(target_path: Path): 638 def _migrate_data_from_app_bundle(target_path: Path):
476 """将 .app 内部的旧数据迁移到外部目录(仅 macOS 打包环境)""" 639 """将 .app 内部的旧数据迁移到外部目录(仅 macOS 打包环境)"""
477 if not (getattr(sys, 'frozen', False) and platform.system() == "Darwin"): 640 if not (getattr(sys, 'frozen', False) and platform.system() == "Darwin"):
...@@ -857,6 +1020,42 @@ class HistoryManager: ...@@ -857,6 +1020,42 @@ class HistoryManager:
857 return item 1020 return item
858 return None 1021 return None
859 1022
1023 def load_history_item_fast(self, timestamp: str) -> Optional[HistoryItem]:
1024 """轻量读取单条历史记录:直接从 {timestamp}/metadata.json + 文件扫描。
1025
1026 不读 index.json、不做全量路径修正、不扫其他记录。用于生成完成后
1027 增量刷新 UI,避免每次都走 O(N) 的 load_history_index。
1028 """
1029 record_dir = self.base_path / timestamp
1030 metadata_path = record_dir / "metadata.json"
1031 if not metadata_path.exists():
1032 return None
1033 try:
1034 with open(metadata_path, 'r', encoding='utf-8') as f:
1035 metadata = json.load(f)
1036 generated_image_path = record_dir / "generated.png"
1037 reference_image_paths = sorted(
1038 record_dir.glob("reference_*.png"),
1039 key=lambda p: p.name
1040 )
1041 created_at = (
1042 datetime.fromisoformat(metadata['created_at'])
1043 if 'created_at' in metadata else datetime.now()
1044 )
1045 return HistoryItem(
1046 timestamp=metadata.get('timestamp', timestamp),
1047 prompt=metadata.get('prompt', ''),
1048 generated_image_path=generated_image_path,
1049 reference_image_paths=reference_image_paths,
1050 aspect_ratio=metadata.get('aspect_ratio', ''),
1051 image_size=metadata.get('image_size', ''),
1052 model=metadata.get('model', ''),
1053 created_at=created_at,
1054 )
1055 except Exception as e:
1056 self.logger.warning(f"load_history_item_fast 失败 {timestamp}: {e}")
1057 return None
1058
860 def delete_history_item(self, timestamp: str) -> bool: 1059 def delete_history_item(self, timestamp: str) -> bool:
861 """删除指定的历史记录 1060 """删除指定的历史记录
862 1061
...@@ -2024,14 +2223,24 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -2024,14 +2223,24 @@ class ImageGeneratorWindow(QMainWindow):
2024 from PySide6.QtWidgets import QSplitter 2223 from PySide6.QtWidgets import QSplitter
2025 splitter = QSplitter(Qt.Vertical) 2224 splitter = QSplitter(Qt.Vertical)
2026 2225
2027 # History list (upper part) 2226 # History list (upper part) — 阶段 2 改用 QListView + Model 实现视口懒加载
2028 self.history_list = QListWidget() 2227 self.history_model = HistoryListModel(
2228 history_manager=self.history_manager,
2229 build_placeholder_icon=self.create_placeholder_icon,
2230 logger=self.logger,
2231 parent=self,
2232 )
2233 self.history_list = QListView()
2234 self.history_list.setModel(self.history_model)
2029 self.history_list.setIconSize(QSize(120, 120)) 2235 self.history_list.setIconSize(QSize(120, 120))
2030 self.history_list.setResizeMode(QListWidget.Adjust) 2236 self.history_list.setViewMode(QListView.IconMode)
2031 self.history_list.setViewMode(QListWidget.IconMode) 2237 self.history_list.setResizeMode(QListView.Adjust)
2238 self.history_list.setMovement(QListView.Static) # 禁用拖动重排,行为对齐旧版
2032 self.history_list.setSpacing(10) 2239 self.history_list.setSpacing(10)
2033 self.history_list.setMinimumHeight(200) # Give more space for history list 2240 self.history_list.setUniformItemSizes(False) # IconMode 下保持默认排列
2034 self.history_list.itemClicked.connect(self.load_history_item) 2241 self.history_list.setSelectionMode(QListView.SingleSelection)
2242 self.history_list.setMinimumHeight(200)
2243 self.history_list.clicked.connect(self.load_history_item)
2035 self.history_list.setContextMenuPolicy(Qt.CustomContextMenu) 2244 self.history_list.setContextMenuPolicy(Qt.CustomContextMenu)
2036 self.history_list.customContextMenuRequested.connect(self.show_history_context_menu) 2245 self.history_list.customContextMenuRequested.connect(self.show_history_context_menu)
2037 2246
...@@ -2847,7 +3056,7 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -2847,7 +3056,7 @@ class ImageGeneratorWindow(QMainWindow):
2847 3056
2848 # Save to history 3057 # Save to history
2849 try: 3058 try:
2850 self.history_manager.save_generation( 3059 timestamp = self.history_manager.save_generation(
2851 image_bytes=image_bytes, 3060 image_bytes=image_bytes,
2852 prompt=prompt, 3061 prompt=prompt,
2853 reference_images=reference_images, 3062 reference_images=reference_images,
...@@ -2856,7 +3065,7 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -2856,7 +3065,7 @@ class ImageGeneratorWindow(QMainWindow):
2856 model=model 3065 model=model
2857 ) 3066 )
2858 self.status_label.setText("● 图片生成成功,已保存到历史记录") 3067 self.status_label.setText("● 图片生成成功,已保存到历史记录")
2859 self.refresh_history() 3068 self.prepend_history_item(timestamp)
2860 except Exception as e: 3069 except Exception as e:
2861 self.logger.warning(f"保存到历史记录失败: {e}") 3070 self.logger.warning(f"保存到历史记录失败: {e}")
2862 3071
...@@ -2871,7 +3080,7 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -2871,7 +3080,7 @@ class ImageGeneratorWindow(QMainWindow):
2871 3080
2872 # 自动保存到历史记录 3081 # 自动保存到历史记录
2873 try: 3082 try:
2874 self.history_manager.save_generation( 3083 timestamp = self.history_manager.save_generation(
2875 image_bytes=image_bytes, 3084 image_bytes=image_bytes,
2876 prompt=prompt, 3085 prompt=prompt,
2877 reference_images=reference_images, 3086 reference_images=reference_images,
...@@ -2880,8 +3089,7 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -2880,8 +3089,7 @@ class ImageGeneratorWindow(QMainWindow):
2880 model=model 3089 model=model
2881 ) 3090 )
2882 self.status_label.setText("● 图片生成成功,已保存到历史记录") 3091 self.status_label.setText("● 图片生成成功,已保存到历史记录")
2883 # 刷新历史记录列表 3092 self.prepend_history_item(timestamp)
2884 self.refresh_history()
2885 except Exception as e: 3093 except Exception as e:
2886 print(f"保存到历史记录失败: {e}") 3094 print(f"保存到历史记录失败: {e}")
2887 # 不影响主要功能,静默处理错误 3095 # 不影响主要功能,静默处理错误
...@@ -3035,72 +3243,24 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -3035,72 +3243,24 @@ class ImageGeneratorWindow(QMainWindow):
3035 QMessageBox.critical(self, "错误", f"保存失败: {str(e)}") 3243 QMessageBox.critical(self, "错误", f"保存失败: {str(e)}")
3036 3244
3037 def refresh_history(self): 3245 def refresh_history(self):
3038 """Refresh the history list""" 3246 """Refresh the history list(路线 A 阶段 2:仅重置 model 时间戳列表)。
3247
3248 Model/View 架构下视口内才会调用 data() 渲染缩略图,因此 N 条记录的
3249 全量刷新成本与 N 解耦——只跑一次 load_history_index 拿 timestamps。
3250 """
3039 import time as _time 3251 import time as _time
3040 t_start = _time.monotonic() 3252 t_start = _time.monotonic()
3041 self.logger.info("[refresh_history] 开始刷新历史记录列表...") 3253 self.logger.info("[refresh_history] 开始刷新历史记录列表...")
3042 _flush_logs() 3254 _flush_logs()
3043 3255
3044 t_clear = _time.monotonic()
3045 self.history_list.clear()
3046 self.logger.info(f"[refresh_history] clear() 完成 ({int((_time.monotonic() - t_clear) * 1000)}ms)")
3047 _flush_logs()
3048
3049 t_load = _time.monotonic() 3256 t_load = _time.monotonic()
3050 history_items = self.history_manager.load_history_index() 3257 history_items = self.history_manager.load_history_index()
3051 total = len(history_items) 3258 total = len(history_items)
3052 self.logger.info(f"[refresh_history] 加载到 {total} 条历史记录 ({int((_time.monotonic() - t_load) * 1000)}ms)") 3259 self.logger.info(f"[refresh_history] 加载到 {total} 条历史记录 ({int((_time.monotonic() - t_load) * 1000)}ms)")
3053 _flush_logs() 3260 _flush_logs()
3054 3261
3055 t_loop = _time.monotonic() 3262 timestamps = [item.timestamp for item in history_items]
3056 for idx, item in enumerate(history_items): 3263 self.history_model.reset_timestamps(timestamps)
3057 # Create list item with icon
3058 list_item = QListWidgetItem()
3059
3060 # Set item data
3061 list_item.setData(Qt.UserRole, item.timestamp)
3062
3063 # Create enhanced tooltip with details
3064 tooltip = f"时间: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
3065 tooltip += f"提示词: {item.prompt}\n"
3066 tooltip += f"宽高比: {item.aspect_ratio}\n"
3067 tooltip += f"尺寸: {item.image_size}"
3068 list_item.setToolTip(tooltip)
3069
3070 # 加载缩略图:只走磁盘缓存的 thumb.jpg,避免每次解码 2K 原图
3071 # (2K PNG 解码 ~16MB × 100 条 ~1.7GB 瞬时内存 → macOS 上触发 SIGKILL)
3072 # 如果缩略图生成失败(极罕见)用占位图兜底,绝不回退去加载原图。
3073 if item.generated_image_path.exists():
3074 try:
3075 thumb_path = self.history_manager.get_or_create_thumbnail(item.generated_image_path)
3076 if thumb_path is None:
3077 list_item.setIcon(self.create_placeholder_icon("缩略图\n失败"))
3078 else:
3079 pixmap = QPixmap(str(thumb_path))
3080 if not pixmap.isNull():
3081 scaled_pixmap = pixmap.scaled(120, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation)
3082 list_item.setIcon(QIcon(scaled_pixmap))
3083 else:
3084 self.logger.warning(f"[refresh_history] 缩略图加载为空: {thumb_path}")
3085 list_item.setIcon(self.create_placeholder_icon("图片\n加载失败"))
3086 except Exception as e:
3087 self.logger.error(f"[refresh_history] 缩略图加载异常 {item.timestamp}: {e}")
3088 list_item.setIcon(self.create_placeholder_icon("图片\n错误"))
3089 else:
3090 self.logger.warning(f"[refresh_history] 图片文件不存在: {item.generated_image_path}")
3091 list_item.setIcon(self.create_placeholder_icon("图片\n不存在"))
3092
3093 # Add text info below the icon
3094 # Get prompt preview (first 20 characters)
3095 prompt_preview = item.prompt[:20] + "..." if len(item.prompt) > 20 else item.prompt
3096 list_item.setText(f"{item.timestamp}\n{prompt_preview}")
3097
3098 # Add to list
3099 self.history_list.addItem(list_item)
3100
3101 if (idx + 1) % 20 == 0:
3102 self.logger.info(f"[refresh_history] 渲染进度 {idx + 1}/{total} ({int((_time.monotonic() - t_loop) * 1000)}ms)")
3103 _flush_logs()
3104 3264
3105 # Update count label 3265 # Update count label
3106 self.history_count_label.setText(f"共 {total} 条历史记录") 3266 self.history_count_label.setText(f"共 {total} 条历史记录")
...@@ -3109,9 +3269,29 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -3109,9 +3269,29 @@ class ImageGeneratorWindow(QMainWindow):
3109 if not history_items: 3269 if not history_items:
3110 self.clear_details_panel() 3270 self.clear_details_panel()
3111 3271
3112 self.logger.info(f"[refresh_history] 刷新完成,共渲染 {total} 条 (总耗时 {int((_time.monotonic() - t_start) * 1000)}ms)") 3272 self.logger.info(f"[refresh_history] 刷新完成,共 {total} 条 (总耗时 {int((_time.monotonic() - t_start) * 1000)}ms)")
3113 _flush_logs() 3273 _flush_logs()
3114 3274
3275 def prepend_history_item(self, timestamp: str):
3276 """增量把新生成的历史记录插到列表最前面。
3277
3278 路线 A 阶段 2:直接走 model.prepend_timestamp,O(1)。
3279 异常时回退到 refresh_history(),保证 UI 不脱同步。
3280 """
3281 try:
3282 if not timestamp:
3283 self.refresh_history()
3284 return
3285 self.history_model.prepend_timestamp(timestamp)
3286 total = self.history_model.rowCount()
3287 self.history_count_label.setText(f"共 {total} 条历史记录")
3288 except Exception as e:
3289 self.logger.error(f"[prepend_history_item] 异常, 回退全量刷新: {e}")
3290 try:
3291 self.refresh_history()
3292 except Exception:
3293 pass
3294
3115 def create_placeholder_icon(self, text): 3295 def create_placeholder_icon(self, text):
3116 """Create a placeholder icon with text""" 3296 """Create a placeholder icon with text"""
3117 # Create a 120x120 pixmap 3297 # Create a 120x120 pixmap
...@@ -3131,9 +3311,18 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -3131,9 +3311,18 @@ class ImageGeneratorWindow(QMainWindow):
3131 painter.end() 3311 painter.end()
3132 return QIcon(pixmap) 3312 return QIcon(pixmap)
3133 3313
3134 def load_history_item(self, item): 3314 def load_history_item(self, index_or_item):
3135 """Display history item details when selected""" 3315 """Display history item details when selected。
3136 timestamp = item.data(Qt.UserRole) 3316
3317 QListView 的 clicked 信号传 QModelIndex;同时兼容旧版可能仍传
3318 QListWidgetItem 的代码路径。
3319 """
3320 if isinstance(index_or_item, QModelIndex):
3321 if not index_or_item.isValid():
3322 return
3323 timestamp = index_or_item.data(Qt.UserRole)
3324 else:
3325 timestamp = index_or_item.data(Qt.UserRole)
3137 if not timestamp: 3326 if not timestamp:
3138 return 3327 return
3139 3328
...@@ -3146,11 +3335,11 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -3146,11 +3335,11 @@ class ImageGeneratorWindow(QMainWindow):
3146 3335
3147 def show_history_context_menu(self, position): 3336 def show_history_context_menu(self, position):
3148 """Show context menu for history items""" 3337 """Show context menu for history items"""
3149 item = self.history_list.itemAt(position) 3338 index = self.history_list.indexAt(position)
3150 if not item: 3339 if not index.isValid():
3151 return 3340 return
3152 3341
3153 timestamp = item.data(Qt.UserRole) 3342 timestamp = index.data(Qt.UserRole)
3154 if not timestamp: 3343 if not timestamp:
3155 return 3344 return
3156 3345
...@@ -3180,7 +3369,16 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -3180,7 +3369,16 @@ class ImageGeneratorWindow(QMainWindow):
3180 if reply == QMessageBox.Yes: 3369 if reply == QMessageBox.Yes:
3181 success = self.history_manager.delete_history_item(timestamp) 3370 success = self.history_manager.delete_history_item(timestamp)
3182 if success: 3371 if success:
3372 # 增量从 model 移除该 timestamp,O(N) 查找 + O(1) 删除
3373 removed = self.history_model.remove_timestamp(timestamp)
3374 if not removed:
3375 # 极端情况:model 未持有该 ts(数据漂移),回退全量刷新
3183 self.refresh_history() 3376 self.refresh_history()
3377 else:
3378 total = self.history_model.rowCount()
3379 self.history_count_label.setText(f"共 {total} 条历史记录")
3380 if total == 0:
3381 self.clear_details_panel()
3184 self.status_label.setText("● 历史记录已删除") 3382 self.status_label.setText("● 历史记录已删除")
3185 self.status_label.setStyleSheet("QLabel { color: #34C759; }") 3383 self.status_label.setStyleSheet("QLabel { color: #34C759; }")
3186 else: 3384 else:
...@@ -3223,7 +3421,10 @@ class ImageGeneratorWindow(QMainWindow): ...@@ -3223,7 +3421,10 @@ class ImageGeneratorWindow(QMainWindow):
3223 # Recreate empty directory 3421 # Recreate empty directory
3224 self.history_manager.base_path.mkdir(parents=True, exist_ok=True) 3422 self.history_manager.base_path.mkdir(parents=True, exist_ok=True)
3225 3423
3226 self.refresh_history() 3424 # 增量重置 model 而非走 refresh_history(已无 index.json 可读)
3425 self.history_model.reset_timestamps([])
3426 self.history_count_label.setText("共 0 条历史记录")
3427 self.clear_details_panel()
3227 self.status_label.setText("● 历史记录已清空") 3428 self.status_label.setText("● 历史记录已清空")
3228 self.status_label.setStyleSheet("QLabel { color: #34C759; }") 3429 self.status_label.setStyleSheet("QLabel { color: #34C759; }")
3229 3430
...@@ -4441,7 +4642,7 @@ class StyleDesignerTab(QWidget): ...@@ -4441,7 +4642,7 @@ class StyleDesignerTab(QWidget):
4441 4642
4442 # Save to history 4643 # Save to history
4443 try: 4644 try:
4444 self.parent_window.history_manager.save_generation( 4645 timestamp = self.parent_window.history_manager.save_generation(
4445 image_bytes=image_bytes, 4646 image_bytes=image_bytes,
4446 prompt=prompt, 4647 prompt=prompt,
4447 reference_images=reference_images, 4648 reference_images=reference_images,
...@@ -4449,7 +4650,7 @@ class StyleDesignerTab(QWidget): ...@@ -4449,7 +4650,7 @@ class StyleDesignerTab(QWidget):
4449 image_size=image_size, 4650 image_size=image_size,
4450 model=model 4651 model=model
4451 ) 4652 )
4452 self.parent_window.refresh_history() 4653 self.parent_window.prepend_history_item(timestamp)
4453 except Exception as e: 4654 except Exception as e:
4454 self.logger.warning(f"保存到历史记录失败: {e}") 4655 self.logger.warning(f"保存到历史记录失败: {e}")
4455 4656
...@@ -4502,8 +4703,10 @@ class StyleDesignerTab(QWidget): ...@@ -4502,8 +4703,10 @@ class StyleDesignerTab(QWidget):
4502 ) 4703 )
4503 self.logger.info(f"款式设计已添加到历史记录: {timestamp}") 4704 self.logger.info(f"款式设计已添加到历史记录: {timestamp}")
4504 4705
4505 # 刷新历史记录列表 4706 # 增量插入新记录(避免全量重建触发 macOS 内存压力)
4506 if hasattr(self.parent_window, 'refresh_history'): 4707 if hasattr(self.parent_window, 'prepend_history_item'):
4708 self.parent_window.prepend_history_item(timestamp)
4709 elif hasattr(self.parent_window, 'refresh_history'):
4507 self.parent_window.refresh_history() 4710 self.parent_window.refresh_history()
4508 else: 4711 else:
4509 self.logger.warning("未找到历史记录管理器") 4712 self.logger.warning("未找到历史记录管理器")
......
1 # 设计:历史记录列表视口懒加载(路线 A)
2
3 ## 背景与抉择
4
5 2026-04-24 会话中与用户讨论了三条路线:
6
7 | 路线 | 做法 | 评价 |
8 |---|---|---|
9 | A | `QListView + QAbstractListModel + QStyledItemDelegate`,Qt 原生视口懒加载 | **采纳**。用户零感知,消除"全部 widget 常驻"的根本问题 |
10 | B | 显式分页按钮 / "加载更多" | 破坏线性浏览的心智模型,被用户否决 |
11 | C | 保持 `QListWidget` 只做增量 prepend | **已作为阶段 1 落地**,治标不治本 |
12
13 路线 A 是架构性修复,阶段 2 启动。
14
15 ## 数据结构
16
17 ### 现状(阶段 1 之后)
18
19 ```
20 QListWidget
21 └── 360 × QListWidgetItem
22 ├── QIcon(QPixmap 120×120) ← 全部常驻内存
23 ├── 完整 tooltip 字符串
24 └── text (timestamp + prompt preview)
25 ```
26
27 内存占用:每条 item ~60 KB(QPixmap 纹理 + Qt 对象 overhead),360 条 ≈ 21 MB。**但这是"当前活 widget"的成本,不算 Qt C++ 层在 clear/重建过程中的临时 peak 和回收延迟。** 连续 6 小时高频重建才是真正的内存杀手。
28
29 ### 目标(阶段 2)
30
31 ```
32 HistoryListModel
33 └── list[str] timestamps ← 内存 ~ 360 × 16B = 5.7 KB
34
35 HistoryItemDelegate
36 ├── QPixmapCache (LRU, ~50 MB) ← 只缓存视口 + 最近滚过的
37 └── 每次 paint() 按需从 cache 取 pixmap,miss 则异步加载
38 ```
39
40 内存占用:timestamps 本体几乎为零;pixmap cache 由 Qt 统一管控上限。**不管历史 1000 条还是 10000 条,活内存都只和视口大小相关。**
41
42 ## 关键决策
43
44 ### 1. Model 为何只存 timestamps 而不存完整 HistoryItem
45
46 - timestamp 是稳定主键,`HistoryManager.load_history_item_fast(ts)` 可 O(1) 拿到完整数据
47 - 避免 model 内部持有 360 个 HistoryItem(那样又回到"全量常驻")
48 - metadata.json 读取很快(几 KB / 次),magneto 让 OS 页缓存帮我们做热度管理
49 - **如果实测 paint() 时的 metadata.json 读取成为瓶颈**,再引入轻量 LRU cache(`functools.lru_cache(maxsize=200)` on `load_history_item_fast`
50
51 ### 2. Delegate 的 paint() 是否会阻塞滚动
52
53 会,如果在 paint() 里同步做磁盘 IO / PIL 解码。规避方案:
54
55 - `paint()`**只查 QPixmapCache**,不做 IO
56 - Cache miss → 丢一个 `ThumbnailLoader(QRunnable)``QThreadPool`,当前 paint 返回占位图
57 - 后台加载完 → `cache.insert(key, pixmap)` + `model.dataChanged(index, index, [Qt.DecorationRole])`
58 - 下次 paint 重新从 cache 取到真图,丝滑切换
59
60 ### 3. 与现有 prepend_history_item 的衔接
61
62 阶段 1 的 `prepend_history_item` 目前做的是 `history_list.insertItem(0, widget)`,阶段 2 改造后简化成:
63
64 ```python
65 def prepend_history_item(self, timestamp):
66 self.history_model.insertRow(0, timestamp)
67 ```
68
69 对外接口签名不变,调用方(`on_image_generated` 等 4 处)**零改动**
70
71 ### 4. 尺寸对齐策略
72
73 `QListWidgetItem` 的默认 sizeHint 由 icon size + text 行高决定。我们的 delegate `sizeHint()` 必须返回**同一个值**,否则:
74
75 - 切换架构时列表视觉抖动
76 - 选中高亮条高度不对
77 - 滚动到特定位置时错位
78
79 具体值需在迁移时从 runtime 抓取(比如先给老代码加一次 `print(item.sizeHint())`)。
80
81 ### 5. 信号迁移
82
83 `QListWidget``itemClicked(QListWidgetItem)``QListView``clicked(QModelIndex)`
84 所有槽函数参数类型变化,需要:
85
86 - `timestamp = index.data(Qt.UserRole)` 替换 `item.data(Qt.UserRole)`
87 - 右键菜单从 `itemAt(position)` 改成 `indexAt(position)`
88
89 ## 风险与回滚
90
91 ### 风险
92
93 1. **视觉差异**`QListView` 默认选中样式(蓝底白字)与 `QListWidget` 不完全一致,需 QSS 调齐
94 2. **delegate 尺寸计算 off-by-one**:会表现为列表项之间 1–2 px 间隙或重叠,反复打磨
95 3. **异步缩略图加载竞态**:快速滚动时可能看到闪烁的占位图(可接受,滚停后 ~100ms 内补齐)
96
97 ### 回滚
98
99 每个 step 独立可跑可回滚:
100
101 - Step 1 完成(Model/View 替换)后可单独发版,此时延续 Qt 默认的"见一个画一个"但是每次仍 build widget-like object
102 - Step 2(QPixmapCache + 异步加载)失败时保留 Step 1 的同步加载,性能不如目标但正确性不受影响
103 - 完整回滚:`git revert` 本 change 对应 commit,回到阶段 1 的 QListWidget + 增量 prepend 模式
104
105 ## 性能目标
106
107 | 指标 | 阶段 1(当前) | 阶段 2(目标) |
108 |---|---|---|
109 | 生成完成后 UI 刷新耗时 | ~50 ms(1 条 widget) | ~5 ms(1 次 insertRow) |
110 | 首次切到历史 tab(360 条) | ~250 ms | < 50 ms(只画视口 15 条) |
111 | 首次切到历史 tab(1000 条) | ~700 ms(线性增长) | < 50 ms(与总数无关) |
112 | 连续 6 小时 100 次生成的活内存峰值 | 不确定,疑似 GB 级 | < 300 MB(cache 上限可控) |
113 | 闪退次数 / 日(Mac) | 14 次 → 期望 0 次(阶段 1 观察中) | 0 次 |
114
115 ## 不做的事
116
117 - **不改 index.json 存储格式** —— 那是另一个独立坑,留给后续 change 处理
118 - **不动 `_fix_history_path` 的路径修正逻辑** —— 同上,独立重构
119 - **不引入虚拟滚动库**(如自写 virtual list)—— Qt 原生 Model/View 已经提供这能力
120 - **不做"翻页按钮"** —— 用户明确否决,会破坏线性浏览体验
1 # 提案:历史记录列表增量渲染 + 视口懒加载(路线 A)
2
3 > 状态:**第一阶段已完成(增量 prepend)**,第二阶段(Model/Delegate 重构)**下周启动**。
4
5 ## Why
6
7 2026-04-24 当天应用在 Mac 上闪退 14 次。根因定位:
8
9 - `app(7).log` 末尾戛然而止,无 Traceback
10 - `crash_log(3).txt` 的 faulthandler 也没捕到任何信号栈
11 - **两处都没栈 = 只能是 SIGKILL**(faulthandler 无法拦截 SIGKILL,Unix 铁律)→ 99% 是 macOS jetsam 内存压力强杀
12
13 `image_generator.py` 历史记录渲染路径有三个相互放大的问题:
14
15 1. **每次生成完图片都 `refresh_history()` 全量重建**`:2859``:2884``:4452``:4507`)。360 条 × 120×120 QPixmap + QIcon + QListWidgetItem,6 小时内累计创建 ~9 万个 Qt 对象,C++ 层回收滞后 + 内存碎片化。
16 2. **`load_history_index()` 每次调用都做 O(N) 路径修正**,360 条 × 2 图 = 720 次 `os.stat` 在 UI 主线程上。崩溃前 11 秒内被 UI 事件重复触发了 7 次。
17 3. **`QListWidget` 架构决定"全部 widget 常驻"**,用户只看得到视口内 10–15 行,但 Qt 必须为所有 360 条持有 QPixmap。历史记录越多,风险越高。
18
19 代码注释已自承风险:`# macOS 上触发 SIGKILL``image_generator.py:3071`)。
20
21 ## What Changes
22
23 ### 阶段 1:增量 prepend(已完成,2026-04-24)
24
25 历史记录是 append-only 数据(生成后 prompt/图片/参数不可编辑),生成完成后只需追加单条到列表首位,无需全量重建。
26
27 - 新增 `HistoryManager.load_history_item_fast(timestamp)` — 只读 `{timestamp}/metadata.json` + 扫目录下 `reference_*.png`**不触碰 `index.json`、不扫其他记录、不做路径修正**
28 - 抽出 `_build_history_list_item(item)` — 单条 widget 构建逻辑,供全量和增量共用
29 - 新增 `prepend_history_item(timestamp)` — 增量插入到列表首位,异常时自动回退 `refresh_history()` 全量
30 - 四处生成完成回调从 `refresh_history()` 切换为 `prepend_history_item(timestamp)`
31
32 ### 阶段 2:Model/Delegate 视口懒加载(下周启动)
33
34 阶段 1 消除了"每次生成的 O(N) 重建",但"首次加载/手动刷新"仍是一次性画 360 个 widget。真正的根治是换架构:
35
36 - `QListWidget``QListView + HistoryListModel(QAbstractListModel) + HistoryItemDelegate(QStyledItemDelegate)`
37 - Model 只存 `list[str]` 的 timestamps(内存几乎为零)
38 - Delegate 在 `paint()` 时按需读 metadata + 缩略图,滚出视口自动不再绘制
39 - **效果等同于无限懒加载,用户零感知,不引入"翻页按钮"类的 UI 变化**
40
41 额外优化:
42 - `QPixmapCache`(LRU,默认 ~50 MB)缓存最近加载的缩略图
43 - 后台线程(`QThreadPool + QRunnable`)异步加载缩略图,主线程占位 placeholder,缩略图回来再触发重绘
44 - `delete` / `clear` 改为 model 级别增量(`removeRow` / `reset`),不再触发"全量 refresh"
45
46 ## Impact
47
48 - **Affected specs**: history-list-rendering(新,阶段 2 时建立)
49 - **Affected code**:
50 - `image_generator.py`: HistoryManager / ImageGeneratorWindow 历史 tab 相关方法
51 - 阶段 2 会新增 `HistoryListModel` / `HistoryItemDelegate` 两个类
52 - **User-visible**:
53 - 阶段 1:**无感知**(仅性能/稳定性提升)
54 - 阶段 2:历史 tab 的选中样式、间距、hover 可能有 1–2 px 的视觉差异(`QListView` 默认样式 vs `QListWidget`),需要 QSS 调齐
55 - **Risk**:
56 - 阶段 2 触及历史 tab 核心交互路径(单击/双击/右键菜单/详情面板联动),需要完整回归
57 - delegate 的尺寸计算(`sizeHint`)必须和现有 icon size + text 行高严格一致,否则滚动位置/选中高亮会错位
1 # 任务列表:历史记录列表增量渲染 + 视口懒加载
2
3 ## 阶段 1:增量 prepend(已完成 2026-04-24)
4
5 - [x] 1.1 `HistoryManager.load_history_item_fast(timestamp)` — 轻量单条读取(不走 index.json)
6 - [x] 1.2 抽出 `_build_history_list_item(item)` — 单条 widget 构建逻辑
7 - [x] 1.3 `prepend_history_item(timestamp)` — 增量插入 + 异常回退 `refresh_history()`
8 - [x] 1.4 四处 `refresh_history()` 调用点切换为 `prepend_history_item(timestamp)`
9 - [x] 1.4.1 `on_generation_complete``image_generator.py:2895`
10 - [x] 1.4.2 `on_image_generated``image_generator.py:2919`
11 - [x] 1.4.3 `_on_my_task_completed``image_generator.py:4514`
12 - [x] 1.4.4 款式设计 tab `on_generation_success``image_generator.py:4568`
13 - [x] 1.5 语法自检通过(`python -c "import ast; ast.parse(...)"`
14
15 **仍保留全量刷新**(刻意保留,不影响使用习惯):
16 - 手动点"刷新"按钮
17 - 首次切到历史 tab
18 - 删除单条 / 清空全部(阶段 2 才会动)
19
20 ## 阶段 2:Model/Delegate 视口懒加载(下周启动)
21
22 ### Step 1 — 引入 Model/View 架构(已完成 2026-04-27)
23
24 - [x] 2.1 新增 `HistoryListModel(QAbstractListModel)`
25 - [x] 2.1.1 数据仅 `list[str]` timestamps(按时间戳倒序)
26 - [x] 2.1.2 实现 `rowCount` / `data(index, role)` / `flags`
27 - [x] 2.1.3 `data()` 按需调 `HistoryManager.load_history_item_fast(timestamp)`,内部 OrderedDict LRU(max=300)缓存 icon/display_text/tooltip
28 - [x] 2.1.4 支持 `prepend_timestamp` / `remove_timestamp` / `reset_timestamps` / `invalidate_cache`
29 - [~] 2.2 `HistoryItemDelegate(QStyledItemDelegate)`**Step 1 暂未引入自定义 delegate**
30 - [x] 2.2.1 沿用 `QStyledItemDelegate` 默认实现(IconMode 默认绘制 icon + 文本,与 QListWidget 一致)
31 - [x] 2.2.2 sizeHint 沿用默认(与 QListWidget IconMode 行为一致)
32 - 备注:Step 2 引入异步 thumbnail 加载时再考虑是否需要自定义 delegate
33 - [x] 2.3 历史 tab 替换
34 - [x] 2.3.1 `self.history_list = QListWidget(...)``QListView(...)` + IconMode/IconSize/Spacing/Adjust/Static 全部对齐旧版
35 - [x] 2.3.2 `setModel(self.history_model)`(delegate 沿用默认)
36 - [x] 2.3.3 `clicked(QModelIndex)` / `customContextMenuRequested` + `indexAt(position)` 信号迁移
37 - [x] 2.4 `refresh_history` 简化为 `history_model.reset_timestamps([item.timestamp for ...])`(不再建 widget)
38 - [x] 2.5 `prepend_history_item` 简化为 `history_model.prepend_timestamp(timestamp)`
39 - [x] 2.6 `delete_history_item` UI 侧改为 `history_model.remove_timestamp(timestamp)`(失败回退 refresh)
40 - [x] 2.7 `clear_history` UI 侧改为 `history_model.reset_timestamps([])`
41
42 ### Step 2 — 缩略图缓存 + 异步加载(性能优化)
43
44 - [ ] 2.8 `QPixmapCache` 接入 delegate `paint()`(key = thumb_path)
45 - [ ] 2.9 设置 cache limit(默认 ~50 MB,足够 ~500 张 thumb.jpg)
46 - [ ] 2.10 `ThumbnailLoader(QRunnable)` 后台加载
47 - [ ] 2.10.1 `QThreadPool.globalInstance()` 丢任务
48 - [ ] 2.10.2 加载完回主线程 `dataChanged(index)` 触发 delegate 重绘
49 - [ ] 2.10.3 cache miss 时 delegate 先画占位图,不阻塞滚动
50
51 ### Step 3 — 收尾与回归
52
53 - [ ] 2.11 回归测试:
54 - [ ] 2.11.1 单击历史项 → 详情面板正常联动
55 - [ ] 2.11.2 双击 / 右键菜单 / 删除 / 清空 全部正常
56 - [ ] 2.11.3 生成新图 → 立即出现在列表顶部
57 - [ ] 2.11.4 滚动 300+ 条流畅,Activity Monitor 观察内存稳定
58 - [ ] 2.11.5 Mac 连续工作 4 小时无闪退
59 - [ ] 2.12 删除过时的 `_build_history_list_item` / `QListWidgetItem` 构造代码
60 - [ ] 2.13 更新本文档(阶段 2 勾选完毕)
61
62 ## 验证标准
63
64 - **阶段 1**:Mac 连续工作一天,崩溃次数从 14 次/日显著下降(目标:0 次)
65 - **阶段 2**:历史记录 1000 条时,首次切到历史 tab 的 UI 响应时间 < 200ms;滚动过程中内存峰值不超过 500 MB