مقاله فنی

بازورودی OnExit در یک ویرایشگر درجای فرم در Delphi

کنترل TPDFlibViewer در PDF Library for Delphi یک ویرایشگر درجای فیلد فرم را از طریق رویداد OnExit خودِ ویرایشگر commit می‌کند، و آن انتخاب طراحی یک تله‌ی کلاسیک VCL از Delphi را پنهان می‌کند: پنهان‌کردن، تغییر والد، یا نابودکردن یک کنترل کانون‌شده از درون handler خودِ OnExitاش می‌تواند OnExit را برای دومین‌بار پیش از برگشتن فراخوانی اول شلیک کند، و منطق commit را دوباره به درون خودش بفرستد

شکستی که این تولید می‌کند در برابر یک بازتولید تمیز مقاومت می‌کند. یک کاربر سریع از میان یک ردیف از فیلدهای متنی روی یک فرم درخواست اسکن‌شده Tab می‌زند، و هر ازگاهی نمایشگر یک access violation پرتاب می‌کند، یا بدتر، به اجرا ادامه می‌دهد درحالی‌که بی‌سروصدا مقدار اشتباه را در یک فیلد دو Tab قبل می‌نویسد. آن را در تقاضا بازتولید کنید و باگ با نگاه به عقب آشکار به‌نظر می‌رسد؛ آن را از یک گزارش کرش مشتری تکی تعقیب کنید و شبیه یک شبح به‌نظر می‌رسد، چون اینکه آیا دومین OnExit واقعاً شلیک می‌شود به زمان‌بندی handle-پنجره و کانون بستگی دارد که با نوع فیلد، سرعت تایپ، و هر چیز دیگری که صف پیام در آن لحظه انجام می‌دهد جابه‌جا می‌شود

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 جایی است که مشکل شروع می‌شود

نمودار TPDFlibViewer که یک ویرایشگر زنده VCL را روی بیت‌مپ صفحه PDF رندرشده می‌نشاند و OnKeyDown و OnExit را به handlerهای نمایشگر متصل می‌کند
BeginEditFormField یک کنترل واقعی فوکوس‌گرفته روی صفحه می‌گذارد، و هر چنین ادیتوری با OnExit همراه است — دری که reentrancy از آن عبور می‌کند

چرا پنهان‌کردن ویرایشگر برای دومین‌بار OnExit را شلیک می‌کند؟

TWinControl در VCL یک تغییر روی Visible یا Parent یک کنترل کانون‌شده را دلیلی برای جابه‌جاکردن کانون از آن فوراً در نظر می‌گیرد، و جابه‌جاکردن کانون از یک کنترل دقیقاً همان چیزی است که رویداد OnExit آن کنترل را شلیک می‌کند، همزمان، پیش از اینکه انتساب ویژگی‌ای که آن را ماشه کشیده اصلاً برگردد. CommitInplaceEditor، متدی که PDF Library for Delphi برای بستن ویرایشگر درجا و نوشتن مقدارش برگشت به فیلد فرم استفاده می‌کند، در راه بیرون‌رفتن دقیقاً باید همان دو کار را انجام دهد: Editor.Visible را False تنظیم کند و Editor.Parent را nil تنظیم کند پس کنترل از رسم‌کردن روی صفحه دست می‌کشد و از دریافت ورودی دست می‌کشد. هرکدام از آن‌ها را انجام دهید درحالی‌که ویرایشگر همچنان کانون دارد، که تقریباً همیشه همین‌طور است چون کاربر همین الان آن را ترک کرده، و OnExit در میانه‌ی همان فراخوانی‌ای که قرار بود آخرین چیزی باشد که OnExit آن ویرایشگر تا به‌حال ماشه کشیده دوباره شلیک می‌شود

وقتی CommitInplaceEditor خودش را دوباره وارد کند چه چیزی اشتباه می‌رود؟

یک متد commit ساده‌لوحانه به یکی از این دو روش هزینه‌اش را می‌پردازد. یا مقدار فیلد را دوبار می‌نویسد، یک‌بار از فراخوانی اصلی و یک‌بار از فراخوانی بازورودی که پیش از اینکه اولی وضعیت خودش را لمس‌کردن تمام کند خزید، یا تلاش می‌کند کنترل ویرایشگر را آزاد کند درحالی‌که یک frame پایین‌تر در پشته‌ی فراخوانی همچنان درون handler رویداد خودِ همان کنترل است، که قلمرو تعریف‌نشده در 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;      // کنترل کانون‌شده اینجا reparent شد: OnExit
                               // دوباره شلیک می‌شود و به همین متد بازورود می‌کند
  FEditor.Free;                // درحالی‌که یک فراخواننده پایین‌تر در
  FEditor := nil;              // پشته همچنان درون handler OnExit آن است، آزاد می‌شود
end;

ارجاع را پیش از لمس‌کردن کنترل nil کنید

رفع اشکالی که 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 پیش از اینکه هر چیزی که قادر به ماشه‌کشیدن OnExit است اجرا شود nil باشد، که همان قید ترتیبی تکی است که بقیه‌ی متد را برای نوشتن در هر سبکی که در غیر این‌صورت طبیعی است ایمن می‌کند

هرگز یک کنترل را از درون رویداد خودش آزاد نکنید

