diff --git a/devel/0168.md b/devel/0168.md new file mode 100644 index 0000000000..221ce3999b --- /dev/null +++ b/devel/0168.md @@ -0,0 +1,63 @@ +# [0168] 改进 PDF 阅读器的性能 + +## 1 相关文档 +- [dddd.md](dddd.md) - 任务文档模板 +- Okular 源码 (`~/git/okular`) - 参考其 tile 管理、可见性裁剪与缓存策略 + +## 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` +- `src/Plugins/MuPDF/mupdf_picture.cpp` +- `src/Plugins/MuPDF/mupdf_renderer.hpp` + +## 3 如何测试 + +### 3.1 确定性测试(单元测试) +```bash +xmake b qt_pdf_reader_widget_test +xmake r qt_pdf_reader_widget_test +``` + +### 3.2 非确定性测试(文档验证) +```bash +xmake b stem +# 启动 Mogan,打开多页 PDF,测试以下场景: +# 1. 快速滚动时无明显卡顿 +# 2. 缩放后快速滚动 +# 3. 大文件(50页以上)的加载与浏览性能 +``` + +## 4 如何提交 + +提交前执行以下最少步骤: +```bash +gf fmt --changed-since=main +xmake b qt_pdf_reader_widget_test +xmake r qt_pdf_reader_widget_test +``` + +## 5 What + +1. **文档对象常驻**:避免每次渲染页面都重新打开 PDF 文档(`fz_open_document_with_stream` 开销巨大)。✅ +2. **懒加载 label 尺寸调整**:缩放时不遍历所有页面 label 调整尺寸,只处理当前可见及附近的页面。✅ +3. **LRU 缓存淘汰**:为 `pageCache_` 增加大小上限与 LRU 淘汰策略,防止大文档内存泄漏。✅ +4. ~~**渲染请求去重**:在防抖期间阻止重复的 `rebuildPages` 渲染调用。~~(已有 zoom/resize debounce,暂不改动) + +## 6 Why + +当前实现每次滚动或缩放都会触发 `rebuildPages()`,而 `renderPageToLabel()` 内部每次都会: +- 重新创建 `fz_buffer` +- 重新打开 `fz_document` +- 重新加载 `fz_page` + +对于多页 PDF,这导致滚动和缩放性能极差。PDF 可视区域是有限的,不需要对所有页面都做完整操作。 + +## 7 How + +参考 Okular 的设计思路: + +1. **`fz_document` 生命周期管理**:在 `loadFromFile` 时打开文档并保存指针,在 `clear()` 或对象析构时释放。`renderPageToLabel` 直接使用已打开的文档。 +2. **可见性裁剪**:`rebuildPages` 已经只渲染可见区域,但 `applyZoomToLabels` 仍遍历全部。改为仅在 `rebuildPages` 时按需调整 label 尺寸。 +3. **LRU 缓存**:将 `QHash` 改为 `QCache` 或手动维护 LRU 列表,设置最大缓存条目数(如 30 张)。 +4. **预加载策略**:保持现有的 `PRELOAD_MARGIN` 预加载,但确保预加载不阻塞主线程渲染。 diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.cpp b/src/Plugins/Qt/qt_pdf_reader_widget.cpp index b85671036e..9b34ce3f4e 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.cpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.cpp @@ -77,7 +77,9 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) overLink_ (false), zoomDebounceTimer_ (nullptr), resizeDebounceTimer_ (nullptr), gestureSafetyTimer_ (nullptr), inPinchGesture_ (false), blockRender_ (false), pinchStartZoom_ (1.0), - renderCallCount_ (0) { + renderCallCount_ (0), pdfDocHandle_ (nullptr), pdfStreamHandle_ (nullptr), + pdfBufferHandle_ (nullptr) { + pageCache_.setMaxCost (30); mainLayout_= new QVBoxLayout (this); mainLayout_->setContentsMargins (0, 0, 0, 0); @@ -178,7 +180,7 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent) &PDFReaderWidget::finishPinchGesture); } -PDFReaderWidget::~PDFReaderWidget () {} +PDFReaderWidget::~PDFReaderWidget () { clear (); } void PDFReaderWidget::setupToolBar () { @@ -351,6 +353,12 @@ PDFReaderWidget::applyZoomToLabels () { int width= pageWidth (); if (width <= 0) return; + // 仅调整可见及预加载范围内的 label,避免大文档缩放时遍历全部页面 + int scrollY = scrollArea_->verticalScrollBar ()->value (); + int viewportHeight= scrollArea_->viewport ()->height (); + int minY = scrollY - PRELOAD_MARGIN; + int maxY = scrollY + viewportHeight + PRELOAD_MARGIN; + int childCount= pageLayout_->count (); for (int i= 0; i < childCount && i < pageCount_; ++i) { QLayoutItem* item= pageLayout_->itemAt (i); @@ -361,7 +369,13 @@ PDFReaderWidget::applyZoomToLabels () { : pageAspectRatio_; if (aspect <= 0.0) aspect= 1.414; int height= qMax (1, qRound (width * aspect)); - label->setFixedSize (width, height); + + int labelTop = PAGE_MARGIN + i * (height + PAGE_MARGIN); + int labelBottom= labelTop + height; + + if (labelBottom >= minY && labelTop <= maxY) { + label->setFixedSize (width, height); + } } } @@ -739,19 +753,18 @@ PDFReaderWidget::renderPageToLabel (int pageNumber, QLabel* label, // 尝试从缓存读取 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); + QPixmap* cached= pageCache_.object (key); + if (cached) { + 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); + pageCache_.remove (key); } fz_context* ctx= mupdf_context (); @@ -784,41 +797,26 @@ PDFReaderWidget::renderPageToLabel (int pageNumber, QLabel* label, } } - fz_document* doc = nullptr; - fz_pixmap* pix = nullptr; - fz_page* page = nullptr; - fz_buffer* buf = nullptr; - fz_stream* stream = nullptr; - bool success= false; + fz_pixmap* pix = nullptr; + fz_page* page = nullptr; + bool success= false; - fz_var (doc); fz_var (pix); fz_var (page); - fz_var (buf); - fz_var (stream); - - fz_try (ctx) { - buf= fz_new_buffer_from_copied_data ( - ctx, reinterpret_cast (pdfData_.constData ()), - pdfData_.size ()); - - stream= fz_open_buffer (ctx, buf); - doc = fz_open_document_with_stream (ctx, "pdf", stream); - - if (!doc) { - fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to open PDF document"); - } - int totalPages= fz_count_pages (ctx, doc); - if (totalPages <= 0) { - fz_throw (ctx, FZ_ERROR_GENERIC, "PDF has no pages"); - } + // 复用常驻的 PDF 文档句柄 + if (!pdfDocHandle_) { + errorString_= qt_translate ("PDF document not open"); + hasError_ = true; + return false; + } - if (pageNumber < 0 || pageNumber >= totalPages) { + fz_try (ctx) { + if (pageNumber < 0 || pageNumber >= pageCount_) { pageNumber= 0; } - page= fz_load_page (ctx, doc, pageNumber); + page= fz_load_page (ctx, (fz_document*) pdfDocHandle_, pageNumber); if (!page) { fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to load page %d", pageNumber); } @@ -881,8 +879,8 @@ PDFReaderWidget::renderPageToLabel (int pageNumber, QLabel* label, label->setPixmap (pixmap); label->setFixedSize (targetWidth, targetHeight); - // 写入缓存 - pageCache_.insert (key, pixmap); + // 写入缓存(QCache 取得所有权) + pageCache_.insert (key, new QPixmap (pixmap)); success= true; } fz_catch (ctx) { @@ -894,9 +892,6 @@ PDFReaderWidget::renderPageToLabel (int pageNumber, QLabel* label, if (pix) fz_drop_pixmap (ctx, pix); if (page) fz_drop_page (ctx, page); - if (stream) fz_drop_stream (ctx, stream); - if (buf) fz_drop_buffer (ctx, buf); - if (doc) fz_drop_document (ctx, doc); return success; } @@ -953,18 +948,22 @@ PDFReaderWidget::rebuildPages () { 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); + QPixmap* cached= pageCache_.object (key); + if (cached) { + qreal dpr= devicePixelRatioF (); + int pxW= qMax (1, qRound (width * dpr)); + int pxH= qMax (1, qRound (height * dpr)); + QPixmap scaled; + if (cached->width () != pxW || cached->height () != pxH) { + scaled= cached->scaled (pxW, pxH, Qt::KeepAspectRatio, + Qt::FastTransformation); + scaled.setDevicePixelRatio (dpr); + } + else { + scaled= *cached; + scaled.setDevicePixelRatio (dpr); } - label->setPixmap (cached); + label->setPixmap (scaled); } else { label->clear (); @@ -1017,11 +1016,9 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { } } - fz_document* doc = nullptr; - fz_buffer* buf = nullptr; - fz_stream* stream= nullptr; + fz_buffer* buf = nullptr; + fz_stream* stream= nullptr; - fz_var (doc); fz_var (buf); fz_var (stream); @@ -1031,16 +1028,16 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { ctx, reinterpret_cast (pdfData_.constData ()), pdfData_.size ()); - stream= fz_open_buffer (ctx, buf); - doc = fz_open_document_with_stream (ctx, "pdf", stream); + stream = fz_open_buffer (ctx, buf); + pdfDocHandle_= fz_open_document_with_stream (ctx, "pdf", stream); - if (doc) { - pageCount_= fz_count_pages (ctx, doc); + if (pdfDocHandle_) { + pageCount_= fz_count_pages (ctx, (fz_document*) pdfDocHandle_); opened = (pageCount_ > 0); if (opened && pageCount_ > 0) { pageAspectRatios_.reserve (pageCount_); for (int i= 0; i < pageCount_; ++i) { - fz_page* page= fz_load_page (ctx, doc, i); + fz_page* page= fz_load_page (ctx, (fz_document*) pdfDocHandle_, i); if (page) { fz_rect bbox = fz_bound_page (ctx, page); double aspect= (bbox.y1 - bbox.y0) / (bbox.x1 - bbox.x0); @@ -1063,11 +1060,13 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { hasError_ = true; } - if (stream) fz_drop_stream (ctx, stream); - if (buf) fz_drop_buffer (ctx, buf); - if (doc) fz_drop_document (ctx, doc); - if (!opened) { + if (pdfDocHandle_) { + fz_drop_document (ctx, (fz_document*) pdfDocHandle_); + pdfDocHandle_= nullptr; + } + if (stream) fz_drop_stream (ctx, stream); + if (buf) fz_drop_buffer (ctx, buf); if (!hasError_) { errorString_= qt_translate ("Failed to open PDF"); hasError_ = true; @@ -1075,6 +1074,10 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) { return false; } + // 保存 stream/buf 句柄,确保文档生命周期内它们有效 + pdfStreamHandle_= stream; + pdfBufferHandle_= buf; + extractPageLinks (); // 创建所有页面 label(先不渲染,由 rebuildPages 统一处理可见性) @@ -1116,6 +1119,21 @@ PDFReaderWidget::clear () { delete item; } + // 释放常驻 PDF 文档句柄 + fz_context* ctx= mupdf_context (); + if (pdfDocHandle_) { + fz_drop_document (ctx, (fz_document*) pdfDocHandle_); + pdfDocHandle_= nullptr; + } + if (pdfStreamHandle_) { + fz_drop_stream (ctx, (fz_stream*) pdfStreamHandle_); + pdfStreamHandle_= nullptr; + } + if (pdfBufferHandle_) { + fz_drop_buffer (ctx, (fz_buffer*) pdfBufferHandle_); + pdfBufferHandle_= nullptr; + } + updatePageNavigation (); } @@ -1127,25 +1145,12 @@ PDFReaderWidget::extractPageLinks () { fz_context* ctx= mupdf_context (); if (!ctx) return; - fz_document* doc = nullptr; - fz_buffer* buf = nullptr; - fz_stream* stream= nullptr; - - fz_var (doc); - fz_var (buf); - fz_var (stream); + if (!pdfDocHandle_) return; fz_try (ctx) { - buf= fz_new_buffer_from_copied_data ( - ctx, reinterpret_cast (pdfData_.constData ()), - pdfData_.size ()); - stream= fz_open_buffer (ctx, buf); - doc = fz_open_document_with_stream (ctx, "pdf", stream); - if (!doc) fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to open PDF"); - pageLinks_.resize (pageCount_); for (int i= 0; i < pageCount_; ++i) { - fz_page* page= fz_load_page (ctx, doc, i); + fz_page* page= fz_load_page (ctx, (fz_document*) pdfDocHandle_, i); if (!page) continue; fz_link* links= fz_load_links (ctx, page); if (links) { @@ -1170,7 +1175,8 @@ PDFReaderWidget::extractPageLinks () { if (pl.uri.startsWith ("#") || pl.uri.startsWith ("#nameddest=") || pl.uri.startsWith ("#page=")) { float xp= 0, yp= 0; - fz_location loc= fz_resolve_link (ctx, doc, link->uri, &xp, &yp); + fz_location loc= fz_resolve_link (ctx, (fz_document*) pdfDocHandle_, + link->uri, &xp, &yp); if (loc.page >= 0) { pl.page= loc.page; // 0-based page index } @@ -1185,10 +1191,6 @@ PDFReaderWidget::extractPageLinks () { fz_catch (ctx) { qWarning () << "MuPDF link extraction error:" << fz_caught_message (ctx); } - - if (stream) fz_drop_stream (ctx, stream); - if (buf) fz_drop_buffer (ctx, buf); - if (doc) fz_drop_document (ctx, doc); } void diff --git a/src/Plugins/Qt/qt_pdf_reader_widget.hpp b/src/Plugins/Qt/qt_pdf_reader_widget.hpp index 6dd7979e8e..52759a384f 100644 --- a/src/Plugins/Qt/qt_pdf_reader_widget.hpp +++ b/src/Plugins/Qt/qt_pdf_reader_widget.hpp @@ -8,6 +8,7 @@ #ifndef QT_PDF_READER_WIDGET_HPP #define QT_PDF_READER_WIDGET_HPP +#include #include #include #include @@ -88,6 +89,11 @@ class PDFReaderWidget : public QWidget { void setTestLinks (int page, const QVector& links); bool isOverLink () const; + // 测试接口:缓存上限与当前大小 + int cacheMaxSize () const { return pageCache_.maxCost (); } + int cacheSize () const { return pageCache_.size (); } + void setCacheMaxSize (int size) { pageCache_.setMaxCost (size); } + Q_SIGNALS: void linkClicked (const QString& uri); @@ -170,8 +176,13 @@ private slots: PdfLink currentLink_; bool overLink_; - // 页面渲染缓存:key = (pageNumber, targetWidth) - QHash pageCache_; + // 页面渲染缓存:key = (pageNumber, targetWidth),带 LRU 淘汰 + QCache pageCache_; + + // PDF 文档常驻句柄(避免每次渲染都重新打开文档) + void* pdfDocHandle_; // fz_document* + void* pdfStreamHandle_; // fz_stream* + void* pdfBufferHandle_; // fz_buffer* // 防抖定时器 QTimer* zoomDebounceTimer_; diff --git a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp index cb671f4e73..dadbdbb002 100644 --- a/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp +++ b/tests/Plugins/Qt/qt_pdf_reader_widget_test.cpp @@ -1271,6 +1271,69 @@ private slots: delete widget; } + + // ============================================================ + // Cache limit tests (TDD for [0168] PDF performance) + // ============================================================ + + void test_cacheRespectsMaxSize () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->setCacheMaxSize (2); + widget->resize (400, 300); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + int initialSize= widget->cacheSize (); + QVERIFY (initialSize >= 1); + + // 缩放触发不同 targetWidth 的渲染 + widget->setZoomFactor (1.5); + QTest::qWait (300); // ZOOM_DEBOUNCE_MS(200) + margin + QApplication::processEvents (); + + int size2= widget->cacheSize (); + QVERIFY (size2 >= initialSize); + + // 再次缩放,上限为 2,不应超过 + widget->setZoomFactor (2.0); + QTest::qWait (300); + QApplication::processEvents (); + + QCOMPARE (widget->cacheSize (), 2); + + delete widget; + } + + void test_cacheDoesNotGrowUnbounded () { + PDFReaderWidget* widget= new PDFReaderWidget (); + widget->resize (400, 300); + widget->show (); + + url pdfUrl= url_system ("$TEXMACS_PATH/tests/PDF/pdf_1_4_sample.pdf"); + if (is_regular (pdfUrl)) { + widget->loadFromFile (to_qstring (as_string (pdfUrl))); + } + QApplication::processEvents (); + + QVERIFY (widget->cacheSize () >= 1); + + // 反复缩放 8 次 + for (int i= 0; i < 8; ++i) { + widget->setZoomFactor (0.5 + i * 0.15); + QTest::qWait (300); // ZOOM_DEBOUNCE_MS(200) + margin + QApplication::processEvents (); + } + + // 默认上限 30,但单页 PDF 缓存条目数等于不同 width 的数量 + QVERIFY (widget->cacheSize () <= widget->cacheMaxSize ()); + + delete widget; + } }; QTEST_MAIN (TestPdfReaderWidget)