Bài viết kỹ thuật

Kết xuất lũy tiến có thể hủy bỏ của PDF trong Delphi (PDFium)

Hầu hết các trang PDF rasterise (tạo ảnh điểm) trong vài mili giây và bạn không bao giờ phải nghĩ về nó. Sau đó, một người dùng mở một bản vẽ kỹ thuật A1, một trang chứa đầy hàng chục nghìn nét vẽ vector, hoặc một tấm áp phích chứa đầy các nhóm độ trong suốt (transparency) và các lớp mặt nạ mềm (soft masks), và lệnh gọi duy nhất vẽ ra nó mất hai hoặc ba giây. Nếu lệnh gọi đó chạy trên luồng giao diện người dùng (UI thread), cửa sổ sẽ ngừng vẽ lại, thanh tiêu đề chuyển sang màu xám và hệ điều hành đề nghị hủy ứng dụng. Công việc này là hợp lệ. Trang đó thực sự cần nhiều thời gian như vậy. Khuyết điểm nằm ở chỗ việc kết xuất là một lệnh gọi chặn duy nhất, không thể chia nhỏ với không có cách nào để ngoi lên lấy không khí và không có cách nào để dừng lại

Bài viết này nói chính xác về một trong hai vấn đề đó: hủy bỏ một quá trình kết xuất trang đơn kéo dài mà không làm đóng băng giao diện người dùng. Người dùng đã nhấp vào trang tiếp theo, hoặc phóng to, hoặc đóng tài liệu, và quá trình kết xuất đang diễn ra giờ là một công việc vô ích nên kết thúc ở cơ hội tiếp theo thay vì chạy cho đến khi hoàn thành. Việc làm mượt mà quá trình cuộn và phóng to bằng cách lưu trữ bộ nhớ cache (caching) những gì đã được tạo ảnh điểm là một mối quan tâm riêng với thiết kế riêng của nó, được đề cập trong bài viết đồng hành có liên kết ở cuối. Ở đây câu hỏi duy nhất là làm thế nào để làm cho một quá trình kết xuất lũy tiến phản hồi lại một yêu cầu hủy bỏ một cách nhanh chóng và sạch sẽ

API kết xuất lũy tiến mà PDFium đã cung cấp sẵn

PDFium đã dự đoán trước một nửa làm đóng băng của vấn đề. Cùng với FPDF_RenderPageBitmap dùng một lần, nó phơi bày một biến thể lũy tiến chia một trang thành các khối công việc. Bạn gọi FPDF_RenderPageBitmap_Start một lần để thiết lập quá trình kết xuất vào một bitmap đích, sau đó gọi đi gọi lại FPDF_RenderPage_Continue. Mỗi lần gọi Continue rasterise một lát cắt có giới hạn và trả về một trạng thái. FPDF_RENDER_TOBECONTINUED có nghĩa là có thêm việc để làm, FPDF_RENDER_DONE có nghĩa là trang đã hoàn thành, và FPDF_RENDER_FAILED có nghĩa là nó đã dừng lại do một lỗi. Khi vòng lặp kết thúc, bạn gọi FPDF_RenderPage_Close để giải phóng trạng thái lũy tiến trên mỗi trang. Vì quyền điều khiển được trả lại cho mã của bạn giữa các lát cắt, bạn có thể bơm thông báo (pump messages), cập nhật chỉ báo tiến trình, hoặc kiểm tra xem công việc có còn được mong muốn nữa hay không

Cơ chế mà PDFium cung cấp cho việc quyết định khi nào nên nhường quyền (yield) là một cấu trúc callback (struct) có tên IFSDK_PAUSE. Bạn giao nó cho Start và cho mọi Continue. Sau mỗi khối, PDFium gọi con trỏ hàm NeedToPauseNow của nó, và nếu nó trả về một giá trị khác không, Continue hiện tại dừng sớm và trao lại quyền điều khiển với FPDF_RENDER_TOBECONTINUED. Cấu trúc này cũng mang một trường version, phải được đặt bằng 1, và một con trỏ user dạng tự do mà PDFium không bao giờ chạm tới và truyền qua nguyên vẹn. Con trỏ nguyên vẹn đó chính là bản lề của toàn bộ thiết kế theo sau

Đổi mục đích sử dụng tạm dừng (pause) thành hủy bỏ (cancel)

