Bài viết kỹ thuật

Tái nhập OnExit trong một bộ chỉnh sửa biểu mẫu tại-chỗ Delphi

Điều khiển TPDFlibViewer của PDFlibPas commit một bộ chỉnh sửa trường biểu mẫu tại-chỗ thông qua chính sự kiện OnExit của bộ chỉnh sửa, và lựa chọn thiết kế đó ẩn giấu một cạm bẫy VCL Delphi kinh điển: việc ẩn, đổi cha, hay hủy một điều khiển đang có focus từ bên trong chính handler OnExit của nó có thể kích hoạt OnExit lần thứ hai trước khi lệnh gọi đầu tiên trả về, đẩy logic commit quay ngược vào chính nó

Lỗi mà điều này tạo ra chống lại việc tái tạo sạch sẽ. Một người dùng tab nhanh qua một loạt trường văn bản trên một biểu mẫu ứng tuyển đã scan, và thỉnh thoảng trình xem ném ra một access violation, hoặc tệ hơn, tiếp tục chạy trong khi âm thầm viết sai giá trị vào một trường hai lần tab về trước. Tái tạo nó theo yêu cầu và lỗi trông hiển nhiên khi nhìn lại; truy tìm nó từ một báo cáo crash của một khách hàng đơn lẻ và nó trông như một bóng ma, vì việc OnExit thứ hai có thực sự phát ra hay không phụ thuộc vào định thời window-handle và focus thay đổi theo loại trường, tốc độ gõ, và bất cứ điều gì khác message queue đang làm tại khoảnh khắc đó

TPDFlibViewer đặt một bộ chỉnh sửa thật lên trên một trang đã render như thế nào

TPDFlibViewer render mỗi trang PDF thành một bitmap và không biến các trường biểu mẫu thành điều khiển VCL sống theo mặc định, nên BeginEditFormField là phương thức nối cầu hai thế giới: được gọi với một chỉ số trường, nó tra cứu hình chữ nhật của trường và chuyển đổi nó thành tọa độ client, sau đó thả một TEdit hay TMemo thật lên trên hình chữ nhật đó cho một trường văn bản, hay một TComboBox ở kiểu csDropDownList cho một trường lựa chọn, đầy đủ với giá trị hiện tại của trường đã nạp sẵn. ISO 32000-2 §12.7 định nghĩa một trường biểu mẫu văn bản hay lựa chọn bên trong một PDF là gì, nhưng không có gì trong đặc tả đó nói cách một ứng dụng Windows nên để ai đó gõ vào một trường như vậy, và khoảng trống đó chính xác là điều BeginEditFormField tồn tại để lấp đầy. Cả OnKeyDown lẫn OnExit đều được nối với cùng hai phương thức trình xem, InplaceEditorKeyDown và InplaceEditorExit, trên mỗi bộ chỉnh sửa TPDFlibViewer tạo ra, một sự ghép cặp không đổi kể từ khi việc điền biểu mẫu tương tác lần đầu xuất hiện trong v3.220.0, và OnExit là nơi rắc rối bắt đầu

Vì sao việc ẩn bộ chỉnh sửa kích hoạt OnExit lần thứ hai?

TWinControl trong VCL coi một thay đổi ở Visible hay Parent trên một điều khiển đang có focus là một lý do để di chuyển focus khỏi nó ngay lập tức, và di chuyển focus khỏi một điều khiển chính xác là điều kích hoạt sự kiện OnExit của điều khiển đó, đồng bộ, trước cả khi phép gán thuộc tính đã kích hoạt nó trả về. CommitInplaceEditor, phương thức PDFlibPas dùng để đóng bộ chỉnh sửa tại-chỗ và ghi giá trị của nó trở lại trường biểu mẫu, cần làm chính xác hai điều đó trên đường ra: đặt Editor.Visible thành False và đặt Editor.Parent thành nil để điều khiển ngừng vẽ lên trên trang và ngừng nhận đầu vào. Làm một trong hai điều đó trong khi bộ chỉnh sửa vẫn còn focus, thứ nó gần như luôn có vì người dùng vừa rời khỏi nó, và OnExit phát ra lần nữa giữa chính lệnh gọi lẽ ra là thứ cuối cùng OnExit của bộ chỉnh sửa đó từng kích hoạt

