Τεχνικό Άρθρο

Επανεισερχόμενο OnExit σε Επεξεργαστή Πεδίων Φόρμας Επί Τόπου σε Delphi

Το στοιχείο ελέγχου TPDFlibViewer του PDFlibPas δεσμεύει έναν επεξεργαστή πεδίου φόρμας επί τόπου μέσω του ίδιου του συμβάντος OnExit του επεξεργαστή, και αυτή η σχεδιαστική επιλογή κρύβει μια κλασική παγίδα του Delphi VCL: η απόκρυψη, η αλλαγή γονέα, ή η καταστροφή ενός εστιασμένου στοιχείου ελέγχου μέσα από τον δικό του χειριστή OnExit μπορεί να πυροδοτήσει το OnExit για δεύτερη φορά πριν επιστρέψει η πρώτη κλήση, στέλνοντας τη λογική δέσμευσης πίσω στον εαυτό της

Η αποτυχία που παράγει αυτό αντιστέκεται σε καθαρή αναπαραγωγή. Ένας χρήστης πατάει γρήγορα Tab μέσα από μια σειρά πεδίων κειμένου σε μια σκαναρισμένη αίτηση, και κάθε τόσο ο θεατής πετάει μια παραβίαση πρόσβασης, ή χειρότερα, συνεχίζει να τρέχει ενώ σιωπηλά γράφει τη λάθος τιμή σε ένα πεδίο δύο tabs πίσω. Αναπαράγετέ το κατά παραγγελία και το σφάλμα φαίνεται προφανές εκ των υστέρων· κυνηγήστε το από μία αναφορά κατάρρευσης ενός μόνο πελάτη και μοιάζει με φάντασμα, γιατί το αν πράγματι πυροδοτείται το δεύτερο OnExit εξαρτάται από χρονισμό λαβής παραθύρου και εστίασης που αλλάζει με τον τύπο πεδίου, την ταχύτητα πληκτρολόγησης, και ό,τι άλλο κάνει η ουρά μηνυμάτων εκείνη τη στιγμή

Πώς το TPDFlibViewer Τοποθετεί έναν Πραγματικό Επεξεργαστή Πάνω σε μια Αποδοσμένη Σελίδα

Το TPDFlibViewer αποδίδει κάθε σελίδα PDF σε bitmap και δεν μετατρέπει τα πεδία φόρμας σε ζωντανά στοιχεία ελέγχου VCL από προεπιλογή, οπότε η BeginEditFormField είναι η μέθοδος που γεφυρώνει τους δύο κόσμους: καλούμενη με δείκτη πεδίου, αναζητά το ορθογώνιο του πεδίου και το μετατρέπει σε συντεταγμένες πελάτη, μετά ρίχνει ένα πραγματικό TEdit ή TMemo πάνω από εκείνο το ορθογώνιο για ένα πεδίο κειμένου, ή ένα TComboBox σε στυλ csDropDownList για ένα πεδίο επιλογής, με την τρέχουσα τιμή του πεδίου ήδη φορτωμένη. Το ISO 32000-2 §12.7 ορίζει τι είναι ένα πεδίο φόρμας κειμένου ή επιλογής μέσα σε ένα PDF, αλλά τίποτα σε εκείνη την προδιαγραφή δεν λέει πώς μια εφαρμογή Windows πρέπει να αφήνει κάποιον να πληκτρολογεί σε ένα τέτοιο πεδίο, και εκείνο το κενό είναι ακριβώς αυτό που υπάρχει για να γεμίσει η BeginEditFormField. Και τα δύο OnKeyDown και OnExit είναι συνδεδεμένα με τις ίδιες δύο μεθόδους θεατή, InplaceEditorKeyDown και InplaceEditorExit, σε κάθε επεξεργαστή που δημιουργεί το TPDFlibViewer, ένας συνδυασμός που αποστέλλεται αμετάβλητος από τότε που η διαδραστική συμπλήρωση φόρμας πρωτοεμφανίστηκε στην v3.220.0, και το OnExit είναι εκεί όπου ξεκινά το πρόβλημα

Γιατί η Απόκρυψη του Επεξεργαστή Πυροδοτεί το OnExit για Δεύτερη Φορά;

