简介:QPdfium 是面向 Qt 开发者的 PDF 渲染模块,基于 pdfium 将 PDF 页面转换为 QImage,适合在桌面程序中集成 PDF 阅读、显示与基础解析能力,尤其匹配 MSVC 2017 64 位开发环境。压缩包共 25 个文件,约 18.37MB,包含 4 个头文件、4 个动态库,以及静态库、导出库、构建配置、调试符号和版本说明等配套内容,兼顾运行、链接、调试与工程接入,便于按构建版本分别引用。已有 42 人学习下载,说明它对正在实现 Qt 桌面端文档功能的开发者有参考价值。借助现成接口,读者可以减少从零编写 PDF 解析与页面渲染代码的工作量,在 Qt 界面中较稳定地完成文档展示,并为后续搜索、注释等功能扩展提供基础。对需要离线查看文档、制作阅读器或补充预览窗口的团队,这类库能缩短环境搭建和接口摸索时间,也可作为理解模块目录、依赖关系与编译配置的参考。
1. Qt 程序里塞进一个 PDF 阅读器,难点从来不是“画出来”
Qt 桌面项目要加 PDF 预览,需求听着很小:打开、翻页、缩放、搜个字。真动手会发现,卡住人的往往不是渲染代码,而是“有没有一份能直接链进 MSVC 2017 64 位工程的二进制库”。Qt 自带的 QtPdf 模块跟版本绑得很死,换工具链就得自己编引擎,工程一多就没人愿意维护。这份 Qt 集成库给的正是 MSVC 2017 x64 下开箱可链的构建产物:头文件、导入库、运行库齐备,底层是那套 Chromium 系的开源 PDF 渲染引擎,上层包成了 Qt 风格的文档对象和页面对象。它适合两类人:已有 Qt Widgets 项目、要内置阅读能力但不想接手编译引擎的开发者;以及想先拿现成二进制把渲染管线跑通、回头再啃源码的人。下面按工具链、渲染、文本层、踩坑、验证的顺序拆。
2. 工具链对齐:MSVC 2017 x64 的 ABI 要求要提前算清
2.1 为什么“换个编译器重新编译一下”是幻觉
MSVC 的二进制兼容只对同一大版本内的工具集成立。VS2017 的 v141 工具集、VS2019 的 v142、VS2022 的 v143,三者编出来的目标文件不能随便混链,尤其是静态库。你把一份 v141 编出来的 .lib 塞进 v142 的工程,链接器大概率能过,运行时却在某个虚函数调用或 STL 容器析构处崩掉,堆栈还停在系统 DLL 里,这就是典型的“链接成功、运行翻车”。
第二个变量是 C 运行库。MSVC 的/MT(静态链 CRT)和/MD(动态链 CRT)必须全工程一致。库是/MD编的,你的程序用/MT,两套 CRT 各管各的堆,new出来的内存在另一侧delete,轻则断言,重则静默损坏。这个坑在 PDF 场景里格外常见,因为渲染出来的位图对象往往在库内部申请、在你的代码里释放。
第三个变量是迭代器调试级别。Debug 配置下_ITERATOR_DEBUG_LEVEL=2,Release 下是 0,混用会直接报 LNK2038 的 mismatch 错误。这条反而是好事,链接器替你拦住了;前面两条才是黑匣子。
提示:拿到库先跑
dumpbin /headers xxx.lib | findstr machine,确认是 x64;再跑dumpbin /dependents xxx.dll,把依赖的 VC 运行库名字记下来,和你工程的工具集版本对一遍。
判断标准很简单:库依赖MSVCP140.dll/VCRUNTIME140.dll说明走的是/MD动态 CRT;如果dumpbin看不到这些依赖,多半是/MT静态链,那你的工程也得跟着改。这一步花五分钟,能省掉后面半天的玄学排查。
2.2 三步把库接进 qmake 或 CMake 工程
推荐目录布局:把头文件放third_party/qpdfimu/include,导入库放lib,运行时 DLL 放bin。资源包里的库名前缀是qpdfimu,如果你的包内文件名带d后缀(调试版常见命名),按实际改。
CMake 写法:
cmake_minimum_required(VERSION 3.16) project(PdfViewer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 用 msvc2017_64 套件的 Qt5,组件按需增减 find_package(Qt5 REQUIRED COMPONENTS Widgets Concurrent) set(QPDFIMU_ROOT "${CMAKE_SOURCE_DIR}/third_party/qpdfimu") # 以导入目标的方式接入,避免到处写 include 路径 add_library(qpdfimu SHARED IMPORTED) set_target_properties(qpdfimu PROPERTIES IMPORTED_IMPLIB "${QPDFIMU_ROOT}/lib/qpdfimu.lib" IMPORTED_LOCATION "${QPDFIMU_ROOT}/bin/qpdfimu.dll" INTERFACE_INCLUDE_DIRECTORIES "${QPDFIMU_ROOT}/include" ) add_executable(PdfViewer main.cpp pdfpagewidget.cpp pdfpagewidget.h ) target_link_libraries(PdfViewer PRIVATE Qt5::Widgets Qt5::Concurrent qpdfimu) # 构建后把 DLL 复制到输出目录,省得手动改 PATH add_custom_command(TARGET PdfViewer POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${QPDFIMU_ROOT}/bin/qpdfimu.dll" "$<TARGET_FILE_DIR:PdfViewer>" )IMPORTED_IMPLIB指向链接期用的.lib,IMPORTED_LOCATION指向运行期用的.dll,两者必须同名同位数,写错一个的症状是“编译过了、启动报找不到模块”。POST_BUILD那一段别省,多配置生成器(Debug/Release 分目录)下手工拷贝最容易漏。
老工程还在用 qmake 的话,等价写法:
QT += widgets concurrent INCLUDEPATH += $$PWD/third_party/qpdfimu/include LIBS += -L$$PWD/third_party/qpdfimu/lib -lqpdfimu # 构建后拷 DLL,路径按你的输出目录调整 QMAKE_POST_LINK += $$quote(cmd /c copy /Y $$shell_path($$PWD/third_party/qpdfimu/bin/qpdfimu.dll) $$shell_path($$OUT_PWD/release))-lqpdfimu这种写法在 MSVC 下会被翻译成qpdfimu.lib,如果包内的调试版叫qpdfimud.lib,Debug 配置就得切换库名,通常用CONFIG(debug, debug|release)分支处理。
2.3 先跑一个只数页数的最小例子
接入完成别急着写界面,先写十几行验证二进制能加载、能取页、能读尺寸。这一步过不了,后面所有渲染代码都是白写。
// smoke_test.cpp —— 只验证加载链路,不涉及界面 #include <QCoreApplication> #include <QDebug> #include <QFileInfo> #include "qpdfium.h" // 头文件名以包内实际为准 int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); if (argc < 2) { qWarning() << "usage: smoke_test <file.pdf>"; return 1; } const QString path = QString::fromLocal8Bit(argv[1]); qInfo() << "file exists:" << QFileInfo::exists(path) << "size:" << QFileInfo(path).size(); QPdfium *doc = QPdfium::loadFile(path); if (!doc || !doc->isValid()) { qCritical() << "load failed, error code:" << (doc ? int(doc->lastError()) : -1); return 2; } qInfo() << "pages:" << doc->pageCount(); for (int i = 0; i < qMin(3, doc->pageCount()); ++i) { auto page = doc->page(i); qInfo() << "page" << i << "w/h(pt):" << page.widthF() << page.heightF() << "rotation:" << page.rotation(); } delete doc; return 0; }逻辑说明:loadFile走的是文件路径而不是QFile对象,说明库内部自己管 IO,传入前要保证路径是本地编码正确的字符串,中文路径在这里最容易出问题(第 5 章细说)。isValid()和lastError()是排查加载失败的唯二入口,别只判断空指针。
参数说明:widthF()/heightF()返回的是 PDF 页面坐标系下的尺寸,单位是 1/72 英寸的点(pt),不是像素。一页 A4 大约是 595×842 pt。后面算渲染像素必须乘缩放系数和 DPI 系数,这两个系数搞混是画面模糊和内存暴涨的根源。
3. 渲染进 QWidget:分页、缩放、滚动条的三次对齐
3.1 四段式管线:加载、取页、出图、贴控件
PDF 渲染的模型是“按需光栅化”,没有“打开就得到一张图”这回事。一份文档是一个容器,每一页是独立对象,每次渲染都要指定目标像素尺寸重新画一遍。所以整条链路拆成四段:文档对象常驻、页面对象按需取、渲染成QImage、在paintEvent里贴到控件上。
控件骨架:
// pdfpagewidget.h #pragma once #include <QWidget> #include <QImage> #include <QFutureWatcher> class QPdfium; class PdfPageWidget : public QWidget { Q_OBJECT public: explicit PdfPageWidget(QWidget *parent = nullptr); ~PdfPageWidget() override; bool load(const QString &path); void gotoPage(int index); void setZoom(double factor); // 1.0 表示 100% protected: void paintEvent(QPaintEvent *e) override; void resizeEvent(QResizeEvent *e) override; private: void requestRender(); void onRenderFinished(); QPdfium *m_doc = nullptr; int m_page = 0; double m_zoom = 1.0; QImage m_cache; // 当前页已渲染的位图 QFutureWatcher<QImage> m_watcher; // 后台渲染 };渲染函数是核心,值得单独写一遍:
// 把某一页渲染成位图:像素尺寸 = 页面点尺寸 × 缩放 × 设备像素比 QImage PdfPageWidget::renderPage(int index, double zoom, qreal dpr) { auto page = m_doc->page(index); const int w = qRound(page.widthF() * zoom * dpr); const int h = qRound(page.heightF() * zoom * dpr); if (w <= 0 || h <= 0) return QImage(); QImage img = page.render(w, h); // 参数是目标像素宽高 if (img.isNull()) return img; img.setDevicePixelRatio(dpr); // 让 Qt 按逻辑像素布局,避免二次拉伸 return img; } void PdfPageWidget::paintEvent(QPaintEvent *) { QPainter p(this); p.fillRect(rect(), Qt::darkGray); if (m_cache.isNull()) return; // 水平居中,垂直顶部对齐;滚动由外层 QScrollArea 负责 const int x = (width() - m_cache.width() / m_cache.devicePixelRatio()) / 2; p.drawImage(QPoint(x, 0), m_cache); }逻辑说明:page.render(w, h)的入参是像素,单位不是点,也不是逻辑像素。setDevicePixelRatio(dpr)这一行决定了控件拿到的是“逻辑尺寸”,在 150% 缩放的高分屏上如果不设,图片会被按逻辑像素再放大一次,字边发虚。
参数说明:m_zoom控制用户可见的缩放级别,通常按 0.1 步进,范围 0.25~4.0;dpr取devicePixelRatioF(),不要取整数值,Windows 上存在 1.25、1.5 这类分数缩放。
3.2 缩放与 DPI:两个系数乘错就白干
这里要区分三个尺寸概念,混掉任何一个都会踩坑:
| 概念 | 单位 | 来源 | 用在哪 |
|---|---|---|---|
| 页面点尺寸 | pt(1/72 英寸) | page.widthF()/heightF() | 计算渲染像素的基数 |
| 渲染像素尺寸 | px | 点尺寸 × zoom × dpr | 传给render(w, h) |
| 控件逻辑尺寸 | 逻辑 px | 像素尺寸 / dpr | 布局、滚动条范围 |
滚动条范围一定要用逻辑尺寸算。如果在QScrollArea里直接setWidget一个像素尺寸的控件,高分屏上会出现滚动条能拖但内容拖不到底的诡异现象,原因就是滚动区域按逻辑像素算、控件按物理像素报尺寸。
常见做法是把渲染控件塞进QScrollArea,setWidgetResizable(false),然后每次缩放后调用widget->resize(cache.size() / dpr)。缩放级别变化时清空缓存重渲,别试图对已有位图做scaled(),那是二次采样,放大时边缘会糊得厉害。
3.3 懒加载与 LRU:只渲染你看得见的那几页
连续滚动模式下,把整份文档全部渲染成位图是内存自杀。一页 A4 在 150% 缩放下约 1240×1754 像素,ARGB32 每像素 4 字节,单页接近 8.7 MB,两百页的标书直接吃掉 1.7 GB。
正确做法是只保留可视区附近的若干页位图,用容量固定的 LRU 缓存管理。Qt 里可以直接用QCache<QString, QImage>,key 用页码 + 缩放级别拼字符串,容量按自己的内存预算设。
// 页码+缩放的组合做 key,缩放变化时天然失效 QString cacheKey(int page, double zoom) const { return QStringLiteral("%1@%2").arg(page).arg(zoom, 0, 'f', 2); } QImage PdfPageWidget::pageImage(int page) { const QString key = cacheKey(page, m_zoom); if (auto *hit = m_cacheStore.object(key)) return *hit; QImage img = renderPage(page, m_zoom, devicePixelRatioF()); // QCache 接管指针所有权,容量按 MB 折算成字节 m_cacheStore.insert(key, new QImage(img), img.sizeInBytes()); return img; }逻辑说明:QCache::insert的第三个参数是这条目的“开销”,超过总容量时按 LRU 淘汰。把sizeInBytes()作为开销,容量就能直接按字节预算设,比按“条数”精确得多。
参数说明:容量给多少合适?我的经验值是“可视页数 × 单页字节 × 3”,预留上下各一页的缓冲;常见桌面应用给 256 MB 上限就够了,同时在内存吃紧的机器上要能通过配置降到 128 MB。
另外渲染是 CPU 密集型操作,一页复杂排版的 A4 在中端机器上要 30~80 ms,滚动时同步渲染会明显卡顿。用QtConcurrent::run把渲染丢到线程池,主线程只负责显示上一次的结果,配合QFutureWatcher在完成后触发update()。注意库对象本身通常不是线程安全的,多线程渲染同一文档对象必须加锁,或者每个线程各自持有一份文档实例——前者简单,后者费内存。
4. 文本层能不能用:提取、坐标映射与搜索高亮
4.1 三种输出形态,别拿纯文本去做定位
PDF 里的文字不是“一段字符串”,而是带位置的字形序列。同一行文字可能由多个独立的文本块拼出来,阅读顺序要靠坐标重排。所以提取接口一般有三档:
- 整页纯文本:最省事,直接返回一页拼好的字符串,适合全文索引、复制粘贴。
- 带坐标的文本块:返回每个文本块的内容 + 页面坐标系下的包围盒,适合做高亮、标注、点击取词。
- 字符级信息:每个字符的坐标、字号、字体名,做精细排版分析才需要,一般用不上。
第一档够用就跑,不要一上来啃第三档:
// 全文导出,用换页符分隔,便于下游按页切分 QString exportAllText(QPdfium *doc) { QString out; out.reserve(doc->pageCount() * 2000); for (int i = 0; i < doc->pageCount(); ++i) { auto page = doc->page(i); out += page.text(); out += QLatin1Char('\f'); // 分页符 } return out; }逻辑说明:\f是表单换页符,很多文本工具认它,后续想按页切直接用split('\f')。如果文档是扫描件,text()返回空串是正常现象,不是接口坏了——扫描件里只有图像,没有文字层,要上 OCR,那是另一个话题。
参数说明:reserve的估算是经验值,避免长文档反复扩容。几百页的文档一次性导出成 QString,内存占用大约是纯文本的 2~3 倍(UTF-16 存储),超大文档建议改成流式写入文件。
4.2 坐标映射:页面坐标系到控件坐标系
高亮框画不准,九成是坐标系没对齐。PDF 页面坐标系的约定要看库实现,多数是左上角为原点、Y 轴向下(跟屏幕一致),但也存在左下角原点的实现。拿到的包围盒先做个可视化验证:在第一页左上角画一个 50×50 的矩形,看它是不是出现在预期位置。
映射公式本身不复杂:
// 页面坐标(pt) -> 控件坐标(逻辑 px) // zoom 是缩放系数,dpr 是设备像素比,origin 是页面在控件内的绘制原点 QPointF PdfPageWidget::pageToWidget(const QPointF &pt) const { const qreal s = m_zoom * devicePixelRatioF(); return QPointF(pt.x() * s, pt.y() * s) / devicePixelRatioF() + m_origin; } // 反向:控件坐标 -> 页面坐标,点击取词用 QPointF PdfPageWidget::widgetToPage(const QPointF &pt) const { const qreal s = m_zoom * devicePixelRatioF(); return (pt - m_origin) * devicePixelRatioF() / s; }逻辑说明:先乘zoom再乘dpr得到物理像素偏移,最后除以dpr回到逻辑像素——因为QPainter在设置了devicePixelRatio的控件上默认按逻辑像素工作。m_origin是页面在控件里的左上角偏移,居中显示时它不为零,漏掉这一项高亮就会整体偏移。
参数说明:m_zoom必须是渲染时用的那个值,不能取“当前 UI 显示的百分比 / 100”重新算一遍,两者精度不同会积累亚像素误差,多页跳转后高亮框会漂移几个像素。
4.3 搜索高亮:先全文匹配,再可视定位
搜索的实现路径是“遍历所有页 → 每页查匹配 → 记录页码和包围盒 → 跳到第一个命中并高亮”。全文档扫描在几百页规模下是百毫秒级,可以直接同步做;上千页就要考虑后台线程加进度条。
struct SearchHit { int page; QRectF rect; // 页面坐标系下的包围盒 }; QVector<SearchHit> searchAll(QPdfium *doc, const QString &keyword) { QVector<SearchHit> hits; if (keyword.isEmpty()) return hits; for (int i = 0; i < doc->pageCount(); ++i) { auto page = doc->page(i); // 接口名以头文件为准,常见形态是返回矩形列表 const QVector<QRectF> rects = page.searchText(keyword, Qt::CaseInsensitive); for (const QRectF &r : rects) { hits.push_back({i, r}); } } return hits; }逻辑说明:返回值一定是“矩形列表”而不是“字符串列表”,因为同一个词在一页里可能出现多次,每次都要单独高亮。大小写敏感参数显式传,别依赖默认值,不同实现的默认行为不一样。
参数说明:Qt::CaseInsensitive对中文没有意义,但英文合同、技术文档里必须要有。如果库的搜索接口不支持大小写选项,就在应用层做两次查询再合并结果。
高亮绘制放在paintEvent里,遍历当前页的命中矩形,用半透明黄色填充:
void PdfPageWidget::paintHighlights(QPainter &p, int page) { p.setBrush(QColor(255, 235, 59, 110)); p.setPen(Qt::NoPen); for (const auto &hit : m_hits) { if (hit.page != page) continue; const QPointF tl = pageToWidget(hit.rect.topLeft()); const QPointF br = pageToWidget(hit.rect.bottomRight()); p.drawRect(QRectF(tl, br).normalized()); } }注意QRectF(tl, br)用两个对角点构造后要normalized(),否则某些实现返回的矩形是“右下到左上”,宽高为负,画出来什么都没有。
5. 避坑与排查:五个让我半夜爬起来改代码的现象
5.1 启动即报 0xc000007b,或者弹窗说找不到模块
现象:双击 exe 直接闪退,事件查看器里是0xc000007b;或者提示“找不到 qpdfimu.dll”,但文件明明就在 exe 旁边。
原因:0xc000007b基本都是位数不匹配——32 位进程加载了 64 位 DLL,或者反过来。第二种更隐蔽:DLL 在,但它依赖的 VC 运行库缺了某个版本,Windows 的加载器报的是“找不到主模块”,实际缺的是依赖项。
解决:先用dumpbin /headers确认 exe 和 DLL 都是machine (x64);再用 Dependencies 或dumpbin /dependents把 DLL 的依赖链整个拉出来,逐个确认系统目录里有没有。VC 运行库建议直接装对应年份的 x64 可再发行包,别手工拷msvcp140.dll到程序目录,版本错配会引发更诡异的问题。
5.2 加载成功、渲染到第二页就崩
现象:第一页正常显示,翻到第二页时进程直接退出,没有异常对话框,Debug 模式下偶尔能停在某个析构函数里。
原因:上一页的页面对象没释放就取了新页,或者页面对象是在 A 线程创建的、在 B 线程析构的。PDF 引擎内部对页面对象有引用计数,跨线程销毁是最常见的崩法。
解决:页面对象用完立即释放,不要长期持有;所有涉及同一文档对象的方法调用串行化,用QMutex包住渲染入口。如果确实要多线程渲染,每个线程用独立的文档实例,内存翻倍但省心。我一般会在renderPage外面加一把递归锁,宁可牺牲一点并发也不冒这个险。
5.3 中文路径或含空格的文件打不开
现象:D:\文档\合同.pdf加载失败,lastError()返回一个看不出所以然的负数;把文件改成D:\test.pdf就好了。
原因:Windows 上QString是 UTF-16,但底层库的加载接口可能收的是const char*,走的是本地 ANSI 代码页。中文路径在这种转换里丢失,或者空格被当成了参数分隔。
解决:优先用库提供的宽字符加载入口(loadFile(const wchar_t*)或接受QString的重载),没有的话用QFile::encodeName()转本地编码,别用toUtf8()。同时检查路径里有没有#、%这类会被 URL 解析器吃掉的字符——有些实现内部会把路径当 URL 处理。
5.4 高 DPI 屏幕上文字发虚、边缘锯齿明显
现象:1080p 显示器上很清楚,换到 2K 笔记本(150% 缩放)就糊,截图放大能看到明显的双线性插值痕迹。
原因:渲染时没乘devicePixelRatio,按逻辑像素渲染再被系统放大;或者渲染时乘了,但QImage没设devicePixelRatio,画的时候又被当逻辑尺寸缩放一次。两次插值叠加,糊得没法看。
解决:渲染像素 = 点尺寸 × zoom × dpr,QImage::setDevicePixelRatio(dpr)必须调用。验证方法很简单:在 150% 屏幕上截一张图,看文字边缘是单像素清晰还是两像素渐变;后者说明有一次多余的缩放。
5.5 大文档翻到几十页后内存持续上涨
现象:打开一份 300 页的标书,翻页翻到三四十页,任务管理器里进程内存爬到 2 GB 以上,再翻开始卡顿甚至闪退。
原因:每页渲染的QImage都被存起来了,没有上限也没有淘汰。一页 8 MB,两百页就是 1.6 GB,加上 Qt 的图像缓存和 GPU 纹理,实际占用更高。
解决:上QCache并按字节设容量上限(见 3.3 节),缩放或跳页时主动清空。另外排查一下QPixmapCache的全局上限,Qt 默认值在部分版本里偏大,大图场景要手动QPixmapCache::setCacheLimit()调小。还有一个容易被忽略的点:渲染出来的QImage如果转成了QPixmap又没释放,纹理内存是走显存和系统内存双份的。
6. 进阶:把渲染缓存做对,以及一套十分钟能跑完的验证清单
缓存做完只是及格线,真正影响手感的是“什么时候该重渲”。我吃过一次亏:用户把窗口从 1080p 显示器拖到 4K 外接屏,devicePixelRatio从 1.0 变成 2.0,控件尺寸没变,缓存 key 里的 zoom 也没变,于是旧的 1.0 位图被直接拉伸显示,整页糊成一片。修法是在QEvent::DevicePixelRatioChange或者screenChanged信号里主动清空缓存并重渲。
更省事的一招是把 dpr 也塞进缓存 key,让缓存自己失效,代价是拖窗口的瞬间会有一批废弃条目占着内存,等 LRU 自然淘汰即可:
QString cacheKey(int page, double zoom, qreal dpr) const { // dpr 取两位小数,避免浮点抖动产生大量无效 key return QStringLiteral("%1@%2@%3") .arg(page) .arg(zoom, 0, 'f', 2) .arg(dpr, 0, 'f', 2); }验证清单我固定跑这几项,每次换库版本或者升 Qt 都过一遍,十分钟能省掉一天的回归:
| 检查项 | 操作 | 通过标准 |
|---|---|---|
| 位数与依赖 | dumpbin /headers+dumpbin /dependents | 全部 x64,依赖齐全 |
| 加载容错 | 传不存在的路径 / 损坏 PDF / 加密 PDF | 返回错误码,不崩溃 |
| 中文路径 | 放到含中文和空格的目录下加载 | 正常打开 |
| 高分屏 | 125%、150%、200% 三档缩放 | 文字边缘清晰,无二次拉伸 |
| 内存曲线 | 连续翻 200 页,观察进程内存 | 稳定在缓存上限附近,不持续上涨 |
| 文本层 | 普通文档 + 扫描件各一份 | 前者有字,后者返回空而不是崩溃 |
| 加密文档 | 带打开密码的 PDF | 提示输入密码或明确报错 |
最后说一个线程安全的细节:库的文档对象几乎都不是线程安全的,但“渲染”和“取文本”用的内部状态是同一份。我在一个项目里图省事,主线程渲染、子线程抽取文本做索引,跑了两个小时才崩一次,日志里什么都留不下。从那以后我每次接入新的 PDF 库,都会先写一个两线程交叉调用的压测用例,跑满一千次再往下写业务代码,这个习惯比任何文档都可靠。希望帮到你。
本文还有配套的精品资源,点击获取