Articol tehnic

Reentranță OnExit într-un editor de formular pe loc în Delphi

Controlul TPDFlibViewer al PDFlibPas confirmă un editor de câmp de formular pe loc prin propriul eveniment OnExit al editorului, iar acea alegere de design ascunde o capcană clasică VCL Delphi: ascunderea, redirecționarea, sau distrugerea unui control focalizat din interiorul propriului său handler OnExit poate declanșa OnExit a doua oară înainte ca primul apel să revină, trimițând logica de confirmare înapoi în ea însăși

Eșecul pe care îl produce asta rezistă unei reproduceri curate. Un utilizator tastează rapid Tab printr-o serie de câmpuri de text pe un formular de aplicație scanat, iar din când în când vizualizatorul ridică o încălcare de acces, sau mai rău, continuă să ruleze scriind silențios valoarea greșită într-un câmp cu două tab-uri în urmă. Reproduceți-l la cerere, iar bug-ul arată evident retrospectiv; urmăriți-l dintr-un raport de accident al unui singur client, iar arată ca o fantomă, pentru că dacă al doilea OnExit chiar se declanșează depinde de sincronizarea handle-ului de fereastră și focus-ului care se schimbă cu tipul de câmp, viteza de tastare, și orice altceva face coada de mesaje în acel moment

Cum pune TPDFlibViewer un editor real deasupra unei pagini randate

TPDFlibViewer randează fiecare pagină PDF la un bitmap și nu transformă implicit câmpurile de formular în controale VCL vii, așa că BeginEditFormField este metoda care leagă cele două lumi: apelată cu un index de câmp, caută dreptunghiul câmpului și îl convertește în coordonate de client, apoi așează un TEdit sau TMemo real deasupra acelui dreptunghi pentru un câmp de text, sau un TComboBox în stil csDropDownList pentru un câmp de alegere, complet cu valoarea curentă a câmpului deja încărcată. ISO 32000-2 §12.7 definește ce este un câmp de formular de text sau alegere în interiorul unui PDF, dar nimic din acea specificație nu spune cum ar trebui o aplicație Windows să lase pe cineva să tasteze într-unul, iar acel gol este exact ce există BeginEditFormField să umple. Atât OnKeyDown, cât și OnExit sunt conectate la aceleași două metode de vizualizator, InplaceEditorKeyDown și InplaceEditorExit, pe fiecare editor pe care îl creează TPDFlibViewer, o asociere care a fost livrată neschimbată de când completarea interactivă de formular a apărut prima dată în v3.220.0, iar OnExit este locul unde încep problemele

De ce declanșează ascunderea editorului OnExit a doua oară?

TWinControl în VCL tratează o schimbare a Visible sau Parent pe un control focalizat ca un motiv de a muta focus-ul departe de el imediat, iar mutarea focus-ului departe de un control este exact ce declanșează evenimentul OnExit al acelui control, sincron, înainte ca atribuirea de proprietate care l-a declanșat să revină măcar. CommitInplaceEditor, metoda pe care PDFlibPas o folosește pentru a închide editorul pe loc și a-i scrie valoarea înapoi la câmpul de formular, trebuie să facă exact acele două lucruri pe drumul de ieșire: setarea Editor.Visible la False și setarea Editor.Parent la nil, astfel încât controlul încetează să deseneze deasupra paginii și încetează să primească intrare. Faceți oricare din acestea în timp ce editorul încă are focus, ceea ce aproape întotdeauna are întrucât utilizatorul tocmai l-a părăsit, iar OnExit se declanșează din nou la mijlocul chiar acelui apel care trebuia să fie ultimul lucru pe care OnExit-ul acelui editor l-ar declanșa vreodată

Ce merge greșit când CommitInplaceEditor reintră în el însuși?

O metodă de confirmare naivă plătește pentru asta într-unul din două moduri. Fie scrie valoarea câmpului de două ori, o dată din apelul original și o dată din apelul reentrant care s-a strecurat înainte ca primul să termine de atins propria sa stare, fie încearcă să elibereze controlul editor în timp ce un cadru mai jos pe stiva de apel este încă în interiorul propriului handler de eveniment al aceluiași control, ceea ce este teritoriu nedefinit în VCL și apare ca o încălcare de acces care poate indica spre aproape orice linie, nu neapărat cea care a cauzat-o efectiv. Niciun eșec nu are nevoie de un formular mare pentru a se declanșa; un document cu două câmpuri este suficient, cu condiția ca utilizatorul să părăsească al doilea câmp suficient de rapid încât sistemul de operare să încă deruleze mesaje de focus de la primul

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

Puneți referința pe nil înainte de a atinge controlul

