️ 历史记录列表改增量渲染 + 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 栈).
Showing
4 changed files
with
526 additions
and
81 deletions
| ... | @@ -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: |
| 3183 | self.refresh_history() | 3372 | # 增量从 model 移除该 timestamp,O(N) 查找 + O(1) 删除 |
| 3373 | removed = self.history_model.remove_timestamp(timestamp) | ||
| 3374 | if not removed: | ||
| 3375 | # 极端情况:model 未持有该 ts(数据漂移),回退全量刷新 | ||
| 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 |
-
Please register or sign in to post a comment