Mục đích ban đầu của NeedToPauseNow là phân chia thời gian (time-slicing). Trả về khác không khi ngân sách khung (frame) của bạn đã hết, trả về không để tiếp tục kết xuất, và PDFium tạm dừng để bạn có thể làm việc khác trước khi tiếp tục lại cùng một quá trình kết xuất đó. PDFium Component sử dụng lại chính tín hiệu đó cho một động từ khác. Thay vì trả lời "tôi có nên tạm dừng và để bạn tiếp tục lại không", lệnh callback trả lời "công việc này đã bị hủy chưa". Hai điều này ánh xạ lên nhau một cách gọn gàng do những gì vòng lặp làm khi nó nhìn thấy cờ. Một sự tạm dừng thực sự mong đợi một Continue sau đó; một sự hủy bỏ thì không. Khi vòng lặp đang gọi quan sát thấy rằng mã thông báo bị hủy, nó đóng bối cảnh kết xuất và không bao giờ gọi Continue nữa, vì vậy cùng một giá trị trả về khác không mà PDFium đọc là "dừng khối này lại" thực tế trở thành, "dừng hẳn"

Sự hủy bỏ được biểu thị thông qua một giao diện, IPdfCancellationToken, thuộc tính IsCancelled của nó chuyển từ false sang true khi một phần khác của chương trình yêu cầu quá trình kết xuất dừng lại. Cây cầu nối giữa giao diện Pascal đó và lệnh callback C của PDFium là một con trỏ duy nhất. Tham chiếu giao diện của mã thông báo (token) được ghi vào IFSDK_PAUSE.user, và một hàm tĩnh cdecl callback đọc lại nó và truy vấn nó. Đây là vấn đề kinh điển của việc cho phép một thư viện C gọi ngược vào Pascal: lệnh callback phải là một hàm thuần túy với quy ước gọi C, không phải là một phương thức, vì PDFium lưu trữ và gọi một con trỏ hàm trần mà không biết gì về đối tượng Pascal hay Self

type
  TPdfProgressivePause = record
    Pause: IFSDK_PAUSE;            // PDFium reads this; .user holds the token
    Token: IPdfCancellationToken; // strong ref keeps the token alive
  end;

function ProgressivePauseCallback(pThis: PIFSDK_PAUSE): FPDF_BOOL; cdecl;
var
  Token: IPdfCancellationToken;
begin
  Result := 0;
  if (pThis = nil) or (pThis^.user = nil) then
    Exit;
  Token := IPdfCancellationToken(pThis^.user);
  if Token.IsCancelled then
    Result := 1; // non-zero: PDFium stops this chunk
end;

Lệnh callback khôi phục mã thông báo bằng cách ép kiểu (casting) pThis^.user trở lại loại giao diện và đọc IsCancelled. Không có gì trong đó thực hiện cấp phát, khóa (lock), hoặc chặn (block), điều này rất quan trọng vì PDFium gọi nó trên luồng kết xuất (rendering thread) sau mỗi khối và bất kỳ công việc nào được thực hiện ở đây đều được cộng thêm vào chi phí của chính quá trình kết xuất. Cơ chế bảo vệ chống lại cấu trúc nil hoặc một trường user nil có nghĩa là cùng một hàm vẫn an toàn để cài đặt ngay cả trên một tiến trình kết xuất chưa bao giờ được cung cấp mã thông báo thực sự

Giữ cho mã thông báo tồn tại xuyên suốt vòng lặp

Ép kiểu một con trỏ giao diện qua một Pointer thô và trở lại là nơi khởi sinh các lỗi về thời gian sống. Một IInterface trong Delphi được đếm tham chiếu (reference counted), và số lượng đếm chỉ thay đổi khi trình biên dịch có thể thấy một biến kiểu giao diện đang được gán. Việc lưu trữ mã thông báo chỉ như một con trỏ trần bên trong IFSDK_PAUSE.user sẽ hoàn toàn ẩn nó khỏi bộ đếm tham chiếu. Nếu tham chiếu duy nhất khác tới mã thông báo đó ra khỏi phạm vi (out of scope) trong khi vòng lặp Continue vẫn đang chạy, đối tượng sẽ được giải phóng ngay bên dưới lệnh callback, và khối tiếp theo sẽ tham chiếu ngược (dereference) một con trỏ lơ lửng (dangling pointer)

Đó là lý do tại sao bộ mô tả là một bản ghi chứa hai thứ chứ không phải một. Trường Pause là cấu trúc mà PDFium đọc. Trường Token là một tham chiếu kiểu giao diện thực sự mà trình biên dịch đếm, và nó tồn tại không vì lý do nào khác ngoài việc ghim mã thông báo trong bộ nhớ chừng nào bản ghi còn tồn tại. Bản ghi này là một biến cục bộ trên ngăn xếp (stack) của thủ tục kết xuất, vì vậy nó vẫn hợp lệ trong suốt thời gian của vòng lặp và chỉ bị xé bỏ khi thủ tục thoát ra. Con trỏ trần trong user và tham chiếu được đếm trong Token gọi tên cùng một đối tượng; một cái là những gì PDFium có thể đọc, cái kia là thứ giữ cho đối tượng đó khỏi bị thu gom

