Het besturingselement TPDFlibViewer van PDFlibPas commit een in-place-formulierveldeditor via het eigen OnExit-gebeurtenis van de editor, en die ontwerpkeuze verbergt een klassieke Delphi-VCL-valstrik: een gefocust besturingselement verbergen, van ouder wisselen, of vernietigen van binnen zijn eigen OnExit-handler kan OnExit een tweede keer laten afgaan voordat de eerste aanroep terugkeert, wat de commit-logica terugstuurt naar zichzelf
De storing die dit produceert, verzet zich tegen een schone reproductie. Een gebruiker tabt snel door een reeks tekstvelden op een gescand aanvraagformulier, en zo nu en dan werpt de viewer een access violation, of erger, blijft draaien terwijl het stilzwijgend de verkeerde waarde schrijft naar een veld twee tabs terug. Reproduceer het op verzoek en de bug ziet er achteraf voor de hand liggend uit; jaag het na vanuit het crashrapport van één klant en het ziet eruit als een spook, omdat of de tweede OnExit daadwerkelijk afgaat, afhangt van window-handle- en focustiming die verschuift met veldtype, typsnelheid, en wat de berichtenwachtrij op dat moment verder ook doet
Hoe plaatst TPDFlibViewer een echte editor bovenop een gerenderde pagina?
TPDFlibViewer rendert elke PDF-pagina naar een bitmap en verandert formuliervelden standaard niet in levende VCL-besturingselementen, dus BeginEditFormField is de methode die de twee werelden overbrugt: aangeroepen met een veldindex, zoekt het de rechthoek van het veld op en converteert deze naar clientcoördinaten, en plaatst dan een echte TEdit of TMemo bovenop die rechthoek voor een tekstveld, of een TComboBox in csDropDownList-stijl voor een keuzeveld, compleet met de huidige waarde van het veld al geladen. ISO 32000-2 §12.7 definieert wat een tekst- of keuzeformulierveld binnen een PDF is, maar niets in die specificatie zegt hoe een Windows-toepassing iemand erin zou moeten laten typen, en dat gat is precies waarvoor BeginEditFormField bestaat om te vullen. Zowel OnKeyDown als OnExit zijn bedraad naar dezelfde twee viewermethoden, InplaceEditorKeyDown en InplaceEditorExit, op elke editor die TPDFlibViewer aanmaakt, een koppeling die ongewijzigd wordt geleverd sinds interactief formulieren invullen voor het eerst landde in v3.220.0, en OnExit is waar de problemen beginnen
Waarom gaat OnExit een tweede keer af bij het verbergen van de editor?
TWinControl in de VCL behandelt een wijziging aan Visible of Parent op een gefocust besturingselement als een reden om de focus er onmiddellijk vanaf te verplaatsen, en de focus van een besturingselement verplaatsen is precies wat het OnExit-gebeurtenis van dat besturingselement afvuurt, synchroon, voordat de eigenschapstoewijzing die het triggerde zelfs maar terugkeert. CommitInplaceEditor, de methode die PDFlibPas gebruikt om de in-place-editor te sluiten en zijn waarde terug te schrijven naar het formulierveld, moet precies die twee dingen doen op weg naar buiten: Editor.Visible op False zetten en Editor.Parent op nil zetten zodat het besturingselement stopt met bovenop de pagina te tekenen en stopt met invoer te ontvangen. Doe een van beide terwijl de editor nog focus heeft, wat bijna altijd het geval is aangezien de gebruiker deze net heeft verlaten, en OnExit gaat opnieuw af midden in precies de aanroep die verondersteld werd het laatste te zijn dat het OnExit van die editor ooit zou triggeren
Wat gaat er mis wanneer CommitInplaceEditor zichzelf opnieuw binnenkomt?
Een naïeve commit-methode betaalt hiervoor op een van twee manieren. Ofwel schrijft het de waarde van het veld tweemaal, één keer vanuit de oorspronkelijke aanroep en één keer vanuit de reentrante aanroep die binnenglipte voordat de eerste klaar was met het aanraken van zijn eigen toestand, ofwel probeert het het editorbesturingselement vrij te geven terwijl een frame verder naar beneden op de call-stack nog steeds binnen de eigen gebeurtenishandler van datzelfde besturingselement zit, wat onbepaald terrein is in de VCL en zich manifesteert als een access violation die naar bijna elke regel kan wijzen, niet noodzakelijk degene die het daadwerkelijk veroorzaakte. Geen van beide storingen heeft een groot formulier nodig om te triggeren; een document met twee velden is genoeg, mits de gebruiker het tweede veld snel genoeg verlaat zodat het OS nog steeds focusberichten van het eerste aan het afwikkelen is
// 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;
Zet de verwijzing op nil voordat u het besturingselement aanraakt
De fix die PDFlibPas levert, is een enkele herordening: leg de editor vast in een lokale variabele, wis het veld dat ernaar wijst, en begin pas daarna de eigenschappen van het besturingselement te wijzigen. CommitInplaceEditor leest FInplaceEditor in een lokale Editor-variabele, zet FInplaceEditor onmiddellijk op nil, en wijst pas daarna Editor.Visible en Editor.Parent toe. Een reentrante aanroep getriggerd door een van die twee toewijzingen leest FInplaceEditor zelf, vindt deze al nil, en stopt op zijn allereerste regel, voordat het Editor kan aanraken of de waarde van het veld een tweede keer kan schrijven
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 in die listing staat voor de echte tak, die controleert of Editor een TComboBox, een TMemo, of een TEdit is en de waarde ervan dienovereenkomstig leest, aangezien PDFlibPas een ander besturingselement aanmaakt afhankelijk van of het veld een tekstveld of een keuzeveld is. De bewaking maakt niet uit welke tak draait, alleen dat FInplaceEditor nil is voordat er iets draait dat OnExit kan triggeren, wat de ene ordeningsvoorwaarde is die de rest van de methode veilig maakt om te schrijven in welke stijl dan ook die verder natuurlijk aanvoelt
Geef nooit een besturingselement vrij van binnen zijn eigen gebeurtenis
TPDFlibViewer.CommitInplaceEditor roept nooit rechtstreeks Editor.Free aan, en dat is doelbewust: een besturingselement vrijgeven is onveilig terwijl een stackframe dat tot datzelfde besturingselement behoort mogelijk nog steeds boven de aanroep die het vrijgeeft aan het afwikkelen is, reentrante OnExit of niet. PDFlibPas geeft de losgekoppelde editor in plaats daarvan aan een parkeerplek met één slot, FDeadEditor, en geeft wat er ook uit de vorige bewerkingscyclus stond via een kleine helper, ReapDeadEditor, vrij, aangeroepen bij het begin van de volgende BeginEditFormField en nog eens vanuit CloseDocument; elke editor die de viewer aanmaakt, wordt ook door de viewer zelf bezeten, TEdit.Create(Self) in plaats van TEdit.Create(nil), dus zelfs een besturingselement dat nog in FDeadEditor geparkeerd staat wanneer de viewer wordt vernietigd, wordt opgeruimd door gewone VCL-componenteigendom in plaats van te lekken
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;
Waarom komt dit het hardst naar boven tijdens snelle Tab-navigatie?
FocusNextFormField, de methode die PDFlibPas toevoegde in v3.226.0 om Tab- en Shift+Tab-navigatie over een formulier aan te sturen, roept BeginEditFormField aan voor het volgende in aanmerking komende veld bij elke afzonderlijke sprong, en BeginEditFormField begint met CommitInplaceEditor(True) aan te roepen om welke editor het vorige veld ook open liet te doorspoelen. Dat betekent dat elke Tab-druk die een gebruiker maakt tijdens het invullen van een formulier met meerdere velden precies de hierboven beschreven ontkoppel-dan-raak-aan-reeks één keer draait, precies het codepad dat het waarschijnlijkst nog een besturingselement echt gefocust heeft op het moment dat Visible en Parent veranderen, omdat Tab de ene interactie is die vrijwel gegarandeerd de uitgaande editor focus laat behouden totdat de nieuwe erom vraagt
Niets hiervan maakt de bug betrouwbaar te demonstreren, en dat is het waard om ronduit te zeggen in plaats van eroverheen te glijden. Of een gegeven Visible- of Parent-toewijzing daadwerkelijk een synchrone OnExit afdwingt, hangt af van focus- en window-handle-toestand die een debugger al verandert door simpelweg gekoppeld te zijn, die een ongerelateerde herschildering of timer kan verstoren, en die zich anders gedraagt afhankelijk van welke van TEdit, TMemo, of TComboBox toevallig het besturingselement in het spel is. Een bewaking die maar soms wordt uitgeoefend, is de reden waarom dit soort defect zowel code review als handmatig testen overleeft, en het is ook de reden waarom de fix door constructie correct moet zijn, de verwijzing op nil zetten voordat er iets anders gebeurt, in plaats van correct te zijn op basis van wat voor gedrag een handvol handmatige testrondes toevallig observeerden
De algemene vorm van deze fix reikt ver voorbij één viewerbesturingselement. Elk aangepast bewerkingsoppervlak gebouwd door een levend VCL-besturingselement over gerenderde inhoud te leggen, niet alleen een PDF-formulierveld, erft hetzelfde gevaar zodra de eigen sluit-en-commit-logica getriggerd kan worden door zowel een expliciete gebruikersactie als een impliciete focuswijziging, en hetzelfde tweedelige antwoord is van toepassing: wis de verwijzing die het actieve besturingselement identificeert voordat u iets doet dat zijn eigen exit-gebeurtenis zou kunnen triggeren, en roep nooit Free aan vanuit een codepad dat mogelijk nog draait onder de eigen gebeurtenisdispatch van dat besturingselement. Het bredere formulier-invul- en render-oppervlak van TPDFlibViewer, inclusief hoe het beslist welk besturingselementtype voor welk veld te tonen, wordt behandeld in het overzicht van het bouwen van een interactief PDF-viewerbesturingselement in Delphi VCL met PDFlibPas, en de paginabitmapcache die SetFormFieldValueAndRefresh bij elke gecommitte bewerking ongeldig moet maken, wordt apart behandeld in het stuk over de per-monitor-DPI-schijfpaginacache van de viewer
In-place-formulierveldbewerking, Tab-gedreven veldnavigatie, en het reentrancy-veilige commit-pad achter beide maken deel uit van het interactieve viewerbesturingselement dat wordt geleverd met PDFlibPas, de PDF-bibliotheek voor Delphi en C++Builder, naast de rest van zijn pagina-render-, annotatie-, en formulierveld-API-oppervlak