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