Műszaki cikk

OnExit-újrabelépés egy Delphi helybeni form-szerkesztőben

A PDFlibPas TPDFlibViewer vezérlője a helybeni form-mezőszerkesztőt annak saját OnExit eseményén keresztül kötelezi el, és ez a tervezési döntés egy klasszikus Delphi VCL-csapdát rejt: egy fókuszált vezérlő elrejtése, újraszülőzése, vagy megsemmisítése saját OnExit-kezelőjén belülről kiválthatja az OnExit-et másodszor is, mielőtt az első hívás visszatérne, visszaküldve az elkötelezési logikát önmagába

Az ebből keletkező hiba ellenáll a tiszta reprodukciónak. Egy felhasználó gyorsan tabolgat egy sor szövegmezőn át egy beszkennelt jelentkezési lapon, és időnként a megjelenítő hozzáférési hibát dob, vagy rosszabb, tovább fut, miközben csendben a rossz értéket írja egy mezőbe két tabbal korábban. Reprodukáld igény szerint, és a hiba visszatekintve nyilvánvalónak tűnik; üldözd egy egyetlen ügyfél összeomlási jelentéséből, és szellemnek tűnik, mert az, hogy a második OnExit ténylegesen kiváltódik-e, olyan ablak-handle- és fókusz-időzítéstől függ, amely mezőtípussal, gépelési sebességgel, és bármi mással, amit az üzenetsor abban a pillanatban csinál, vált

Hogyan tesz a TPDFlibViewer egy valódi szerkesztőt egy renderelt oldal tetejére

A TPDFlibViewer minden PDF-oldalt bitképre rendereli, és alapértelmezetten nem alakítja a formmezőket élő VCL-vezérlőkké, így a BeginEditFormField az a metódus, amely áthidalja a két világot: egy mezőindexszel meghívva, felkeresi a mező téglalapját, és kliens-koordinátákká alakítja, majd egy valódi TEdit-et vagy TMemo-t helyez arra a téglalapra egy szövegmezőhöz, vagy egy TComboBox-ot csDropDownList stílusban egy választómezőhöz, teljesen betöltve a mező aktuális értékével. Az ISO 32000-2 §12.7 definiálja, mi egy szöveg- vagy választó-formmező egy PDF-en belül, de semmi abban a specifikációban nem mondja meg, hogyan kellene egy Windows-alkalmazásnak lehetővé tennie, hogy valaki begépeljen egyet, és pontosan ezt a rést hivatott betölteni a BeginEditFormField. Mind az OnKeyDown, mind az OnExit ugyanahhoz a két megjelenítő-metódushoz van bekötve, az InplaceEditorKeyDown-hoz és az InplaceEditorExit-hez, minden szerkesztőn, amit a TPDFlibViewer létrehoz, egy párosítás, amely változatlanul szállít, mióta az interaktív formkitöltés először landolt v3.220.0-ban, és az OnExit az, ahol a baj kezdődik

Miért váltja ki a szerkesztő elrejtése másodszor is az OnExit-et?

A VCL-ben a TWinControl a Visible vagy a Parent egy fókuszált vezérlőn történő megváltozását okként kezeli, hogy azonnal elmozdítsa a fókuszt onnan, és a fókusz elmozdítása egy vezérlőtől pontosan az, ami kiváltja annak a vezérlőnek az OnExit eseményét, szinkronban, mielőtt a tulajdonság-hozzárendelés, amely kiváltotta, egyáltalán visszatérne. A CommitInplaceEditor, a metódus, amit a PDFlibPas a helybeni szerkesztő bezárásához és értékének a formmezőbe való visszaírásához használ, pontosan ezt a két dolgot kell tegye kifelé menet: állítsa az Editor.Visible-t hamisra, és az Editor.Parent-et nilre, így a vezérlő abbahagyja az oldal tetejére rajzolást, és abbahagyja a bemenet fogadását. Tedd meg bármelyiket, amíg a szerkesztő még mindig fókuszban van, ami szinte mindig igaz, mivel a felhasználó épp elhagyta, és az OnExit ismét kiváltódik annak a hívásnak a közepén, amelynek az utolsó dolognak kellett volna lennie, amit az a szerkesztő OnExit-je valaha kivált