Το TWinControl στο VCL αντιμετωπίζει μια αλλαγή στο Visible ή στο Parent ενός εστιασμένου στοιχείου ελέγχου ως λόγο για να μετακινήσει αμέσως την εστίαση μακριά από αυτό, και η μετακίνηση της εστίασης μακριά από ένα στοιχείο ελέγχου είναι ακριβώς αυτό που πυροδοτεί το συμβάν OnExit εκείνου του στοιχείου ελέγχου, συγχρόνως, πριν καν επιστρέψει η ανάθεση ιδιότητας που το πυροδότησε. Η CommitInplaceEditor, η μέθοδος που χρησιμοποιεί το PDFlibPas για να κλείσει τον επεξεργαστή επί τόπου και να γράψει την τιμή του πίσω στο πεδίο φόρμας, χρειάζεται να κάνει ακριβώς αυτά τα δύο πράγματα καθώς εξέρχεται: να θέσει το Editor.Visible σε False και το Editor.Parent σε nil ώστε το στοιχείο ελέγχου να σταματήσει να ζωγραφίζεται πάνω από τη σελίδα και να σταματήσει να δέχεται είσοδο. Κάντε οποιοδήποτε από τα δύο ενώ ο επεξεργαστής εξακολουθεί να έχει εστίαση, κάτι που σχεδόν πάντα ισχύει αφού ο χρήστης μόλις τον άφησε, και το OnExit πυροδοτείται ξανά στη μέση ακριβώς της κλήσης που υποτίθεται ότι θα ήταν το τελευταίο πράγμα που θα πυροδοτούσε ποτέ το OnExit εκείνου του επεξεργαστή

Τι Πάει Στραβά Όταν η CommitInplaceEditor Ξαναεισέρχεται στον Εαυτό της;

Μια αφελής μέθοδος δέσμευσης πληρώνει γι' αυτό με έναν από δύο τρόπους. Είτε γράφει την τιμή του πεδίου δύο φορές, μία από την αρχική κλήση και μία από την επανεισερχόμενη κλήση που γλίστρησε μέσα πριν η πρώτη τελειώσει να αγγίζει τη δική της κατάσταση, είτε προσπαθεί να απελευθερώσει το στοιχείο ελέγχου του επεξεργαστή ενώ ένα πλαίσιο στοίβας πιο κάτω στην αλυσίδα κλήσεων βρίσκεται ακόμα μέσα στον δικό του χειριστή συμβάντος εκείνου του ίδιου στοιχείου ελέγχου, κάτι που είναι απροσδιόριστη περιοχή στο VCL και εμφανίζεται ως παραβίαση πρόσβασης που μπορεί να δείχνει σχεδόν σε οποιαδήποτε γραμμή, όχι απαραίτητα σε αυτή που πράγματι το προκάλεσε. Καμία από τις δύο αποτυχίες δεν χρειάζεται μεγάλη φόρμα για να πυροδοτηθεί· μια φόρμα δύο πεδίων είναι αρκετή, αρκεί ο χρήστης να αφήσει το δεύτερο πεδίο αρκετά γρήγορα ώστε το λειτουργικό σύστημα να εξακολουθεί να ξετυλίγει μηνύματα εστίασης από το πρώτο

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

Μηδενίστε την Αναφορά Πριν Αγγίξετε το Στοιχείο Ελέγχου

