技术文章

在 Delphi 中部署 PDFium DLL:修复加载失败

当 pdfium.dll 拒绝在客户机器上加载时,适用于 Delphi 和 Lazarus 的 PDFium Component 将晦涩难懂的原生错误转化为特定的原因。CheckLoadLibrary 例程将 ERROR_BAD_EXE_FORMAT (193) 解读为 32/64 位架构不匹配,而 CheckGetProcAddress 则将缺失导出报告为比绑定版本更旧的 pdfium.dll。这两条消息都指明了修复方法,而不是让您去猜测

这重要,因为原生 DLL 会以三种截然不同的方式失败,而操作系统的原始文本将它们全部混为一谈。架构可能错误、部署的二进制文件对于调用它的 Pascal 绑定来说可能太旧,或者两个线程可能同时竞相绑定它。每一个都有不同的修复方法,而在 v2.11.0 加载生命周期工作中添加的诊断的全部意义就是告诉您实际面对的是哪一个

示意图:Delphi 中 pdfium.dll 的三种加载失败模式及各自的信号与修复——从错误 193 架构不匹配到过期的 DLL 与并发双重绑定
原生 DLL 可能因架构、版本偏差或并发绑定而失败;诊断会点出三者中实际发生的那一个

为什么 pdfium.dll 会加载失败并出现坏的 EXE 格式错误?

Windows 错误 193 ERROR_BAD_EXE_FORMAT 意味着找到了并打开了文件,但其 PE 机器类型与宿主进程不匹配。64 位可执行文件无法加载 32 位的 pdfium.dll,32 位可执行文件也无法加载 64 位的。文件是存在的,因此寻找缺失 DLL 的本能会把您带向完全错误的方向

PDFium 组件在 CheckLoadLibrary 中拦截这种情况:当 LoadLibrary 返回空句柄且 GetLastError 为 193 时,它会附加提示表明找到了该 DLL,但其架构与宿主进程不匹配,并指向匹配的 DLLs/Win32 或 DLLs/Win64 构建。子目录由 BuildDllSubDir 选择,当 IsWin64 为 true 时返回 Win64,否则返回 Win32。将 DLL 部署在绑定实际搜索的目录树下,不匹配就会消失。有一个坑不在部署里,而在调用代码里:TPdf.SetActive 会吞掉每一次加载失败,所以 Active := True 会让组件保持未激活而不抛异常,包在外面的 except 块永远不会运行。想看到消息,请用 TPdfLoadOptions 记录和一个 out 的 TPdfLoadReport 调用 LoadDocument,它会抛出真正的 EPdfError,并在报告里把错误文本记为 plsFailed。从 PDFiumPas v3.122.1 起,失败的 Active := True 也会把这样的失败报告写进 LastLoadReport,所以沿用属性写法的代码可以从 LastLoadReport.ErrorMessage 读到原因,不用再靠猜

uses
  SysUtils, PDFium;

procedure OpenDocument(const AFileName: string);
var
  Pdf: TPdf;
  Report: TPdfLoadReport;
begin
  // 将 pdfium.dll 部署在可执行文件旁,匹配构建目标
  //   <AppDir>\DLLs\Win32\pdfium.dll   用于 32 位宿主进程
  //   <AppDir>\DLLs\Win64\pdfium.dll   用于 64 位宿主进程
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := AFileName;
    try
      // Active := True 会吞掉失败并保持 False;这个
      // 重载会延迟绑定 pdfium.dll 并抛出真正的异常
      Pdf.LoadDocument(TPdfLoadOptions.Default(plmCompatible), Report);
    except
      on E: EPdfError do
        // 消息已指明真正的原因:32/64 位不匹配
        // (ERROR_BAD_EXE_FORMAT)或比此绑定更旧的 pdfium.dll;
        // Report.Status 为 plsFailed,Report.ErrorMessage 存有错误文本
        raise Exception.CreateFmt('PDF engine did not start: %s', [E.Message]);
    end;
    RenderFirstPage(Pdf);   // LoadDocument 返回后 Pdf.Active 即为 True
  finally
    Pdf.Free;
  end;
end;

缺失的 PDFium 导出提示了什么?

第二类失败是版本偏差。随着时间的推移,PDFium 会发布新的导出,而 Pascal 绑定在 LoadLibrary 期间通过 CheckGetProcAddress 解析它需要的每个函数。如果必需的导出不存在,说明绑定比磁盘上的 pdfium.dll 更旧,客观的诊断是部署的 DLL 已过时,而不是损坏。CheckGetProcAddress 函数准确地陈述了这一点:它引发一个 EPdfError 报告部署的 pdfium.dll 比 PDFiumPas 绑定的此构建更旧,并指明匹配的二进制文件所属的 DLLs/<subdir> 路径

两个细节使得该路径非常健壮。首先,CheckGetProcAddress 在引发异常前调用 UnloadLibrary,因此部分绑定永远不会留下半解析的函数指针让下一次尝试绊倒。其次,它报告的 DLL 名称来自 BuildDllName,在正常情况下返回 pdfium.dll,当全局 EnableV8Engine 标志被设置时返回 pdfium.v8.dll。如果您的应用程序为 XFA 或脚本表单启用了 V8 JavaScript 引擎,错误文本将指向 V8 构建版,而不是普通版,这样您就能在第一时间替换正确的文件

PDFium DLL 可以安全地从后台线程加载吗?