var
  Pause: TPdfProgressivePause;
  EffectiveToken: IPdfCancellationToken;
begin
  // ... choose EffectiveToken ...

  // Strong ref first, then publish the same object to PDFium via .user.
  Pause.Token := EffectiveToken;
  Pause.Pause.version := 1;
  Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
  Pause.Pause.user := Pointer(EffectiveToken);

Đóng bối cảnh kết xuất bất kể vòng lặp kết thúc như thế nào

Mỗi lệnh gọi tới FPDF_RenderPageBitmap_Start cấp phát trạng thái lũy tiến mà PDFium liên kết với trang, và trạng thái đó chỉ được giải phóng bởi FPDF_RenderPage_Close. Có ba cách ra khỏi vòng lặp điều khiển. Trang kết thúc và trạng thái cuối cùng là FPDF_RENDER_DONE. Mã thông báo vấp (trips) và vòng lặp thoát sớm báo cáo sự hủy bỏ. Một cái gì đó thất bại và trạng thái là FPDF_RENDER_FAILED. Cả ba phải gọi Close, và đường đi của việc hủy bỏ là dễ làm sai nhất, vì hình dáng tự nhiên của việc "thấy hủy bỏ, thoát ra ngoài" có xu hướng bỏ qua việc dọn dẹp trên đường đến lối thoát. Để lại Close không được tiếp cận sẽ làm rò rỉ trạng thái của mỗi trang, và một trình xem cho phép người dùng hủy hết lần này đến lần khác sẽ tích lũy rò rỉ đó trên mọi trang bị hủy bỏ

Hình dạng mạnh mẽ đưa vòng lặp và việc phân loại kết quả vào trong một lệnh tryFPDF_RenderPage_Close vào lệnh finally tương ứng. Bitmap đích bị hủy trong cùng một khối. Sự hủy bỏ có thể thoát khỏi vòng lặp thông qua một lệnh Exit sớm và lệnh finally vẫn chạy, do đó chỉ có đúng một nơi giải phóng trạng thái lũy tiến và nó không thể bị bỏ qua

Status := FPDF_RenderPageBitmap_Start(PdfBmp, FPage, Left, Top,
  Width, Height, Ord(Rotation), EncodeRenderOptions(Options), Pause.Pause);
try
  while Status = FPDF_RENDER_TOBECONTINUED do
  begin
    if EffectiveToken.IsCancelled then
    begin
      Result := prsCancelled;
      Exit;
    end;
    Status := FPDF_RenderPage_Continue(FPage, Pause.Pause);
  end;

  if EffectiveToken.IsCancelled then
    Result := prsCancelled
  else if Status = FPDF_RENDER_DONE then
    Result := prsDone
  else
    Result := prsFailed;
finally
  // Frees the progressive state Start allocated; mandatory on every path.
  FPDF_RenderPage_Close(FPage);
  FPDFBitmap_Destroy(PdfBmp);
end;

Vòng lặp kiểm tra mã thông báo trước mỗi Continue cũng như dựa vào lệnh callback bên trong nó. Lệnh callback làm ngắn lại khối hiện tại; việc kiểm tra vòng lặp sẽ dừng khối tiếp theo bắt đầu. Cùng với nhau, chúng giới hạn thời gian một lệnh hủy có hiệu lực với mức độ gần bằng thời lượng của một khối

Ba kết quả và bitmap chứa gì sau khi hủy

Điểm vào công khai (public entry point) là TPdf.RenderPageProgressive, và nó trả về một TPdfProgressiveStatus là một trong prsDone, prsCancelled, hoặc prsFailed. Các giá trị này phản ánh các hằng số FPDF_RENDER_* của PDFium trong thuật ngữ Pascal nhưng gói trường hợp hủy bỏ vào như một kết quả hạng nhất chứ không phải là một lỗi

Điểm khiến mọi người bắt gặp là bitmap đích chứa gì sau prsCancelled. Nó không trống rỗng. PDFium kết xuất lũy tiến vào cùng một khối bitmap này qua khối bitmap khác, vì vậy khi lệnh hủy dừng vòng lặp, bitmap sẽ giữ bất cứ thứ gì được vẽ cho đến khoảnh khắc đó, tức là một hình ảnh một phần: một số dải (bands) đã xong, phần còn lại vẫn hiển thị màu tô. Việc kết quả một phần đó có hữu ích hay không tùy thuộc vào người gọi. Một trình xem chuẩn bị vứt bỏ bitmap vì người dùng đã điều hướng tới nơi khác có thể đơn giản là bỏ qua nó. Một trình xem muốn hiển thị bản xem trước với chi phí thấp có thể giữ lại. Những gì bạn không được làm là giả định rằng prsCancelled ngụ ý một bitmap trống hoặc không xác định; nó ngụ ý một ảnh chụp nhanh chân thực về quá trình kết xuất chưa hoàn thành