Điều gì sai khi CommitInplaceEditor tái nhập chính nó?

Một phương thức commit ngây thơ trả giá cho điều này theo một trong hai cách. Hoặc nó viết giá trị của trường hai lần, một lần từ lệnh gọi gốc và một lần từ lệnh gọi tái nhập lén lút vào trước khi lệnh gọi đầu tiên hoàn tất việc đụng vào trạng thái riêng của nó, hoặc nó cố giải phóng điều khiển bộ chỉnh sửa trong khi một frame xa hơn dưới call stack vẫn đang ở bên trong chính handler sự kiện của điều khiển đó, một lãnh thổ không xác định trong VCL và lộ ra như một access violation có thể trỏ vào gần như bất kỳ dòng nào, không nhất thiết là dòng thực sự gây ra nó. Không lỗi nào trong hai lỗi cần một biểu mẫu lớn để kích hoạt; một tài liệu hai-trường là đủ, miễn là người dùng rời khỏi trường thứ hai đủ nhanh để OS vẫn đang unwind các message focus từ trường đầu tiên

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

Đặt tham chiếu về nil trước khi bạn đụng vào điều khiển

Bản sửa PDFlibPas phát hành là một lượt sắp xếp lại duy nhất: chụp bộ chỉnh sửa vào một biến cục bộ, xóa trường trỏ vào nó, và chỉ sau đó mới bắt đầu thay đổi các thuộc tính của điều khiển. CommitInplaceEditor đọc FInplaceEditor vào một biến cục bộ Editor, đặt FInplaceEditor thành nil ngay lập tức, và chỉ sau đó mới gán Editor.Visible và Editor.Parent. Một lệnh gọi tái nhập được kích hoạt bởi một trong hai phép gán đó tự đọc FInplaceEditor, thấy nó đã là nil, và thoát ngay tại dòng đầu tiên của nó, trước khi nó có thể đụng vào Editor hay viết giá trị của trường lần thứ hai

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 trong đoạn liệt kê đó thay cho nhánh thật, kiểm tra liệu Editor có phải một TComboBox, một TMemo, hay một TEdit và đọc giá trị của nó tương ứng, vì PDFlibPas tạo một điều khiển khác nhau tùy theo trường đó là một trường văn bản hay một trường lựa chọn. Sự bảo vệ không quan tâm nhánh nào chạy, chỉ quan tâm FInplaceEditor là nil trước khi bất cứ thứ gì có khả năng kích hoạt OnExit thực thi, đó là ràng buộc thứ tự duy nhất khiến phần còn lại của phương thức an toàn để viết theo bất kỳ phong cách nào khác tự nhiên

Không bao giờ giải phóng một điều khiển từ bên trong chính sự kiện của nó

TPDFlibViewer.CommitInplaceEditor không bao giờ gọi Editor.Free trực tiếp, và điều đó có chủ đích: việc giải phóng một điều khiển không an toàn trong khi một stack frame thuộc về chính lượt phân phối sự kiện của điều khiển đó có thể vẫn đang unwind phía trên lệnh gọi giải phóng nó, dù OnExit tái nhập hay không. Thay vào đó, PDFlibPas đưa bộ chỉnh sửa đã tách rời cho một điểm đỗ một-slot, FDeadEditor, giải phóng bất cứ thứ gì đang ngồi ở đó từ chu kỳ chỉnh sửa trước qua một hàm hỗ trợ nhỏ, ReapDeadEditor, được gọi ở đầu BeginEditFormField tiếp theo và một lần nữa từ CloseDocument; mỗi bộ chỉnh sửa trình xem tạo ra cũng được sở hữu bởi chính trình xem, TEdit.Create(Self) thay vì TEdit.Create(nil), nên ngay cả một điều khiển vẫn đang đỗ trong FDeadEditor khi trình xem bị hủy cũng được quét dọn bởi quyền sở hữu component VCL thông thường thay vì rò rỉ

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;

