Tehnički članak

Uređivanje polja obrasca u mjestu: reentrancy događaja OnExit

Kontrola TPDFlibViewer potvrđuje uređivanje polja obrasca na mjestu kroz vlastiti događaj OnExit uređivača, a taj izbor dizajna skriva klasičnu zamku Delphija i VCL-a: skrivanje, ponovno postavljanje roditelja ili uništavanje fokusirane kontrole iz njezina vlastitog rukovatelja OnExit može pokrenuti OnExit drugi put prije povratka prvog poziva i vratiti logiku potvrde samu u sebe

Greška koju to proizvodi teško se uredno reproducira. Korisnik brzo pritišće Tab kroz niz tekstnih polja na skeniranom obrascu, a preglednik s vremena na vrijeme izazove kršenje pristupa ili, još gore, nastavi raditi dok potajno zapisuje pogrešnu vrijednost u polje dva koraka unatrag. Kada je namjerno reproducirate, greška unatrag izgleda očito; kada je pratite iz izvješća o rušenju jednog korisnika, djeluje poput duha jer to hoće li se drugi OnExit doista pokrenuti ovisi o vremenskom slijedu ručke prozora i fokusa, koji se mijenja s vrstom polja, brzinom tipkanja i svime ostalim što red poruka radi u tom trenutku

Kako TPDFlibViewer postavlja pravi uređivač iznad prikazane stranice

TPDFlibViewer svaku PDF stranicu prikazuje kao bitmapu i prema zadanim postavkama ne pretvara polja obrasca u aktivne VCL kontrole, pa je BeginEditFormField metoda koja povezuje ta dva svijeta: dobiva indeks polja, pronalazi pravokutnik polja i pretvara ga u klijentske koordinate, a zatim na taj pravokutnik postavlja pravi TEdit ili TMemo za tekstno polje, odnosno TComboBox u stilu csDropDownList za polje odabira, pri čemu je trenutačna vrijednost polja već učitana. ISO 32000-2 §12.7 definira što je tekstno ili izborno polje obrasca unutar PDF-a, ali ta specifikacija nigdje ne govori kako Windowsova aplikacija treba korisniku omogućiti unos u njega, a upravo tu prazninu popunjava BeginEditFormField. I OnKeyDown i OnExit povezani su s istim dvjema metodama preglednika, InplaceEditorKeyDown i InplaceEditorExit, na svakom uređivaču koji TPDFlibViewer stvori, a tu počinju problemi s OnExitom

Dijagram TPDFlibViewer-a koji postavlja živi VCL uređivač preko bitmape prikazane PDF stranice te povezuje OnKeyDown i OnExit na rukovatelje preglednika
BeginEditFormField stavlja stvarnu fokusiranu kontrolu preko stranice, a svaki takav editor dolazi s OnExit — vratima kroz koja ulazi reentrancy

Zašto skrivanje uređivača pokreće OnExit drugi put

TWinControl u VCL-u promjenu Visible ili Parent na fokusiranoj kontroli tretira kao razlog za trenutačno premještanje fokusa, a premještanje fokusa s kontrole upravo sinkrono pokreće njezin događaj OnExit, prije nego što se dodjela svojstva koja ga je pokrenula uopće vrati. CommitInplaceEditor, metoda koju PDF Library for Delphi koristi za zatvaranje uređivača na mjestu i zapisivanje njegove vrijednosti natrag u polje obrasca, na izlasku mora učiniti upravo te dvije stvari: postaviti Editor.Visible na False i Editor.Parent na nil kako bi kontrola prestala crtati iznad stranice i prestala primati unos. Učinite bilo koju od tih radnji dok uređivač još ima fokus, što je gotovo uvijek slučaj jer ga je korisnik upravo napustio, i OnExit se pokreće ponovno usred poziva koji je trebao biti posljednja radnja koju je OnExit tog uređivača ikada pokrenuo

Što se događa kada CommitInplaceEditor ponovno uđe u sebe

Naivna metoda potvrde za to plaća na jedan od dva načina. Ili dvaput zapisuje vrijednost polja, jednom iz izvornog poziva i drugi put iz reentrantnog poziva koji se uvukao prije nego što je prvi poziv završio s izmjenom vlastitog stanja, ili pokušava osloboditi kontrolu uređivača dok je okvir poziva dublje na stogu još unutar istog rukovatelja događaja te kontrole, što je u VCL-u nedefinirano područje i pojavljuje se kao kršenje pristupa koji može pokazivati na gotovo bilo koji redak, a ne nužno na onaj koji ga je stvarno uzrokovao. Nijedan od tih kvarova ne zahtijeva veliki obrazac; dovoljan je dokument s dva polja ako korisnik drugo polje napusti dovoljno brzo da operacijski sustav još razmotava poruke fokusa prvog