TPDFlibViewer.CommitInplaceEditor هرگز مستقیم Editor.Free را فرا نمی‌خواند، و این عمدی است: آزادکردن یک کنترل ناامن است درحالی‌که یک frame پشته متعلق به dispatch رویداد خودِ همان کنترل ممکن است همچنان بالای فراخوانی‌ای که آن را آزاد می‌کند در حال بازشدن باشد، بازورودی 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);   // هر ویرایشگری که همچنان باز است را flush کن
  // ... جستجوی فیلد و تبدیل مستطیل حذف شده ...
  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) برای flush‌کردن هر ویرایشگری که فیلد قبلی باز رها کرده باز می‌شود. این یعنی هر فشار Tabای که یک کاربر در حین پرکردن یک فرم چندفیلدی می‌زند دقیقاً همان دنباله‌ی جدا-سپس-لمس توصیف‌شده در بالا را یک‌بار اجرا می‌کند، که دقیقاً همان مسیر کد است که محتمل‌تر است هنوز یک کنترل واقعاً کانون‌شده در لحظه‌ای که Visible و Parent تغییر می‌کنند داشته باشد، چون Tab همان یک تعاملی است که تقریباً تضمین می‌کند ویرایشگر خروجی کانون را دقیقاً تا زمانی که یکی جدید آن را بخواهد نگه دارد

فلوچارت PDF Library for Delphi که نشان می‌دهد چگونه تنظیم Editor.Parent روی nil برای ویرایشگر فوکوس‌دار باعث فعال شدن دوباره OnExit و ورود مجدد به CommitInplaceEditor پیش از بازگشت فراخوانی اول می‌شود
یک انتساب reparenting تکی، commit را به خودش برمی‌گرداند و هم حالت خرابی double-write و هم premature-Free را باز می‌کند

هیچ‌کدام از این‌ها باگ را برای نمایش‌دادن قابل‌اعتماد نمی‌کند، و ارزش دارد این را صریح بگوییم نه اینکه از رویش رد شویم. اینکه آیا یک انتساب مشخص Visible یا Parent واقعاً یک OnExit همزمان را اجبار می‌کند به وضعیت کانون و handle-پنجره بستگی دارد که یک دیباگر صرفاً با پیوست‌شدن آن را تغییر می‌دهد، که یک رسم‌مجدد یا تایمر بی‌ربط می‌تواند مختل کند، و که بسته به اینکه کدام‌یک از TEdit، TMemo، یا TComboBox اتفاقاً کنترل در بازی است متفاوت رفتار می‌کند. یک نگهبان که فقط گاهی اعمال می‌شود دلیل آن است که این نوع نقص از بازبینی کد و تست دستی هر دو جان سالم به‌در می‌برد، و همچنین دلیل آن است که رفع اشکال باید از طریق ساخت درست باشد، nil‌کردن ارجاع پیش از اینکه هر چیز دیگری اتفاق بیفتد، نه درست بر اساس هر رفتاری که مشتی پاس تست دستی اتفاقاً مشاهده کردند

شکل کلی این رفع اشکال فراتر از یک کنترل نمایشگر سفر می‌کند. هر سطح ویرایش سفارشی که با روی‌هم‌گذاری یک کنترل زنده‌ی VCL روی محتوای رندرشده ساخته شده، نه فقط یک فیلد فرم PDF، همان خطر را به ارث می‌برد همان لحظه‌ای که منطق بستن-و-commit آن می‌تواند هم توسط یک اقدام صریح کاربر و هم یک تغییر ضمنی کانون ماشه کشیده شود، و همان پاسخ دوبخشی اعمال می‌شود: ارجاعی که کنترل فعال را شناسایی می‌کند پیش از انجام هر کاری که ممکن است رویداد خروج خودش را ماشه بکشد پاک کنید، و هرگز Free را از یک مسیر کدی فرا نخوانید که ممکن است همچنان زیر dispatch رویداد خودِ آن کنترل در حال اجرا باشد. سطح گسترده‌تر پرکردن فرم و رندر TPDFlibViewer، شامل اینکه چطور تصمیم می‌گیرد کدام نوع کنترل را برای کدام فیلد نشان دهد، در مرور ساخت یک کنترل نمایشگر PDF تعاملی در Delphi VCL با PDF Library for Delphi پوشش داده شده، و کش بیت‌مپ صفحه‌ای که SetFormFieldValueAndRefresh باید در هر ویرایش commit‌شده باطل کند جداگانه در مقاله‌ی کش دیسک صفحه‌ی به‌ازای-هر-مانیتور DPI نمایشگر پوشش داده شده

PDF Library for Delphi: توالی شش‌مرحله‌ای مرتب commit که FInplaceEditor را پیش از دست زدن به کنترل خالی می‌کند، OnExit بازگشتی را no-op تلقی می‌کند و Free را از طریق جایگاه FDeadEditor به تعویق می‌اندازد
صفرکردن ارجاع در گام اول باعث می‌شود هر فراخوانی reentrant فوراً خارج شود، و جایگاه ادیتورِ پارک‌شده هر Free را به چرخه‌ای می‌برد که پشته‌هایش خاموش‌اند

ویرایش درجای فیلد فرم، ناوبری فیلد هدایت‌شده-با-Tab، و مسیر commit ایمن-از-بازورودی پشت هر دو بخشی از کنترل نمایشگر تعاملی هستند که با PDF Library for Delphi، کتابخانه‌ی PDF برای Delphi و C++Builder، عرضه می‌شوند، در کنار بقیه‌ی سطح API رندر صفحه، حاشیه‌نویسی، و فیلد فرمش