️ 历史记录列表改增量渲染 + 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
242 additions
and
0 deletions
This diff is collapsed.
Click to expand it.
| 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