Odborný článok

Reentrancia OnExit v editore polí formulára na mieste v Delphi

Ovládací prvok TPDFlibViewer v PDFlibPas potvrdzuje editor poľa formulára na mieste cez vlastnú udalosť OnExit tohto editora, a toto konštrukčné rozhodnutie skrýva klasickú pascu VCL v Delphi: skrytie, zmena rodiča alebo zničenie zaostreného ovládacieho prvku zvnútra jeho vlastnej obslužnej rutiny OnExit môže spustiť OnExit druhýkrát skôr, než sa prvé volanie vráti, čím pošle logiku potvrdenia späť do seba samej

Zlyhanie, ktoré to spôsobuje, sa vzpiera čistej reprodukcii. Používateľ rýchlo prechádza tabulátorom cez sériu textových polí na naskenovanom prihláškovom formulári, a z času na čas prehliadač vyhodí porušenie prístupu, alebo horšie, beží ďalej, pričom potichu zapisuje nesprávnu hodnotu do poľa o dva tabulátory späť. Reprodukujte to na požiadanie a chyba pôsobí spätne zjavne; naháňajte ju z jedinej správy o páde od zákazníka a pôsobí ako duch, pretože či sa druhé OnExit skutočne spustí, závisí od časovania okna a fokusu, ktoré sa mení podľa typu poľa, rýchlosti písania a čohokoľvek iného, čo front správ v danom okamihu robí

Ako TPDFlibViewer umiestňuje skutočný editor nad vykreslenú stranu

TPDFlibViewer vykresľuje každú stranu PDF do bitmapy a predvolene nemení polia formulára na živé ovládacie prvky VCL, takže BeginEditFormField je metóda, ktorá premosťuje oba svety: zavolaná s indexom poľa vyhľadá obdĺžnik poľa a prevedie ho na klientske súradnice, potom nad tento obdĺžnik umiestni skutočný TEdit alebo TMemo pre textové pole, alebo TComboBox v štýle csDropDownList pre výberové pole, s aktuálnou hodnotou poľa už načítanou. ISO 32000-2 §12.7 definuje, čo je textové alebo výberové pole formulára vnútri PDF, no nič v tejto špecifikácii nehovorí, ako má windowsová aplikácia niekomu dovoliť doňho písať, a práve túto medzeru BeginEditFormField vypĺňa. OnKeyDown aj OnExit sú napojené na tie isté dve metódy prehliadača, InplaceEditorKeyDown a InplaceEditorExit, na každom editore, ktorý TPDFlibViewer vytvorí, párovanie, ktoré sa nezmenilo od chvíle, keď interaktívne vypĺňanie formulárov prvýkrát prišlo vo verzii v3.220.0, a práve pri OnExit sa začínajú problémy

Prečo skrytie editora spúšťa OnExit druhýkrát?

TWinControl vo VCL považuje zmenu Visible alebo Parent na zaostrenom ovládacom prvku za dôvod okamžite od neho odsunúť fokus, a odsunutie fokusu od ovládacieho prvku je presne to, čo spustí jeho udalosť OnExit, synchrónne, ešte pred tým, než sa vráti aj samotné priradenie vlastnosti, ktoré ho vyvolalo. CommitInplaceEditor, metóda, ktorú PDFlibPas používa na zatvorenie editora na mieste a zapísanie jeho hodnoty späť do poľa formulára, musí na ceste von urobiť presne tieto dve veci: nastaviť Editor.Visible na False a Editor.Parent na nil, aby sa ovládací prvok prestal kresliť nad stranou a prestal prijímať vstup. Urobte ktorúkoľvek z nich, kým editor stále má fokus, čo takmer vždy má, keďže ho používateľ práve opustil, a OnExit sa spustí znova uprostred toho istého volania, ktoré malo byť poslednou vecou, akú kedy toto OnExit editora vyvolá

Čo sa pokazí, keď CommitInplaceEditor opätovne vstúpi sám do seba?

Naivná metóda potvrdenia za to zaplatí jedným z dvoch spôsobov. Buď zapíše hodnotu poľa dvakrát, raz z pôvodného volania a raz z reentrantného volania, ktoré sa vkradlo skôr, než prvé volanie stihlo dokončiť dotyk vlastného stavu, alebo sa pokúsi uvoľniť ovládací prvok editora, kým rámec o niečo nižšie v zásobníku volaní je stále vnútri obslužnej rutiny udalosti toho istého ovládacieho prvku, čo je vo VCL nedefinované územie a prejaví sa ako porušenie prístupu, ktoré môže ukazovať takmer na hocijaký riadok, nie nutne na ten, ktorý ho v skutočnosti spôsobil. Ani jedno zo zlyhaní nepotrebuje veľký formulár na spustenie; postačí dvojpoľový dokument, pokiaľ používateľ opustí druhé pole dostatočne rýchlo na to, aby operačný systém ešte stále odvíjal správy o fokuse z prvého

// 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;

Vynulujte referenciu skôr, než sa dotknete ovládacieho prvku

Oprava, ktorú PDFlibPas dodáva, je jediné preusporiadanie: zachytiť editor do lokálnej premennej, vyčistiť pole, ktoré naň ukazuje, a až potom začať meniť vlastnosti ovládacieho prvku. CommitInplaceEditor prečíta FInplaceEditor do lokálnej premennej Editor, okamžite nastaví FInplaceEditor na nil, a až potom priradí Editor.Visible a Editor.Parent. Reentrantné volanie vyvolané ktorýmkoľvek z týchto dvoch priradení samo prečíta FInplaceEditor, zistí, že je už nil, a skončí na svojom úplne prvom riadku, skôr než sa stihne dotknúť Editor alebo zapísať hodnotu poľa druhýkrát

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 v tomto výpise zastupuje skutočnú vetvu, ktorá kontroluje, či je Editor typu TComboBox, TMemo, alebo TEdit, a podľa toho číta jeho hodnotu, keďže PDFlibPas vytvára odlišný ovládací prvok podľa toho, či je pole textové alebo výberové. Poistke nezáleží na tom, ktorá vetva beží, len na tom, že FInplaceEditor je nil ešte pred spustením čohokoľvek schopného vyvolať OnExit, čo je jediné poradové obmedzenie, ktoré robí zvyšok metódy bezpečným napísať v hocijakom inak prirodzenom štýle

