Techninis straipsnis

OnExit pakartotinis įėjimas Delphi įterptame formų rengyklėje

PDFlibPas TPDFlibViewer valdiklis įrašo formos laukų pakeitimus per paties rengyklės OnExit įvykį, o toks sprendimas slepia klasikinį Delphi VCL spąstą: paslėpus, perkėlus į kitą tėvinį valdiklį arba sunaikinus fokusuotą valdiklį jo paties OnExit apdorojimo programoje, OnExit gali būti iškviestas antrą kartą dar negrįžus iš pirmojo kvietimo, todėl įrašymo logika vėl nukreipiama į save

Dėl to atsirandantį gedimą sunku švariai atkartoti. Naudotojas greitai spaudo tabuliavimo klavišą per nuskenuotos paraiškos formos tekstinių laukų seką, ir kartkartėmis peržiūros programa išmeta prieigos pažeidimą arba, dar blogiau, toliau veikia tyliai įrašydama neteisingą reikšmę į lauką, esantį dviem laukais anksčiau. Atkūrus problemą pagal poreikį, klaida atrodo akivaizdi žvelgiant atgal, tačiau tiriant ją pagal vieno kliento strigties ataskaitą ji primena vaiduoklį, nes tai, ar antras OnExit iš tiesų bus iškviestas, priklauso nuo lango deskriptoriaus ir fokuso laiko parametrų, kuriuos keičia lauko tipas, rašymo greitis ir visa kita, ką tuo metu daro pranešimų eilė

Kaip TPDFlibViewer įkeltame puslapyje pateikia tikrą rengyklę

TPDFlibViewer kiekvieną PDF puslapį pateikia kaip taškinį vaizdą ir pagal numatytuosius nustatymus nepaverčia formos laukų aktyviais VCL valdikliais, todėl BeginEditFormField yra metodas, sujungiantis šiuos du pasaulius: iškviestas su lauko indeksu jis suranda lauko stačiakampį ir paverčia jo koordinates kliento koordinatėmis, tada tekstinio lauko stačiakampio viršuje sukuria tikrą TEdit arba TMemo, o pasirinkimo laukui su csDropDownList stiliumi sukuria TComboBox, kartu jau įkeldamas dabartinę lauko reikšmę. ISO 32000-2 §12.7 apibrėžia, kas PDF faile yra tekstinis arba pasirinkimo formos laukas, tačiau ši specifikacija nieko nesako apie tai, kaip Windows programa turėtų leisti į jį įvesti tekstą, ir būtent šią spragą užpildo BeginEditFormField. Kiekvienoje TPDFlibViewer sukurtoje rengyklėje OnKeyDown ir OnExit susiejami su tais pačiais dviem peržiūros programos metodais InplaceEditorKeyDown ir InplaceEditorExit, o ši pora nuo interaktyvaus formų pildymo įtraukimo v3.220.0 versijoje išliko nepakeista, todėl problemos prasideda būtent OnExit vietoje

Kodėl paslėpus rengyklę OnExit iškviečiamas antrą kartą

VCL TWinControl fokusuoto valdiklio Visible arba Parent pakeitimą laiko priežastimi nedelsiant perkelti fokusą, o fokuso perkėlimas nuo valdiklio yra būtent tai, kas sinchroniškai iškviečia jo OnExit įvykį dar negrįžus iš paties savybės priskyrimo, kuris jį sukėlė. CommitInplaceEditor metodas, kurį PDFlibPas naudoja įterptai rengyklei uždaryti ir jos reikšmei grąžinti į formos lauką, išeidamas turi atlikti būtent šiuos du veiksmus: nustatyti Editor.Visible į False ir Editor.Parent į nil, kad valdiklis nustotų būti rodomas puslapio viršuje ir nebegautų įvesties. Atlikus bet kurį iš šių veiksmų, kol rengyklė dar fokusuota, o taip beveik visada ir yra, nes naudotojas ką tik iš jos išėjo, OnExit iškviečiamas viduryje būtent to kvietimo, kuris turėjo būti paskutinis veiksmas, sukeltas tos rengyklės OnExit

