PDFlibPas' Steuerelement TPDFlibViewer committet einen Inline-Formularfeld-Editor über das eigene OnExit-Ereignis des Editors, und diese Design-Entscheidung verbirgt eine klassische Delphi-VCL-Falle: Ein fokussiertes Steuerelement von innerhalb seines eigenen OnExit-Handlers zu verstecken, neu zu verankern (reparent) oder zu zerstören, kann OnExit ein zweites Mal auslösen, bevor der erste Aufruf zurückkehrt, und schickt die Commit-Logik zurück in sich selbst
Der dadurch erzeugte Fehlschlag widersetzt sich einer sauberen Reproduktion. Ein Benutzer tabbt schnell durch eine Reihe von Textfeldern auf einem gescannten Antragsformular, und hin und wieder wirft der Viewer eine Zugriffsverletzung, oder schlimmer, läuft weiter, während er still den falschen Wert in ein Feld zwei Tabs zurück schreibt. Reproduziert man es auf Abruf, sieht der Bug im Nachhinein offensichtlich aus; jagt man ihn anhand des Absturzberichts eines einzelnen Kunden, sieht er wie ein Geist aus, denn ob das zweite OnExit tatsächlich feuert, hängt von Fenster-Handle- und Fokus-Timing ab, das sich mit Feldtyp, Tippgeschwindigkeit, und was auch immer sonst die Nachrichtenwarteschlange in diesem Moment tut, verschiebt
Wie legt TPDFlibViewer einen echten Editor über eine gerenderte Seite?
TPDFlibViewer rendert jede PDF-Seite zu einer Bitmap und verwandelt Formularfelder standardmäßig nicht in lebende VCL-Steuerelemente, sodass BeginEditFormField die Methode ist, die die beiden Welten verbindet: mit einem Feldindex aufgerufen, schlägt sie das Rechteck des Felds nach und rechnet es in Client-Koordinaten um, legt dann für ein Textfeld ein echtes TEdit oder TMemo über dieses Rechteck, oder eine TComboBox im Stil csDropDownList für ein Auswahlfeld, komplett mit dem bereits geladenen aktuellen Wert des Felds. ISO 32000-2 §12.7 definiert, was ein Text- oder Auswahl-Formularfeld innerhalb eines PDFs ist, aber nichts in dieser Spezifikation sagt, wie eine Windows-Anwendung jemanden hineintippen lassen soll, und genau diese Lücke soll BeginEditFormField füllen. Sowohl OnKeyDown als auch OnExit sind mit denselben zwei Viewer-Methoden verdrahtet, InplaceEditorKeyDown und InplaceEditorExit, auf jedem von TPDFlibViewer erzeugten Editor, eine Paarung, die seit dem ersten Landen interaktiven Formularausfüllens in v3.220.0 unverändert ausgeliefert wird, und OnExit ist, wo der Ärger beginnt
Warum feuert das Verstecken des Editors OnExit ein zweites Mal?
TWinControl in der VCL behandelt eine Änderung an Visible oder Parent an einem fokussierten Steuerelement als Grund, den Fokus sofort davon wegzubewegen, und den Fokus von einem Steuerelement wegzubewegen ist genau das, was das OnExit-Ereignis dieses Steuerelements auslöst, synchron, bevor die Eigenschaftszuweisung, die es ausgelöst hat, überhaupt zurückkehrt. CommitInplaceEditor, die Methode, die PDFlibPas verwendet, um den Inline-Editor zu schließen und seinen Wert zurück in das Formularfeld zu schreiben, muss auf dem Weg hinaus genau diese zwei Dinge tun: Editor.Visible auf False setzen und Editor.Parent auf nil setzen, damit das Steuerelement aufhört, über die Seite zu zeichnen, und aufhört, Eingaben zu empfangen. Tut man eines von beidem, während der Editor noch den Fokus hat, was fast immer der Fall ist, da der Benutzer ihn gerade verlassen hat, feuert OnExit erneut, mitten in genau dem Aufruf, der eigentlich das Letzte hätte sein sollen, das das OnExit dieses Editors je auslöst
Was geht schief, wenn CommitInplaceEditor sich selbst reentert?
Eine naive Commit-Methode bezahlt dafür auf eine von zwei Arten. Entweder schreibt sie den Wert des Felds zweimal, einmal aus dem ursprünglichen Aufruf und einmal aus dem reentranten Aufruf, der sich einschlich, bevor der erste fertig war, seinen eigenen Zustand anzufassen, oder sie versucht, das Editor-Steuerelement freizugeben, während ein Frame weiter unten im Call-Stack noch innerhalb des eigenen Event-Handlers desselben Steuerelements ist, was in der VCL unerforschtes Terrain ist und sich als Zugriffsverletzung zeigt, die auf fast jede Zeile zeigen kann, nicht notwendigerweise die, die sie tatsächlich verursacht hat. Keiner der beiden Fehlschläge braucht ein großes Formular, um sich auszulösen; ein Zwei-Felder-Dokument genügt, vorausgesetzt der Benutzer verlässt das zweite Feld schnell genug, damit das Betriebssystem noch dabei ist, Fokus-Nachrichten vom ersten abzuwickeln
// 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;
Die Referenz auf nil setzen, bevor man das Steuerelement anfasst
Die von PDFlibPas ausgelieferte Lösung ist eine einzige Umordnung: den Editor in einer lokalen Variable erfassen, das Feld, das darauf zeigt, löschen, und erst danach beginnen, die Eigenschaften des Steuerelements zu ändern. CommitInplaceEditor liest FInplaceEditor in eine lokale Editor-Variable, setzt FInplaceEditor sofort auf nil, und weist erst danach Editor.Visible und Editor.Parent zu. Ein durch eine dieser beiden Zuweisungen ausgelöster reentranter Aufruf liest FInplaceEditor selbst, findet es bereits nil, und verlässt bei seiner allerersten Zeile, bevor er Editor anfassen oder den Wert des Felds ein zweites Mal schreiben kann
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 steht in diesem Listing stellvertretend für den echten Zweig, der prüft, ob Editor eine TComboBox, ein TMemo oder ein TEdit ist, und seinen Wert entsprechend liest, da PDFlibPas ein unterschiedliches Steuerelement erzeugt, je nachdem, ob das Feld ein Text- oder ein Auswahlfeld ist. Die Absicherung kümmert sich nicht darum, welcher Zweig läuft, nur darum, dass FInplaceEditor nil ist, bevor irgendetwas ausgeführt wird, das OnExit auslösen könnte, was die eine Reihenfolge-Einschränkung ist, die den Rest der Methode sicher macht, in welchem Stil auch immer ansonsten natürlich wäre, geschrieben zu werden
Nie ein Steuerelement von innerhalb seines eigenen Ereignisses freigeben
TPDFlibViewer.CommitInplaceEditor ruft nie direkt Editor.Free auf, und das ist Absicht: Ein Steuerelement freizugeben ist unsicher, während ein zu genau diesem Steuerelement gehörender Stack-Frame des eigenen Event-Dispatches noch oberhalb des Aufrufs, der es freigibt, abgewickelt werden könnte, reentrantes OnExit oder nicht. PDFlibPas übergibt den abgehängten Editor stattdessen an einen Ein-Slot-Parkplatz, FDeadEditor, und gibt frei, was von dem vorherigen Bearbeitungszyklus dort saß, über einen kleinen Helfer, ReapDeadEditor, aufgerufen am Anfang des nächsten BeginEditFormField und noch einmal von CloseDocument; jeder vom Viewer erzeugte Editor gehört auch dem Viewer selbst, TEdit.Create(Self) statt TEdit.Create(nil), sodass sogar ein Steuerelement, das noch in FDeadEditor geparkt ist, wenn der Viewer zerstört wird, von gewöhnlichem VCL-Komponenten-Eigentum aufgesammelt wird, statt auszulaufen
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;
Warum zeigt sich das am stärksten bei schneller Tab-Navigation?
FocusNextFormField, die Methode, die PDFlibPas in v3.226.0 hinzufügte, um Tab- und Umschalt+Tab-Navigation über ein Formular zu steuern, ruft BeginEditFormField für das nächste in Frage kommende Feld bei jedem einzelnen Sprung auf, und BeginEditFormField beginnt damit, CommitInplaceEditor(True) aufzurufen, um zu leeren, welcher Editor auch immer vom vorherigen Feld noch offen war. Das bedeutet, jeder Tab-Druck, den ein Benutzer beim Ausfüllen eines mehrfeldrigen Formulars macht, durchläuft genau die oben beschriebene Abhäng-dann-Anfass-Sequenz einmal, genau der Codepfad, bei dem es am wahrscheinlichsten ist, dass ein Steuerelement in dem Moment, in dem sich Visible und Parent ändern, tatsächlich fokussiert ist noch, denn Tab ist die eine Interaktion, die fast garantiert den ausgehenden Editor bis zu dem Moment, in dem der neue danach fragt, den Fokus behalten lässt
Nichts davon macht den Bug zuverlässig demonstrierbar, und das lohnt es sich, deutlich zu sagen, statt darüber hinwegzugehen. Ob eine gegebene Visible- oder Parent-Zuweisung tatsächlich ein synchrones OnExit erzwingt, hängt von Fokus- und Fenster-Handle-Zustand ab, den ein Debugger allein durch sein Angehängtsein ändert, den ein unabhängiges Neuzeichnen oder ein Timer stören kann, und der sich unterschiedlich verhält, je nachdem, welches von TEdit, TMemo oder TComboBox gerade das im Spiel befindliche Steuerelement ist. Eine Absicherung, die nur manchmal ausgeübt wird, ist der Grund, warum diese Art von Defekt sowohl Code-Review als auch manuelles Testen übersteht, und es ist auch der Grund, warum die Lösung konstruktionsbedingt korrekt sein muss, die Referenz auf nil zu setzen, bevor irgendetwas anderes passiert, statt korrekt zu sein durch welches Verhalten auch immer eine Handvoll manueller Testdurchläufe zufällig beobachtet hat
Die allgemeine Form dieser Lösung reicht weit über ein einzelnes Viewer-Steuerelement hinaus. Jede benutzerdefinierte Bearbeitungsoberfläche, gebaut durch Überlagern eines lebenden VCL-Steuerelements über gerenderten Inhalt, nicht nur ein PDF-Formularfeld, erbt dieselbe Gefahr in dem Moment, in dem ihre Schließen-und-Committen-Logik sowohl durch eine ausdrückliche Benutzeraktion als auch durch eine implizite Fokusänderung ausgelöst werden kann, und dieselbe zweiteilige Antwort gilt: Die Referenz, die das aktive Steuerelement identifiziert, löschen, bevor irgendetwas getan wird, das dessen eigenes Exit-Ereignis auslösen könnte, und nie Free von einem Codepfad aus aufrufen, der noch unterhalb des eigenen Event-Dispatches dieses Steuerelements laufen könnte. TPDFlibViewers breitere Formularausfüll- und Rendering-Oberfläche, einschließlich wie er entscheidet, welchen Steuerelement-Typ er für welches Feld zeigt, wird in der Übersicht zum Bau eines interaktiven PDF-Viewer-Steuerelements in Delphi VCL mit PDFlibPas behandelt, und der Seiten-Bitmap-Cache, den SetFormFieldValueAndRefresh bei jeder committeten Bearbeitung invalidieren muss, wird separat in dem Beitrag zum Pro-Monitor-DPI-Festplatten-Seiten-Cache des Viewers behandelt
Inline-Formularfeld-Bearbeitung, Tab-gesteuerte Feldnavigation, und der reentranz-sichere Commit-Pfad hinter beiden sind Teil des interaktiven Viewer-Steuerelements, das mit PDFlibPas, der PDF-Bibliothek für Delphi und C++Builder, ausgeliefert wird, zusammen mit dem Rest seiner Seiten-Rendering-, Annotations- und Formularfeld-API-Oberfläche