Skip to content
Draft
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
63 changes: 63 additions & 0 deletions devel/0168.md
Original file line number Diff line number Diff line change
@@ -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<PdfPageCacheKey, QPixmap>` 改为 `QCache` 或手动维护 LRU 列表,设置最大缓存条目数(如 30 张)。
4. **预加载策略**:保持现有的 `PRELOAD_MARGIN` 预加载,但确保预加载不阻塞主线程渲染。
178 changes: 90 additions & 88 deletions src/Plugins/Qt/qt_pdf_reader_widget.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -178,7 +180,7 @@ PDFReaderWidget::PDFReaderWidget (QWidget* parent)
&PDFReaderWidget::finishPinchGesture);
}

PDFReaderWidget::~PDFReaderWidget () {}
PDFReaderWidget::~PDFReaderWidget () { clear (); }

void
PDFReaderWidget::setupToolBar () {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}
}

Expand Down Expand Up @@ -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 ();
Expand Down Expand Up @@ -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<const unsigned char*> (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);
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 ();
Expand Down Expand Up @@ -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);

Expand All @@ -1031,16 +1028,16 @@ PDFReaderWidget::loadFromFile (const QString& filePath, int dpi) {
ctx, reinterpret_cast<const unsigned char*> (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);
Expand All @@ -1063,18 +1060,24 @@ 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;
}
return false;
}

// 保存 stream/buf 句柄,确保文档生命周期内它们有效
pdfStreamHandle_= stream;
pdfBufferHandle_= buf;

extractPageLinks ();

// 创建所有页面 label(先不渲染,由 rebuildPages 统一处理可见性)
Expand Down Expand Up @@ -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 ();
}

Expand All @@ -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<const unsigned char*> (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) {
Expand All @@ -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
}
Expand All @@ -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
Expand Down
Loading
Loading