Kas nutinka, kai CommitInplaceEditor įeina į save pakartotinai

Naivus įrašymo metodas už tai sumoka vienu iš dviejų būdų. Jis arba du kartus įrašo lauko reikšmę, pirmą kartą iš pradinio kvietimo, o antrą kartą iš pakartotinio kvietimo, įsiterpusio dar nebaigus tvarkyti savo būsenos, arba bando atlaisvinti rengyklės valdiklį, kai žemiau esančiame steko kadre dar vykdomas to paties valdiklio įvykių apdorojimas, o tai VCL yra neapibrėžta situacija ir pasireiškia prieigos pažeidimu, galinčiu nurodyti beveik bet kurią eilutę, nebūtinai tą, kuri iš tikrųjų jį sukėlė. Nė vienam iš šių gedimų nereikia didelės formos: pakanka dviejų laukų dokumento, jei naudotojas pakankamai greitai palieka antrą lauką, kad operacinė sistema dar nebūtų baigusi tvarkyti pirmojo lauko fokuso pranešimų

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

Nustatykite nuorodą į nil prieš liesdami valdiklį

PDFlibPas pateikiamas pataisymas yra vienas veiksmų eiliškumo pakeitimas: išsaugokite rengyklę vietiniame kintamajame, išvalykite į ją rodančią reikšmę ir tik tada pradėkite keisti valdiklio savybes. CommitInplaceEditor nuskaito FInplaceEditor į vietinį kintamąjį Editor, iš karto nustato FInplaceEditor į nil ir tik po to priskiria Editor.Visible bei Editor.Parent. Pakartotinis kvietimas, sukeltas bet kurio iš šių dviejų priskyrimų, patikrina patį FInplaceEditor, randa, kad jis jau yra nil, ir baigia darbą pirmoje eilutėje, prieš paliesdamas Editor arba antrą kartą įrašydamas lauko reikšmę

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;

Šiame sąraše SaveEditorValue reiškia tikrąją šaką, kuri patikrina, ar Editor yra TComboBox, TMemo arba TEdit, ir atitinkamai nuskaito jo reikšmę, nes PDFlibPas sukuria skirtingą valdiklį priklausomai nuo to, ar laukas yra tekstinis, ar pasirinkimo. Apsaugai nesvarbu, kuri šaka vykdoma, svarbu tik tai, kad FInplaceEditor būtų nil prieš atliekant bet kokį veiksmą, galintį sukelti OnExit, nes būtent ši eiliškumo sąlyga leidžia likusį metodą saugiai rašyti tokiu stiliumi, kuris kitu požiūriu yra natūralus

Niekada neatlaisvinkite valdiklio jo paties įvykyje

TPDFlibViewer.CommitInplaceEditor niekada tiesiogiai nekviečia Editor.Free, ir tai daroma tyčia: valdiklio atlaisvinimas yra nesaugus, kol to paties valdiklio įvykių iškvietimui priklausantis steko kadras dar gali būti tvarkomas virš atlaisvinimo kvietimo, nesvarbu, ar OnExit buvo iškviestas pakartotinai. Vietoj to PDFlibPas perduoda atskirtą rengyklę į vienos vietos saugojimo lauką FDeadEditor, o tai, kas jame buvo likę iš ankstesnio redagavimo ciklo, atlaisvina maža pagalbinė funkcija ReapDeadEditor, iškviečiama kito BeginEditFormField pradžioje ir dar kartą iš CloseDocument. Kiekviena peržiūros programos sukurta rengyklė taip pat priklauso pačiai peržiūros programai, nes naudojamas TEdit.Create(Self), o ne TEdit.Create(nil), todėl net ir ta rengyklė, kuri peržiūros programos sunaikinimo metu tebėra FDeadEditor, bus sutvarkyta įprastos VCL komponentų nuosavybės tvarkos ir nenutekės

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;

