技术文章

Delphi 就地表单编辑器中的 OnExit 重入

PDFlibPas 的 TPDFlibViewer 控件通过编辑器自身的 OnExit 事件提交就地表单字段编辑,而这一设计选择隐藏了 Delphi VCL 中一个经典陷阱:在控件自身的 OnExit 处理程序中隐藏、重新设置父级或销毁一个具有焦点的控件,可能会在第一次调用返回前再次触发 OnExit,使提交逻辑重新进入自身

由此产生的故障很难稳定复现。用户在扫描的申请表上快速使用 Tab 切换一串文本字段,查看器偶尔会抛出访问冲突;更糟糕的是,它可能继续运行,却悄悄把错误的值写入前面两次 Tab 的字段。按需复现时,这个错误事后看起来显而易见;但从单个客户的崩溃报告追查时,它却像幽灵一样,因为第二次 OnExit 是否真的触发,取决于窗口句柄和焦点时序,而这些时序会随字段类型、输入速度以及消息队列当时正在处理的其他工作而变化

TPDFlibViewer 如何在渲染页面上放置真实编辑器

TPDFlibViewer 会将每个 PDF 页面渲染为位图,默认不会把表单字段转换为实时 VCL 控件,因此 BeginEditFormField 是连接这两个世界的方法:传入字段索引后,它会查找字段矩形并将其转换为客户区坐标,然后在该矩形上放置一个真实的 TEdit 或 TMemo 来编辑文本字段,或者以 csDropDownList 样式放置一个 TComboBox 来编辑选择字段,并预先加载字段当前值。ISO 32000-2 §12.7 定义了 PDF 内部文本或选择表单字段的含义,但该规范没有规定 Windows 应用程序应如何让用户向其中输入内容,而 BeginEditFormField 存在的目的正是填补这一空白。TPDFlibViewer 为每个创建的编辑器都将 OnKeyDown 和 OnExit 连接到同一组查看器方法 InplaceEditorKeyDown 和 InplaceEditorExit,这种配对自交互式表单填充首次在 v3.220.0 中加入以来一直保持不变,而问题正是从 OnExit 开始

为什么隐藏编辑器会再次触发 OnExit

VCL 中的 TWinControl 会将具有焦点控件的 Visible 或 Parent 发生变化视为需要立即移走焦点的原因,而移走控件的焦点正是触发其 OnExit 事件的操作,并且这个事件会同步发生,甚至早于触发它的属性赋值返回。PDFlibPas 用于关闭就地编辑器并将其值写回表单字段的方法 CommitInplaceEditor,在退出过程中恰好需要完成这两件事:将 Editor.Visible 设为 False,并将 Editor.Parent 设为 nil,使控件停止绘制在页面上方,也停止接收输入。在编辑器仍然具有焦点时执行其中任一操作,而用户刚刚离开它后通常确实如此,OnExit 就会在原本应该是该编辑器 OnExit 唯一一次触发的方法内部再次触发

CommitInplaceEditor 重新进入自身时会发生什么

朴素的提交方法会以两种方式之一为此付出代价。它要么将字段值写入两次,一次来自原始调用,另一次来自在第一次调用完成处理自身状态前悄悄插入的重入调用;要么在调用栈更深处的栈帧仍处于同一控件自身的事件处理程序中时尝试释放编辑器控件,这在 VCL 中属于未定义行为,表现为访问冲突,错误位置可能指向几乎任何一行,而不一定是实际引发问题的那一行。触发这两种故障并不需要大型表单;只要用户离开第二个字段的速度足够快,使操作系统仍在展开第一个字段的焦点消息,两字段文档就已经足够

// Naive version: reads fine in review, fails only under real typing speed
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // still running inside FEditor's own OnExit
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // focused control reparented here: OnExit
                               // fires again, re-entering this same method
  FEditor.Free;                // freed while a caller further down the
  FEditor := nil;              // stack is still inside its OnExit handler
end;

在接触控件前先将引用置为 nil

PDFlibPas 采用的修复只是调整顺序:将编辑器保存到局部变量,清除指向它的字段,然后才开始修改控件属性。CommitInplaceEditor 将 FInplaceEditor 读入局部的 Editor 变量,立即将 FInplaceEditor 设为 nil,之后才赋值 Editor.Visible 和 Editor.Parent。由其中任一赋值触发的重入调用会读取 FInplaceEditor 本身,发现它已经是 nil,于是在第一行就退出,不会接触 Editor,也不会再次写入字段值

procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
  Editor: TWinControl;