Soluția pe care o livrează PDFlibPas este o singură reordonare: capturați editorul într-o variabilă locală, goliți câmpul care indică spre el, și doar apoi începeți să schimbați proprietățile controlului. CommitInplaceEditor citește FInplaceEditor într-o variabilă locală Editor, setează FInplaceEditor la nil imediat, și doar după aceea atribuie Editor.Visible și Editor.Parent. Un apel reentrant declanșat de oricare din acele două atribuiri citește FInplaceEditor el însuși, îl găsește deja nil, și iese pe chiar prima sa linie, înainte de a putea atinge Editor sau scrie valoarea câmpului a doua oară

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 din acea listă stă în locul ramurii reale, care verifică dacă Editor este un TComboBox, un TMemo, sau un TEdit și îi citește valoarea în consecință, întrucât PDFlibPas creează un control diferit în funcție de dacă câmpul este un câmp de text sau un câmp de alegere. Garda nu-i pasă care ramură rulează, doar că FInplaceEditor este nil înainte ca orice capabil să declanșeze OnExit să se execute, ceea ce este singura constrângere de ordonare care face restul metodei sigur de scris în orice stil este altfel natural

Nu eliberați niciodată un control din interiorul propriului său eveniment

TPDFlibViewer.CommitInplaceEditor nu apelează niciodată Editor.Free direct, iar asta este deliberat: eliberarea unui control este nesigură în timp ce un cadru de stivă aparținând aceluiași control ar putea încă să se deruleze deasupra apelului care îl eliberează, reentrant OnExit sau nu. PDFlibPas în schimb predă editorul detașat unui loc de parcare cu un singur slot, FDeadEditor, eliberând orice stătea acolo din ciclul de editare anterior printr-un mic helper, ReapDeadEditor, apelat la începutul următorului BeginEditFormField și încă o dată din CloseDocument; fiecare editor creat de vizualizator este de asemenea deținut de vizualizatorul însuși, TEdit.Create(Self) în loc de TEdit.Create(nil), așa că chiar și un control încă parcat în FDeadEditor când vizualizatorul este distrus este preluat de deținerea obișnuită a componentei VCL, în loc să se scurgă

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;

De ce apare asta cel mai greu în timpul navigării rapide cu Tab?

FocusNextFormField, metoda pe care PDFlibPas a adăugat-o în v3.226.0 pentru a conduce navigarea Tab și Shift+Tab pe un formular, apelează BeginEditFormField pentru următorul câmp eligibil la fiecare salt individual, iar BeginEditFormField se deschide apelând CommitInplaceEditor(True) pentru a descărca orice editor a lăsat deschis câmpul anterior. Asta înseamnă că fiecare apăsare de Tab pe care o face un utilizator în timp ce completează un formular multi-câmp rulează exact secvența detașare-apoi-atingere descrisă mai sus o dată, ceea ce este exact calea de cod cel mai probabil să încă aibă un control efectiv focalizat în momentul în care Visible și Parent se schimbă, pentru că Tab este singura interacțiune aproape garantată să lase editorul ieșitor ținând focus-ul chiar până când cel nou îl cere

Nimic din toate acestea nu face bug-ul fiabil de demonstrat, iar asta merită spus direct, nu trecut cu vederea. Dacă o anumită atribuire Visible sau Parent efectiv forțează un OnExit sincron depinde de starea de focus și handle de fereastră pe care un debugger o schimbă doar prin faptul că este atașat, pe care un repaint sau timer fără legătură o poate perturba, și care se comportă diferit în funcție de care din TEdit, TMemo, sau TComboBox se întâmplă să fie controlul în joc. O gardă care doar câteodată este exercitată este motivul pentru care acest tip de defect supraviețuiește atât revizuirii codului, cât și testării manuale deopotrivă, și este de asemenea motivul pentru care soluția trebuie să fie corectă prin construcție, punând referința pe nil înainte ca orice altceva să se întâmple, în loc de corectă prin orice comportament au observat întâmplător câteva treceri de test manual

Forma generală a acestei soluții călătorește mult dincolo de un singur control de vizualizator. Orice suprafață de editare personalizată construită suprapunând un control VCL viu peste conținut randat, nu doar un câmp de formular PDF, moștenește același pericol în momentul în care logica sa de închidere-și-confirmare poate fi declanșată atât de o acțiune explicită a utilizatorului, cât și de o schimbare implicită de focus, iar același răspuns în două părți se aplică: goliți referința care identifică controlul activ înainte de a face orice ar putea declanșa propriul său eveniment de ieșire, și nu apelați niciodată Free dintr-o cale de cod care ar putea încă rula sub propriul dispecerizare de eveniment al acelui control. Suprafața mai largă de completare de formular și randare a TPDFlibViewer, incluzând cum decide ce tip de control să arate pentru ce câmp, este acoperită în prezentarea generală a construirii unui control de vizualizator PDF interactiv în Delphi VCL cu PDFlibPas, iar cache-ul de bitmap de pagină pe care SetFormFieldValueAndRefresh trebuie să îl invalideze la fiecare editare confirmată este acoperit separat în piesa despre cache-ul de pagină pe disc per-monitor DPI al vizualizatorului

Editarea de câmp de formular pe loc, navigarea de câmp condusă de Tab, și calea de confirmare sigură-la-reentranță din spatele ambelor fac parte din controlul de vizualizator interactiv livrat cu PDFlibPas, biblioteca PDF pentru Delphi și C++Builder, alături de restul suprafeței sale de API de randare de pagină, adnotare, și câmp de formular