Технічна стаття

Повторний вхід OnExit у редакторі полів форми на місці в Delphi

Елемент керування TPDFlibViewer у PDF Library for Delphi фіксує редактор поля форми на місці через власну подію OnExit цього редактора, і цей вибір дизайну ховає класичну пастку VCL Delphi: приховування, зміна батька чи знищення сфокусованого елемента керування зсередини його власного обробника OnExit може спричинити повторне спрацювання OnExit ще до того, як перший виклик повернеться, надсилаючи логіку фіксації назад у саму себе

Збій, який це виробляє, опирається чистому відтворенню. Користувач швидко перемикається клавішею Tab через ряд текстових полів на скановану форму заявки, і час від часу переглядач піднімає порушення доступу, чи, гірше, продовжує працювати, тихо записуючи неправильне значення в поле два табулювання назад. Відтворіть це на вимогу, і помилка виглядає очевидною заднім числом; переслідуйте її зі звіту про збій одного клієнта, і вона виглядає як привид, бо чи справді спрацьовує другий OnExit, залежить від часу дескриптора вікна та фокуса, що зсувається залежно від типу поля, швидкості друку та будь-чого іншого, що робить черга повідомлень тієї миті

Як TPDFlibViewer кладе справжній редактор поверх відрендереної сторінки

TPDFlibViewer рендерить кожну сторінку PDF у растрове зображення й типово не перетворює поля форми на живі елементи керування VCL, тож BeginEditFormField — метод, що з'єднує два світи: викликаний з індексом поля, він шукає прямокутник поля й перетворює його на координати клієнта, потім кладе справжній TEdit чи TMemo поверх цього прямокутника для текстового поля, чи TComboBox у стилі csDropDownList для поля вибору, з уже завантаженим поточним значенням поля. ISO 32000-2 §12.7 визначає, чим є текстове поле форми чи поле вибору всередині PDF, але ніщо в цій специфікації не каже, як застосунок Windows має дозволити комусь ввести в нього текст, і саме цю прогалину покликаний заповнити BeginEditFormField. І OnKeyDown, і OnExit підключені до тих самих двох методів переглядача, InplaceEditorKeyDown та InplaceEditorExit, на кожному редакторі, який створює TPDFlibViewer, поєднання, що постачається незмінним з часу, коли інтерактивне заповнення форм уперше з'явилося в v3.220.0, і саме OnExit — там, де починається проблема

Діаграма TPDFlibViewer: живий VCL-редактор розміщується поверх бітмапу відрендереної сторінки PDF, а OnKeyDown і OnExit з'єднуються з обробниками переглядача
BeginEditFormField розміщує справжній керунок із фокусом поверх сторінки, і кожен такий редактор поставляється з OnExit — це двері, через які заходить реентерабельність

Чому приховування редактора спричиняє повторне спрацювання OnExit?

TWinControl у VCL трактує зміну Visible чи Parent сфокусованого елемента керування як причину негайно перемістити фокус з нього, а переміщення фокуса з елемента керування — саме те, що спричиняє подію OnExit цього елемента керування, синхронно, до того, як присвоєння властивості, що це спричинило, взагалі повертається. CommitInplaceEditor, метод, який PDF Library for Delphi використовує для закриття редактора на місці й запису його значення назад у поле форми, мусить зробити рівно ці дві речі на виході: встановити Editor.Visible у False та встановити Editor.Parent у nil, щоб елемент керування перестав малюватися поверх сторінки й перестав отримувати ввід. Зробіть будь-що з цього, поки редактор усе ще має фокус, а він майже завжди має, оскільки користувач щойно його покинув, і OnExit спрацьовує знову посеред того самого виклику, що мав бути останнім, що коли-небудь спричиняв OnExit цього редактора

Що йде не так, коли CommitInplaceEditor повторно входить у себе?

Наївний метод фіксації платить за це одним із двох способів. Або він записує значення поля двічі, один раз з оригінального виклику й один раз з повторного виклику, що прослизнув до того, як перший закінчив торкатися власного стану, або намагається звільнити елемент керування редактора, поки кадр стека далі внизу все ще всередині власного обробника подій того самого елемента керування, що є невизначеною територією у VCL і проявляється як порушення доступу, що може вказувати майже на будь-який рядок, не обов'язково той, що справді його спричинив. Жоден зі збоїв не потребує великої форми для спрацювання; двополевого документа достатньо, за умови, що користувач покидає друге поле достатньо швидко, щоб ОС усе ще розгортала повідомлення фокуса з першого

