Техническая статья

Реентерабельность OnExit во встроенном редакторе форм на Delphi

Элемент управления TPDFlibViewer в PDFlibPas фиксирует встроенный редактор поля формы через собственное событие 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, метод, что 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 рендеринга страниц, аннотаций и полей форм