مقاله فنی

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

کنترل TPDFlibViewer در PDFlibPas یک ویرایشگر درجای فیلد فرم را از طریق رویداد 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 جایی است که مشکل شروع می‌شود

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

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

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

یک متد commit ساده‌لوحانه به یکی از این دو روش هزینه‌اش را می‌پردازد. یا مقدار فیلد را دوبار می‌نویسد، یک‌بار از فراخوانی اصلی و یک‌بار از فراخوانی بازورودی که پیش از اینکه اولی وضعیت خودش را لمس‌کردن تمام کند خزید، یا تلاش می‌کند کنترل ویرایشگر را آزاد کند درحالی‌که یک frame پایین‌تر در پشته‌ی فراخوانی همچنان درون handler رویداد خودِ همان کنترل است، که قلمرو تعریف‌نشده در VCL است و به‌عنوان یک access violation نمایان می‌شود که می‌تواند به تقریباً هر خطی اشاره کند، نه لزوماً همانی که واقعاً باعثش شده. هیچ‌کدام از این شکست‌ها به یک فرم بزرگ برای ماشه‌کشیدن نیاز ندارد؛ یک سند دو-فیلدی کافی است، به‌شرطی که کاربر فیلد دوم را به‌اندازه‌ی کافی سریع ترک کند که سیستم‌عامل هنوز در حال باز کردن پیام‌های کانون از اولی باشد

// 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;

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

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

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

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

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

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

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