Η διόρθωση που αποστέλλει το PDFlibPas είναι μια απλή αναδιάταξη: συλλάβετε τον επεξεργαστή σε μια τοπική μεταβλητή, καθαρίστε το πεδίο που δείχνει σε αυτόν, και μόνο τότε ξεκινήστε να αλλάζετε τις ιδιότητες του στοιχείου ελέγχου. Η CommitInplaceEditor διαβάζει το FInplaceEditor σε μια τοπική μεταβλητή Editor, θέτει το FInplaceEditor σε nil αμέσως, και μόνο μετά αναθέτει τα Editor.Visible και Editor.Parent. Μια επανεισερχόμενη κλήση που πυροδοτείται από οποιαδήποτε από αυτές τις δύο αναθέσεις διαβάζει η ίδια το FInplaceEditor, το βρίσκει ήδη nil, και εξέρχεται στην ίδια την πρώτη της γραμμή, πριν προλάβει να αγγίξει το Editor ή να γράψει την τιμή του πεδίου για δεύτερη φορά

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 σε εκείνη τη λίστα αντιπροσωπεύει τον πραγματικό κλάδο, ο οποίος ελέγχει αν το Editor είναι TComboBox, TMemo, ή TEdit και διαβάζει την τιμή του ανάλογα, αφού το PDFlibPas δημιουργεί διαφορετικό στοιχείο ελέγχου ανάλογα με το αν το πεδίο είναι πεδίο κειμένου ή πεδίο επιλογής. Η προστασία δεν ενδιαφέρεται ποιος κλάδος τρέχει, μόνο ότι το FInplaceEditor είναι nil πριν εκτελεστεί οτιδήποτε ικανό να πυροδοτήσει το OnExit, που είναι ο μόνος περιορισμός σειράς που κάνει το υπόλοιπο της μεθόδου ασφαλές να γραφτεί με όποιο στυλ είναι κατά τα άλλα φυσικό

Ποτέ Μην Απελευθερώνετε ένα Στοιχείο Ελέγχου Μέσα από το Δικό του Συμβάν

Η TPDFlibViewer.CommitInplaceEditor ποτέ δεν καλεί Editor.Free απευθείας, και αυτό είναι σκόπιμο: η απελευθέρωση ενός στοιχείου ελέγχου είναι επισφαλής ενώ ένα πλαίσιο στοίβας που ανήκει σε εκείνο το ίδιο στοιχείο ελέγχου ενδέχεται ακόμα να ξετυλίγεται πάνω από την κλήση που το απελευθερώνει, επανεισερχόμενο OnExit ή όχι. Το PDFlibPas αντ' αυτού παραδίδει τον αποσυνδεδεμένο επεξεργαστή σε μια θέση στάθμευσης μονής υποδοχής, το FDeadEditor, απελευθερώνοντας ό,τι κάθονταν εκεί από τον προηγούμενο κύκλο επεξεργασίας μέσω ενός μικρού βοηθού, της ReapDeadEditor, που καλείται στην αρχή της επόμενης BeginEditFormField και μία ακόμα φορά από την CloseDocument· κάθε επεξεργαστής που δημιουργεί ο θεατής ανήκει επίσης στον ίδιο τον θεατή, TEdit.Create(Self) αντί για TEdit.Create(nil), οπότε ακόμα και ένα στοιχείο ελέγχου που παραμένει σταθμευμένο στο FDeadEditor όταν καταστρέφεται ο θεατής μαζεύεται από τη συνηθισμένη ιδιοκτησία στοιχείων VCL αντί να διαρρεύσει

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;

Γιατί Αυτό Εμφανίζεται Πιο Έντονα κατά τη Γρήγορη Πλοήγηση με Tab;

Η FocusNextFormField, η μέθοδος που πρόσθεσε το PDFlibPas στην v3.226.0 για να οδηγεί την πλοήγηση Tab και Shift+Tab σε μια φόρμα, καλεί την BeginEditFormField για το επόμενο επιλέξιμο πεδίο σε κάθε μεμονωμένο άλμα, και η BeginEditFormField ξεκινά καλώντας την CommitInplaceEditor(True) για να ξεπλύνει όποιον επεξεργαστή είχε αφήσει ανοιχτό το προηγούμενο πεδίο. Αυτό σημαίνει ότι κάθε πάτημα Tab που κάνει ένας χρήστης ενώ συμπληρώνει μια φόρμα πολλαπλών πεδίων τρέχει ακριβώς την ακολουθία αποσύνδεσης-και-μετά-αγγίγματος που περιγράφηκε παραπάνω μία φορά, που είναι ακριβώς η διαδρομή κώδικα με τη μεγαλύτερη πιθανότητα να έχει ακόμα ένα στοιχείο ελέγχου γνησίως εστιασμένο τη στιγμή που αλλάζουν τα Visible και Parent, γιατί το Tab είναι η μία αλληλεπίδραση σχεδόν σίγουρη να αφήνει τον εξερχόμενο επεξεργαστή να κρατά την εστίαση μέχρι ακριβώς τη στιγμή που τη ζητά ο νέος

