คอนโทรล TPDFlibViewer ของ PDFlibPas commit ตัวแก้ไขฟิลด์ฟอร์มแบบ in-place ผ่าน event OnExit ของตัวแก้ไขเอง และการตัดสินใจออกแบบนั้นซ่อนกับดักคลาสสิกของ Delphi VCL ไว้ คือการซ่อน, การเปลี่ยน parent หรือการทำลายคอนโทรลที่มี focus อยู่จากภายใน OnExit handler ของมันเอง สามารถยิง OnExit ครั้งที่สองก่อนที่การเรียกครั้งแรกจะคืนค่ากลับมา ส่ง logic การ commit กลับเข้าไปในตัวมันเอง
ความล้มเหลวที่สิ่งนี้สร้างขึ้นต้านทานการทำให้เกิดซ้ำอย่างสะอาด ผู้ใช้กด Tab อย่างรวดเร็วผ่านชุดฟิลด์ข้อความบนฟอร์มใบสมัครที่สแกนมา และเป็นครั้งคราว viewer จะยก access violation หรือแย่กว่านั้น ยังคงทำงานต่อในขณะที่เขียนค่าที่ผิดเข้าไปในฟิลด์ที่ผ่านมาสอง tab ก่อนอย่างเงียบๆ ทำให้เกิดซ้ำได้ตามต้องการและบั๊กก็ดูชัดเจนเมื่อมองย้อนกลับ ไล่ตามมันจาก crash report ของลูกค้ารายเดียวและมันดูเหมือนผี เพราะ OnExit ตัวที่สองจะยิงจริงหรือไม่ขึ้นอยู่กับจังหวะเวลาของ window-handle และ focus ที่เปลี่ยนไปตามประเภทฟิลด์, ความเร็วในการพิมพ์ และอะไรก็ตามอื่นที่ message queue กำลังทำ ณ ขณะนั้น
TPDFlibViewer วางตัวแก้ไขจริงทับหน้าที่ render ไว้อย่างไร
TPDFlibViewer render แต่ละหน้า PDF เป็นบิตแมป และไม่เปลี่ยนฟิลด์ฟอร์มให้เป็นคอนโทรล VCL ที่มีชีวิตโดยค่าเริ่มต้น ดังนั้น BeginEditFormField จึงเป็น method ที่เชื่อมสองโลกเข้าด้วยกัน เรียกด้วยดัชนีฟิลด์ มันค้นหาสี่เหลี่ยมของฟิลด์และแปลงเป็นพิกัด client แล้ววาง TEdit หรือ TMemo จริงทับสี่เหลี่ยมนั้นสำหรับฟิลด์ข้อความ หรือ TComboBox แบบ csDropDownList สำหรับฟิลด์แบบเลือก พร้อมค่าปัจจุบันของฟิลด์ที่โหลดไว้แล้ว ISO 32000-2 §12.7 นิยามว่าฟิลด์ฟอร์มแบบข้อความหรือแบบเลือกคืออะไรภายใน PDF แต่ไม่มีอะไรในสเปคนั้นบอกว่าแอปพลิเคชัน Windows ควรให้ใครสักคนพิมพ์เข้าไปในมันได้อย่างไร และช่องว่างนั้นเองคือสิ่งที่ BeginEditFormField มีอยู่เพื่อเติมเต็ม ทั้ง OnKeyDown และ OnExit ถูกต่อสายเข้ากับสอง method ของ viewer เดียวกัน คือ InplaceEditorKeyDown และ InplaceEditorExit บนตัวแก้ไขทุกตัวที่ TPDFlibViewer สร้างขึ้น เป็นการจับคู่ที่ส่งออกมาโดยไม่เปลี่ยนแปลงตั้งแต่การกรอกฟอร์มแบบโต้ตอบลงตัวครั้งแรกใน v3.220.0 และ OnExit คือจุดที่ปัญหาเริ่มต้น
ทำไมการซ่อนตัวแก้ไขถึงยิง OnExit เป็นครั้งที่สอง
TWinControl ใน VCL ปฏิบัติต่อการเปลี่ยน Visible หรือ Parent บนคอนโทรลที่มี focus อยู่ว่าเป็นเหตุผลที่จะย้าย focus ออกจากมันทันที และการย้าย focus ออกจากคอนโทรลก็คือสิ่งที่ยิง event OnExit ของคอนโทรลนั้นพอดี แบบ synchronous ก่อนที่การกำหนด property ที่กระตุ้นมันจะคืนค่ากลับมาด้วยซ้ำ CommitInplaceEditor ซึ่งเป็น method ที่ PDFlibPas ใช้ปิดตัวแก้ไข in-place และเขียนค่าของมันกลับเข้าฟิลด์ฟอร์ม ต้องทำสองอย่างนั้นพอดีตอนออก คือตั้ง Editor.Visible เป็น False และตั้ง Editor.Parent เป็น nil เพื่อให้คอนโทรลหยุดวาดทับหน้าและหยุดรับอินพุต ทำอย่างใดอย่างหนึ่งนั้นในขณะที่ตัวแก้ไขยังคงมี focus อยู่ ซึ่งมันแทบจะมีเสมอเพราะผู้ใช้เพิ่งออกจากมัน แล้ว OnExit ก็ยิงอีกครั้งกลางการเรียกที่ควรจะเป็นสิ่งสุดท้ายที่ OnExit ของตัวแก้ไขนั้นจะกระตุ้นเลย
เกิดอะไรขึ้นเมื่อ CommitInplaceEditor เรียกซ้ำตัวเอง
method commit แบบไร้เดียงสาจ่ายราคาสำหรับสิ่งนี้ด้วยวิธีใดวิธีหนึ่งในสอง มันเขียนค่าของฟิลด์สองครั้ง ครั้งหนึ่งจากการเรียกเดิมและอีกครั้งจากการเรียกที่เรียกซ้ำที่แทรกเข้ามาก่อนที่ตัวแรกจะแตะ state ของตัวเองเสร็จ หรือไม่ก็มันพยายาม free คอนโทรลตัวแก้ไขในขณะที่ frame ที่ลึกกว่าใน call stack ยังคงอยู่ภายใน event handler ของคอนโทรลตัวเดียวกันนั้น ซึ่งเป็นดินแดนที่ไม่นิยามไว้ใน VCL และปรากฏเป็น access violation ที่ชี้ไปที่บรรทัดใดก็ได้เกือบทั้งหมด ไม่จำเป็นต้องเป็นตัวที่ทำให้เกิดจริง ทั้งสองความล้มเหลวไม่ต้องการฟอร์มขนาดใหญ่เพื่อกระตุ้นเลย เอกสารสองฟิลด์ก็เพียงพอ ตราบใดที่ผู้ใช้ออกจากฟิลด์ที่สองเร็วพอที่ OS จะยังคลาย message ของ focus จากตัวแรกอยู่
// 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 reference ก่อนที่คุณจะแตะคอนโทรล
ทางแก้ที่ PDFlibPas ส่งออกมาคือการจัดลำดับใหม่เพียงครั้งเดียว จับตัวแก้ไขไว้ในตัวแปรท้องถิ่น เคลียร์ฟิลด์ที่ชี้ไปยังมัน และเริ่มเปลี่ยน property ของคอนโทรลก็ต่อเมื่อทำแบบนั้นแล้วเท่านั้น 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 ใน listing นั้นแทน branch จริง ซึ่งตรวจสอบว่า Editor เป็น TComboBox, TMemo หรือ TEdit และอ่านค่าของมันตามนั้น เพราะ PDFlibPas สร้างคอนโทรลที่ต่างกันขึ้นอยู่กับว่าฟิลด์เป็นฟิลด์ข้อความหรือฟิลด์แบบเลือก guard ไม่สนใจว่า branch ไหนรัน สนใจแค่ว่า FInplaceEditor เป็น nil ก่อนที่อะไรที่มีความสามารถกระตุ้น OnExit จะทำงานเท่านั้น ซึ่งเป็นข้อจำกัดด้านลำดับเดียวที่ทำให้ส่วนที่เหลือของ method เขียนได้อย่างปลอดภัยในสไตล์ใดก็ตามที่เป็นธรรมชาติอยู่แล้ว
อย่า free คอนโทรลจากภายใน event ของตัวเองเด็ดขาด
TPDFlibViewer.CommitInplaceEditor ไม่เคยเรียก Editor.Free ตรงๆ เลย และนั่นเป็นความจงใจ การ free คอนโทรลไม่ปลอดภัยในขณะที่ stack frame ที่เป็นของ event dispatch ของคอนโทรลตัวเดียวกันนั้นอาจยังคงคลายอยู่เหนือการเรียกที่ free มัน ไม่ว่าจะเป็น OnExit ที่เรียกซ้ำหรือไม่ก็ตาม PDFlibPas ส่งตัวแก้ไขที่ถูกแยกออกไปยังจุดจอดแบบช่องเดียวแทน คือ FDeadEditor free อะไรก็ตามที่นั่งอยู่ที่นั่นจากรอบการแก้ไขก่อนหน้าผ่าน helper เล็กๆ คือ ReapDeadEditor ที่ถูกเรียกตอนเริ่ม BeginEditFormField ครั้งถัดไป และอีกครั้งจาก CloseDocument ตัวแก้ไขทุกตัวที่ viewer สร้างขึ้นยังเป็นของ viewer เองด้วย คือ TEdit.Create(Self) แทนที่จะเป็น TEdit.Create(nil) ดังนั้นแม้แต่คอนโทรลที่ยังจอดอยู่ใน FDeadEditor เมื่อ viewer ถูกทำลาย ก็ถูกกวาดไปโดยความเป็นเจ้าของ VCL component ปกติ แทนที่จะรั่วไหล
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 ซึ่งเป็น method ที่ PDFlibPas เพิ่มเข้ามาใน v3.226.0 เพื่อขับเคลื่อนการนำทางด้วย Tab และ Shift+Tab ข้ามฟอร์ม เรียก BeginEditFormField สำหรับฟิลด์ที่มีสิทธิ์ถัดไปในทุกการกระโดดครั้งเดียว และ BeginEditFormField เปิดด้วยการเรียก CommitInplaceEditor(True) เพื่อ flush ตัวแก้ไขใดก็ตามที่ฟิลด์ก่อนหน้าทิ้งไว้เปิดอยู่ นั่นหมายความว่าทุกการกด Tab ที่ผู้ใช้ทำขณะกรอกฟอร์มหลายฟิลด์รันลำดับ detach-แล้ว-แตะ ที่อธิบายไว้ข้างต้นครั้งหนึ่งพอดี ซึ่งเป็น code path พอดีที่มีแนวโน้มมากที่สุดที่จะยังมีคอนโทรลที่มี focus จริงๆ อยู่ ณ ขณะที่ Visible และ Parent เปลี่ยน เพราะ Tab เป็นการโต้ตอบตัวเดียวที่แทบจะรับประกันว่าจะทิ้งตัวแก้ไขที่กำลังจะออกให้ถือ focus ไว้จนกว่าตัวใหม่จะขอมัน
ไม่มีอะไรในนี้ที่ทำให้บั๊กนี้เชื่อถือได้ที่จะสาธิต และควรพูดตรงๆ แทนที่จะกลบเกลื่อน การกำหนด Visible หรือ Parent ที่กำหนดจะบังคับ OnExit แบบ synchronous จริงหรือไม่ ขึ้นอยู่กับ state ของ focus และ window-handle ที่ debugger เปลี่ยนแค่ด้วยการต่ออยู่ ที่ repaint หรือ timer ที่ไม่เกี่ยวข้องสามารถรบกวนได้ และที่มีพฤติกรรมต่างกันขึ้นอยู่กับว่า TEdit, TMemo หรือ TComboBox ตัวไหนบังเอิญเป็นคอนโทรลที่เล่นอยู่ guard ที่ถูกทดสอบแค่บางครั้งคือเหตุผลที่ข้อบกพร่องประเภทนี้อยู่รอดผ่านทั้ง code review และการทดสอบด้วยมือ และก็เป็นเหตุผลที่ทางแก้ต้องถูกต้องโดยโครงสร้าง คือ nil reference ก่อนที่อะไรอื่นจะเกิดขึ้น แทนที่จะถูกต้องตามพฤติกรรมที่การทดสอบด้วยมือจำนวนหนึ่งบังเอิญสังเกตเห็น
รูปร่างทั่วไปของการแก้ไขนี้เดินทางไปไกลเกินกว่าคอนโทรล viewer ตัวเดียว พื้นผิวการแก้ไขแบบกำหนดเองใดก็ตามที่สร้างขึ้นด้วยการซ้อนคอนโทรล VCL ที่มีชีวิตทับเนื้อหาที่ render ไว้ ไม่ใช่แค่ฟิลด์ฟอร์ม PDF สืบทอดอันตรายเดียวกันนี้ทันทีที่ logic ปิด-แล้ว-commit ของมันถูกกระตุ้นได้ทั้งจากการกระทำของผู้ใช้ที่ชัดเจนและการเปลี่ยน focus โดยนัย และคำตอบสองส่วนเดียวกันนี้ใช้ได้ คือเคลียร์ reference ที่ระบุคอนโทรลที่ทำงานอยู่ก่อนทำอะไรก็ตามที่อาจกระตุ้น exit event ของมันเอง และอย่าเรียก Free จาก code path ที่อาจยังคงรันอยู่ข้างใต้ event dispatch ของคอนโทรลนั้นเองเด็ดขาด พื้นผิวการกรอกฟอร์มและการ render ที่กว้างกว่าของ TPDFlibViewer รวมถึงวิธีที่มันตัดสินใจว่าจะแสดงคอนโทรลประเภทไหนสำหรับฟิลด์ไหน ครอบคลุมในภาพรวมการสร้างคอนโทรล PDF viewer แบบโต้ตอบใน Delphi VCL ด้วย PDFlibPas และ cache บิตแมปหน้าที่ SetFormFieldValueAndRefresh ต้องทำให้ไม่ถูกต้องในทุกการแก้ไขที่ commit แล้ว ครอบคลุมแยกต่างหากในบทความเรื่อง cache หน้าบนดิสก์แบบ per-monitor DPI ของ viewer
การแก้ไขฟิลด์ฟอร์มแบบ in-place, การนำทางฟิลด์ด้วย Tab และเส้นทาง commit ที่ปลอดภัยต่อการเรียกซ้ำเบื้องหลังทั้งสอง เป็นส่วนหนึ่งของคอนโทรล viewer แบบโต้ตอบที่มาพร้อมกับPDFlibPas ไลบรารี PDF สำหรับ Delphi และ C++Builder ควบคู่ไปกับพื้นผิว API การ render หน้า, annotation และฟิลด์ฟอร์มที่เหลือ