Bài viết kỹ thuật

Trình xem PDFium Delphi: Bộ nhớ đệm kết xuất và Chiến thuật thu phóng mượt

Hãy giữ nút zoom trong một trình xem PDF ngây thơ và theo dõi biểu đồ CPU. Một lần nhấn của một điều khiển zoom tự lặp lại kích hoạt hàng chục bước zoom mỗi giây, và nếu mỗi bước khởi động một lần render lại chất lượng đầy đủ của trang đang hiển thị, các lần render sẽ chồng chất nhanh hơn tốc độ chúng hoàn thành. Trang render riêng lẻ thì ổn, có lẽ mất 180 ms cho một bản quét A4, nhưng giờ bạn đang chạy hàng chục lần render 180 ms cho công việc mà người dùng đã bỏ qua từ lâu. Trình xem bị đơ, một lõi CPU dính ở mức 100%, và đến khi màn hình bắt kịp thì người dùng đã dừng lại ở một mức zoom cách đó bốn lần render. Phương thuốc không phải là một bộ raster nhanh hơn. Đó là một cache trả về các trang đã hoàn thành ngay lập tức và một vòng lặp render sẵn sàng từ bỏ công việc ngay khi nó trở nên lỗi thời

PDFium Component trao cho bạn các thành phần cho cả hai việc đó và đứng ngoài phần chính sách. Bạn nhận được các bitmap do bên gọi sở hữu, một renderer tiệm tiến nhận một token hủy, các chế độ khớp tính lại zoom khi đổi kích thước, và một lệnh gọi tiling cho các trang quá lớn để render trọn vẹn. Điều nó cố tình không cung cấp chính là bộ nhớ đệm, bởi vì chính sách loại bỏ đúng đắn phụ thuộc vào viewport của bạn, trần bộ nhớ của nền tảng của bạn, và cách người dùng của bạn cuộn trang. Đó là quyết định của bạn cần làm cho đúng, và hậu quả của việc làm sai chính là sự đơ máy và rò rỉ bộ nhớ đó

Milliseconds và megabyte đi đâu

Hãy đặt con số vào chi phí trước khi thiết kế bất cứ điều gì. Một trang A4 ở 96 DPI có kích thước khoảng 794 x 1123 pixel, tức khoảng 3,5 MB dưới dạng bitmap 32-bit. Zoom lên 200% thì con số đó tăng gấp bốn. Ở 400% trên màn hình DPI cao, bạn đang cấp phát và lấp đầy một bitmap trang duy nhất từ 50 đến 60 MB, và một trình xem cuộn liên tục giữ nhiều trang sống cùng lúc. Chi phí raster hóa theo dõi số pixel đầu ra, vì vậy mỗi lần gấp đôi mức zoom sẽ làm cả thời gian render lẫn bộ nhớ tăng gấp bốn cùng nhau

Hai hệ quả rơi thẳng ra từ phép tính đó. Một cache mà khóa của nó bỏ qua mức zoom là vô giá trị, bởi vì chính cử chỉ mà nó cần tăng tốc, việc zoom, lại tạo ra một bitmap mới mỗi lần. Và một cache không giới hạn sẽ làm một tiến trình 32-bit hết không gian địa chỉ chính xác trên những tài liệu mà người dùng zoom mạnh nhất: các bản quét giấy chứng nhận dày đặc, bản vẽ kỹ thuật, bản đồ khổ lớn. Cache phải được đánh khóa đúng cách và giới hạn chắc chắn, và không điều nào là tùy chọn

Cái gì thuộc về khóa của cache