Τίποτα από αυτά δεν κάνει το σφάλμα αξιόπιστο να επιδειχθεί, και αξίζει να ειπωθεί ξεκάθαρα αντί να παραβλεφθεί. Το αν μια δεδομένη ανάθεση Visible ή Parent πράγματι εξαναγκάζει ένα σύγχρονο OnExit εξαρτάται από κατάσταση εστίασης και λαβής παραθύρου που αλλάζει ένας debugger απλώς επειδή είναι συνδεδεμένος, που μπορεί να διαταράξει ένα άσχετο repaint ή timer, και που συμπεριφέρεται διαφορετικά ανάλογα με το ποιο από τα TEdit, TMemo, ή TComboBox τυχαίνει να είναι το στοιχείο ελέγχου σε παιχνίδι. Μια προστασία που ασκείται μόνο μερικές φορές είναι ο λόγος που αυτό το είδος ελαττώματος επιβιώνει τόσο από code review όσο και από χειροκίνητο testing εξίσου, και είναι επίσης ο λόγος που η διόρθωση πρέπει να είναι σωστή από κατασκευής, μηδενίζοντας την αναφορά πριν συμβεί οτιδήποτε άλλο, παρά σωστή βάσει όποιας συμπεριφοράς έτυχε να παρατηρήσουν μερικά χειροκίνητα περάσματα δοκιμής

Το γενικό σχήμα αυτής της διόρθωσης ταξιδεύει πολύ πέρα από ένα στοιχείο ελέγχου θεατή. Οποιαδήποτε προσαρμοσμένη επιφάνεια επεξεργασίας χτισμένη τοποθετώντας ένα ζωντανό στοιχείο ελέγχου VCL πάνω από αποδοσμένο περιεχόμενο, όχι μόνο ένα πεδίο φόρμας PDF, κληρονομεί τον ίδιο κίνδυνο τη στιγμή που η λογική κλεισίματος-και-δέσμευσής της μπορεί να πυροδοτηθεί τόσο από μια ρητή ενέργεια χρήστη όσο και από μια σιωπηρή αλλαγή εστίασης, και η ίδια απάντηση δύο μερών εφαρμόζεται: καθαρίστε την αναφορά που ταυτοποιεί το ενεργό στοιχείο ελέγχου πριν κάνετε οτιδήποτε θα μπορούσε να πυροδοτήσει το δικό του συμβάν εξόδου, και ποτέ μην καλείτε Free από μια διαδρομή κώδικα που θα μπορούσε ακόμα να τρέχει κάτω από τον δικό του χειριστή συμβάντος εκείνου του στοιχείου ελέγχου. Η ευρύτερη επιφάνεια συμπλήρωσης φόρμας και απόδοσης του TPDFlibViewer, συμπεριλαμβανομένου του πώς αποφασίζει ποιον τύπο στοιχείου ελέγχου να δείξει για ποιο πεδίο, καλύπτεται στην επισκόπηση χτισίματος ενός διαδραστικού στοιχείου ελέγχου θεατή PDF σε Delphi VCL με το PDFlibPas, και η cache bitmap σελίδας που πρέπει να ακυρώσει η SetFormFieldValueAndRefresh σε κάθε δεσμευμένη επεξεργασία καλύπτεται ξεχωριστά στο κομμάτι για την cache σελίδας δίσκου ανά DPI οθόνης του θεατή

Η επεξεργασία πεδίων φόρμας επί τόπου, η πλοήγηση πεδίων μέσω Tab, και η ασφαλής από επανεισερχόμενες κλήσεις διαδρομή δέσμευσης πίσω από αμφότερες είναι μέρος του διαδραστικού στοιχείου ελέγχου θεατή που αποστέλλεται με το PDFlibPas, τη βιβλιοθήκη PDF για Delphi και C++Builder, μαζί με το υπόλοιπο της επιφάνειας API του για απόδοση σελίδων, annotations, και πεδία φόρμας