技術記事

DelphiのインプレースフォームエディタにおけるOnExitの再入

PDFlibPasのTPDFlibViewerコントロールは、エディタ自身のOnExitイベントを通じてインプレースのフォームフィールドエディタをコミットする。この設計上の選択は、古典的なDelphi VCLの罠を隠している:フォーカスされたコントロールを、自身のOnExitハンドラの内部から隠す、親を変える、あるいは破棄することは、最初の呼び出しが戻る前にOnExitを2回目発火させ、コミットロジックをそれ自身へ送り返すことがある

これが生み出す失敗は、クリーンな再現に抵抗する。ユーザーがスキャンされた申請書上の一連のテキストフィールドを素早くタブで移動すると、時折ビューアがアクセス違反を発生させるか、もっと悪いことに、実行を続けながら2つ前のタブのフィールドに静かに間違った値を書き込む。それを要求に応じて再現すると、そのバグは後から見れば明白に見える;単一の顧客のクラッシュレポートからそれを追いかけると、それは幽霊のように見える、なぜなら2回目のOnExitが実際に発火するかどうかは、フィールドタイプ、入力速度、そしてその瞬間にメッセージキューが行っている他の何によっても変わるウィンドウハンドルとフォーカスのタイミングに依存するからだ

TPDFlibViewerはどうやってレンダリングされたページの上に本物のエディタを置くのか

TPDFlibViewerは各PDFページをビットマップにレンダリングし、既定ではフォームフィールドをライブなVCLコントロールに変えない。そのためBeginEditFormFieldはこの2つの世界を橋渡しするメソッドである:フィールドインデックスとともに呼ばれると、そのフィールドの矩形を検索しクライアント座標へ変換し、その後、テキストフィールドの場合はその矩形の上に本物のTEditTMemoを、選択フィールドの場合はcsDropDownListスタイルのTComboBoxを、フィールドの現在の値がすでにロードされた状態で落とし込む。ISO 32000-2 §12.7はPDF内部のテキストや選択フォームフィールドが何であるかを定義しているが、その仕様は、Windowsアプリケーションがどうやって誰かにそれへ入力させるべきかについては何も語っておらず、そのギャップこそがBeginEditFormFieldが埋めるために存在するものである。OnKeyDownOnExitの両方は、TPDFlibViewerが作成するすべてのエディタで、同じ2つのビューアのメソッド、InplaceEditorKeyDownInplaceEditorExitに配線されている。このペアリングは、対話的なフォーム入力が最初にv3.220.0で導入されて以来変わらずに出荷されており、OnExitこそが問題が始まる場所である

なぜエディタを隠すとOnExitが2回目発火するのか

VCLのTWinControlは、フォーカスされたコントロールに対するVisibleParentの変更を、そこからフォーカスを即座に移すべき理由として扱う。そしてあるコントロールからフォーカスを移すことこそが、まさにそのコントロールのOnExitイベントを、それを引き起こしたプロパティ代入自体が戻る前に、同期的に発火させるものである。インプレースエディタを閉じ、その値をフォームフィールドへ書き戻すためにPDFlibPasが使うメソッドであるCommitInplaceEditorは、その終わりに正確にその2つのことを行う必要がある:コントロールがページの上に描画されるのを止め、入力を受け取るのを止めるために、Editor.VisibleをFalseに設定し、Editor.Parentをnilに設定することだ。エディタがまだフォーカスを持っている間にそのどちらかを行うと(ユーザーがちょうどそこを離れたのだからほとんど常にそうなっているのだが)、そのエディタのOnExitが最後に発火するはずだったまさにその呼び出しの途中で、OnExitが再び発火する

CommitInplaceEditorがそれ自身に再入すると何がおかしくなるのか

素朴なコミットメソッドは、2つの方法のどちらかでこの代償を払う。フィールドの値を2回書き込む(1回は元の呼び出しから、もう1回は最初の呼び出しがまだ自身の状態に触れ終わる前に忍び込んだ再入呼び出しから)か、あるいは呼び出しスタックのさらに下にあるフレームがまだその同じコントロール自身のイベントハンドラの内部にいる間にそのエディタコントロールを解放しようとし、これはVCLでは未定義の領域であり、実際に原因ではないかもしれないほとんどどの行でも指しうるアクセス違反として現れる。どちらの失敗も引き起こすのに大きなフォームを必要としない;ユーザーがOSがまだ最初のフィールドからのフォーカスメッセージを巻き戻している間に十分速く2つ目のフィールドを離れる限り、2フィールドの文書で十分である

