Елемент керування TPDFlibViewer у PDFlibPas фіксує редактор поля форми на місці через власну подію 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 — там, де починається проблема
Чому приховування редактора спричиняє повторне спрацювання 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, залежить від стану фокуса та дескриптора вікна, який налагоджувач змінює самим фактом підключення, який непов'язане перемальовування чи таймер можуть збурити, і що поводиться інакше залежно від того, який із TEdit, TMemo чи TComboBox опиняється елементом керування в грі. Охорона, що спрацьовує лише іноді, — причина, чому цей тип дефекту переживає і перегляд коду, і ручне тестування однаково, і також причина, чому виправлення має бути коректним за побудовою, обнуляючи посилання до того, як щось інше відбудеться, а не коректним за тим, яку б поведінку не спостерігала жменька ручних тестових прогонів
Загальна форма цього виправлення поширюється далеко за межі одного елемента керування переглядачем. Будь-яка власна поверхня редагування, побудована накладанням живого елемента керування VCL на відрендерений вміст, не лише поле форми PDF, успадковує ту саму небезпеку тієї миті, коли її логіку закриття-й-фіксації можуть спричинити і явна дія користувача, і неявна зміна фокуса, і застосовується та сама відповідь із двох частин: очистіть посилання, що ідентифікує активний елемент керування, перш ніж робити щось, що могло б спричинити його власну подію виходу, і ніколи не викликайте Free зі шляху коду, що все ще міг би виконуватися під власною диспетчеризацією подій цього елемента керування. Ширша поверхня заповнення форм та рендерингу TPDFlibViewer, включно з тим, як він вирішує, який тип елемента керування показати для якого поля, розглянута в огляді побудови інтерактивного елемента керування переглядачем PDF у Delphi VCL з PDFlibPas, а кеш растрового зображення сторінки, який SetFormFieldValueAndRefresh мусить інвалідувати на кожному зафіксованому редагуванні, розглянутий окремо у статті про дисковий кеш сторінок переглядача з урахуванням DPI кожного монітора
Редагування поля форми на місці, навігація полями через Tab, та безпечний до повторного входу шлях фіксації за обома — частина інтерактивного елемента керування переглядачем, що постачається з PDFlibPas, бібліотекою PDF для Delphi та C++Builder, поряд з рештою поверхні API рендерингу сторінок, анотацій та полів форм