Элемент управления TPDFlibViewer в PDF Library for Delphi фиксирует встроенный редактор поля формы через собственное событие OnExit редактора, и это конструктивное решение скрывает классическую ловушку Delphi VCL: скрытие, смена родителя или уничтожение сфокусированного элемента управления изнутри его собственного обработчика OnExit может породить OnExit во второй раз ещё до того, как вернётся первый вызов, отправляя логику фиксации обратно в саму себя
Отказ, что это производит, сопротивляется чистому воспроизведению. Пользователь быстро проходит табуляцией через ряд текстовых полей на отсканированной форме заявления, и время от времени просмотрщик выбрасывает нарушение доступа, или, что хуже, продолжает работать, молча записывая неверное значение в поле на два табуляции назад. Воспроизведите это по требованию, и ошибка выглядит очевидной задним числом; преследуйте её из отчёта о сбое одного клиента, и она выглядит призраком, потому что действительно ли сработает второй 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, метод, что 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; защита выше
// превращает этот реентерабельный вызов в no-op
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 — то самое взаимодействие, почти гарантированно оставляющее уходящий редактор держащим фокус вплоть до того момента, когда новый его запросит
Ничто из этого не делает ошибку надёжно демонстрируемой, и это стоит сказать прямо, а не сгладить. Действительно ли данное присваивание Visible или Parent форсирует синхронный OnExit, зависит от состояния фокуса и дескриптора окна, что меняет сам отладчик простым фактом подключения, что может возмутить не связанная перерисовка или таймер, и что ведёт себя по-разному в зависимости от того, какой из TEdit, TMemo или TComboBox случайно оказался элементом управления в игре. Защита, что срабатывает лишь иногда, — причина, по которой такой дефект переживает и ревью кода, и ручное тестирование в равной мере, и также причина, по которой исправление должно быть корректным по построению, обнуляя ссылку прежде, чем произойдёт что-либо ещё, а не корректным по тому поведению, что случайно наблюдала горстка ручных проходов тестирования
Общая форма этого исправления простирается далеко за пределы одного элемента управления просмотрщика. Любая пользовательская поверхность редактирования, построенная наложением живого элемента управления VCL на отрисованное содержимое, не только поле формы PDF, наследует ту же опасность в момент, когда её логику закрытия-и-фиксации могут вызвать и явное действие пользователя, и неявное изменение фокуса, и применяется тот же ответ из двух частей: очистить ссылку, что идентифицирует активный элемент управления, прежде чем сделать что-либо, способное вызвать его собственное событие выхода, и никогда не вызывать Free из пути кода, что всё ещё может выполняться под собственной диспетчеризацией события того элемента управления. Более широкая поверхность заполнения форм и рендеринга TPDFlibViewer, включая то, как он решает, какой тип элемента управления показать для какого поля, описана в обзоре построения интерактивного элемента управления просмотрщиком PDF в Delphi VCL с PDF Library for Delphi, а кэш растровых изображений страниц, что SetFormFieldValueAndRefresh должен инвалидировать при каждой зафиксированной правке, описан отдельно в статье о дисковом кэше страниц просмотрщика с учётом DPI на монитор
Встроенное редактирование полей формы, навигация по полям через Tab и путь фиксации, безопасный к реентерабельности, за обоими — часть интерактивного элемента управления просмотрщиком, поставляемого с PDF Library for Delphi, PDF-библиотекой для Delphi и C++Builder, наряду с остальной поверхностью её API рендеринга страниц, аннотаций и полей форм