现在从任何线程加载库都是安全的;而从多个线程调用 PDFium API 仍然不安全。这是两个独立的保证,保持它们的分离是稳定的工作线程池与间歇性崩溃之间的区别。后台渲染通常通过 TPdfFuture<T> 工作线程运行每个页面渲染,第一个触及 TPdf 的 Future 就是触发延迟绑定的因素。在没有保护的情况下,两个工作线程可能都会观察到未加载的库,都会运行绑定序列,覆盖模块句柄并泄露首次加载

示意图:BuildDllSubDir 依据宿主位数选择 Win32 或 Win64 的 pdfium.dll 文件夹,使 Delphi 应用避免错误 193 架构不匹配
BuildDllSubDir 按宿主位数选择 DLLs 文件夹;带上另一种构建则会以错误 193 而非文件缺失的形式暴露

v2.11.0 的工作通过 PDFiumLoadLock 关闭了该窗口(一个包裹了 LoadLibrary 和 UnloadLibrary 入口的进程全局 TRTLCriticalSection)。临界区使得检查然后加载的序列成为原子操作,因此没有两个线程可以同时看到 Loaded=False 并进行双重绑定,也没有线程可以在另一个线程正处于绑定中时释放 DLL。该锁在单元 initialization 部分中创建,并在 finalization 中拆除,受 PDFiumLoadLockReady 标志保护,因此即使在关机期间配对也是安全的。如果您需要带取消的完整工作与答复模式,配套文章 带可取消 Future 的后台渲染 进行了端到端的详细介绍

uses
  PDFium, FPdfAsync;

// 工作线程方法在后台线程上运行。第一个绑定的 Future
// pdfium.dll 由 PDFiumLoadLock 序列化,因此第二个并发
// 工作线程不能双重绑定或泄露模块句柄
function TReportForm.RenderThumbnail(
  const AToken: IPdfCancellationToken): TBitmap;
var
  Pdf: TPdf;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'report.pdf';
    Pdf.Active := True;         // 安全并发绑定
    AToken.ThrowIfCancelled;
    Result := Pdf.Thumbnail;    // 此 TPdf 仅由一个线程拥有
  finally
    Pdf.Free;
  end;
end;

procedure TReportForm.StartRender;
begin
  TPdfFuture<TBitmap>.Run(RenderThumbnail, ThumbnailReady);
end;

procedure TReportForm.ThumbnailReady(
  const AResult: TPdfFutureResult<TBitmap>);
begin
  if AResult.IsSuccess then
    Preview.Picture.Assign(AResult.Value);
end;

加载锁不保护的内容

这里的边界值得明确说明,因为很容易过度解读该修复。PDFiumLoadLock 仅序列化全局绑定状态:模块句柄和 FPDF_* 函数指针赋值。底层的 PDFium C API 仍然不是线程安全的,正如上游头文件所声明的那样,因此调用 FPDF_* 函数或在线程之间共享同一个 FPDF_DOCUMENT 仍然是您的职责来进行序列化。安全的形状是上面的代码:每个工作线程拥有自己的 TPdf,绝不将活动文档交给另一个线程。有关该边界及围绕它的 ABI 规则的更深入探讨,请参见 强化 PDFium VCL 绑定以防 ABI 和内存故障

为什么 finalization 和 FPDF_DestroyLibrary 很重要

在关闭路径中存在一个更微妙的空白。PDFium 单元没有 finalization 部分,这意味着 FPDF_DestroyLibrary 永远不会在进程退出时运行;操作系统收回了 DLL memory 并跳过了 PDFium 自己的清理,即工作线程联接、设置 EnableV8Engine 时的 V8 隔离区处置以及字体和缓存卸载。在普通渲染工作负载上,这是拆卸时安静的泄漏,但启用 V8 引擎后,每次运行都会泄漏一个 JavaScript 隔离区,这在重复的自动化中很快就会显现出来

现在的 finalization 部分调用 UnloadLibrary,从而调用 FPDF_DestroyLibrary 并释放模块。这存在于单元范围内是有原因的:TPdf.Destroy 仅设置 Active := False 来关闭当前文档,并且它刻意不卸载全局库,因此多实例应用程序在整个进程生命周期中保持一个共享的 PDFium 绑定处于活动状态。将拆除放在 finalization 中意味着共享库恰好在单元卸载时被释放一次,并且 UnloadLibrary 首先检查其 Loaded 标志,因此如果从未激活过 PDFium,该调用就是无害的空操作

示意图:两个 TPdfFuture 工作线程经 PDFiumLoadLock 关键节串行化,确保 pdfium.dll 在 Delphi 应用中只绑定一次
PDFiumLoadLock 让“先检查后加载”的序列原子化;每个工作线程仍需各持一个 TPdf,因为 PDFium C API 不是线程安全的

简短的部署核对清单

三种习惯可以防止在现场出现几乎所有的加载失败。使 DLL 架构与宿主进程相匹配,并在 DLLs/Win32 或 DLLs/Win64 下发运;保持 pdfium.dll 与绑定版本同步,使必需的导出不缺失;绝不在线程之间共享 TPdf 或文档句柄,即使绑定本身现在是原子的。如果您为 Delphi 和 Lazarus 构建相同的源,绊倒跨目标部署的打包差异将在关于 Delphi 和 FPC 交叉编译器陷阱 的说明中涵盖。这里描述的诊断、加载锁和 finalization 清理都包含在适用于 Delphi 和 C++Builder 的 PDFium Component 中