Mi romlik el, amikor a CommitInplaceEditor újrabelép önmagába?

Egy naiv elkötelezési metódus két módon fizet meg ezért. Vagy kétszer írja meg a mező értékét, egyszer az eredeti hívásból, és egyszer az újrabelépő hívásból, amely besurrant, mielőtt az első befejezte volna saját állapotának érintését, vagy megpróbálja felszabadítani a szerkesztővezérlőt, miközben egy hívási vermen lejjebb lévő keret még mindig ugyanannak a vezérlőnek saját eseménykezelőjén belül van, ami definiálatlan terület a VCL-ben, és hozzáférési hibaként mutatkozik meg, amely szinte bármely sorra mutathat, nem feltétlenül arra, amelyik ténylegesen okozta. Egyik hibának sincs szüksége egy nagy formra a kiváltáshoz; egy kétmezős dokumentum elég, feltéve hogy a felhasználó elég gyorsan elhagyja a második mezőt ahhoz, hogy az OS még mindig letekerje az első fókuszüzeneteit

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

Nullázd a hivatkozást, mielőtt hozzányúlnál a vezérlőhöz

A javítás, amit a PDFlibPas szállít, egyetlen átrendezés: rögzítsd a szerkesztőt egy helyi változóban, töröld a mezőt, amely rá mutat, és csak azután kezdd el megváltoztatni a vezérlő tulajdonságait. A CommitInplaceEditor beolvassa a FInplaceEditor-t egy helyi Editor változóba, azonnal nilre állítja a FInplaceEditor-t, és csak azután rendeli hozzá az Editor.Visible-t és Editor.Parent-et. Egy újrabelépő hívás, amit e két hozzárendelés bármelyike kivált, saját maga olvassa be a FInplaceEditor-t, azt már nilnek találja, és kilép a saját legelső során, mielőtt hozzáérhetne az Editor-hoz, vagy másodszor is megírhatná a mező értéké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;

A SaveEditorValue abban a listázásban a valódi ágat helyettesíti, amely ellenőrzi, hogy az Editor TComboBox-e, TMemo-e, vagy TEdit-e, és ennek megfelelően olvassa be az értékét, mivel a PDFlibPas eltérő vezérlőt hoz létre attól függően, hogy a mező szövegmező-e vagy választómező. Az őrt nem érdekli, melyik ág fut, csak az, hogy a FInplaceEditor nil legyen, mielőtt bármi, ami képes kiváltani az OnExit-et, végrehajtódna, ami az az egyetlen sorrendi megkötés, amely biztonságossá teszi a metódus többi részének megírását bármilyen egyébként természetes stílusban

Soha ne szabadíts fel egy vezérlőt saját eseményén belülről

A TPDFlibViewer.CommitInplaceEditor soha nem hívja meg közvetlenül az Editor.Free-t, és ez szándékos: egy vezérlő felszabadítása nem biztonságos, amíg egy, ugyanahhoz a vezérlőhöz tartozó saját eseménydiszpécselés stack-kerete még mindig letekeredik a hívás fölött, amely felszabadítja, újrabelépő OnExit vagy sem. A PDFlibPas ehelyett átadja a leválasztott szerkesztőt egy egyhelyes parkolóhelynek, a FDeadEditor-nak, felszabadítva bármit, ami az előző szerkesztési ciklusból ott ült, egy kis segédfüggvényen keresztül, a ReapDeadEditor-on, amelyet a következő BeginEditFormField elején hívnak meg, és még egyszer a CloseDocument-ből; minden szerkesztőt, amit a megjelenítő létrehoz, maga a megjelenítő is birtokol, TEdit.Create(Self), nem TEdit.Create(nil), így még egy vezérlő is, amely a FDeadEditor-ban parkol, amikor a megjelenítő megsemmisül, a szokásos VCL-komponens-tulajdonlás söpri fel, ahelyett hogy szivárogna

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;

