Odborný článok

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

Ovládací prvok TPDFlibViewer v PDF Library for Delphi 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

Diagram TPDFlibViewer umiestňujúci živý editor VCL nad bitmapu vykreslenej strany PDF a zapájajúci OnKeyDown a OnExit na obsluhy prehliadača
BeginEditFormField kladie skutočný zameraný ovládací prvok na vrch strany a každý taký editor sa dodáva s OnExit — dverami, ktorými prechádza reentrancia

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ú PDF Library for Delphi 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

// Naivná verzia: pri prehliadke kódu vyzerá v poriadku, zlyhá až pri reálnej rýchlosti písania
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // stále bežíme vnútri vlastného OnExit FEditor
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // zaostrený ovládací prvok tu dostane nového rodiča: OnExit
                               // sa spustí znova, reentrantne vstupuje do tej istej metódy
  FEditor.Free;                // uvoľnený, kým volajúci nižšie v
  FEditor := nil;              // zásobníku je stále vnútri svojho obslužného programu OnExit
end;

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

Oprava, ktorú PDF Library for Delphi 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;                      // reentrantné volanie tu skončí a zastaví sa
  FInplaceEditor := nil;       // odpoj skôr, než sa ovládacieho prvku čokoľvek dotkne
  if Save then
    SaveEditorValue(Editor);   // bezpečné: FInplaceEditor je už nil
  Editor.Visible := False;
  Editor.Parent := nil;        // môže znova spustiť OnExit; poistka vyššie
                                // premení toto reentrantné volanie na no-op
  ReapDeadEditor;               // uvoľni, čokoľvek zostalo zaparkované z minulého cyklu
  FDeadEditor := Editor;        // namiesto uvoľnenia tu tento zaparkuj
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 PDF Library for Delphi 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. PDF Library for Delphi 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;          // teraz bezpečné: vlastný OnExit tohto ovládacieho prvku
    FDeadEditor := nil;        // sa dokončil aspoň pred jedným cyklom úprav
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // vyprázdni akýkoľvek stále otvorený editor
  // ... vyhľadanie poľa a prevod obdĺžnika vynechané ...
  ReapDeadEditor;              // teraz je bezpečné uvoľniť editor zaparkovaný z minulého cyklu
  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ú PDF Library for Delphi 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ý

Vývojový diagram PDF Library for Delphi ukazujúci, ako nastavenie Editor.Parent na nil pri zameranom editore spôsobí opätovné spustenie OnExit a znovuvstup do CommitInplaceEditor skôr, než prvé volanie vráti
Jediné priradenie prečlenenia rodiča pošle commit späť do seba a otvorí tak režimy zlyhania dvojitého zápisu aj predčasného uvoľnenia

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 PDF Library for Delphi, 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

PDF Library for Delphi: Usporiadaná šesťkroková sekvencia potvrdenia, ktorá vymaže FInplaceEditor skôr, než sa dotkne ovládacieho prvku, zaobchádza s reentrným OnExit ako s no-op a odloží Free cez slot FDeadEditor
Nastavenie referencie na nil ako prvé spôsobí, že každé reentrántne volanie skončí okamžite, a slot parkovaného editora presúva každé Free do cyklu, ktorého zásobníky sú tiché

Ú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 PDF Library for Delphi, PDF knižnicou pre Delphi a C++Builder, popri zvyšku jej povrchu na vykresľovanie strán, anotácie a polia formulárov