Một bitmap đã cache chỉ an toàn để tái sử dụng khi mọi đầu vào đã định hình các pixel của nó vẫn còn khớp. Điều đó có nghĩa là số trang, mức zoom hiệu dụng (hoặc tương đương là kích thước pixel đầu ra), góc xoay, DPI màn hình, và các tùy chọn render đang có hiệu lực khi nó được tạo ra. Một trang được render với reAnnotations là một hình ảnh khác so với cùng trang đó không có chúng, và một lượt grayscale qua reGrayscale lại khác nữa. Bỏ đi bất kỳ yếu tố nào trong số này khỏi khóa và các lỗi trở nên có thể đoán trước: một lớp phủ chú thích còn sót lại sau khi người thẩm định đã xóa bình luận, hoặc một trang bỗng trở nên mờ ngay khi người dùng kéo cửa sổ từ màn hình laptop sang màn hình 4K ngoài và DPI thay đổi bên dưới một bitmap đã cũ

Lượt tra cache render PDFium trong một viewer Delphi, nơi khóa cache hợp thành trang, zoom, xoay, DPI màn hình, và các tùy chọn render, một cú trúng trả bitmap trong vài micro giây, và việc đuổi cache giải phóng mọi bitmap bị bỏ
Khóa cache phủ mọi đầu vào tạo hình pixel, và eviction giải phóng các bitmap nó loại bỏ
function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // cache giờ đây sở hữu bitmap này
end;

Khi trúng cache, thao tác này trả về trong vài micro giây, đó chính là toàn bộ ý nghĩa. Câu hỏi khó hơn là chuyện gì xảy ra với các bitmap bị loại khỏi cache, và điều đó hóa ra là một câu hỏi về việc ai sở hữu chúng

Ai giải phóng bitmap

Dạng hàm của RenderPage trả về một TBitmap mà bên gọi sở hữu. Trong một lần xuất khẩu một lượt, quyền sở hữu đó rõ ràng và dễ tuân thủ. Bên trong một cache, nó trở thành lỗi rò rỉ phổ biến nhất trong các trình xem PDF Delphi, bởi vì dictionary giờ giữ tham chiếu duy nhất đến từng bitmap, và một TDictionary thông thường chỉ giải phóng khóa và giá trị cho bạn nếu chúng là kiểu managed. Một TBitmap thì không phải vậy. Loại bỏ một mục mà không gọi Free và các pixel vẫn còn được cấp phát mà không có gì trỏ tới chúng

Lý do điều này lọt lưới là vì thời điểm. Một bài kiểm thử smoke test mười phút không bao giờ zoom đủ nhiều trang khác biệt để nhận ra; rò rỉ chỉ lộ diện sau khi ai đó đã cuộn và zoom một tài liệu dài trong vài giờ, lúc đó tiến trình đang giữ hàng trăm bitmap trang mồ côi và máy bắt đầu paging. Đó là lý do việc loại bỏ thuộc về phiên bản đầu tiên của cache, không phải một phiên bản sau này. Đặt giới hạn cho cache theo số byte ước tính, được tính bằng chiều rộng nhân chiều cao nhân bốn, loại bỏ các trang ít được dùng gần đây nhất nằm ngoài viewport và cửa sổ prefetch, và giải phóng mọi bitmap khi bạn loại bỏ nó. Đối với các lần vẽ thực sự thoáng qua, các overload render vào một TBitmap do bên gọi cung cấp hoặc thẳng vào một HDC cho phép bạn bỏ qua hoàn toàn điệu nhảy quyền sở hữu. Xem trước khi in là trường hợp hiển nhiên, vì bạn render mỗi tờ một lần và việc cache nó không mang lại gì

Render tiệm tiến và hủy trung thực

Các overload RenderPage thông thường sẽ khóa cho đến khi trang hoàn thành, đó chính xác là hành vi bạn không muốn trong khi người dùng vẫn đang di chuyển điều khiển zoom. Vì vậy bạn tìm đến RenderPageProgressive. Nó nhận một IPdfCancellationToken và trả về một trong các giá trị prsDone, prsCancelled, hoặc prsFailed. Chi tiết hành vi khiến người ta mắc bẫy là việc hủy không xảy ra tức thì. Token được thăm dò tại ranh giới các khối bên trong quá trình render, vì vậy một token bạn báo hiệu giữa chừng một khối chỉ có hiệu lực khi khối đó hoàn tất. Trên một trang phức tạp, độ trễ giữa lúc yêu cầu và lúc dừng lại có thể lên đến hàng chục milliseconds. Hãy thiết kế xoay quanh khoảng trễ đó thay vì ước gì nó không tồn tại: hủy token trước đó ngay khoảnh khắc một giá trị zoom mới đến, nhưng đừng cho rằng lần render cũ dừng lại ngay khi bạn yêu cầu

