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) 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 改造后简化成:

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. 信号迁移

QListWidgetitemClicked(QListWidgetItem)QListViewclicked(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 已经提供这能力
  • 不做"翻页按钮" —— 用户明确否决,会破坏线性浏览体验