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 (
QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout,
QLabel, QLineEdit, QPushButton, QCheckBox, QTextEdit,
QComboBox, QScrollArea, QGroupBox, QFileDialog, QMessageBox,
QListWidget, QListWidgetItem, QTabWidget, QSplitter,
QListWidget, QListWidgetItem, QListView, QTabWidget, QSplitter,
QMenu, QProgressBar, QInputDialog
)
from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QMimeData
from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QMimeData, QAbstractListModel, QModelIndex
from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices, QAction, QImage, QDragEnterEvent, QDropEvent, QDrag, QMouseEvent
from PySide6.QtCore import QUrl
......@@ -472,6 +472,169 @@ class HistoryItem:
)
class HistoryListModel(QAbstractListModel):
"""历史记录列表的 Qt Model(路线 A 阶段 2)。
数据本体只持有 list[str] 时间戳;渲染所需的 HistoryItem / 缩略图 QIcon 走
LRU 缓存按需懒加载。配合 QListView IconMode,Qt 只会对视口可见 + 邻近
几行调用 data(),从而实现"历史记录涨到 1000+ 也不会一次性建 N 个 widget"。
Step 1:data(DecorationRole) 内同步加载 thumbnail 并缓存为 QIcon。
Step 2 后会改为 QPixmapCache + QRunnable 异步加载。
"""
_CACHE_LIMIT = 300
def __init__(self, history_manager, build_placeholder_icon, logger, parent=None):
super().__init__(parent)
self._history_manager = history_manager
self._build_placeholder_icon = build_placeholder_icon
self._logger = logger
self._timestamps: List[str] = []
# OrderedDict 维护 LRU:timestamp -> dict(item / icon / display_text / tooltip)
from collections import OrderedDict
self._cache: 'OrderedDict[str, Dict[str, Any]]' = OrderedDict()
# ---- Qt model interface -------------------------------------------------
def rowCount(self, parent=QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self._timestamps)
def flags(self, index: QModelIndex):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def data(self, index: QModelIndex, role: int = Qt.DisplayRole):
if not index.isValid():
return None
row = index.row()
if row < 0 or row >= len(self._timestamps):
return None
timestamp = self._timestamps[row]
if role == Qt.UserRole:
return timestamp
cached = self._get_or_build(timestamp)
if cached is None:
return None
if role == Qt.DisplayRole:
return cached.get('display_text', '')
if role == Qt.ToolTipRole:
return cached.get('tooltip', '')
if role == Qt.DecorationRole:
return cached.get('icon')
return None
# ---- 增量操作 -----------------------------------------------------------
def reset_timestamps(self, timestamps: List[str]):
"""重置整个列表(用于刷新按钮 / 首次加载 / 清空)。"""
self.beginResetModel()
self._timestamps = list(timestamps)
self._cache.clear()
self.endResetModel()
def prepend_timestamp(self, timestamp: str):
"""把新 timestamp 插到最前。"""
self.beginInsertRows(QModelIndex(), 0, 0)
self._timestamps.insert(0, timestamp)
self.endInsertRows()
def remove_timestamp(self, timestamp: str) -> bool:
"""按 timestamp 删除一行。"""
try:
row = self._timestamps.index(timestamp)
except ValueError:
return False
self.beginRemoveRows(QModelIndex(), row, row)
del self._timestamps[row]
self._cache.pop(timestamp, None)
self.endRemoveRows()
return True
def row_of(self, timestamp: str) -> int:
try:
return self._timestamps.index(timestamp)
except ValueError:
return -1
def invalidate_cache(self, timestamp: Optional[str] = None):
"""业务操作(路径修正、缩略图重建等)后让缓存失效。"""
if timestamp is None:
self._cache.clear()
else:
self._cache.pop(timestamp, None)
# ---- 内部 ---------------------------------------------------------------
def _get_or_build(self, timestamp: str) -> Optional[Dict[str, Any]]:
"""按需构建并 LRU 缓存渲染数据。"""
cached = self._cache.get(timestamp)
if cached is not None:
self._cache.move_to_end(timestamp)
return cached
try:
item = self._history_manager.load_history_item_fast(timestamp)
except Exception as e:
self._logger.warning(f"[HistoryListModel] load 失败 {timestamp}: {e}")
item = None
if item is None:
placeholder = {
'item': None,
'icon': self._build_placeholder_icon("已删除"),
'display_text': f"{timestamp}\n(已删除)",
'tooltip': f"时间戳: {timestamp}\n记录已不存在",
}
self._put_cache(timestamp, placeholder)
return placeholder
prompt_preview = item.prompt[:20] + "..." if len(item.prompt) > 20 else item.prompt
display_text = f"{item.timestamp}\n{prompt_preview}"
tooltip = (
f"时间: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"提示词: {item.prompt}\n"
f"宽高比: {item.aspect_ratio}\n"
f"尺寸: {item.image_size}"
)
icon = self._load_thumbnail_icon(item)
rendered = {
'item': item,
'icon': icon,
'display_text': display_text,
'tooltip': tooltip,
}
self._put_cache(timestamp, rendered)
return rendered
def _load_thumbnail_icon(self, item: HistoryItem) -> QIcon:
"""同步加载缩略图为 QIcon(Step 2 改异步)。"""
if not item.generated_image_path.exists():
return self._build_placeholder_icon("图片\n不存在")
try:
thumb_path = self._history_manager.get_or_create_thumbnail(item.generated_image_path)
if thumb_path is None:
return self._build_placeholder_icon("缩略图\n失败")
pixmap = QPixmap(str(thumb_path))
if pixmap.isNull():
return self._build_placeholder_icon("图片\n加载失败")
scaled = pixmap.scaled(120, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation)
return QIcon(scaled)
except Exception as e:
self._logger.error(f"[HistoryListModel] 缩略图异常 {item.timestamp}: {e}")
return self._build_placeholder_icon("图片\n错误")
def _put_cache(self, timestamp: str, value: Dict[str, Any]):
self._cache[timestamp] = value
self._cache.move_to_end(timestamp)
while len(self._cache) > self._CACHE_LIMIT:
self._cache.popitem(last=False)
def _migrate_data_from_app_bundle(target_path: Path):
"""将 .app 内部的旧数据迁移到外部目录(仅 macOS 打包环境)"""
if not (getattr(sys, 'frozen', False) and platform.system() == "Darwin"):
......@@ -857,6 +1020,42 @@ class HistoryManager:
return item
return None
def load_history_item_fast(self, timestamp: str) -> Optional[HistoryItem]:
"""轻量读取单条历史记录:直接从 {timestamp}/metadata.json + 文件扫描。
不读 index.json、不做全量路径修正、不扫其他记录。用于生成完成后
增量刷新 UI,避免每次都走 O(N) 的 load_history_index。
"""
record_dir = self.base_path / timestamp
metadata_path = record_dir / "metadata.json"
if not metadata_path.exists():
return None
try:
with open(metadata_path, 'r', encoding='utf-8') as f:
metadata = json.load(f)
generated_image_path = record_dir / "generated.png"
reference_image_paths = sorted(
record_dir.glob("reference_*.png"),
key=lambda p: p.name
)
created_at = (
datetime.fromisoformat(metadata['created_at'])
if 'created_at' in metadata else datetime.now()
)
return HistoryItem(
timestamp=metadata.get('timestamp', timestamp),
prompt=metadata.get('prompt', ''),
generated_image_path=generated_image_path,
reference_image_paths=reference_image_paths,
aspect_ratio=metadata.get('aspect_ratio', ''),
image_size=metadata.get('image_size', ''),
model=metadata.get('model', ''),
created_at=created_at,
)
except Exception as e:
self.logger.warning(f"load_history_item_fast 失败 {timestamp}: {e}")
return None
def delete_history_item(self, timestamp: str) -> bool:
"""删除指定的历史记录
......@@ -2024,14 +2223,24 @@ class ImageGeneratorWindow(QMainWindow):
from PySide6.QtWidgets import QSplitter
splitter = QSplitter(Qt.Vertical)
# History list (upper part)
self.history_list = QListWidget()
# History list (upper part) — 阶段 2 改用 QListView + Model 实现视口懒加载
self.history_model = HistoryListModel(
history_manager=self.history_manager,
build_placeholder_icon=self.create_placeholder_icon,
logger=self.logger,
parent=self,
)
self.history_list = QListView()
self.history_list.setModel(self.history_model)
self.history_list.setIconSize(QSize(120, 120))
self.history_list.setResizeMode(QListWidget.Adjust)
self.history_list.setViewMode(QListWidget.IconMode)
self.history_list.setViewMode(QListView.IconMode)
self.history_list.setResizeMode(QListView.Adjust)
self.history_list.setMovement(QListView.Static) # 禁用拖动重排,行为对齐旧版
self.history_list.setSpacing(10)
self.history_list.setMinimumHeight(200) # Give more space for history list
self.history_list.itemClicked.connect(self.load_history_item)
self.history_list.setUniformItemSizes(False) # IconMode 下保持默认排列
self.history_list.setSelectionMode(QListView.SingleSelection)
self.history_list.setMinimumHeight(200)
self.history_list.clicked.connect(self.load_history_item)
self.history_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.history_list.customContextMenuRequested.connect(self.show_history_context_menu)
......@@ -2847,7 +3056,7 @@ class ImageGeneratorWindow(QMainWindow):
# Save to history
try:
self.history_manager.save_generation(
timestamp = self.history_manager.save_generation(
image_bytes=image_bytes,
prompt=prompt,
reference_images=reference_images,
......@@ -2856,7 +3065,7 @@ class ImageGeneratorWindow(QMainWindow):
model=model
)
self.status_label.setText("● 图片生成成功,已保存到历史记录")
self.refresh_history()
self.prepend_history_item(timestamp)
except Exception as e:
self.logger.warning(f"保存到历史记录失败: {e}")
......@@ -2871,7 +3080,7 @@ class ImageGeneratorWindow(QMainWindow):
# 自动保存到历史记录
try:
self.history_manager.save_generation(
timestamp = self.history_manager.save_generation(
image_bytes=image_bytes,
prompt=prompt,
reference_images=reference_images,
......@@ -2880,8 +3089,7 @@ class ImageGeneratorWindow(QMainWindow):
model=model
)
self.status_label.setText("● 图片生成成功,已保存到历史记录")
# 刷新历史记录列表
self.refresh_history()
self.prepend_history_item(timestamp)
except Exception as e:
print(f"保存到历史记录失败: {e}")
# 不影响主要功能,静默处理错误
......@@ -3035,72 +3243,24 @@ class ImageGeneratorWindow(QMainWindow):
QMessageBox.critical(self, "错误", f"保存失败: {str(e)}")
def refresh_history(self):
"""Refresh the history list"""
"""Refresh the history list(路线 A 阶段 2:仅重置 model 时间戳列表)。
Model/View 架构下视口内才会调用 data() 渲染缩略图,因此 N 条记录的
全量刷新成本与 N 解耦——只跑一次 load_history_index 拿 timestamps。
"""
import time as _time
t_start = _time.monotonic()
self.logger.info("[refresh_history] 开始刷新历史记录列表...")
_flush_logs()
t_clear = _time.monotonic()
self.history_list.clear()
self.logger.info(f"[refresh_history] clear() 完成 ({int((_time.monotonic() - t_clear) * 1000)}ms)")
_flush_logs()
t_load = _time.monotonic()
history_items = self.history_manager.load_history_index()
total = len(history_items)
self.logger.info(f"[refresh_history] 加载到 {total} 条历史记录 ({int((_time.monotonic() - t_load) * 1000)}ms)")
_flush_logs()
t_loop = _time.monotonic()
for idx, item in enumerate(history_items):
# Create list item with icon
list_item = QListWidgetItem()
# Set item data
list_item.setData(Qt.UserRole, item.timestamp)
# Create enhanced tooltip with details
tooltip = f"时间: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
tooltip += f"提示词: {item.prompt}\n"
tooltip += f"宽高比: {item.aspect_ratio}\n"
tooltip += f"尺寸: {item.image_size}"
list_item.setToolTip(tooltip)
# 加载缩略图:只走磁盘缓存的 thumb.jpg,避免每次解码 2K 原图
# (2K PNG 解码 ~16MB × 100 条 ~1.7GB 瞬时内存 → macOS 上触发 SIGKILL)
# 如果缩略图生成失败(极罕见)用占位图兜底,绝不回退去加载原图。
if item.generated_image_path.exists():
try:
thumb_path = self.history_manager.get_or_create_thumbnail(item.generated_image_path)
if thumb_path is None:
list_item.setIcon(self.create_placeholder_icon("缩略图\n失败"))
else:
pixmap = QPixmap(str(thumb_path))
if not pixmap.isNull():
scaled_pixmap = pixmap.scaled(120, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation)
list_item.setIcon(QIcon(scaled_pixmap))
else:
self.logger.warning(f"[refresh_history] 缩略图加载为空: {thumb_path}")
list_item.setIcon(self.create_placeholder_icon("图片\n加载失败"))
except Exception as e:
self.logger.error(f"[refresh_history] 缩略图加载异常 {item.timestamp}: {e}")
list_item.setIcon(self.create_placeholder_icon("图片\n错误"))
else:
self.logger.warning(f"[refresh_history] 图片文件不存在: {item.generated_image_path}")
list_item.setIcon(self.create_placeholder_icon("图片\n不存在"))
# Add text info below the icon
# Get prompt preview (first 20 characters)
prompt_preview = item.prompt[:20] + "..." if len(item.prompt) > 20 else item.prompt
list_item.setText(f"{item.timestamp}\n{prompt_preview}")
# Add to list
self.history_list.addItem(list_item)
if (idx + 1) % 20 == 0:
self.logger.info(f"[refresh_history] 渲染进度 {idx + 1}/{total} ({int((_time.monotonic() - t_loop) * 1000)}ms)")
_flush_logs()
timestamps = [item.timestamp for item in history_items]
self.history_model.reset_timestamps(timestamps)
# Update count label
self.history_count_label.setText(f"共 {total} 条历史记录")
......@@ -3109,9 +3269,29 @@ class ImageGeneratorWindow(QMainWindow):
if not history_items:
self.clear_details_panel()
self.logger.info(f"[refresh_history] 刷新完成,共渲染 {total} 条 (总耗时 {int((_time.monotonic() - t_start) * 1000)}ms)")
self.logger.info(f"[refresh_history] 刷新完成,共 {total} 条 (总耗时 {int((_time.monotonic() - t_start) * 1000)}ms)")
_flush_logs()
def prepend_history_item(self, timestamp: str):
"""增量把新生成的历史记录插到列表最前面。
路线 A 阶段 2:直接走 model.prepend_timestamp,O(1)。
异常时回退到 refresh_history(),保证 UI 不脱同步。
"""
try:
if not timestamp:
self.refresh_history()
return
self.history_model.prepend_timestamp(timestamp)
total = self.history_model.rowCount()
self.history_count_label.setText(f"共 {total} 条历史记录")
except Exception as e:
self.logger.error(f"[prepend_history_item] 异常, 回退全量刷新: {e}")
try:
self.refresh_history()
except Exception:
pass
def create_placeholder_icon(self, text):
"""Create a placeholder icon with text"""
# Create a 120x120 pixmap
......@@ -3131,9 +3311,18 @@ class ImageGeneratorWindow(QMainWindow):
painter.end()
return QIcon(pixmap)
def load_history_item(self, item):
"""Display history item details when selected"""
timestamp = item.data(Qt.UserRole)
def load_history_item(self, index_or_item):
"""Display history item details when selected。
QListView 的 clicked 信号传 QModelIndex;同时兼容旧版可能仍传
QListWidgetItem 的代码路径。
"""
if isinstance(index_or_item, QModelIndex):
if not index_or_item.isValid():
return
timestamp = index_or_item.data(Qt.UserRole)
else:
timestamp = index_or_item.data(Qt.UserRole)
if not timestamp:
return
......@@ -3146,11 +3335,11 @@ class ImageGeneratorWindow(QMainWindow):
def show_history_context_menu(self, position):
"""Show context menu for history items"""
item = self.history_list.itemAt(position)
if not item:
index = self.history_list.indexAt(position)
if not index.isValid():
return
timestamp = item.data(Qt.UserRole)
timestamp = index.data(Qt.UserRole)
if not timestamp:
return
......@@ -3180,7 +3369,16 @@ class ImageGeneratorWindow(QMainWindow):
if reply == QMessageBox.Yes:
success = self.history_manager.delete_history_item(timestamp)
if success:
# 增量从 model 移除该 timestamp,O(N) 查找 + O(1) 删除
removed = self.history_model.remove_timestamp(timestamp)
if not removed:
# 极端情况:model 未持有该 ts(数据漂移),回退全量刷新
self.refresh_history()
else:
total = self.history_model.rowCount()
self.history_count_label.setText(f"共 {total} 条历史记录")
if total == 0:
self.clear_details_panel()
self.status_label.setText("● 历史记录已删除")
self.status_label.setStyleSheet("QLabel { color: #34C759; }")
else:
......@@ -3223,7 +3421,10 @@ class ImageGeneratorWindow(QMainWindow):
# Recreate empty directory
self.history_manager.base_path.mkdir(parents=True, exist_ok=True)
self.refresh_history()
# 增量重置 model 而非走 refresh_history(已无 index.json 可读)
self.history_model.reset_timestamps([])
self.history_count_label.setText("共 0 条历史记录")
self.clear_details_panel()
self.status_label.setText("● 历史记录已清空")
self.status_label.setStyleSheet("QLabel { color: #34C759; }")
......@@ -4441,7 +4642,7 @@ class StyleDesignerTab(QWidget):
# Save to history
try:
self.parent_window.history_manager.save_generation(
timestamp = self.parent_window.history_manager.save_generation(
image_bytes=image_bytes,
prompt=prompt,
reference_images=reference_images,
......@@ -4449,7 +4650,7 @@ class StyleDesignerTab(QWidget):
image_size=image_size,
model=model
)
self.parent_window.refresh_history()
self.parent_window.prepend_history_item(timestamp)
except Exception as e:
self.logger.warning(f"保存到历史记录失败: {e}")
......@@ -4502,8 +4703,10 @@ class StyleDesignerTab(QWidget):
)
self.logger.info(f"款式设计已添加到历史记录: {timestamp}")
# 刷新历史记录列表
if hasattr(self.parent_window, 'refresh_history'):
# 增量插入新记录(避免全量重建触发 macOS 内存压力)
if hasattr(self.parent_window, 'prepend_history_item'):
self.parent_window.prepend_history_item(timestamp)
elif hasattr(self.parent_window, 'refresh_history'):
self.parent_window.refresh_history()
else:
self.logger.warning("未找到历史记录管理器")
......
# 设计:历史记录列表视口懒加载(路线 A)
## 背景与抉择
2026-04-24 会话中与用户讨论了三条路线:
| 路线 | 做法 | 评价 |
|---|---|---|
| A | `QListView + QAbstractListModel + QStyledItemDelegate`,Qt 原生视口懒加载 | **采纳**。用户零感知,消除"全部 widget 常驻"的根本问题 |
| B | 显式分页按钮 / "加载更多" | 破坏线性浏览的心智模型,被用户否决 |
| C | 保持 `QListWidget` 只做增量 prepend | **已作为阶段 1 落地**,治标不治本 |
路线 A 是架构性修复,阶段 2 启动。
## 数据结构
### 现状(阶段 1 之后)
```
QListWidget
└── 360 × QListWidgetItem
├── QIcon(QPixmap 120×120) ← 全部常驻内存
├── 完整 tooltip 字符串
└── text (timestamp + prompt preview)
```
内存占用:每条 item ~60 KB(QPixmap 纹理 + Qt 对象 overhead),360 条 ≈ 21 MB。**但这是"当前活 widget"的成本,不算 Qt C++ 层在 clear/重建过程中的临时 peak 和回收延迟。** 连续 6 小时高频重建才是真正的内存杀手。
### 目标(阶段 2)
```
HistoryListModel
└── list[str] timestamps ← 内存 ~ 360 × 16B = 5.7 KB
HistoryItemDelegate
├── QPixmapCache (LRU, ~50 MB) ← 只缓存视口 + 最近滚过的
└── 每次 paint() 按需从 cache 取 pixmap,miss 则异步加载
```
内存占用:timestamps 本体几乎为零;pixmap cache 由 Qt 统一管控上限。**不管历史 1000 条还是 10000 条,活内存都只和视口大小相关。**
## 关键决策
### 1. Model 为何只存 timestamps 而不存完整 HistoryItem
- timestamp 是稳定主键,`HistoryManager.load_history_item_fast(ts)` 可 O(1) 拿到完整数据
- 避免 model 内部持有 360 个 HistoryItem(那样又回到"全量常驻")
- metadata.json 读取很快(几 KB / 次),magneto 让 OS 页缓存帮我们做热度管理
- **如果实测 paint() 时的 metadata.json 读取成为瓶颈**,再引入轻量 LRU cache(`functools.lru_cache(maxsize=200)` on `load_history_item_fast`
### 2. Delegate 的 paint() 是否会阻塞滚动
会,如果在 paint() 里同步做磁盘 IO / PIL 解码。规避方案:
- `paint()`**只查 QPixmapCache**,不做 IO
- Cache miss → 丢一个 `ThumbnailLoader(QRunnable)``QThreadPool`,当前 paint 返回占位图
- 后台加载完 → `cache.insert(key, pixmap)` + `model.dataChanged(index, index, [Qt.DecorationRole])`
- 下次 paint 重新从 cache 取到真图,丝滑切换
### 3. 与现有 prepend_history_item 的衔接
阶段 1 的 `prepend_history_item` 目前做的是 `history_list.insertItem(0, widget)`,阶段 2 改造后简化成:
```python
def prepend_history_item(self, timestamp):
self.history_model.insertRow(0, timestamp)
```
对外接口签名不变,调用方(`on_image_generated` 等 4 处)**零改动**
### 4. 尺寸对齐策略
`QListWidgetItem` 的默认 sizeHint 由 icon size + text 行高决定。我们的 delegate `sizeHint()` 必须返回**同一个值**,否则:
- 切换架构时列表视觉抖动
- 选中高亮条高度不对
- 滚动到特定位置时错位
具体值需在迁移时从 runtime 抓取(比如先给老代码加一次 `print(item.sizeHint())`)。
### 5. 信号迁移
`QListWidget``itemClicked(QListWidgetItem)``QListView``clicked(QModelIndex)`
所有槽函数参数类型变化,需要:
- `timestamp = index.data(Qt.UserRole)` 替换 `item.data(Qt.UserRole)`
- 右键菜单从 `itemAt(position)` 改成 `indexAt(position)`
## 风险与回滚
### 风险
1. **视觉差异**`QListView` 默认选中样式(蓝底白字)与 `QListWidget` 不完全一致,需 QSS 调齐
2. **delegate 尺寸计算 off-by-one**:会表现为列表项之间 1–2 px 间隙或重叠,反复打磨
3. **异步缩略图加载竞态**:快速滚动时可能看到闪烁的占位图(可接受,滚停后 ~100ms 内补齐)
### 回滚
每个 step 独立可跑可回滚:
- Step 1 完成(Model/View 替换)后可单独发版,此时延续 Qt 默认的"见一个画一个"但是每次仍 build widget-like object
- Step 2(QPixmapCache + 异步加载)失败时保留 Step 1 的同步加载,性能不如目标但正确性不受影响
- 完整回滚:`git revert` 本 change 对应 commit,回到阶段 1 的 QListWidget + 增量 prepend 模式
## 性能目标
| 指标 | 阶段 1(当前) | 阶段 2(目标) |
|---|---|---|
| 生成完成后 UI 刷新耗时 | ~50 ms(1 条 widget) | ~5 ms(1 次 insertRow) |
| 首次切到历史 tab(360 条) | ~250 ms | < 50 ms(只画视口 15 条) |
| 首次切到历史 tab(1000 条) | ~700 ms(线性增长) | < 50 ms(与总数无关) |
| 连续 6 小时 100 次生成的活内存峰值 | 不确定,疑似 GB 级 | < 300 MB(cache 上限可控) |
| 闪退次数 / 日(Mac) | 14 次 → 期望 0 次(阶段 1 观察中) | 0 次 |
## 不做的事
- **不改 index.json 存储格式** —— 那是另一个独立坑,留给后续 change 处理
- **不动 `_fix_history_path` 的路径修正逻辑** —— 同上,独立重构
- **不引入虚拟滚动库**(如自写 virtual list)—— Qt 原生 Model/View 已经提供这能力
- **不做"翻页按钮"** —— 用户明确否决,会破坏线性浏览体验
# 提案:历史记录列表增量渲染 + 视口懒加载(路线 A)
> 状态:**第一阶段已完成(增量 prepend)**,第二阶段(Model/Delegate 重构)**下周启动**。
## Why
2026-04-24 当天应用在 Mac 上闪退 14 次。根因定位:
- `app(7).log` 末尾戛然而止,无 Traceback
- `crash_log(3).txt` 的 faulthandler 也没捕到任何信号栈
- **两处都没栈 = 只能是 SIGKILL**(faulthandler 无法拦截 SIGKILL,Unix 铁律)→ 99% 是 macOS jetsam 内存压力强杀
`image_generator.py` 历史记录渲染路径有三个相互放大的问题:
1. **每次生成完图片都 `refresh_history()` 全量重建**`:2859``:2884``:4452``:4507`)。360 条 × 120×120 QPixmap + QIcon + QListWidgetItem,6 小时内累计创建 ~9 万个 Qt 对象,C++ 层回收滞后 + 内存碎片化。
2. **`load_history_index()` 每次调用都做 O(N) 路径修正**,360 条 × 2 图 = 720 次 `os.stat` 在 UI 主线程上。崩溃前 11 秒内被 UI 事件重复触发了 7 次。
3. **`QListWidget` 架构决定"全部 widget 常驻"**,用户只看得到视口内 10–15 行,但 Qt 必须为所有 360 条持有 QPixmap。历史记录越多,风险越高。
代码注释已自承风险:`# macOS 上触发 SIGKILL``image_generator.py:3071`)。
## What Changes
### 阶段 1:增量 prepend(已完成,2026-04-24)
历史记录是 append-only 数据(生成后 prompt/图片/参数不可编辑),生成完成后只需追加单条到列表首位,无需全量重建。
- 新增 `HistoryManager.load_history_item_fast(timestamp)` — 只读 `{timestamp}/metadata.json` + 扫目录下 `reference_*.png`**不触碰 `index.json`、不扫其他记录、不做路径修正**
- 抽出 `_build_history_list_item(item)` — 单条 widget 构建逻辑,供全量和增量共用
- 新增 `prepend_history_item(timestamp)` — 增量插入到列表首位,异常时自动回退 `refresh_history()` 全量
- 四处生成完成回调从 `refresh_history()` 切换为 `prepend_history_item(timestamp)`
### 阶段 2:Model/Delegate 视口懒加载(下周启动)
阶段 1 消除了"每次生成的 O(N) 重建",但"首次加载/手动刷新"仍是一次性画 360 个 widget。真正的根治是换架构:
- `QListWidget``QListView + HistoryListModel(QAbstractListModel) + HistoryItemDelegate(QStyledItemDelegate)`
- Model 只存 `list[str]` 的 timestamps(内存几乎为零)
- Delegate 在 `paint()` 时按需读 metadata + 缩略图,滚出视口自动不再绘制
- **效果等同于无限懒加载,用户零感知,不引入"翻页按钮"类的 UI 变化**
额外优化:
- `QPixmapCache`(LRU,默认 ~50 MB)缓存最近加载的缩略图
- 后台线程(`QThreadPool + QRunnable`)异步加载缩略图,主线程占位 placeholder,缩略图回来再触发重绘
- `delete` / `clear` 改为 model 级别增量(`removeRow` / `reset`),不再触发"全量 refresh"
## Impact
- **Affected specs**: history-list-rendering(新,阶段 2 时建立)
- **Affected code**:
- `image_generator.py`: HistoryManager / ImageGeneratorWindow 历史 tab 相关方法
- 阶段 2 会新增 `HistoryListModel` / `HistoryItemDelegate` 两个类
- **User-visible**:
- 阶段 1:**无感知**(仅性能/稳定性提升)
- 阶段 2:历史 tab 的选中样式、间距、hover 可能有 1–2 px 的视觉差异(`QListView` 默认样式 vs `QListWidget`),需要 QSS 调齐
- **Risk**:
- 阶段 2 触及历史 tab 核心交互路径(单击/双击/右键菜单/详情面板联动),需要完整回归
- delegate 的尺寸计算(`sizeHint`)必须和现有 icon size + text 行高严格一致,否则滚动位置/选中高亮会错位
# 任务列表:历史记录列表增量渲染 + 视口懒加载
## 阶段 1:增量 prepend(已完成 2026-04-24)
- [x] 1.1 `HistoryManager.load_history_item_fast(timestamp)` — 轻量单条读取(不走 index.json)
- [x] 1.2 抽出 `_build_history_list_item(item)` — 单条 widget 构建逻辑
- [x] 1.3 `prepend_history_item(timestamp)` — 增量插入 + 异常回退 `refresh_history()`
- [x] 1.4 四处 `refresh_history()` 调用点切换为 `prepend_history_item(timestamp)`
- [x] 1.4.1 `on_generation_complete``image_generator.py:2895`
- [x] 1.4.2 `on_image_generated``image_generator.py:2919`
- [x] 1.4.3 `_on_my_task_completed``image_generator.py:4514`
- [x] 1.4.4 款式设计 tab `on_generation_success``image_generator.py:4568`
- [x] 1.5 语法自检通过(`python -c "import ast; ast.parse(...)"`
**仍保留全量刷新**(刻意保留,不影响使用习惯):
- 手动点"刷新"按钮
- 首次切到历史 tab
- 删除单条 / 清空全部(阶段 2 才会动)
## 阶段 2:Model/Delegate 视口懒加载(下周启动)
### Step 1 — 引入 Model/View 架构(已完成 2026-04-27)
- [x] 2.1 新增 `HistoryListModel(QAbstractListModel)`
- [x] 2.1.1 数据仅 `list[str]` timestamps(按时间戳倒序)
- [x] 2.1.2 实现 `rowCount` / `data(index, role)` / `flags`
- [x] 2.1.3 `data()` 按需调 `HistoryManager.load_history_item_fast(timestamp)`,内部 OrderedDict LRU(max=300)缓存 icon/display_text/tooltip
- [x] 2.1.4 支持 `prepend_timestamp` / `remove_timestamp` / `reset_timestamps` / `invalidate_cache`
- [~] 2.2 `HistoryItemDelegate(QStyledItemDelegate)`**Step 1 暂未引入自定义 delegate**
- [x] 2.2.1 沿用 `QStyledItemDelegate` 默认实现(IconMode 默认绘制 icon + 文本,与 QListWidget 一致)
- [x] 2.2.2 sizeHint 沿用默认(与 QListWidget IconMode 行为一致)
- 备注:Step 2 引入异步 thumbnail 加载时再考虑是否需要自定义 delegate
- [x] 2.3 历史 tab 替换
- [x] 2.3.1 `self.history_list = QListWidget(...)``QListView(...)` + IconMode/IconSize/Spacing/Adjust/Static 全部对齐旧版
- [x] 2.3.2 `setModel(self.history_model)`(delegate 沿用默认)
- [x] 2.3.3 `clicked(QModelIndex)` / `customContextMenuRequested` + `indexAt(position)` 信号迁移
- [x] 2.4 `refresh_history` 简化为 `history_model.reset_timestamps([item.timestamp for ...])`(不再建 widget)
- [x] 2.5 `prepend_history_item` 简化为 `history_model.prepend_timestamp(timestamp)`
- [x] 2.6 `delete_history_item` UI 侧改为 `history_model.remove_timestamp(timestamp)`(失败回退 refresh)
- [x] 2.7 `clear_history` UI 侧改为 `history_model.reset_timestamps([])`
### Step 2 — 缩略图缓存 + 异步加载(性能优化)
- [ ] 2.8 `QPixmapCache` 接入 delegate `paint()`(key = thumb_path)
- [ ] 2.9 设置 cache limit(默认 ~50 MB,足够 ~500 张 thumb.jpg)
- [ ] 2.10 `ThumbnailLoader(QRunnable)` 后台加载
- [ ] 2.10.1 `QThreadPool.globalInstance()` 丢任务
- [ ] 2.10.2 加载完回主线程 `dataChanged(index)` 触发 delegate 重绘
- [ ] 2.10.3 cache miss 时 delegate 先画占位图,不阻塞滚动
### Step 3 — 收尾与回归
- [ ] 2.11 回归测试:
- [ ] 2.11.1 单击历史项 → 详情面板正常联动
- [ ] 2.11.2 双击 / 右键菜单 / 删除 / 清空 全部正常
- [ ] 2.11.3 生成新图 → 立即出现在列表顶部
- [ ] 2.11.4 滚动 300+ 条流畅,Activity Monitor 观察内存稳定
- [ ] 2.11.5 Mac 连续工作 4 小时无闪退
- [ ] 2.12 删除过时的 `_build_history_list_item` / `QListWidgetItem` 构造代码
- [ ] 2.13 更新本文档(阶段 2 勾选完毕)
## 验证标准
- **阶段 1**:Mac 连续工作一天,崩溃次数从 14 次/日显著下降(目标:0 次)
- **阶段 2**:历史记录 1000 条时,首次切到历史 tab 的 UI 响应时间 < 200ms;滚动过程中内存峰值不超过 500 MB