Teknisk artikkel

OnExit-reentrans i en Delphi in-place-redigerer for skjemafelt

TPDFlibViewers kontroll i PDFlibPas lagrer en in-place-feltredigerer gjennom redigererens egen OnExit-hendelse, og dette designvalget skjuler en klassisk VCL-felle i Delphi: å skjule, flytte til en ny parent eller destruere en fokusert kontroll inne i dens egen OnExit-håndterer kan utløse OnExit en gang til før det første kallet rekker å returnere, slik at commit-logikken sendes tilbake inn i seg selv

Feilen dette gir er vanskelig å reprodusere rent. En bruker tabber raskt gjennom en rekke tekstfelt på et skannet søknadsskjema, og med jevne mellomrom kaster viseren en tilgangskrenkelse (access violation), eller enda verre, fortsetter å kjøre mens den stille skriver feil verdi inn i et felt to tabbetrykk tilbake. Reproduser den på kommando, og feilen virker opplagt i ettertid; jakt på den ut fra en enkelt kunderapport om krasj, og den fremstår som et gjenferd, fordi hvorvidt den andre OnExit-hendelsen faktisk utløses avhenger av vindushåndtak- og fokustiming som endrer seg med felttype, skrivehastighet og hva ellers meldingskøen driver med i akkurat det øyeblikket

Hvordan TPDFlibViewer legger en ekte redigerer oppå en rendret side

TPDFlibViewer rendrer hver PDF-side til et bitmap og gjør ikke skjemafelt om til levende VCL-kontroller som standard, så BeginEditFormField er metoden som bygger bro mellom de to verdenene: kalt med en feltindeks slår den opp feltets rektangel, konverterer det til klientkoordinater, og legger deretter en ekte TEdit eller TMemo oppå det rektangelet for et tekstfelt, eller en TComboBox i csDropDownList-stil for et valgfelt, komplett med feltets gjeldende verdi allerede lastet inn. ISO 32000-2 §12.7 definerer hva et tekst- eller valgfelt i et skjema er inne i en PDF, men ingenting i den spesifikasjonen sier noe om hvordan en Windows-applikasjon skal la noen skrive inn i et slikt felt, og det gapet er nettopp det BeginEditFormField finnes for å fylle. Både OnKeyDown og OnExit er koblet til de samme to visermetodene, InplaceEditorKeyDown og InplaceEditorExit, på hver eneste redigerer TPDFlibViewer oppretter — en kobling som har ligget uendret siden interaktiv skjemautfylling først kom i v3.220.0 — og OnExit er der problemene begynner

Hvorfor utløser skjuling av redigereren OnExit en gang til?

TWinControl i VCL behandler en endring av Visible eller Parent på en fokusert kontroll som en grunn til å flytte fokus bort fra den umiddelbart, og å flytte fokus bort fra en kontroll er nettopp det som utløser den kontrollens OnExit-hendelse, synkront, før selve tilordningen som utløste det i det hele tatt rekker å returnere. CommitInplaceEditor, metoden PDFlibPas bruker for å lukke in-place-redigereren og skrive verdien tilbake til skjemafeltet, må gjøre nøyaktig disse to tingene på vei ut: sette Editor.Visible til False og sette Editor.Parent til nil, slik at kontrollen slutter å tegnes oppå siden og slutter å motta input. Gjør man én av delene mens redigereren fortsatt har fokus — noe den nesten alltid har, siden brukeren nettopp forlot den — og OnExit utløses igjen midt i selve kallet som skulle vært det siste den redigereren noensinne utløste sin OnExit fra

Hva går galt når CommitInplaceEditor reentrerer seg selv?

En naiv commit-metode betaler for dette på én av to måter. Enten skriver den feltets verdi to ganger, én gang fra det opprinnelige kallet og én gang fra det reentrante kallet som snek seg inn før det første rakk å ferdigstille håndteringen av sin egen tilstand, eller den forsøker å frigjøre redigererkontrollen mens en stakkramme lenger nede fortsatt befinner seg inne i den samme kontrollens egen hendelseshåndterer, noe som er udefinert territorium i VCL og viser seg som en tilgangskrenkelse som kan peke på nesten hvilken som helst linje — ikke nødvendigvis den som faktisk forårsaket den. Ingen av feilene krever et stort skjema for å utløses; ett skjema med to felt er nok, forutsatt at brukeren forlater det andre feltet raskt nok til at OS-en fortsatt holder på å avvikle fokusmeldinger fra det første

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

Nill referansen før du rører kontrollen