begin
  Editor := FInplaceEditor;
  if not Assigned(Editor) then
    Exit;                      // a reentrant call lands here and stops
  FInplaceEditor := nil;       // detach before the control is touched at all
  if Save then
    SaveEditorValue(Editor);   // safe: FInplaceEditor is already nil
  Editor.Visible := False;
  Editor.Parent := nil;        // may fire OnExit again; the guard above
                                // turns that reentrant call into a no-op
  ReapDeadEditor;               // free whatever was parked last cycle
  FDeadEditor := Editor;        // park this one instead of freeing it here
end;

示例中的 SaveEditorValue 代表真实分支,该分支会检查 Editor 是 TComboBox、TMemo 还是 TEdit,并据此读取其值,因为 PDFlibPas 会根据字段是文本字段还是选择字段来创建不同的控件。无论执行哪个分支,保护逻辑都不关心具体类型,只要求在执行任何可能触发 OnExit 的操作前将 FInplaceEditor 置为 nil,这一顺序约束使方法的其余部分可以用其他自然方式安全编写

永远不要在控件自身的事件中释放控件

TPDFlibViewer.CommitInplaceEditor 从不直接调用 Editor.Free,这是有意为之:当属于该控件自身事件分发的栈帧可能仍在释放控件的调用上方展开时,释放控件是不安全的,无论是否发生 OnExit 重入。PDFlibPas 会将脱离的编辑器交给单槽停放位置 FDeadEditor,通过小型辅助方法 ReapDeadEditor 释放上一次编辑周期中停放的对象;该方法会在下一次 BeginEditFormField 开始时调用,并在 CloseDocument 中再次调用。查看器创建的每个编辑器也都由查看器自身拥有,使用 TEdit.Create(Self) 而不是 TEdit.Create(nil),因此即使查看器销毁时控件仍停放在 FDeadEditor 中,也会由普通的 VCL 组件所有权机制回收,而不会泄漏

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // safe now: this control's own OnExit
    FDeadEditor := nil;        // finished at least one edit cycle ago
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // flush whatever editor is still open
  // ... field lookup and rectangle conversion omitted ...
  ReapDeadEditor;              // now safe to free last cycle's parked editor
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

为什么快速使用 Tab 导航时最容易出现这个问题

PDFlibPas 在 v3.226.0 中加入了 FocusNextFormField 方法,用于驱动表单中的 Tab 和 Shift+Tab 导航;该方法会在每次跳转时为下一个符合条件的字段调用 BeginEditFormField,而 BeginEditFormField 开始时会调用 CommitInplaceEditor(True),提交前一个字段留下的编辑器。这意味着用户在填写多字段表单时每按一次 Tab,都会完整执行上面描述的先脱离再接触序列一次,而这正是 Visible 和 Parent 发生变化时控件最可能仍真正具有焦点的代码路径,因为 Tab 是几乎必然让即将离开的编辑器一直保持焦点,直到新编辑器请求焦点的交互

这些情况并不会让错误变得容易稳定演示,这一点值得直说,而不是轻描淡写地带过。某次 Visible 或 Parent 赋值是否真的强制同步触发 OnExit,取决于焦点和窗口句柄状态;调试器只要附加上去就会改变这些状态,无关的重绘或计时器也可能扰动它们,而且具体表现还会因实际使用的是 TEdit、TMemo 还是 TComboBox 而不同。一个只会偶尔执行的保护逻辑,正是这类缺陷能够同时逃过代码审查和手工测试的原因,也是修复必须从构造上保证正确的原因:先将引用置为 nil,再进行其他操作,而不是依赖少数几次手工测试中碰巧观察到的行为来证明正确

这种修复的总体形态远不止适用于一个查看器控件。任何通过在渲染内容上叠加实时 VCL 控件来构建的自定义编辑界面,不只是 PDF 表单字段编辑器,只要其关闭并提交的逻辑既可能由明确的用户操作触发,也可能由隐式的焦点变化触发,就会继承同样的风险,而答案也同样分为两部分:在执行任何可能触发活动控件自身退出事件的操作前,先清除标识该控件的引用;永远不要从可能仍在该控件自身事件分发之下运行的代码路径中调用 Free。TPDFlibViewer 更广泛的表单填充和渲染界面,包括它如何决定针对不同字段显示哪种控件,详见使用 PDFlibPas 在 Delphi VCL 中构建交互式 PDF 查看器控件的概览;SetFormFieldValueAndRefresh 必须在每次提交编辑时使页面位图缓存失效,该缓存的内容另见查看器按显示器 DPI 划分的磁盘页面缓存文章

就地表单字段编辑、由 Tab 驱动的字段导航,以及支撑这两者的防重入提交路径,都是交互式查看器控件的一部分;该控件随PDFlibPas,这款面向 Delphi 和 C++Builder 的 PDF 库一同提供,并包含其余页面渲染、注释和表单字段 API 功能