Контролата TPDFlibViewer на PDF Library for Delphi потвърждава in-place редактор на поле от формуляр чрез собственото събитие OnExit на редактора, и този проектен избор крие класически капан в Delphi VCL: скриването, смяната на родителя или унищожаването на фокусирана контрола отвътре в собствения ѝ обработчик на OnExit може да задейства OnExit втори път, преди първото извикване да се върне, като изпраща логиката за потвърждаване обратно в самата себе си
Отказът, който това произвежда, се съпротивлява на чисто възпроизвеждане. Потребител бързо преминава с Tab през поредица текстови полета в сканиран формуляр за кандидатстване и от време на време визуализаторът хвърля access violation, или по-лошо, продължава да работи, докато тихо записва грешната стойност в поле два Tab-а назад. Възпроизведете го при поискване и грешката изглежда очевидна в ретроспекция; гонете я от доклада за срив на един-единствен клиент и тя изглежда като призрак, защото дали вторият OnExit наистина се задейства зависи от времето на window handle и фокуса, което се мести с типа на полето, скоростта на писане и каквото и друго да прави опашката със съобщения в този миг
Как 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, методът, който PDF Library for Delphi използва, за да затвори in-place редактора и да запише стойността му обратно в полето на формуляра, трябва да направи точно тези две неща на излизане: да зададе Editor.Visible на False и да зададе Editor.Parent на nil, така че контролата да спре да се рисува върху страницата и да спре да получава вход. Направете което и да е от двете, докато редакторът все още има фокус, което почти винаги е така, тъй като потребителят току-що го е напуснал, и OnExit се задейства отново по средата на самото извикване, което трябваше да е последното нещо, което OnExit на този редактор някога задейства
Какво се обърква, когато CommitInplaceEditor влезе повторно в себе си?
Наивен метод за потвърждаване плаща за това по един от два начина. Или записва стойността на полето два пъти, веднъж от първоначалното извикване и веднъж от повторното извикване, което се е промъкнало, преди първото да е приключило с докосването на собственото си състояние, или се опитва да освободи контролата на редактора, докато кадър по-надолу в стека на извикванията все още е вътре в собствения обработчик на събития на същата тази контрола, което е неопределена територия във VCL и се проявява като access violation, който може да сочи към почти всеки ред, не непременно към този, който наистина го е причинил. Нито един от двата отказа не се нуждае от голям формуляр, за да се задейства; документ с две полета е достатъчен, стига потребителят да напусне второто поле достатъчно бързо, за да разгъва ОС все още съобщенията за фокус от първото
// Наивна версия: чете се добре при преглед, проваля се само при реална скорост на писане
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 зависи от състояние на фокуса и window handle, което дебъгерът променя само с това, че е прикачен, което несвързано прерисуване или таймер може да смути, и което се държи различно в зависимост от това кой от TEdit, TMemo или TComboBox се окаже контролата в игра. Защита, която само понякога се упражнява, е причината този вид дефект да преживява както code review, така и ръчно тестване, и е също причината поправката да трябва да бъде правилна по конструкция, зануляваща референцията, преди да се случи каквото и да е друго, а не правилна според поведението, което шепа ръчни тестови преминавания случайно са наблюдавали
Общата форма на тази поправка пътува далеч отвъд една контрола за визуализация. Всяка персонализирана повърхност за редактиране, изградена чрез наслагване на жива VCL контрола върху рендирано съдържание, не само поле от PDF формуляр, наследява същата опасност в момента, в който логиката ѝ за затваряне и потвърждаване може да бъде задействана както от изрично потребителско действие, така и от неявна промяна на фокуса, и същият отговор от две части важи: изчистете референцията, която идентифицира активната контрола, преди да направите каквото и да е, което може да задейства собственото ѝ събитие за излизане, и никога не извиквайте Free от кодов път, който все още може да се изпълнява под диспечирането на събития на същата тази контрола. По-широката повърхност за попълване на формуляри и рендиране на TPDFlibViewer, включително как решава кой тип контрола да покаже за кое поле, е разгледана в обзора на изграждането на интерактивна контрола за визуализация на PDF в Delphi VCL с PDF Library for Delphi, а кешът с bitmap-и на страници, който SetFormFieldValueAndRefresh трябва да инвалидира при всяка потвърдена редакция, е разгледан отделно в статията за дисковия кеш на страници на визуализатора с DPI за всеки монитор
In-place редактирането на полета от формуляри, навигацията между полета с Tab и безопасният спрямо повторно влизане път за потвърждаване зад двете са част от интерактивната контрола за визуализация, доставяна с PDF Library for Delphi, PDF библиотеката за Delphi и C++Builder, редом с останалата част от нейната API повърхност за рендиране на страници, анотации и полета от формуляри