Dòng thời gian render tiến bộ PDFium trong Delphi, nơi mỗi yêu cầu zoom mới hủy token trước đó, việc hủy đậu tại một ranh giới chunk, các render bị vượt qua trả prsCancelled, và lần thử cuối cùng trả prsDone
Mỗi yêu cầu zoom mới hủy token render trước đó, và việc hủy đáp tại một biên chunk
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // từ bỏ lần render trước đang chạy dở
  FTokenSource := TPdfCancellationTokenSource.New;  // unit FPdfAsync

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // đã bị thay thế bởi một yêu cầu mới hơn: bỏ qua âm thầm
    prsFailed:    ShowRenderFailure;
  end;
end;

Trong quá trình tương tác, prsCancelled là kết quả bình thường, không phải ngoại lệ. Hầu hết các lần render mà một cử chỉ zoom khởi động sẽ bị thay thế trước khi hoàn thành, vì vậy hãy coi việc hủy là thường lệ và bỏ qua kết quả một cách âm thầm. Một hàng đợi render ghi log mỗi lần hủy như một cảnh báo sẽ chôn vùi lỗi thực sự quan trọng dưới hàng nghìn dòng nhiễu. Để màn hình không trông như bị chết trong khi lần render thật đang chạy, hãy kết hợp đường tiệm tiến với một vật thay thế rẻ tiền: phóng to lại bitmap đã cache trước đó lên mức zoom mới và trình bày nó ngay lập tức. Nó trông hơi mờ trong khoảng một hoặc hai trăm milliseconds, nhưng cảm giác như tức thì, và nó mua cho lần render chất lượng đầy đủ khoảng thời gian nó cần để hoàn thành hoặc bị hủy bởi cử chỉ tiếp theo

Chế độ khớp trang mà zoom âm thầm tắt đi

Thuộc tính FitMode của trình xem, đặt thành pfmFitPage hoặc pfmFitWidth, tính lại mức zoom ở mỗi lần đổi kích thước để trang tiếp tục vừa khít khi cửa sổ thay đổi. Điểm mắc là việc gán trực tiếp Zoom sẽ đặt lại FitMode về pfmNone. Là một mặc định thì điều đó đúng: một người dùng cố tình gõ 150% không muốn lần đổi kích thước cửa sổ tiếp theo vứt bỏ nó đi. Nhưng nó gây bất ngờ cho bất kỳ ai kết nối một nút zoom-in dưới dạng Zoom := Zoom * 1.25 rồi không hiểu tại sao fit-to-width ngừng phản hồi sau lần nhấp đầu tiên. Nếu thanh công cụ của bạn cung cấp cả zoom rõ ràng lẫn các chế độ khớp, bạn phải tự nhớ lựa chọn khớp cuối cùng của người dùng và gán lại nó khi họ nhấn nút khớp lần nữa. Component sẽ không tự khôi phục một chế độ mà một phép gán zoom vừa xóa, và nó không được thiết kế để làm vậy

Một ngân sách bộ nhớ bạn có thể bảo vệ

Một ngân sách bạn có thể viết ra là một ngân sách bạn có thể lập luận bảo vệ trong một buổi review mã, vì vậy hãy bắt đầu từ một kịch bản cụ thể. Giả sử cuộn liên tục giữ trang đang hiển thị cộng với một trang prefetch phía trên và phía dưới, cùng với một dải hình thu nhỏ. Ở mức 100% trên màn hình 96-DPI, ba bitmap kích thước đầy đủ đó tốn khoảng 3,5 MB mỗi cái, không đáng kể. Ở mức 300% trên màn hình 4K, cùng ba bitmap đó tốn khoảng 30 MB mỗi cái, và đó là trước khi cache đã giữ lại dù chỉ một trang lịch sử. Sự tăng trưởng nằm ở cử chỉ, không phải ở tài liệu

