技術文章

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 釋放上一個編輯週期中停放的控制項;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 新增的 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 一起交付