Articolo tecnico

Rientranza OnExit in un Editor di Campo Modulo In-Place Delphi

Il controllo TPDFlibViewer di PDFlibPas conferma un editor di campo modulo in-place tramite l'evento OnExit proprio dell'editor, e quella scelta di design nasconde una classica trappola della VCL Delphi: nascondere, riassegnare genitore, o distruggere un controllo a fuoco dall'interno del proprio gestore OnExit può generare OnExit una seconda volta prima che la prima chiamata ritorni, rimandando la logica di conferma dentro se stessa

Il fallimento che questo produce resiste a una riproduzione pulita. Un utente attraversa velocemente con Tab una serie di campi di testo su un modulo di domanda scansionato, e ogni tanto il visualizzatore solleva un access violation, o peggio, continua a girare mentre silenziosamente scrive il valore sbagliato in un campo due tab indietro. Riproducilo a comando e il bug sembra ovvio col senno di poi; inseguilo da un singolo report di crash di un cliente e sembra un fantasma, perché se il secondo OnExit effettivamente si generi dipende da tempistiche di handle di finestra e fuoco che cambiano con il tipo di campo, la velocità di digitazione, e qualunque altra cosa la coda dei messaggi stia facendo in quell'istante

Come mette TPDFlibViewer un vero editor sopra una pagina renderizzata

TPDFlibViewer renderizza ogni pagina PDF in un bitmap e non trasforma i campi modulo in controlli VCL vivi per default, quindi BeginEditFormField è il metodo che fa da ponte tra i due mondi: chiamato con un indice di campo, cerca il rettangolo del campo e lo converte in coordinate client, poi cala un vero TEdit o TMemo sopra quel rettangolo per un campo di testo, o una TComboBox in stile csDropDownList per un campo di scelta, completo del valore corrente del campo già caricato. ISO 32000-2 §12.7 definisce cosa sia un campo modulo di testo o scelta dentro un PDF, ma nulla in quella specifica dice come un'applicazione Windows dovrebbe permettere a qualcuno di digitarci dentro, e quel vuoto è esattamente ciò che BeginEditFormField esiste per colmare. Sia OnKeyDown sia OnExit sono collegati agli stessi due metodi del visualizzatore, InplaceEditorKeyDown e InplaceEditorExit, su ogni editor che TPDFlibViewer crea, un abbinamento distribuito invariato da quando la compilazione interattiva di moduli è approdata per la prima volta nella v3.220.0, e OnExit è dove iniziano i guai

Perché nascondere l'editor genera OnExit una seconda volta?

TWinControl nella VCL tratta un cambiamento a Visible o Parent su un controllo a fuoco come un motivo per spostare immediatamente il fuoco altrove, e spostare il fuoco altrove da un controllo è esattamente ciò che genera l'evento OnExit di quel controllo, in modo sincrono, prima ancora che l'assegnazione di proprietà che l'ha scatenato ritorni. CommitInplaceEditor, il metodo che PDFlibPas usa per chiudere l'editor in-place e riscriverne il valore nel campo modulo, deve fare precisamente quelle due cose in uscita: impostare Editor.Visible su False e impostare Editor.Parent su nil cosicché il controllo smetta di disegnare sopra la pagina e smetta di ricevere input. Fai una delle due mentre l'editor ha ancora il fuoco, cosa che accade quasi sempre poiché l'utente lo ha appena lasciato, e OnExit si genera di nuovo nel mezzo proprio della chiamata che avrebbe dovuto essere l'ultima cosa che l'OnExit di quell'editor avrebbe mai scatenato

Cosa va storto quando CommitInplaceEditor rientra in se stesso?

Un metodo di conferma ingenuo paga questo in uno di due modi. O scrive il valore del campo due volte, una dalla chiamata originale e una dalla chiamata rientrante che si è infilata prima che la prima finisse di toccare il proprio stato, oppure tenta di liberare il controllo editor mentre un frame più in basso nello stack di chiamata è ancora dentro il gestore evento proprio di quello stesso controllo, il che è territorio non definito nella VCL e si manifesta come un access violation che può puntare quasi a qualsiasi riga, non necessariamente quella che l'ha effettivamente causato. Nessuno dei due fallimenti richiede un modulo grande per scattare; un documento a due campi basta, a patto che l'utente lasci il secondo campo abbastanza velocemente da far sì che il sistema operativo stia ancora srotolando i messaggi di fuoco dal primo

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

Azzera il riferimento prima di toccare il controllo