Phép tính bộ nhớ bitmap PDFium cho một viewer Delphi, nơi mỗi lần zoom gấp đôi làm bộ nhớ trang gấp bốn, cuộn liên tục giữ ba trang sống, một ngân sách LRU có trần bảo vệ cache, và RenderTile xử lý các bản vù vượt cỡ
Mỗi lần zoom gấp đôi nhân bốn bộ nhớ bitmap, nên cache cần một trần cứng và các ô tile cho những trang quá cỡ

Một mặc định hợp lý cho một tiến trình Delphi 32-bit là ngân sách bitmap 256 MB dưới cơ chế loại bỏ LRU. Trên 64-bit bạn có thể mở rộng theo RAM vật lý, nhưng vẫn giữ một trần cứng dù thế nào đi nữa, bởi vì lỗi bạn đang phòng ngừa không phải là tiến trình của bạn bị crash. Đó là toàn bộ máy tính vật lộn với file phân trang của nó trong khi trình xem của bạn về mặt kỹ thuật vẫn đang chạy và người dùng thắc mắc tại sao mọi thứ khác lại chậm lại. Một trần cứng thất bại một cách có thể đoán trước; một cache không giới hạn thất bại bằng cách kéo theo cả màn hình desktop. Hình thu nhỏ xứng đáng có cách xử lý riêng: render mỗi hình một lần ở kích thước mục tiêu nhỏ của nó và giữ nó trong một pool riêng biệt mà logic LRU không bao giờ chạm tới. Tái tạo một hình thu nhỏ 120 pixel bằng cách thu nhỏ một bitmap trang đầy đủ 60 MB là cách lãng phí nhất có thể để tạo ra một con tem

Một số trang đơn lẻ đánh bại mọi ngân sách. Một bản vẽ kỹ thuật khổ E hoặc một bản đồ lớn được render trọn vẹn ở mức 400% là một lần cấp phát hàng trăm megabyte, và không có chính sách loại bỏ nào khiến điều đó chấp nhận được. Câu trả lời ở đây là ngừng render toàn bộ trang. RenderTile chỉ raster hóa vùng tại offset pixel (Left, Top) bên trong một trang được coi là thu phóng theo khái niệm thành PageWidth nhân PageHeight, vì vậy bạn chỉ render hình chữ nhật đang hiển thị cộng với một biên một ô để lướt mượt mà, và bạn gộp các offset của ô vào khóa cache cùng với zoom. Giữ kích thước ô cố định trong suốt tài liệu. Các ô cố định có nghĩa là một thay đổi DPI làm mất hiệu lực toàn bộ lưới một cách gọn gàng, trong khi các ô có kích thước thay đổi khiến bạn phải đuổi theo các đường nối lộ rõ giữa các vùng được render ở các tỷ lệ hơi khác nhau

Hai tính năng liền kề âm thầm cộng dồn vào tất cả những điều này. Các lượt lọc màu như grayscale hoặc đảo ngược màu chạy sau khi render và tạo ra một bitmap kích thước đầy đủ thứ hai mỗi lần, làm gấp đôi dung lượng mỗi trang của bất kỳ khung nhìn nào sử dụng chúng; chi phí đó là chủ đề của lọc màu cho người thị lực kém trong trình xem PDF Delphi. Và một trình xem đánh dấu các từ trong khi chuyển văn bản thành giọng nói làm mất hiệu lực khung nhìn đã render ở mỗi từ được đọc, vì vậy sự tương tác giữa các lần vẽ lại phần đánh dấu và tốc độ giọng nói quan trọng hơn thoạt nhìn, như được đề cập trong đánh dấu TTS từng từ một

Các overload render, các mã trạng thái tiệm tiến, và bản thân component trình xem được ghi lại trên trang sản phẩm cho PDFium Component