// Naivna verzija: čita se dobro pri pregledu, ali otkazuje samo pri stvarnoj brzini tipkanja
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // još uvijek se izvršava unutar vlastitog OnExit editora FEditor
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // fokusirana kontrola ovdje mijenja roditelja: OnExit
                               // opet se okida, ponovno ulazeći u ovu istu metodu
  FEditor.Free;                // oslobođen dok se pozivatelj dalje niz
  FEditor := nil;              // stog i dalje nalazi unutar njegovog OnExit handlera
end;

Postavite referencu na nil prije dodirivanja kontrole

Popravak koji isporučuje PDF Library for Delphi svodi se na jedno promijenjeno redoslijed: spremite uređivač u lokalnu varijablu, očistite polje koje pokazuje na njega i tek tada počnite mijenjati svojstva kontrole. CommitInplaceEditor čita FInplaceEditor u lokalnu varijablu Editor, odmah postavlja FInplaceEditor na nil i tek nakon toga dodjeljuje Editor.Visible i Editor.Parent. Reentrantni poziv koji pokrene bilo koja od tih dviju dodjela čita sam FInplaceEditor, pronalazi da je već nil i izlazi u prvom retku, prije nego što može dodirnuti Editor ili drugi put zapisati vrijednost polja

procedure TPDFlibViewer.CommitInplaceEditor(Save: Boolean);
var
  Editor: TWinControl;
begin
  Editor := FInplaceEditor;
  if not Assigned(Editor) then
    Exit;                      // ponovljeni poziv ovdje završava i zaustavlja se
  FInplaceEditor := nil;       // odspoji prije nego što se kontrole uopće dotaknu
  if Save then
    SaveEditorValue(Editor);   // sigurno: FInplaceEditor je već nil
  Editor.Visible := False;
  Editor.Parent := nil;        // može ponovno okinuti OnExit; zaštita iznad
                                // pretvara taj ponovljeni poziv u no-op
  ReapDeadEditor;               // oslobodi što god je parkirano u prošlom ciklusu
  FDeadEditor := Editor;        // parkiraj ovaj umjesto da ga ovdje oslobodiš
end;

SaveEditorValue u tom popisu predstavlja stvarnu granu koja provjerava je li Editor TComboBox, TMemo ili TEdit i prema tome čita njegovu vrijednost jer PDF Library for Delphi stvara različitu kontrolu ovisno o tome je li polje tekstno ili izborno. Zaštita ne ovisi o tome koja se grana izvršava, nego samo o tome da FInplaceEditor bude nil prije bilo čega što može pokrenuti OnExit, a upravo je to redoslijedno ograničenje zbog kojeg ostatak metode možete napisati u stilu koji je inače najprirodniji

Nikada nemojte osloboditi kontrolu iz njezina vlastitog događaja

TPDFlibViewer.CommitInplaceEditor nikada izravno ne poziva Editor.Free i to je namjerno: oslobađanje kontrole nije sigurno dok se okvir stoga koji pripada vlastitoj obradi događaja te kontrole možda još odmotava iznad poziva koji je oslobađa, bez obzira na reentrantni OnExit. PDF Library for Delphi umjesto toga predaje odvojeni uređivač jednom mjestu za privremeno čuvanje, FDeadEditor, a ono što je ondje ostalo iz prethodnog ciklusa uređivanja oslobađa malim pomoćnikom ReapDeadEditor, koji se poziva na početku sljedećeg BeginEditFormField i još jednom iz CloseDocument; svaki uređivač koji preglednik stvori ujedno je u vlasništvu samog preglednika, TEdit.Create(Self) umjesto TEdit.Create(nil), pa čak i kontrolu koja pri uništavanju preglednika još stoji u FDeadEditoru preuzima uobičajeno vlasništvo VCL komponente umjesto da procuri

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // sada sigurno: OnExit ove kontrole
    FDeadEditor := nil;        // završio je barem jedan ciklus uređivanja ranije
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // isprazni koji god editor je još otvoren
  // ... traženje polja i pretvorba pravokutnika izostavljeni ...
  ReapDeadEditor;              // sada je sigurno osloboditi editor parkiran u prošlom ciklusu
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

