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
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