// Наївна версія: виглядає нормально під час перегляду, зазнає невдачі лише при реальній швидкості друку
procedure TMyPdfViewer.EditorExit(Sender: TObject);
begin
  CommitEditor;               // усе ще виконується всередині власного OnExit FEditor
end;

procedure TMyPdfViewer.CommitEditor;
begin
  if not Assigned(FEditor) then
    Exit;
  SaveFieldValue(FEditor.Text);
  FEditor.Parent := nil;      // сфокусований елемент керування переприв'язаний тут: OnExit
                               // спрацьовує знову, повторно входячи в цей самий метод
  FEditor.Free;                // звільняється, поки викликач далі вниз по
  FEditor := nil;              // стеку все ще перебуває всередині свого обробника OnExit
end;

Обнуліть посилання до того, як торкнетеся елемента керування

Виправлення, яке постачає PDF Library for Delphi, — одне перевпорядкування: захопіть редактор у локальну змінну, очистіть поле, що на нього вказує, і лише тоді почніть змінювати властивості елемента керування. 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;                      // повторний виклик потрапляє сюди і зупиняється
  FInplaceEditor := nil;       // від'єднати до того, як елемент керування взагалі торкнуться
  if Save then
    SaveEditorValue(Editor);   // безпечно: FInplaceEditor уже nil
  Editor.Visible := False;
  Editor.Parent := nil;        // може знову спрацювати OnExit; охорона вище
                                // перетворює цей повторний виклик на холосту операцію
  ReapDeadEditor;               // звільнити те, що було запарковане в попередньому циклі
  FDeadEditor := Editor;        // запаркувати цей замість того, щоб звільняти його тут
end;

SaveEditorValue у цьому лістингу заміняє справжню гілку, що перевіряє, чи Editor — TComboBox, TMemo чи TEdit, і читає його значення відповідно, оскільки PDF Library for Delphi створює різний елемент керування залежно від того, чи поле — текстове поле чи поле вибору. Охорона не переймається тим, яка гілка виконується, лише тим, що FInplaceEditor дорівнює nil до того, як виконається будь-що, здатне спричинити OnExit, і саме це єдине обмеження порядку робить решту методу безпечним для написання в будь-якому іншому стилі, який в іншому випадку природний

Ніколи не звільняйте елемент керування зсередини його власної події

TPDFlibViewer.CommitInplaceEditor ніколи не викликає Editor.Free напряму, і це навмисно: звільняти елемент керування небезпечно, поки кадр стека, що належить диспетчеризації подій того самого елемента керування, все ще може розгортатися над викликом, що його звільняє, повторний OnExit чи ні. PDF Library for Delphi натомість передає від'єднаний редактор в однослотове місце паркування, FDeadEditor, звільняючи те, що сиділо там із попереднього циклу редагування, через невеликий помічник, ReapDeadEditor, викликаний на початку наступного BeginEditFormField і ще раз із CloseDocument; кожен редактор, який створює переглядач, також належить самому переглядачу, TEdit.Create(Self), а не TEdit.Create(nil), тож навіть елемент керування, все ще запаркований у FDeadEditor, коли переглядач знищується, підбирається звичайним володінням компонентами VCL замість того, щоб протікати

procedure TPDFlibViewer.ReapDeadEditor;
begin
  if Assigned(FDeadEditor) then
  begin
    FDeadEditor.Free;          // безпечно тепер: власний OnExit цього елемента керування
    FDeadEditor := nil;        // завершився принаймні один цикл редагування тому
  end;
end;

function TPDFlibViewer.BeginEditFormField(FieldIndex: Integer): Integer;
var
  Edit: TEdit;
begin
  Result := 0;
  CommitInplaceEditor(True);   // скинути будь-який редактор, що ще відкритий
  // ... пошук поля та перетворення прямокутника опущені ...
  ReapDeadEditor;              // тепер безпечно звільнити запаркований редактор попереднього циклу
  Edit := TEdit.Create(Self);
  Edit.Parent := Self;
  Edit.OnExit := InplaceEditorExit;
  FInplaceEditor := Edit;
  FInplaceEditor.SetFocus;
  Result := 1;