Nikdy neuvoľňujte ovládací prvok zvnútra jeho vlastnej udalosti

TPDFlibViewer.CommitInplaceEditor nikdy nevolá Editor.Free priamo, a je to zámerné: uvoľnenie ovládacieho prvku je nebezpečné, kým sa rámec zásobníka patriaci tomu istému ovládaciemu prvku vlastnej dispečerskej udalosti môže ešte stále odvíjať nad volaním, ktoré ho uvoľňuje, reentrantné OnExit či nie. PDFlibPas namiesto toho odovzdáva odpojený editor na jednoslotové parkovacie miesto, FDeadEditor, pričom uvoľňuje čokoľvek, čo tam sedelo z predchádzajúceho cyklu úprav, cez malú pomocnú metódu ReapDeadEditor, volanú na začiatku ďalšieho BeginEditFormField a ešte raz z CloseDocument; každý editor, ktorý prehliadač vytvorí, je tiež vo vlastníctve samotného prehliadača, TEdit.Create(Self) namiesto TEdit.Create(nil), takže aj ovládací prvok stále zaparkovaný v FDeadEditor v čase zničenia prehliadača je zametaný bežným vlastníctvom komponentov VCL namiesto toho, aby unikol

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;

Prečo sa toto najtvrdšie prejavuje pri rýchlej navigácii tabulátorom?

FocusNextFormField, metóda, ktorú PDFlibPas pridal vo verzii v3.226.0 na riadenie navigácie Tab a Shift+Tab naprieč formulárom, volá BeginEditFormField pre ďalšie oprávnené pole pri každom jednom skoku, a BeginEditFormField sa začína volaním CommitInplaceEditor(True), aby vyprázdnil akýkoľvek editor, ktorý predchádzajúce pole ponechalo otvorený. To znamená, že každé stlačenie tabulátora, ktoré používateľ urobí pri vypĺňaní viacpoľového formulára, spustí presne opísanú sekvenciu odpojenia a následného dotyku raz, čo je práve tá cesta kódu, na ktorej je najpravdepodobnejšie, že ovládací prvok bude v okamihu zmeny Visible a Parent skutočne zaostrený, pretože Tab je tá jedna interakcia takmer zaručene ponechávajúca odchádzajúci editor s fokusom až do chvíle, keď oň požiada nový

Nič z toho nerobí chybu spoľahlivo demonštrovateľnou, a to stojí za to povedať otvorene namiesto zahladenia. Či dané priradenie Visible alebo Parent skutočne vynúti synchrónne OnExit, závisí od stavu fokusu a handle okna, ktorý debugger mení už len tým, že je pripojený, ktorý môže narušiť nesúvisiace prekreslenie alebo časovač, a ktorý sa správa odlišne podľa toho, či je práve v hre TEdit, TMemo, alebo TComboBox. Poistka, ktorá sa len niekedy skutočne uplatní, je dôvodom, prečo takýto druh chyby prežije aj code review, aj manuálne testovanie, a je to tiež dôvod, prečo musí byť oprava korektná svojou konštrukciou, vynulovanie referencie skôr, než sa čokoľvek iné stane, namiesto korektnej len podľa toho, aké správanie náhodou pozorovalo pár manuálnych testovacích behov

Všeobecný tvar tejto opravy siaha ďaleko za jeden ovládací prvok prehliadača. Akýkoľvek vlastný editačný povrch postavený na prekrytí vykresleného obsahu živým ovládacím prvkom VCL, nielen pole formulára PDF, dedí rovnaké riziko vo chvíli, keď jeho logiku zatvorenia a potvrdenia môže spustiť tak explicitná akcia používateľa, ako aj implicitná zmena fokusu, a platí tá istá dvojdielna odpoveď: vyčistiť referenciu identifikujúcu aktívny ovládací prvok skôr, než urobíte čokoľvek, čo by mohlo vyvolať jeho vlastnú udalosť opustenia, a nikdy nevolať Free z cesty kódu, ktorá by mohla ešte stále bežať pod vlastnou dispečerskou udalosťou tohto ovládacieho prvku. Širší povrch vypĺňania formulárov a vykresľovania v TPDFlibViewer, vrátane toho, ako rozhoduje, aký typ ovládacieho prvku zobraziť pre ktoré pole, pokrýva prehľad budovania interaktívneho ovládacieho prvku prehliadača PDF v Delphi VCL s PDFlibPas, a vyrovnávacia pamäť bitmáp strán, ktorú musí SetFormFieldValueAndRefresh invalidovať pri každej potvrdenej úprave, je pokrytá samostatne v článku o diskovej vyrovnávacej pamäti strán prehliadača podľa DPI jednotlivých monitorov

Úprava polí formulára na mieste, navigácia poľami cez tabulátor a cesta potvrdenia bezpečná voči reentrancii za oboma sú súčasťou interaktívneho ovládacieho prvku prehliadača dodávaného s PDFlibPas, PDF knižnicou pre Delphi a C++Builder, popri zvyšku jej povrchu na vykresľovanie strán, anotácie a polia formulárov