// 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が出荷する修正は、単純な順序の並べ替えである:エディタをローカル変数に取り込み、それを指すフィールドをクリアし、それから初めてコントロールのプロパティを変え始める。CommitInplaceEditorFInplaceEditorをローカルなEditor変数に読み込み、即座にFInplaceEditorをnilに設定し、その後になって初めてEditor.VisibleEditor.Parentを代入する。その2つの代入のどちらかによって引き起こされる再入呼び出しは、FInplaceEditor自体を読み、すでにnilであることを見つけ、Editorに触れたり値を2回目書き込んだりする前に、まさにその最初の行で終了する

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は、実際の分岐の代わりを務めている。それはEditorTComboBoxTMemoTEditのどれであるかをチェックし、それに応じてその値を読む。なぜならPDFlibPasは、フィールドがテキストフィールドか選択フィールドかによって異なるコントロールを作成するからだ。このガードは、どちらの分岐が実行されるかは気にしない、OnExitを発火させうる何かが実行される前にFInplaceEditorがnilであることだけを気にする。これが、メソッドの残りを他の点では自然などんなスタイルで書いても安全にする唯一の順序制約である

自身のイベントの内部からコントロールを解放しない

TPDFlibViewer.CommitInplaceEditorは決してEditor.Freeを直接呼ばない。そしてそれは意図的なものだ:あるコントロールを解放することは、その同じコントロール自身のイベントディスパッチに属するスタックフレームが、それを解放する呼び出しの上でまだ巻き戻し中かもしれない間は安全ではない、再入するOnExitであろうとなかろうとだ。PDFlibPasは代わりに、切り離されたエディタを単一スロットの駐車場所であるFDeadEditorに渡し、そこに前回の編集サイクルから座っていたものを、次のBeginEditFormFieldの開始時ともう一度CloseDocumentから呼ばれる小さなヘルパーReapDeadEditorを通じて解放する;ビューアが作成するすべてのエディタもまた、TEdit.Create(nil)ではなくTEdit.Create(Self)としてビューア自身が所有している。そのためビューアが破棄されるときに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ナビゲーションの間に最も強く現れるのか

フォーム全体にわたるTabとShift+Tabのナビゲーションを駆動するためにPDFlibPasがv3.226.0で追加したメソッドであるFocusNextFormFieldは、一回一回のホップごとに次の適格なフィールドに対してBeginEditFormFieldを呼び、BeginEditFormFieldCommitInplaceEditor(True)を呼ぶことから始まり、前のフィールドが開いたままにしていたどんなエディタもフラッシュする。これは、複数フィールドのフォームに入力する間にユーザーが押すすべてのTabキーが、上記で説明した切り離してから触れるという正確なシーケンスを一度実行することを意味する。これはまさに、VisibleとParentが変わる瞬間にコントロールが本当にフォーカスされている可能性が最も高いコードパスである、なぜならTabは、新しいエディタがそれを求めるまさにその時まで、出て行くエディタにフォーカスを保持させ続けることがほぼ保証されている唯一の対話だからだ

これらのどれも、このバグを実演可能で信頼できるものにするわけではなく、それは取り繕うのではなく率直に言う価値がある。あるVisibleやParentの代入が実際に同期的なOnExitを強制するかどうかは、デバッガが接続されるだけで変わってしまうフォーカスとウィンドウハンドルの状態、無関係な再描画やタイマーが乱すことのある状態、そして実際に関わっているコントロールがTEdit、TMemo、TComboBoxのどれであるかによって異なる振る舞いに依存する。時々しか演習されないガードこそが、この種の欠陥がコードレビューと手動テストの両方を生き延びる理由であり、それはまた、この修正が、一握りの手動テストパスがたまたま観測した挙動によって正しいのではなく、構造上正しくなければならない理由でもある——他の何かが起こる前に参照をnilにすることだ

この修正の一般的な形は、一つのビューアコントロールをはるかに超えて広く通用する。レンダリングされたコンテンツの上にライブなVCLコントロールを重ねることで構築されたどんなカスタム編集面も(PDFフォームフィールドに限らず)、そのクローズ・コミットロジックが明示的なユーザーアクションと暗黙のフォーカス変更の両方によって引き起こされうる瞬間、同じ危険を受け継ぐ。そして同じ2部構成の答えが当てはまる:アクティブなコントロールを識別する参照を、その自身の終了イベントを引き起こすかもしれない何かをする前にクリアすること、そしてそのコントロール自身のイベントディスパッチの下でまだ実行されているかもしれないコードパスからは決してFreeを呼ばないことだ。TPDFlibViewerのより広いフォーム入力とレンダリングの面、どのフィールドにどのコントロールタイプを表示するか決める方法を含めて、PDFlibPasによるDelphi VCLの対話的PDFビューアコントロールの構築の概要で扱われている。そしてSetFormFieldValueAndRefreshがコミットされた編集のたびに無効化しなければならないページビットマップキャッシュは、ビューアのモニターごとのDPIディスクページキャッシュに関する記事で別途扱われている

インプレースのフォームフィールド編集、Tab駆動のフィールドナビゲーション、そしてその両方の背後にある再入安全なコミット経路は、そのページレンダリング、注釈、フォームフィールドAPIの面の残りとともに、DelphiおよびC++Builder向けPDFライブラリであるPDFlibPasに同梱される対話的ビューアコントロールの一部である