Løsningen PDFlibPas leverer er en enkel omordning: fang redigereren i en lokal variabel, tøm feltet som peker til den, og først deretter begynn å endre kontrollens egenskaper. CommitInplaceEditor leser FInplaceEditor inn i en lokal Editor-variabel, setter FInplaceEditor til nil umiddelbart, og først etter det tilordnes Editor.Visible og Editor.Parent. Et reentrant kall utløst av én av disse to tilordningene leser selv FInplaceEditor, finner at den allerede er nil, og avslutter på selve den første linjen, før den rekker å røre Editor eller skrive feltets verdi en gang til

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 i den kodelisten representerer den virkelige grenen, som sjekker om Editor er en TComboBox, en TMemo eller en TEdit og leser verdien deretter, siden PDFlibPas oppretter en annen kontrolltype avhengig av om feltet er et tekstfelt eller et valgfelt. Vakten (guard) bryr seg ikke om hvilken gren som kjører, bare om FInplaceEditor er nil før noe som kan utløse OnExit kjøres — det er den ene rekkefølgebetingelsen som gjør resten av metoden trygg å skrive i hvilken stil som ellers er naturlig

Frigjør aldri en kontroll inne fra dens egen hendelse

TPDFlibViewer.CommitInplaceEditor kaller aldri Editor.Free direkte, og det er bevisst: å frigjøre en kontroll er utrygt mens en stakkramme som tilhører den samme kontrollens egen hendelsesutsendelse fortsatt kan holde på å avvikles over kallet som frigjør den, reentrant OnExit eller ei. I stedet overleverer PDFlibPas den frakoblede redigereren til en enkeltplass parkeringsplass, FDeadEditor, og frigjør det som lå der fra forrige redigeringssyklus gjennom en liten hjelpemetode, ReapDeadEditor, som kalles ved starten av neste BeginEditFormField og en gang til fra CloseDocument; hver redigerer viseren oppretter eies dessuten av viseren selv, TEdit.Create(Self) i stedet for TEdit.Create(nil), slik at selv en kontroll som fortsatt står parkert i FDeadEditor når viseren destrueres blir feid opp av vanlig VCL-komponenteierskap i stedet for å lekke

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;

Hvorfor viser dette seg tydeligst ved rask Tab-navigasjon?

FocusNextFormField, metoden PDFlibPas la til i v3.226.0 for å drive Tab- og Shift+Tab-navigasjon gjennom et skjema, kaller BeginEditFormField for neste kvalifiserte felt ved hvert eneste hopp, og BeginEditFormField starter med å kalle CommitInplaceEditor(True) for å flushe den redigereren forrige felt eventuelt lot stå åpen. Det betyr at for hvert Tab-trykk en bruker gjør mens vedkommende fyller ut et skjema med flere felt, kjøres nøyaktig den frakoblingen-så-berøring-sekvensen beskrevet ovenfor én gang — og det er nettopp den kodebanen som mest sannsynlig fortsatt har en kontroll genuint fokusert i det øyeblikket Visible og Parent endres, fordi Tab er den ene interaksjonen som nesten garantert lar den utgående redigereren beholde fokus helt til den nye ber om det

Ingenting av dette gjør feilen pålitelig å demonstrere, og det er verdt å si rett ut i stedet for å glatte over det. Hvorvidt en gitt Visible- eller Parent-tilordning faktisk tvinger frem en synkron OnExit avhenger av fokus- og vindushåndtaktilstand som en debugger endrer bare ved å være tilkoblet, som en urelatert omtegning eller timer kan forstyrre, og som oppfører seg forskjellig avhengig av hvilken av TEdit, TMemo eller TComboBox som er kontrollen i spill. En vakt (guard) som bare av og til blir utøvd er grunnen til at denne typen svakhet overlever både kodegjennomgang og manuell testing, og det er også grunnen til at løsningen må være korrekt ved konstruksjon — å nille referansen før noe annet skjer — fremfor korrekt ut fra hva noen få manuelle testomganger tilfeldigvis observerte

Den generelle formen på denne løsningen strekker seg langt utover én enkelt viserkontroll. Enhver tilpasset redigeringsflate bygget ved å legge en levende VCL-kontroll oppå rendret innhold, ikke bare et PDF-skjemafelt, arver samme fare i det øyeblikket dens lukk-og-lagre-logikk kan utløses både av en eksplisitt brukerhandling og et implisitt fokusskifte, og det samme to-delte svaret gjelder: tøm referansen som identifiserer den aktive kontrollen før noe som kan utløse dens egen exit-hendelse gjøres, og kall aldri Free fra en kodebane som fortsatt kan kjøre under den samme kontrollens egen hendelsesutsendelse. TPDFlibViewers bredere overflate for skjemautfylling og rendring, inkludert hvordan den avgjør hvilken kontrolltype som skal vises for hvilket felt, er dekket i oversikten over å bygge en interaktiv PDF-viserkontroll i Delphi VCL med PDFlibPas, og bitmap-cachen for sider som SetFormFieldValueAndRefresh må ugyldiggjøre ved hver lagrede redigering er dekket separat i artikkelen om viserens diskbaserte sidecache per skjerm-DPI

In-place-redigering av skjemafelt, Tab-drevet feltnavigasjon, og den reentrans-sikre commit-veien bak begge er del av den interaktive viserkontrollen som følger med PDFlibPas, PDF-biblioteket for Delphi og C++Builder, sammen med resten av dens API-flate for siderendring, annotasjoner og skjemafelt