Kodėl tai labiausiai išryškėja greitai naršant Tab klavišu

FocusNextFormField metodas, kurį PDFlibPas pridėjo v3.226.0 versijoje Tab ir Shift+Tab naršymui tarp formos laukų valdyti, kiekvieno perėjimo metu iškviečia BeginEditFormField kitam tinkamam laukui, o BeginEditFormField pradeda nuo CommitInplaceEditor(True), kad įrašytų ankstesnio lauko dar atidarytą rengyklę. Tai reiškia, kad kiekvienas naudotojo paspaustas Tab klavišas pildant kelių laukų formą vieną kartą vykdo tiksliai pirmiau aprašytą atskyrimo ir palietimo seką, o šis kodo kelias kaip tik labiausiai tikėtina, kad valdiklis dar bus iš tiesų fokusuotas tuo momentu, kai pakeičiamos Visible ir Parent savybės, nes Tab yra sąveika, beveik garantuojanti, kad išeinanti rengyklė išlaikys fokusą iki pat tos akimirkos, kai naujoji jo paprašys

Visa tai nepadaro klaidos patikimai pademonstruojamos, ir verta tai aiškiai pasakyti, o ne nutylėti. Ar konkretus Visible arba Parent priskyrimas iš tiesų privers sinchroniškai iškviesti OnExit, priklauso nuo fokuso ir lango deskriptoriaus būsenos, kurią vien prisijungęs derintuvas pakeičia, kurią gali paveikti nesusijęs perpiešimas arba laikmatis ir kuri skiriasi priklausomai nuo to, ar naudojamas TEdit, TMemo, ar TComboBox. Apsauga, kuri suveikia tik kartais, yra priežastis, kodėl tokio tipo defektas išlieka nepastebėtas tiek peržiūrint kodą, tiek atliekant rankinius bandymus, ir taip pat priežastis, kodėl pataisymas turi būti teisingas pagal konstrukciją, nustačius nuorodą į nil prieš bet kokį kitą veiksmą, o ne teisingas vien dėl to, ką parodė keli sėkmingi rankinių bandymų ciklai

Bendra šio pataisymo forma pritaikoma kur kas plačiau nei vienam peržiūros programos valdikliui. Bet kuri pasirinktinio redagavimo sąsaja, sukurta uždengiant pateiktą turinį aktyviu VCL valdikliu, ne tik PDF formos laukui, paveldi tą patį pavojų vos tik jos uždarymo ir įrašymo logiką galima sukelti tiek aiškiu naudotojo veiksmu, tiek netiesioginiu fokuso pasikeitimu, o tas pats dviejų dalių atsakymas galioja ir čia: išvalykite nuorodą, identifikuojančią aktyvų valdiklį, prieš atlikdami bet ką, kas galėtų sukelti jo paties išėjimo įvykį, ir niekada nekvieskite Free iš kodo kelio, kuris dar gali būti vykdomas po to valdiklio įvykių apdorojimu. Platesnis TPDFlibViewer formų pildymo ir pateikimo paviršius, įskaitant tai, kaip jis nusprendžia, kokį valdiklio tipą rodyti konkrečiam laukui, aprašytas apžvalgoje apie interaktyvaus PDF peržiūros programos valdiklio kūrimą Delphi VCL naudojant PDFlibPas, o puslapio taškinio vaizdo podėlis, kurį SetFormFieldValueAndRefresh turi panaikinti po kiekvieno įrašyto pakeitimo, atskirai aprašytas straipsnyje apie peržiūros programos kiekvieno monitoriaus DPI disko puslapio podėlį

Formos laukų redagavimas vietoje, Tab valdomas laukų naršymas ir abiejų funkcijų reentrantiškumui atsparus įrašymo kelias yra interaktyvaus peržiūros programos valdiklio, pateikiamo su PDFlibPas, PDF biblioteka Delphi ir C++Builder, dalis kartu su likusiu puslapių pateikimo, anotacijų ir formos laukų API paviršiumi