design.md
5.28 KB
设计:历史记录列表视口懒加载(路线 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)onload_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 改造后简化成:
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)
风险与回滚
风险
-
视觉差异:
QListView默认选中样式(蓝底白字)与QListWidget不完全一致,需 QSS 调齐 - delegate 尺寸计算 off-by-one:会表现为列表项之间 1–2 px 间隙或重叠,反复打磨
- 异步缩略图加载竞态:快速滚动时可能看到闪烁的占位图(可接受,滚停后 ~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 已经提供这能力
- 不做"翻页按钮" —— 用户明确否决,会破坏线性浏览体验