Miért mutatkozik ez meg leginkább gyors Tab-navigáció közben?

A FocusNextFormField, a metódus, amit a PDFlibPas v3.226.0-ban adott hozzá a Tab és Shift+Tab navigáció vezérléséhez egy formon át, meghívja a BeginEditFormField-et a következő jogosult mezőhöz minden egyes lépésnél, és a BeginEditFormField a CommitInplaceEditor(True) meghívásával kezd, hogy kiürítse bármit, amit az előző mező nyitva hagyott. Ez azt jelenti, hogy minden Tab-lenyomás, amit egy felhasználó egy többmezős form kitöltése közben tesz, egyszer lefuttatja a fent leírt pontos leválasztás-majd-érintés sorozatot, ami pontosan az a kódútvonal, amely a leginkább hordozhat egy ténylegesen fókuszban lévő vezérlőt abban a pillanatban, amikor a Visible és a Parent megváltozik, mert a Tab az az egy interakció, amely szinte garantáltan a kimenő szerkesztőnél tartja a fókuszt egészen addig, amíg az új nem kéri

Ebből semmi nem teszi a hibát megbízhatóan bemutathatóvá, és ezt érdemes egyszerűen kimondani, nem elkenni. Az, hogy egy adott Visible- vagy Parent-hozzárendelés ténylegesen szinkron OnExit-et kényszerít-e ki, olyan fókusz- és ablak-handle-állapottól függ, amit egy debugger már azzal megváltoztat, hogy csatolva van, amit egy nem kapcsolódó újrarajzolás vagy időzítő megzavarhat, és amely eltérően viselkedik attól függően, melyik van játékban a TEdit, a TMemo, vagy a TComboBox közül. Egy őr, amelyet csak néha gyakorolnak, az oka annak, hogy ez a fajta hiba túléli mind a kódáttekintést, mind a kézi tesztelést, és ez az oka annak is, hogy a javításnak felépítésénél fogva helyesnek kell lennie, a hivatkozás nullázásával, mielőtt bármi más történne, nem pedig annak alapján helyesnek, amit néhány kézi tesztátfutás véletlenül megfigyelt

Ennek a javításnak az általános alakja messze túlmutat egyetlen megjelenítő-vezérlőn. Bármely egyéni szerkesztési felület, amelyet egy élő VCL-vezérlő renderelt tartalomra rétegezésével építenek, nem csak egy PDF formmező, ugyanazt a veszélyt örökli abban a pillanatban, amikor a bezárás-és-elkötelezés logikáját mind egy explicit felhasználói művelet, mind egy implicit fókuszváltás kiválthatja, és ugyanaz a kétrészes válasz alkalmazandó: töröld azt a hivatkozást, amely az aktív vezérlőt azonosítja, mielőtt bármit tennél, ami kiválthatná saját exit-eseményét, és soha ne hívj Free-t egy olyan kódútvonalról, amely még mindig futhat annak a vezérlőnek saját eseménydiszpécselése alatt. A TPDFlibViewer szélesebb form-kitöltési és renderelési felülete, beleértve azt, hogyan dönti el, melyik vezérlőtípust mutassa melyik mezőhöz, a Delphi VCL-ben interaktív PDF-megjelenítő vezérlő építéséről szóló áttekintésben szerepel a PDFlibPasszal, és a lapbitkép-gyorsítótár, amit a SetFormFieldValueAndRefresh-nek minden elkötelezett szerkesztésnél érvénytelenítenie kell, külön a a megjelenítő monitoronkénti DPI lemez-oldalgyorsítótáráról szóló cikkben szerepel

A helybeni formmező-szerkesztés, a Tab-vezérelt mezőnavigáció, és a mindkettő mögötti újrabelépés-biztos elkötelezési útvonal a Delphihez és C++Builderhez készült PDFlibPas PDF-könyvtárral szállított interaktív megjelenítő vezérlő része, oldal-renderelési, annotáció-, és formmező-API felületének többi részével együtt