Skip to content

Commit 141a90b

Browse files
da-liiiclaude
andauthored
[0129] 优化 PDF 缩放性能:页面缓存、可见性裁剪与防抖 (#3371)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1a2e363 commit 141a90b

3 files changed

Lines changed: 346 additions & 26 deletions

File tree

devel/0129.md

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# [0129] PDF 缩放性能优化
2+
3+
## 1 相关文档
4+
- [dddd.md](dddd.md) - 任务文档模板
5+
- [0120.md](0120.md) - 在 Mogan STEM 中实现 PDF 阅读功能
6+
7+
## 2 任务相关的代码文件
8+
- `src/Plugins/Qt/qt_pdf_reader_widget.hpp`
9+
- `src/Plugins/Qt/qt_pdf_reader_widget.cpp`
10+
- `tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp`
11+
12+
## 3 如何测试
13+
14+
### 3.1 确定性测试(单元测试)
15+
```bash
16+
xmake run qt_pdf_reader_widget_test
17+
```
18+
19+
### 3.2 非确定性测试(文档验证)
20+
```bash
21+
# 启动 Mogan STEM,打开一个多页 PDF 文件
22+
# 验证:
23+
# 1. Ctrl+滚轮缩放时流畅,不卡顿
24+
# 2. 窗口大小调整时不会卡死
25+
# 3. 快速连续缩放后有防抖效果,最终只渲染一次
26+
# 4. 滚动长文档时,非可见页面不阻塞渲染
27+
```
28+
29+
## 4 如何提交
30+
31+
提交前执行以下最少步骤:
32+
33+
```bash
34+
xmake build qt_pdf_reader_widget_test stem
35+
xmake run qt_pdf_reader_widget_test
36+
```
37+
38+
## 5 What
39+
40+
优化 `PDFReaderWidget` 的缩放与渲染性能。当前实现存在严重性能瓶颈:
41+
1. 每次缩放(Ctrl+滚轮)都同步重新渲染**所有**页面
42+
2. 窗口 Resize 也触发全部页面重新渲染
43+
3. 没有缓存机制,翻页或轻微缩放后重复渲染相同页面
44+
4. 不可见页面也参与渲染,浪费 CPU/GPU 资源
45+
46+
参考 Okular 的 Tile-based 渲染架构,引入页面级缓存、可见性裁剪、缩放防抖等优化策略。
47+
48+
## 6 Why
49+
50+
`PDFReaderWidget` 使用 MuPDF 逐页矢量渲染,每页渲染成本较高(打开文档、解析流、光栅化)。对于 50 页以上的 PDF,缩放时同步渲染全部页面会导致 UI 冻结数秒,体验极差。必须通过算法优化减少无效渲染。
51+
52+
## 7 How
53+
54+
### 7.1 Okular 核心算法分析
55+
56+
Okular 的缩放性能优化基于以下核心机制(文件参考 `okular/core/tilesmanager.cpp``okular/part/pageview.cpp``okular/gui/pagepainter.cpp`):
57+
58+
1. **瓦片化渲染 (Tile-based Rendering)**
59+
每页初始划分为 4x4 网格(16 个 Tile)。若 Tile 像素数超过 `TILES_MAXSIZE` (200万),递归四分为子 Tile。这样高缩放下只渲染可见区域的小块,而非整页。
60+
61+
2. **可见性裁剪 (Viewport Clipping)**
62+
`slotRequestVisiblePixmaps()` 只请求与视口相交的 Tile,并扩展 512px 预加载边距。完全不可见的页面不发起渲染请求。
63+
64+
3. **异步渲染 (Async Rendering)**
65+
渲染请求以 `PixmapRequest` 异步发送到后台 Generator,UI 线程不阻塞。渲染完成后通过 `setPixmap()` 回填 Tile。
66+
67+
4. **脏标记与增量更新 (Dirty Flag)**
68+
缩放/旋转时将所有 Tile 标记为 `dirty=true`,但不清除缓存。绘制时先显示旧缓存的缩放版本(Qt::FastTransformation),只重新请求 dirty 的可见 Tile。
69+
70+
5. **瓦片缓存 LRU 淘汰 (LRU Eviction)**
71+
`cleanupPixmapMemory()` 按 Manhattan 距离对 Tile 排序,优先淘汰距离视口中心最远且不可见的 Tile。可见 Tile 永不淘汰。
72+
73+
6. **请求去重 (Request Deduplication)**
74+
`isRequesting()` / `setRequest()` 避免对同一区域重复发起渲染请求,防止快速滚动/缩放时的请求风暴。
75+
76+
7. **降级显示 (Progressive Rendering)**
77+
后端支持 `isPartialPixmap=true` 的部分更新。大图的背景可先显示,文字层随后叠加,避免白屏等待。
78+
79+
### 7.2 Mogan 适配方案
80+
81+
Mogan 的 `PDFReaderWidget` 基于连续滚动的 QLabel 列表,架构比 Okular 简单(无 Tile 层级、无 Generator 线程池)。采用**页面级**优化而非完整 Tile 系统,在现有架构上最大化收益:
82+
83+
#### 7.2.1 页面渲染缓存 (Page Render Cache)
84+
85+
- 新增 `PageCacheKey { int pageNumber; int targetWidth; }` 作为缓存键
86+
- 使用 `QHash<PageCacheKey, QPixmap>` 存储已渲染页面
87+
- `renderPageToLabel()` 先从缓存查找,命中则直接 `setPixmap`,跳过 MuPDF 渲染
88+
- 缓存仅在以下情况失效:
89+
- `zoomFactor` 变化导致 `targetWidth` 改变
90+
- `loadFromFile()` 加载新文档
91+
- `clear()` 清除文档
92+
93+
#### 7.2.2 可见性裁剪 (Visibility Culling)
94+
95+
- `rebuildPages()` 不再遍历所有页面
96+
- 计算当前视口 Y 范围 `scrollY``scrollY + viewportHeight`
97+
- 只渲染满足 `labelY + labelHeight > scrollY - preloadMargin``labelY < scrollY + viewportHeight + preloadMargin` 的页面
98+
- 不可见页面保留为空白占位符或旧缓存图
99+
- 滚动时通过 `verticalScrollBar()->valueChanged` 信号触发按需渲染
100+
101+
#### 7.2.3 缩放防抖 (Zoom Debounce)
102+
103+
- 新增 `QTimer* zoomDebounceTimer_`(超时 200ms)
104+
- `wheelEvent` 中 Ctrl+滚轮缩放时,只更新 `zoomFactor_`,不立即 `rebuildPages()`
105+
- 启动/重启防抖定时器,连续缩放时不断重置
106+
- 定时器超时后统一调用 `rebuildPages()`,只渲染最终状态
107+
108+
#### 7.2.4 Resize 防抖 (Resize Debounce)
109+
110+
- 新增 `QTimer* resizeDebounceTimer_`(超时 300ms)
111+
- `eventFilter``QEvent::Resize` 不立即 `rebuildPages()`
112+
- 防抖后统一重建,避免拖拽窗口大小时的连续重渲染
113+
114+
#### 7.2.5 降级显示 (Fast Fallback)
115+
116+
- 缩放导致缓存失效时,若某页旧缓存仍存在,先以 `Qt::FastTransformation` 缩放到目标尺寸显示
117+
- 后台渲染完成后替换为 `Qt::SmoothTransformation` 高清图
118+
- 实现思路:每个 QLabel 先尝试显示旧缓存的 Fast 缩放版本,再异步(或延迟同步)替换为清晰版本
119+
120+
### 7.3 数据结构修改
121+
122+
**`qt_pdf_reader_widget.hpp`** 新增:
123+
```cpp
124+
struct PageCacheKey {
125+
int pageNumber;
126+
int targetWidth;
127+
bool operator==(const PageCacheKey& other) const;
128+
};
129+
130+
// 自定义 qHash
131+
inline uint qHash(const PageCacheKey& key, uint seed = 0);
132+
133+
QHash<PageCacheKey, QPixmap> pageCache_;
134+
QTimer* zoomDebounceTimer_;
135+
QTimer* resizeDebounceTimer_;
136+
static constexpr int PRELOAD_MARGIN = 200; // 预加载边距(像素)
137+
```
138+
139+
### 7.4 核心函数修改
140+
141+
1. **`renderPageToLabel()`**:先查 `pageCache_`,命中则直接返回;未命中则 MuPDF 渲染后写入缓存。
142+
143+
2. **`rebuildPages()`**:
144+
- 遍历前先计算视口范围
145+
- 对每个 `QLabel* label`,若其在视口+预加载范围内,调用 `renderPageToLabel()`
146+
- 若不在范围内但缓存存在,显示缓存的 Fast 缩放版本
147+
- 若不在范围内且无缓存,显示空白占位符(保持布局高度)
148+
149+
3. **`setZoomFactor()`**:
150+
- 更新 `zoomFactor_`
151+
- 启动 `zoomDebounceTimer_`
152+
- 不立即调用 `rebuildPages()`
153+
154+
4. **`eventFilter()` Resize 处理**:
155+
- 启动 `resizeDebounceTimer_`
156+
- 不立即调用 `rebuildPages()`
157+
158+
5. **`clear()`**:清空 `pageCache_`。
159+
160+
### 7.5 测试策略
161+
162+
**测试 1:`test_zoom_debounce()`**
163+
- 快速连续调用 `setZoomFactor()` 10 次
164+
- 验证 `renderPageToLabel` 实际执行次数 < 10(理想为 1)
165+
166+
**测试 2:`test_page_cache_hit()`**
167+
- 加载 PDF,渲染第 1 页
168+
- 再次调用 `renderPageToLabel(0, label, width)`
169+
- 验证渲染耗时显著降低(或验证 MuPDF 未再次调用)
170+
171+
**测试 3:`test_visibility_culling()`**
172+
- 加载 10 页 PDF,视口仅显示第 1 页
173+
- 验证非可见页面的 `renderPageToLabel` 未被调用(除非预加载范围包含)
174+
175+
### 7.6 注意事项
176+
177+
- `pageCache_` 的总内存消耗约为 `sum(pixmap.width * pixmap.height * 4)` 字节。对于 100 页、每页 1000x1400 的 PDF,全缓存约 560MB。暂不设 LRU 上限,因 Mogan 以 STEM 文档为主,PDF 为辅助阅读,通常不会同时打开超大 PDF。若后续需要,可引入 Okular 式的距离排序淘汰。
178+
- MuPDF 的 `fz_context` 非线程安全,若未来引入多线程异步渲染,需为每个线程创建独立 context。
179+
- 防抖定时器使用 `Qt::PreciseTimer` 还是 `Qt::CoarseTimer` 对体验影响不大,默认即可。

0 commit comments

Comments
 (0)