Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions devel/0129.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# [0129] PDF 缩放性能优化

## 1 相关文档
- [dddd.md](dddd.md) - 任务文档模板
- [0120.md](0120.md) - 在 Mogan STEM 中实现 PDF 阅读功能

## 2 任务相关的代码文件
- `src/Plugins/Qt/qt_pdf_reader_widget.hpp`
- `src/Plugins/Qt/qt_pdf_reader_widget.cpp`
- `tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp`

## 3 如何测试

### 3.1 确定性测试(单元测试)
```bash
xmake run qt_pdf_reader_widget_test
```

### 3.2 非确定性测试(文档验证)
```bash
# 启动 Mogan STEM,打开一个多页 PDF 文件
# 验证:
# 1. Ctrl+滚轮缩放时流畅,不卡顿
# 2. 窗口大小调整时不会卡死
# 3. 快速连续缩放后有防抖效果,最终只渲染一次
# 4. 滚动长文档时,非可见页面不阻塞渲染
```

## 4 如何提交

提交前执行以下最少步骤:

```bash
xmake build qt_pdf_reader_widget_test stem
xmake run qt_pdf_reader_widget_test
```

## 5 What

优化 `PDFReaderWidget` 的缩放与渲染性能。当前实现存在严重性能瓶颈:
1. 每次缩放(Ctrl+滚轮)都同步重新渲染**所有**页面
2. 窗口 Resize 也触发全部页面重新渲染
3. 没有缓存机制,翻页或轻微缩放后重复渲染相同页面
4. 不可见页面也参与渲染,浪费 CPU/GPU 资源

参考 Okular 的 Tile-based 渲染架构,引入页面级缓存、可见性裁剪、缩放防抖等优化策略。

## 6 Why

`PDFReaderWidget` 使用 MuPDF 逐页矢量渲染,每页渲染成本较高(打开文档、解析流、光栅化)。对于 50 页以上的 PDF,缩放时同步渲染全部页面会导致 UI 冻结数秒,体验极差。必须通过算法优化减少无效渲染。

## 7 How

### 7.1 Okular 核心算法分析

Okular 的缩放性能优化基于以下核心机制(文件参考 `okular/core/tilesmanager.cpp`、`okular/part/pageview.cpp`、`okular/gui/pagepainter.cpp`):

1. **瓦片化渲染 (Tile-based Rendering)**
每页初始划分为 4x4 网格(16 个 Tile)。若 Tile 像素数超过 `TILES_MAXSIZE` (200万),递归四分为子 Tile。这样高缩放下只渲染可见区域的小块,而非整页。

2. **可见性裁剪 (Viewport Clipping)**
`slotRequestVisiblePixmaps()` 只请求与视口相交的 Tile,并扩展 512px 预加载边距。完全不可见的页面不发起渲染请求。

3. **异步渲染 (Async Rendering)**
渲染请求以 `PixmapRequest` 异步发送到后台 Generator,UI 线程不阻塞。渲染完成后通过 `setPixmap()` 回填 Tile。

4. **脏标记与增量更新 (Dirty Flag)**
缩放/旋转时将所有 Tile 标记为 `dirty=true`,但不清除缓存。绘制时先显示旧缓存的缩放版本(Qt::FastTransformation),只重新请求 dirty 的可见 Tile。

5. **瓦片缓存 LRU 淘汰 (LRU Eviction)**
`cleanupPixmapMemory()` 按 Manhattan 距离对 Tile 排序,优先淘汰距离视口中心最远且不可见的 Tile。可见 Tile 永不淘汰。

6. **请求去重 (Request Deduplication)**
`isRequesting()` / `setRequest()` 避免对同一区域重复发起渲染请求,防止快速滚动/缩放时的请求风暴。

7. **降级显示 (Progressive Rendering)**
后端支持 `isPartialPixmap=true` 的部分更新。大图的背景可先显示,文字层随后叠加,避免白屏等待。

### 7.2 Mogan 适配方案

Mogan 的 `PDFReaderWidget` 基于连续滚动的 QLabel 列表,架构比 Okular 简单(无 Tile 层级、无 Generator 线程池)。采用**页面级**优化而非完整 Tile 系统,在现有架构上最大化收益:

#### 7.2.1 页面渲染缓存 (Page Render Cache)

- 新增 `PageCacheKey { int pageNumber; int targetWidth; }` 作为缓存键
- 使用 `QHash<PageCacheKey, QPixmap>` 存储已渲染页面
- `renderPageToLabel()` 先从缓存查找,命中则直接 `setPixmap`,跳过 MuPDF 渲染
- 缓存仅在以下情况失效:
- `zoomFactor` 变化导致 `targetWidth` 改变
- `loadFromFile()` 加载新文档
- `clear()` 清除文档

