技術記事

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

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

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

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

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

TPDFlibViewer がレンダリング済み PDF ページビットマップ上に動作中の VCL エディターを重ね、OnKeyDown と OnExit をビューアーハンドラーへ接続する図
BeginEditFormField はフォーカスを持つ実際のコントロールをページの上に載せ、その種のエディターはすべて OnExit を備えて出荷されます。ここが再入が入り込む扉です

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

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

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

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

// 素朴な実装: レビューでは問題ないが、実際の入力速度で初めて失敗する
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // まだ FEditor 自身の OnExit の内部で実行中
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // フォーカスされたコントロールをここで再親化: OnExit
                               // が再度発火し、同じこのメソッドに再入する
  FEditor.Free;                // スタックのさらに下にいる呼び出し元がまだ自分の
  FEditor := nil;              // OnExit ハンドラの中にいる間に解放される
end;

コントロールに触れる前に参照をnilにする

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

procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
  Editor: TWinControl;
begin
  Editor := FInplaceEditor;
  if not Assigned(Editor) then
    Exit;                      // 再入呼び出しはここに来て停止する
  FInplaceEditor := nil;       // コントロールに全く触れる前に切り離す
  if Save then
    SaveEditorValue(Editor);   // 安全: FInplaceEditor はすでに nil
  Editor.Visible := False;
  Editor.Parent := nil;        // OnExit を再度発火するかもしれない; 上のガードが
                                // その再入呼び出しを no-op にする
  ReapDeadEditor;               // 前回のサイクルで駐車されたものを解放する
  FDeadEditor := Editor;        // ここで解放せず、これを駐車する
end;

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

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

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

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // 今なら安全: このコントロール自身の OnExit は
    FDeadEditor := nil;        // 少なくとも1つ前の編集サイクルで終わっている
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // まだ開いているエディタがあればフラッシュする
  // ... フィールド検索と矩形変換は省略 ...
  ReapDeadEditor;              // 前回のサイクルで駐車されたエディタを解放しても安全
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

なぜこれは高速なTabナビゲーションの間に最も強く現れるのか

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

PDF Library for Delphi のフローチャート。フォーカスを持つエディターに Editor.Parent として nil を設定すると OnExit が再び発火し、最初の CommitInplaceEditor 呼び出しが返る前に再入する様子
1 つの親変更代入がコミットを自身へ送り返し、二重書き込みと premature-Free の両方の障害モードを引き起こします

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

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

PDF Library for Delphi: 順序付き 6 ステップのコミット手順。コントロールへ触れる前に FInplaceEditor をクリアし、再入した OnExit を no-op として扱い、Free を FDeadEditor スロット経由で延期
まず参照を nil にすることで、再入する呼び出しはすべて即座に抜けます。待機させていたエディターのスロットは、スタックが静かな後のサイクルで各 Free を実行します

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