var
  Bmp: TBitmap;
  Token: IPdfCancellationToken;
  Status: TPdfProgressiveStatus;
begin
  Bmp := TBitmap.Create;
  try
    // Token starts un-cancelled; flip Token.IsCancelled from elsewhere
    // (a UI action, a navigation event) to abort the render in flight.
    Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
    case Status of
      prsDone:      Image1.Picture.Assign(Bmp);  // fully rendered
      prsCancelled: ;                            // partial bitmap, usually discarded
      prsFailed:    ShowMessage('Render failed');
    end;
  finally
    Bmp.Free;
  end;
end;

Mã thông báo nil và đường dẫn gọi lại (callback path) không phân nhánh

Hủy bỏ là tùy chọn (opt-in). Một người gọi chỉ muốn kết xuất lũy tiến để hưởng lợi ích của việc bơm thông báo, không có ý định hủy bỏ, phải có khả năng truyền nil cho mã thông báo. Cách hỗ trợ ngây thơ cho điều đó là rải các phép kiểm tra "nếu có mã thông báo được cung cấp" qua lệnh gọi lại và vòng lặp, điều đó có nghĩa là phân nhánh trên mỗi khối và một lệnh gọi lại phải xử lý cả một mã thông báo thực và sự vắng mặt của nó

Việc triển khai tránh điều đó bằng cách thay thế một đối tượng duy nhất (singleton) khi người gọi không truyền gì. Mã thông báo nil được đổi lấy PdfNoCancellationToken, một giao diện có IsCancelled luôn là false. Kể từ thời điểm đó, lệnh gọi lại và vòng lặp có một mã thông báo để truy vấn trong mọi trường hợp, do đó không cái nào cần kiểm tra nil và không cái nào cần đường dẫn đặc biệt. Mã thông báo không-bao-giờ-hủy (never-cancel token) đơn giản là luôn trả lời false, lệnh gọi lại luôn trả về số không, và quá trình kết xuất chạy đến khi hoàn thành giống hệt như một quá trình không thể hủy bỏ. Hành vi tùy chọn được mô hình hóa dưới dạng một mã thông báo không bao giờ kích hoạt thay vì coi đó là sự vắng mặt của mã thông báo, điều này giữ cho đường dẫn nóng (hot path) đồng nhất

// nil -> never-cancel singleton, so the callback path is identical
// whether or not the caller opted into cancellation.
if AToken <> nil then
  EffectiveToken := AToken
else
  EffectiveToken := PdfNoCancellationToken;

Cấu trúc xuất hiện rất nhỏ và đáng nhắc lại, vì nó là phần có thể tái sử dụng. Một thư viện C hỗ trợ gọi lại cung cấp cho bạn chính xác một kênh để truyền trạng thái vào lệnh gọi lại đó, là con trỏ người dùng mờ (opaque user pointer). Đặt một tham chiếu giao diện Pascal được đếm đằng sau con trỏ đó, giữ cho một tham chiếu thực sự thứ hai tồn tại bên cạnh cấu trúc để đối tượng không thể bị thu gom giữa chừng khi gọi, và đọc giao diện trở lại bên trong một hàm cdecl tĩnh. Bọc toàn bộ vòng lặp điều khiển trong một lệnh try và giải phóng bối cảnh gốc trong finally. Khuôn mẫu tương tự chuyển sang mọi thao tác PDFium lũy tiến hoặc điều khiển bởi lệnh gọi lại trong đó mã Pascal phải tiếp tục kiểm soát thời gian sống trong khi C nắm giữ con trỏ

Hủy bỏ chỉ là một nửa của một trình xem phản hồi (responsive viewer). Nửa kia là không hiển thị lại các trang mà bạn đã vẽ và giữ cho việc phóng to và cuộn được mượt mà bằng cách cung cấp các bitmap được lưu trữ, điều này được đề cập trong bài viết của chúng tôi về lưu bộ nhớ cache kết xuất và hiệu suất thu phóng. Để biết cách kết xuất có thể hủy bỏ phù hợp với một trình xem hoàn chỉnh bên cạnh điều hướng, lựa chọn và tìm kiếm, hãy xem xây dựng một trình xem PDF giàu tính năng với PDFium Component. Quá trình kết xuất lũy tiến được mô tả ở đây có trong PDFium Component cho Delphi và Lazarus cùng với các API tải, kết xuất và biểu mẫu được trình bày ở những nơi khác trên blog này