end;

Чому це найгостріше проявляється під час швидкої навігації Tab?

FocusNextFormField, метод, який PDF Library for Delphi додав у v3.226.0 для керування навігацією Tab та Shift+Tab через форму, викликає BeginEditFormField для наступного придатного поля на кожному окремому переході, а BeginEditFormField починається з виклику CommitInplaceEditor(True), щоб скинути будь-який редактор, який залишило відкритим попереднє поле. Це означає, що кожне натискання Tab, яке робить користувач, заповнюючи багатопольову форму, виконує саме описану вище послідовність від'єднання-потім-торкання один раз, що є точно тим шляхом коду, найімовірнішим для того, щоб елемент керування справді був сфокусований у момент, коли Visible та Parent змінюються, бо Tab — та одна взаємодія, майже гарантовано залишає вихідний редактор із фокусом аж до того, як новий його не запросить

Блок-схема PDF Library for Delphi: встановлення Editor.Parent у nil на сфокусованому редакторі змушує OnExit спрацювати знову, повторно входячи в CommitInplaceEditor до повернення першого виклику
Одне присвоєння зі зміною батька повертає фіксацію в неї саму, відкриваючи одразу і подвійний запис, і передчасне звільнення (Free)

Ніщо з цього не робить помилку надійною для демонстрації, і це варто сказати прямо, а не замазати. Чи справді конкретне присвоєння Visible чи Parent примушує синхронний OnExit, залежить від стану фокуса та дескриптора вікна, який налагоджувач змінює самим фактом підключення, який непов'язане перемальовування чи таймер можуть збурити, і що поводиться інакше залежно від того, який із TEdit, TMemo чи TComboBox опиняється елементом керування в грі. Охорона, що спрацьовує лише іноді, — причина, чому цей тип дефекту переживає і перегляд коду, і ручне тестування однаково, і також причина, чому виправлення має бути коректним за побудовою, обнуляючи посилання до того, як щось інше відбудеться, а не коректним за тим, яку б поведінку не спостерігала жменька ручних тестових прогонів

Загальна форма цього виправлення поширюється далеко за межі одного елемента керування переглядачем. Будь-яка власна поверхня редагування, побудована накладанням живого елемента керування VCL на відрендерений вміст, не лише поле форми PDF, успадковує ту саму небезпеку тієї миті, коли її логіку закриття-й-фіксації можуть спричинити і явна дія користувача, і неявна зміна фокуса, і застосовується та сама відповідь із двох частин: очистіть посилання, що ідентифікує активний елемент керування, перш ніж робити щось, що могло б спричинити його власну подію виходу, і ніколи не викликайте Free зі шляху коду, що все ще міг би виконуватися під власною диспетчеризацією подій цього елемента керування. Ширша поверхня заповнення форм та рендерингу TPDFlibViewer, включно з тим, як він вирішує, який тип елемента керування показати для якого поля, розглянута в огляді побудови інтерактивного елемента керування переглядачем PDF у Delphi VCL з PDF Library for Delphi, а кеш растрового зображення сторінки, який SetFormFieldValueAndRefresh мусить інвалідувати на кожному зафіксованому редагуванні, розглянутий окремо у статті про дисковий кеш сторінок переглядача з урахуванням DPI кожного монітора

PDF Library for Delphi: впорядкована шестикрокова послідовність фіксації: FInplaceEditor чиститься до дотику до елемента керування, повторний вхідний OnExit трактується як no-op, а Free відкладається через слот FDeadEditor
Спершу обнуліть посилання — і кожен реентерабельний виклик виходить негайно, а слот відставленого редактора переносить кожен Free у цикл, де стеки спокійні

Редагування поля форми на місці, навігація полями через Tab, та безпечний до повторного входу шлях фіксації за обома — частина інтерактивного елемента керування переглядачем, що постачається з PDF Library for Delphi, бібліотекою PDF для Delphi та C++Builder, поряд з рештою поверхні API рендерингу сторінок, анотацій та полів форм