Zašto se ovo najviše vidi pri brzoj navigaciji tipkom Tab

FocusNextFormField, metoda koju je PDF Library for Delphi dodao u v3.226.0 za upravljanje navigacijom Tab i Shift+Tab kroz obrazac, poziva BeginEditFormField za sljedeće prihvatljivo polje pri svakom prijelazu, a BeginEditFormField počinje pozivom CommitInplaceEditor(True) kako bi ispraznio uređivač koji je prethodno polje ostavilo otvorenim. To znači da svaki pritisak tipke Tab tijekom ispunjavanja obrasca s više polja jednom izvodi točan slijed odvajanja pa dodirivanja opisan iznad, upravo onaj put koda na kojem će kontrola najvjerojatnije još stvarno imati fokus u trenutku promjene Visible i Parent jer je Tab interakcija koja gotovo sigurno zadržava fokus na izlaznom uređivaču sve dok ga novi ne zatraži

PDF Library for Delphi dijagram toka koji prikazuje kako postavljanje Editor.Parent na nil na uređivaču u fokusu ponovno aktivira OnExit te ponovno ulazi u CommitInplaceEditor prije nego prvi poziv vrati
Jedna dodjela koja mijenja roditelja šalje commit natrag u samoga sebe, otvarajući i mod dvostrukog pisanja i mod prijevremenog Free

Ništa od ovoga ne čini grešku pouzdanom za demonstraciju i to vrijedi reći jasno umjesto je uljepšavati. Hoće li određena dodjela Visible ili Parent doista prisiliti sinkroni OnExit ovisi o stanju fokusa i ručke prozora, koje debugger mijenja već samim priključivanjem, koje može poremetiti nepovezano ponovno iscrtavanje ili mjerač vremena i koje se ponaša različito ovisno o tome je li kontrola TEdit, TMemo ili TComboBox. Zaštita koja se aktivira samo ponekad razlog je zbog kojeg takav nedostatak preživi i pregled koda i ručno testiranje, a ujedno je razlog zbog kojeg popravak mora biti ispravan po konstrukciji, postavljanjem reference na nil prije svega ostalog, a ne ispravan samo prema ponašanju koje je slučajno pokazalo nekoliko ručnih prolaza kroz test

Opći oblik ovog popravka primjenjiv je daleko izvan jedne kontrole preglednika. Svaka prilagođena površina za uređivanje izgrađena prekrivanjem prikazanog sadržaja aktivnom VCL kontrolom, a ne samo PDF polje obrasca, nasljeđuje istu opasnost čim se njezina logika zatvaranja i potvrde može pokrenuti i izričitom radnjom korisnika i implicitnom promjenom fokusa, a vrijedi i isti dvodijelni odgovor: očistite referencu koja identificira aktivnu kontrolu prije svega što bi moglo pokrenuti njezin vlastiti izlazni događaj i nikada ne pozivajte Free iz putanje koda koja bi još mogla raditi ispod obrade vlastitog događaja te kontrole. Šira površina TPDFlibViewer-a za ispunjavanje obrazaca i prikazivanje, uključujući način odlučivanja koju vrstu kontrole prikazati za koje polje, obrađena je u pregledu izgradnje interaktivne kontrole PDF preglednika u Delphi VCL-u s PDF Library for Delphi-om, a predmemorija bitmape stranice koju SetFormFieldValueAndRefresh mora poništiti nakon svake potvrđene izmjene zasebno je obrađena u članku o diskovnoj predmemoriji stranica preglednika po monitoru i DPI-ju

PDF Library for Delphi: poredani slijed predaje u šest koraka koji briše FInplaceEditor prije dodirivanja kontrole, ponovni ulazni OnExit tretira kao operaciju bez učinka, a Free odgađa kroz utor FDeadEditor
Prvo postavljanje reference na nil tjera svaki reentrant poziv na trenutni izlaz, a utor parkiranog editora svaki Free premješta u ciklus čiji su stogovi mirni

Uređivanje polja obrasca na mjestu, navigacija poljima pokrenuta tipkom Tab i put potvrde siguran od reentrancyja koji stoji iza obiju funkcija dio su interaktivne kontrole preglednika isporučene uz PDF Library for Delphi, PDF biblioteku za Delphi i C++Builder, zajedno s ostatkom njezine površine za prikaz stranica, bilješke i API polja obrasca