diff --git a/devel/0129.md b/devel/0129.md new file mode 100644 index 0000000000..c8310d89fa --- /dev/null +++ b/devel/0129.md @@ -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` 存储已渲染页面 +- `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 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` 对体验影响不大,默认即可。 diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index 860c6fb4f0..59ed285ed8 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -37,7 +37,8 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) zoomCombo_ (nullptr), prevPageBtn_ (nullptr), pageEdit_ (nullptr), pageTotalLabel_ (nullptr), nextPageBtn_ (nullptr), pageCount_ (0), hasError_ (false), targetDpi_ (DEFAULT_DPI), zoomFactor_ (1.0), - pageAspectRatio_ (0.0), pageBaseWidthPts_ (0.0) { + pageAspectRatio_ (0.0), pageBaseWidthPts_ (0.0), + zoomDebounceTimer_ (nullptr), resizeDebounceTimer_ (nullptr) { mainLayout_= new QVBoxLayout (this); mainLayout_->setContentsMargins (0, 0, 0, 0); @@ -67,6 +68,22 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) connect (scrollArea_->verticalScrollBar (), &QScrollBar::valueChanged, this, &PDFReaderWidget::updatePageNavigation); + connect (scrollArea_->verticalScrollBar (), &QScrollBar::valueChanged, this, + &PDFReaderWidget::rebuildPages); + + // 缩放防抖定时器 + zoomDebounceTimer_= new QTimer (this); + zoomDebounceTimer_->setSingleShot (true); + zoomDebounceTimer_->setInterval (ZOOM_DEBOUNCE_MS); + connect (zoomDebounceTimer_, &QTimer::timeout, this, + &PDFReaderWidget::rebuildPages); + + // Resize 防抖定时器 + resizeDebounceTimer_= new QTimer (this); + resizeDebounceTimer_->setSingleShot (true); + resizeDebounceTimer_->setInterval (RESIZE_DEBOUNCE_MS); + connect (resizeDebounceTimer_, &QTimer::timeout, this, + &PDFReaderWidget::rebuildPages); } PDFReaderWidget::~PDFReaderWidget () {} @@ -165,10 +182,10 @@ PDFReaderWidget::onZoomChanged (int index) { void PDFReaderWidget::setZoomFactor (double factor) { zoomFactor_= qBound (MIN_ZOOM, factor, MAX_ZOOM); + updateZoomDisplay (); if (!pdfData_.isEmpty () && pageCount_ > 0) { - rebuildPages (); + zoomDebounceTimer_->start (); } - updateZoomDisplay (); } void @@ -302,6 +319,31 @@ PDFReaderWidget::pageWidth () const { bool PDFReaderWidget::renderPageToLabel (int pageNumber, QLabel* label, int targetWidth) { + // 计算目标高度(优先使用预缓存的宽高比) + double aspectRatio= pageAspectRatio_; + if (pageNumber >= 0 && pageNumber < pageAspectRatios_.size ()) { + aspectRatio= pageAspectRatios_[pageNumber]; + } + if (aspectRatio <= 0.0) aspectRatio= 1.414; + int targetHeight= qMax (1, qRound (targetWidth * aspectRatio)); + + // 尝试从缓存读取 + PdfPageCacheKey key{pageNumber, targetWidth}; + auto it= pageCache_.find (key); + if (it != pageCache_.end ()) { + QPixmap cached= it.value (); + qreal dpr = devicePixelRatioF (); + int pxW = qMax (1, qRound (targetWidth * dpr)); + int pxH = qMax (1, qRound (targetHeight * dpr)); + if (cached.width () == pxW && cached.height () == pxH) { + label->setPixmap (cached); + label->setFixedSize (targetWidth, targetHeight); + return true; + } + // 尺寸不匹配(如 DPR 变化),移除旧缓存 + pageCache_.erase (it); + } + fz_context* ctx= mupdf_context (); if (!ctx) { errorString_= qt_translate ("PDF engine not available"); @@ -371,12 +413,11 @@ PDFReaderWidget::renderPageToLabel (int pageNumber, QLabel* label, fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to load page %d", pageNumber); } - fz_rect bbox = fz_bound_page (ctx, page); - float pageWidth = bbox.x1 - bbox.x0; - float pageHeight = bbox.y1 - bbox.y0; - float aspectRatio= pageHeight / pageWidth; - - int targetHeight= qMax (1, qRound (targetWidth * aspectRatio)); + fz_rect bbox = fz_bound_page (ctx, page); + float pageWidth = bbox.x1 - bbox.x0; + float pageHeight= bbox.y1 - bbox.y0; + aspectRatio = pageHeight / pageWidth; + targetHeight = qMax (1, qRound (targetWidth * aspectRatio)); qreal dpr = devicePixelRatioF (); int targetPxW= qMax (1, qRound (targetWidth * dpr)); @@ -429,6 +470,9 @@ PDFReaderWidget::renderPageToLabel (int pageNumber, QLabel* label, label->setPixmap (pixmap); label->setFixedSize (targetWidth, targetHeight); + + // 写入缓存 + pageCache_.insert (key, pixmap); success= true; } fz_catch (ctx) { @@ -455,13 +499,65 @@ PDFReaderWidget::rebuildPages () { if (width <= 0) return; int childCount= pageLayout_->count (); + + // 第一轮:统一设置所有 label 的目标尺寸,保证布局正确 for (int i= 0; i < childCount && i < pageCount_; ++i) { QLayoutItem* item= pageLayout_->itemAt (i); if (!item) continue; QLabel* label= qobject_cast (item->widget ()); - if (label) { + if (!label) continue; + double aspect= (i < pageAspectRatios_.size ()) ? pageAspectRatios_[i] + : pageAspectRatio_; + if (aspect <= 0.0) aspect= 1.414; + int height= qMax (1, qRound (width * aspect)); + label->setFixedSize (width, height); + } + + // 计算当前视口范围(考虑预加载边距) + int scrollY = scrollArea_->verticalScrollBar ()->value (); + int viewportHeight= scrollArea_->viewport ()->height (); + int minY = scrollY - PRELOAD_MARGIN; + int maxY = scrollY + viewportHeight + PRELOAD_MARGIN; + + // 第二轮:只渲染可见及预加载范围内的页面 + for (int i= 0; i < childCount && i < pageCount_; ++i) { + QLayoutItem* item= pageLayout_->itemAt (i); + if (!item) continue; + QLabel* label= qobject_cast (item->widget ()); + if (!label) continue; + + double aspect= (i < pageAspectRatios_.size ()) ? pageAspectRatios_[i] + : pageAspectRatio_; + if (aspect <= 0.0) aspect= 1.414; + int height= qMax (1, qRound (width * aspect)); + + // 使用理论 Y 坐标判断可见性(布局 spacing = PAGE_MARGIN) + int labelTop = PAGE_MARGIN + i * (height + PAGE_MARGIN); + int labelBottom= labelTop + height; + + if (labelBottom >= minY && labelTop <= maxY) { renderPageToLabel (i, label, width); } + else { + // 视口外:尝试用缓存的降级版本显示,避免空白跳动 + PdfPageCacheKey key{i, width}; + auto it= pageCache_.find (key); + if (it != pageCache_.end ()) { + QPixmap cached= it.value (); + qreal dpr = devicePixelRatioF (); + int pxW = qMax (1, qRound (width * dpr)); + int pxH = qMax (1, qRound (height * dpr)); + if (cached.width () != pxW || cached.height () != pxH) { + cached= cached.scaled (pxW, pxH, Qt::KeepAspectRatio, + Qt::FastTransformation); + cached.setDevicePixelRatio (dpr); + } + label->setPixmap (cached); + } + else { + label->clear (); + } + } } } @@ -530,12 +626,22 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { pageCount_= fz_count_pages (ctx, doc); opened = (pageCount_ > 0); if (opened && pageCount_ > 0) { - fz_page* page= fz_load_page (ctx, doc, 0); - if (page) { - fz_rect bbox = fz_bound_page (ctx, page); - pageBaseWidthPts_= bbox.x1 - bbox.x0; - pageAspectRatio_ = (bbox.y1 - bbox.y0) / (bbox.x1 - bbox.x0); - fz_drop_page (ctx, page); + pageAspectRatios_.reserve (pageCount_); + for (int i= 0; i < pageCount_; ++i) { + fz_page* page= fz_load_page (ctx, doc, i); + if (page) { + fz_rect bbox = fz_bound_page (ctx, page); + double aspect= (bbox.y1 - bbox.y0) / (bbox.x1 - bbox.x0); + pageAspectRatios_.append (aspect); + if (i == 0) { + pageBaseWidthPts_= bbox.x1 - bbox.x0; + pageAspectRatio_ = aspect; + } + fz_drop_page (ctx, page); + } + else { + pageAspectRatios_.append (1.414); + } } } } @@ -557,18 +663,18 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { return false; } - int width= pageWidth (); + // 创建所有页面 label(先不渲染,由 rebuildPages 统一处理可见性) for (int i= 0; i < pageCount_; ++i) { QLabel* label= new QLabel (contentWidget_); label->setAlignment (Qt::AlignCenter); label->setAutoFillBackground (true); label->setBackgroundRole (QPalette::Base); label->setStyleSheet ("QLabel { border: 1px solid #cccccc; }"); - renderPageToLabel (i, label, width); pageLayout_->addWidget (label); } pageLayout_->addStretch (1); + rebuildPages (); contentWidget_->adjustSize (); updateZoomDisplay (); updatePageNavigation (); @@ -583,6 +689,8 @@ PDFReaderWidget::clear () { errorString_.clear (); pageAspectRatio_ = 0.0; pageBaseWidthPts_= 0.0; + pageAspectRatios_.clear (); + pageCache_.clear (); QLayoutItem* item; while ((item= pageLayout_->takeAt (0)) != nullptr) { @@ -624,10 +732,10 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { else { zoomFactor_= qMax (zoomFactor_ - ZOOM_STEP, MIN_ZOOM); } + updateZoomDisplay (); if (!pdfData_.isEmpty () && pageCount_ > 0) { - rebuildPages (); + zoomDebounceTimer_->start (); } - updateZoomDisplay (); } wheelEvent->accept (); return true; @@ -646,7 +754,7 @@ PDFReaderWidget::eventFilter (QObject* watched, QEvent* event) { } else if (event->type () == QEvent::Resize) { if (!pdfData_.isEmpty () && pageCount_ > 0) { - rebuildPages (); + resizeDebounceTimer_->start (); } } } diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.hpp b/src/Plugins/Qt/qt_pdf_reader_widget.hpp index 7cf791d912..fcaffae93a 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.hpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.hpp @@ -9,14 +9,32 @@ #define QT_PDF_READER_WIDGET_HPP #include +#include #include #include #include #include +#include #include #include #include +/** + * @brief Key for per-page render cache + */ +struct PdfPageCacheKey { + int pageNumber; + int targetWidth; + bool operator== (const PdfPageCacheKey& other) const { + return pageNumber == other.pageNumber && targetWidth == other.targetWidth; + } +}; + +inline uint +qHash (const PdfPageCacheKey& key, uint seed= 0) { + return qHash (key.pageNumber, seed) ^ qHash (key.targetWidth, seed); +} + /** * @brief Continuous-scroll PDF reader widget with toolbar * @@ -88,11 +106,26 @@ private slots: double pageAspectRatio_; double pageBaseWidthPts_; - static constexpr int DEFAULT_DPI= 150; - static constexpr int PAGE_MARGIN= 16; - static constexpr double MIN_ZOOM = 0.1; - static constexpr double MAX_ZOOM = 5.0; - static constexpr double ZOOM_STEP = 0.1; + // 每页宽高比缓存(用于可见性裁剪和快速高度计算) + QVector pageAspectRatios_; + + // 页面渲染缓存:key = (pageNumber, targetWidth) + QHash pageCache_; + + // 防抖定时器 + QTimer* zoomDebounceTimer_; + QTimer* resizeDebounceTimer_; + + static constexpr int DEFAULT_DPI = 150; + static constexpr int PAGE_MARGIN = 16; + static constexpr int PRELOAD_MARGIN = 200; + static constexpr double MIN_ZOOM = 0.1; + static constexpr double MAX_ZOOM = 5.0; + static constexpr double ZOOM_STEP = 0.1; + static constexpr int ZOOM_DEBOUNCE_MS = 200; + static constexpr int RESIZE_DEBOUNCE_MS= 300; }; +/* PdfPageCacheKey qHash defined above */ + #endif // QT_PDF_READER_WIDGET_HPP