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

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 PDFlibPas 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

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

Postavite referencu na nil prije dodirivanja kontrole

Popravak koji isporučuje PDFlibPas 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;                      // 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 u tom popisu predstavlja stvarnu granu koja provjerava je li Editor TComboBox, TMemo ili TEdit i prema tome čita njegovu vrijednost jer PDFlibPas 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. PDFlibPas 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;          // 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;

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

FocusNextFormField, metoda koju je PDFlibPas 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

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 PDFlibPas-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

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 PDFlibPas, PDF biblioteku za Delphi i C++Builder, zajedno s ostatkom njezine površine za prikaz stranica, bilješke i API polja obrasca