La correzione distribuita da PDFlibPas è un semplice riordino: cattura l'editor in una variabile locale, svuota il campo che vi punta, e solo allora inizia a cambiare le proprietà del controllo. CommitInplaceEditor legge FInplaceEditor in una variabile locale Editor, imposta FInplaceEditor a nil immediatamente, e solo dopo assegna Editor.Visible ed Editor.Parent. Una chiamata rientrante scatenata da una delle due assegnazioni legge essa stessa FInplaceEditor, la trova già nil, ed esce alla propria primissima riga, prima di poter toccare Editor o scrivere il valore del campo una seconda volta

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 quell'estratto sta al posto del vero ramo, che verifica se Editor sia una TComboBox, una TMemo, o una TEdit e ne legge il valore di conseguenza, poiché PDFlibPas crea un controllo diverso a seconda che il campo sia un campo di testo o un campo di scelta. La protezione non si preoccupa di quale ramo giri, solo che FInplaceEditor sia nil prima che qualcosa capace di scatenare OnExit venga eseguito, che è l'unico vincolo di ordinamento che rende il resto del metodo sicuro da scrivere in qualunque stile sia altrimenti naturale

Non liberare mai un controllo dall'interno del proprio evento

TPDFlibViewer.CommitInplaceEditor non chiama mai Editor.Free direttamente, ed è deliberato: liberare un controllo è pericoloso mentre un frame di stack appartenente a quello stesso controllo del proprio dispatch di eventi potrebbe ancora srotolarsi sopra la chiamata che lo libera, OnExit rientrante o meno. PDFlibPas passa invece l'editor scollegato a un unico posto di parcheggio a uno slot, FDeadEditor, liberando qualunque cosa fosse seduta lì dal ciclo di modifica precedente tramite un piccolo helper, ReapDeadEditor, chiamato all'inizio del successivo BeginEditFormField e ancora una volta da CloseDocument; ogni editor che il visualizzatore crea è anche posseduto dal visualizzatore stesso, TEdit.Create(Self) invece di TEdit.Create(nil), quindi anche un controllo ancora parcheggiato in FDeadEditor quando il visualizzatore viene distrutto viene raccolto dal normale possesso di componente VCL invece che disperso

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;

Perché questo si manifesta più duramente durante la navigazione veloce con Tab?

FocusNextFormField, il metodo che PDFlibPas ha aggiunto nella v3.226.0 per guidare la navigazione con Tab e Shift+Tab attraverso un modulo, chiama BeginEditFormField per il campo idoneo successivo a ogni singolo salto, e BeginEditFormField inizia chiamando CommitInplaceEditor(True) per svuotare qualunque editor il campo precedente abbia lasciato aperto. Ciò significa che ogni pressione di Tab che un utente fa mentre compila un modulo multi-campo esegue esattamente la sequenza scollega-poi-tocca descritta sopra una volta, che è precisamente il percorso di codice più probabile ad avere ancora un controllo genuinamente a fuoco nel momento in cui Visible e Parent cambiano, perché Tab è l'unica interazione quasi garantita a lasciare l'editor in uscita che detiene il fuoco fino a quando il nuovo non lo richiede

Nulla di ciò rende il bug affidabile da dimostrare, e vale la pena dirlo apertamente invece che sorvolare. Se una data assegnazione Visible o Parent forzi effettivamente un OnExit sincrono dipende da stato di fuoco e handle di finestra che un debugger cambia semplicemente essendo collegato, che un repaint o timer non correlato può perturbare, e che si comporta diversamente a seconda che il controllo in gioco sia TEdit, TMemo, o TComboBox. Una protezione esercitata solo a volte è il motivo per cui questo tipo di difetto sopravvive sia alla revisione del codice sia al test manuale, ed è anche il motivo per cui la correzione deve essere corretta per costruzione, azzerando il riferimento prima che accada qualsiasi altra cosa, piuttosto che corretta in base a qualunque comportamento una manciata di passaggi di test manuali abbia capitato di osservare

La forma generale di questa correzione si estende ben oltre un singolo controllo visualizzatore. Qualsiasi superficie di modifica personalizzata costruita sovrapponendo un controllo VCL vivo su contenuto renderizzato, non solo un campo modulo PDF, eredita lo stesso rischio nel momento in cui la propria logica di chiusura-e-conferma può essere scatenata sia da un'azione utente esplicita sia da un cambio di fuoco implicito, e si applica la stessa risposta in due parti: svuota il riferimento che identifica il controllo attivo prima di fare qualsiasi cosa che potrebbe scatenare il proprio evento di uscita, e non chiamare mai Free da un percorso di codice che potrebbe ancora girare sotto il dispatch di eventi proprio di quel controllo. La più ampia superficie di compilazione moduli e rendering di TPDFlibViewer, incluso come decida quale tipo di controllo mostrare per quale campo, è trattata nella panoramica sulla costruzione di un controllo visualizzatore PDF interattivo in Delphi VCL con PDFlibPas, e la cache bitmap di pagina che SetFormFieldValueAndRefresh deve invalidare a ogni modifica confermata è trattata separatamente nel pezzo sulla cache pagina su disco per-monitor DPI del visualizzatore

La modifica di campo modulo in-place, la navigazione tra campi guidata da Tab, e il percorso di conferma sicuro dalla rientranza dietro entrambi fanno parte del controllo visualizzatore interattivo distribuito con PDFlibPas, la libreria PDF per Delphi e C++Builder, insieme al resto della sua superficie API di rendering pagina, annotazione, e campo modulo