Vì sao điều này lộ rõ nhất trong lúc điều hướng Tab nhanh?

FocusNextFormField, phương thức PDFlibPas thêm vào trong v3.226.0 để điều khiển điều hướng Tab và Shift+Tab qua một biểu mẫu, gọi BeginEditFormField cho trường đủ điều kiện tiếp theo ở mỗi bước nhảy, và BeginEditFormField mở đầu bằng cách gọi CommitInplaceEditor(True) để xả bất cứ bộ chỉnh sửa nào trường trước để mở. Điều đó có nghĩa là mỗi lần nhấn Tab người dùng thực hiện trong khi điền một biểu mẫu nhiều trường chạy đúng chuỗi tách-rồi-đụng được mô tả ở trên một lần, chính xác là đường code có khả năng nhất vẫn còn một điều khiển thực sự có focus tại thời điểm Visible và Parent thay đổi, vì Tab là tương tác duy nhất gần như chắc chắn để lại bộ chỉnh sửa đi ra giữ focus cho đến khi bộ mới yêu cầu nó

Không điều nào trong số này khiến lỗi trở nên đáng tin cậy để minh họa, và điều đó đáng nói thẳng thắn thay vì lướt qua. Việc một phép gán Visible hay Parent cho trước có thực sự buộc một OnExit đồng bộ hay không phụ thuộc vào trạng thái focus và window-handle mà một debugger thay đổi chỉ bằng cách được gắn vào, mà một lượt vẽ lại hay timer không liên quan có thể làm nhiễu loạn, và hành xử khác nhau tùy theo TEdit, TMemo, hay TComboBox nào tình cờ là điều khiển đang chơi. Một sự bảo vệ chỉ đôi khi được thực hiện là lý do loại khiếm khuyết này sống sót qua cả code review lẫn test thủ công như nhau, và đó cũng là lý do bản sửa phải đúng theo cấu trúc, đặt tham chiếu về nil trước khi bất cứ điều gì khác xảy ra, thay vì đúng theo bất cứ hành vi nào một vài lượt test thủ công tình cờ quan sát được

Hình dạng tổng quát của bản sửa này vươn xa hơn nhiều so với một điều khiển trình xem. Bất kỳ bề mặt chỉnh sửa tùy chỉnh nào được xây dựng bằng cách xếp một điều khiển VCL sống lên trên nội dung đã render, không chỉ một trường biểu mẫu PDF, kế thừa cùng mối nguy hiểm ngay khi logic đóng-và-commit của nó có thể được kích hoạt bởi cả một hành động người dùng tường minh lẫn một thay đổi focus ngầm định, và cùng câu trả lời hai phần áp dụng: xóa tham chiếu nhận diện điều khiển đang hoạt động trước khi làm bất cứ điều gì có thể kích hoạt sự kiện thoát riêng của nó, và không bao giờ gọi Free từ một đường code có thể vẫn đang chạy bên dưới chính lượt phân phối sự kiện của điều khiển đó. Bề mặt điền biểu mẫu và render rộng hơn của TPDFlibViewer, bao gồm cách nó quyết định loại điều khiển nào để hiển thị cho trường nào, được nói đến trong tổng quan về xây dựng một điều khiển trình xem PDF tương tác trong Delphi VCL với PDFlibPas, và cache bitmap trang mà SetFormFieldValueAndRefresh phải làm mất hiệu lực ở mỗi lượt chỉnh sửa đã commit được nói đến riêng trong bài viết về cache trang trên đĩa theo DPI từng màn hình của trình xem

Việc chỉnh sửa trường biểu mẫu tại-chỗ, điều hướng trường điều khiển bởi Tab, và đường commit an-toàn-với-tái-nhập đứng sau cả hai là một phần của điều khiển trình xem tương tác đi kèm với PDFlibPas, thư viện PDF dành cho Delphi và C++Builder, cùng với phần còn lại của bề mặt API render trang, chú thích, và trường biểu mẫu của nó