مقال تقني

إعادة دخول OnExit في محرر حقل نموذج في مكانه في Delphi

يُلزِم عنصر التحكم TPDFlibViewer في PDFlibPas محرر حقل نموذج في مكانه عبر حدث OnExit الخاص بالمحرر نفسه، ويخفي ذلك الاختيار التصميمي فخًا كلاسيكيًا في VCL الخاصة بـDelphi: إخفاء، أو إعادة تأصيل (reparent)، أو تدمير عنصر تحكم يحمل التركيز من داخل معالج OnExit الخاص به يمكن أن يُطلق OnExit مرة ثانية قبل أن يعود الاستدعاء الأول، مرسلًا منطق الإلزام إلى نفسه مرة أخرى

الفشل الذي ينتجه هذا يقاوم إعادة الإنتاج النظيفة. يتنقل مستخدم بسرعة عبر تشغيل من حقول نصية على نموذج طلب ممسوح ضوئيًا، ومن حين لآخر يُطلق العارض خرق وصول، أو أسوأ، يستمر في العمل بينما يكتب بصمت القيمة الخاطئة في حقل يبعد تبويبتين إلى الوراء. أعد إنتاجه عند الطلب ويبدو الخلل واضحًا بأثر رجعي؛ لاحقه من تقرير تعطل عميل واحد ويبدو كشبح، لأن ما إذا كانت OnExit الثانية تُطلَق فعليًا يعتمد على توقيت مقبض النافذة والتركيز الذي يتحول بحسب نوع الحقل، وسرعة الكتابة، وأيًّا كان ما تفعله قائمة انتظار الرسائل في تلك اللحظة

كيف يضع TPDFlibViewer محررًا حقيقيًا فوق صفحة مرسومة

يرسم TPDFlibViewer كل صفحة PDF إلى صورة نقطية ولا يحوّل حقول النموذج إلى عناصر تحكم VCL حية افتراضيًا، بحيث تكون BeginEditFormField هي الطريقة التي تربط بين العالمين: عند استدعائها بفهرس حقل، تبحث عن مستطيل الحقل وتحوّله إلى إحداثيات العميل، ثم تضع TEdit أو TMemo حقيقيًا فوق ذلك المستطيل لحقل نصي، أو TComboBox بنمط csDropDownList لحقل اختيار، كاملًا بقيمة الحقل الحالية محمَّلة بالفعل. يعرّف ISO 32000-2 §12.7 ما هو حقل نموذج نصي أو اختياري داخل PDF، لكن لا شيء في ذلك المعيار يقول كيف يجب لتطبيق ويندوز أن يسمح لأحد بالكتابة في واحد، وتلك بالضبط الفجوة التي وُجدت 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، إلى جانب بقية سطح رسم الصفحات، والتعليقات التوضيحية، وواجهة حقول النماذج البرمجية