#### 7.2.2 可见性裁剪 (Visibility Culling)

- `rebuildPages()` 不再遍历所有页面
- 计算当前视口 Y 范围 `scrollY` 到 `scrollY + viewportHeight`
- 只渲染满足 `labelY + labelHeight > scrollY - preloadMargin` 且 `labelY < scrollY + viewportHeight + preloadMargin` 的页面
- 不可见页面保留为空白占位符或旧缓存图
- 滚动时通过 `verticalScrollBar()->valueChanged` 信号触发按需渲染

#### 7.2.3 缩放防抖 (Zoom Debounce)

- 新增 `QTimer* zoomDebounceTimer_`(超时 200ms)
- `wheelEvent` 中 Ctrl+滚轮缩放时,只更新 `zoomFactor_`,不立即 `rebuildPages()`
- 启动/重启防抖定时器,连续缩放时不断重置
- 定时器超时后统一调用 `rebuildPages()`,只渲染最终状态

#### 7.2.4 Resize 防抖 (Resize Debounce)

- 新增 `QTimer* resizeDebounceTimer_`(超时 300ms)
- `eventFilter` 中 `QEvent::Resize` 不立即 `rebuildPages()`
- 防抖后统一重建,避免拖拽窗口大小时的连续重渲染

#### 7.2.5 降级显示 (Fast Fallback)

- 缩放导致缓存失效时,若某页旧缓存仍存在,先以 `Qt::FastTransformation` 缩放到目标尺寸显示
- 后台渲染完成后替换为 `Qt::SmoothTransformation` 高清图
- 实现思路:每个 QLabel 先尝试显示旧缓存的 Fast 缩放版本,再异步(或延迟同步)替换为清晰版本

### 7.3 数据结构修改

**`qt_pdf_reader_widget.hpp`** 新增:
```cpp
struct PageCacheKey {
int pageNumber;
int targetWidth;
bool operator==(const PageCacheKey& other) const;
};

// 自定义 qHash
inline uint qHash(const PageCacheKey& key, uint seed = 0);

QHash<PageCacheKey, QPixmap> pageCache_;
QTimer* zoomDebounceTimer_;
QTimer* resizeDebounceTimer_;
static constexpr int PRELOAD_MARGIN = 200; // 预加载边距(像素)
```

### 7.4 核心函数修改

1. **`renderPageToLabel()`**:先查 `pageCache_`,命中则直接返回;未命中则 MuPDF 渲染后写入缓存。

2. **`rebuildPages()`**:
- 遍历前先计算视口范围
- 对每个 `QLabel* label`,若其在视口+预加载范围内,调用 `renderPageToLabel()`
- 若不在范围内但缓存存在,显示缓存的 Fast 缩放版本
- 若不在范围内且无缓存,显示空白占位符(保持布局高度)

3. **`setZoomFactor()`**:
- 更新 `zoomFactor_`
- 启动 `zoomDebounceTimer_`
- 不立即调用 `rebuildPages()`

4. **`eventFilter()` Resize 处理**:
- 启动 `resizeDebounceTimer_`
- 不立即调用 `rebuildPages()`

5. **`clear()`**:清空 `pageCache_`。

### 7.5 测试策略

**测试 1:`test_zoom_debounce()`**
- 快速连续调用 `setZoomFactor()` 10 次
- 验证 `renderPageToLabel` 实际执行次数 < 10(理想为 1)

**测试 2:`test_page_cache_hit()`**
- 加载 PDF,渲染第 1 页
- 再次调用 `renderPageToLabel(0, label, width)`
- 验证渲染耗时显著降低(或验证 MuPDF 未再次调用)

**测试 3:`test_visibility_culling()`**
- 加载 10 页 PDF,视口仅显示第 1 页
- 验证非可见页面的 `renderPageToLabel` 未被调用(除非预加载范围包含)

### 7.6 注意事项

- `pageCache_` 的总内存消耗约为 `sum(pixmap.width * pixmap.height * 4)` 字节。对于 100 页、每页 1000x1400 的 PDF,全缓存约 560MB。暂不设 LRU 上限,因 Mogan 以 STEM 文档为主,PDF 为辅助阅读,通常不会同时打开超大 PDF。若后续需要,可引入 Okular 式的距离排序淘汰。
- MuPDF 的 `fz_context` 非线程安全,若未来引入多线程异步渲染,需为每个线程创建独立 context。
- 防抖定时器使用 `Qt::PreciseTimer` 还是 `Qt::CoarseTimer` 对体验影响不大,默认即可。
Loading
Loading