บทความเทคนิค

คิวการ Render PDF แบบเบื้องหลังใน Delphi ด้วย HotPDF

คลาส THPDFBackgroundRenderer ของ HotPDF เป็นคลาสลูกของ TThread ที่ render หน้า PDF ที่โหลดอยู่ให้เป็นบิตแมปบน worker thread ทำให้ viewer ของ Delphi ยังคง scroll และ repaint ต่อได้ในขณะที่หน้าหนึ่งยังถูก rasterize อยู่เบื้องหลัง THPDFBackgroundRenderer.RequestPage จัดคิวดัชนีหน้าให้ worker thread นั้น CancelAll ทิ้งทุกอย่างที่ยังรออยู่ และ GetCachedBitmap ส่งบิตแมปที่เสร็จแล้วกลับมาซึ่งผู้เรียกเป็นเจ้าของและต้อง free เอง ลอง scroll สัญญาที่สแกนเป็นสองร้อยหน้าที่ความละเอียดสำหรับพิมพ์บน UI thread เพียงอย่างเดียว แล้วทุกครั้งที่เปลี่ยนหน้าจะหยุดหน้าต่างจนกว่า GDI จะวาดมันเสร็จ ซึ่งเป็นอาการกระตุกที่ THPDFBackgroundRenderer มีอยู่เพื่อกำจัดมันออกไป

ทำไมต้อง render หน้า PDF บน background thread เลย

background thread คุ้มค่ากับความซับซ้อนที่เพิ่มขึ้น เพราะตัว render หน้าของ HotPDF เป็นตัวแปลผล content-stream ตัวจริง ไม่ใช่การ copy บิตแมปราคาถูกที่คืนค่าก่อนใครจะสังเกตเห็น มันเดินผ่าน PDF operator, ถือ graphics-state stack และ rasterize path, ภาพ และ glyph ผ่าน GDI ซึ่งเป็น engine เดียวกับที่ครอบคลุมในการ render หน้า PDF ที่โหลดอยู่ให้เป็น TBitmap รันงานนี้แบบ synchronous ภายใน handler ของการ scroll หรือ paint แล้ว message loop จะหยุดสูบข้อความจนกว่าการเรียกจะคืนค่า ซึ่งนั่นแหละคือสิ่งที่หน้าต่างค้างจริงๆ การใส่ Application.ProcessMessages เข้าไปในการเรียก render ไม่ได้แก้ปัญหานี้ มันแค่ปล่อยให้ message queue ระบายออก แต่ตัว render เองยังคงถือ thread ที่เรียกมันอยู่ ดังนั้นหน้าต่างจะ repaint เนื้อหาเก่าเร็วขึ้นในขณะที่งานจริงยังไม่ขยับไปไหนเลย วิธีเดียวที่จะรักษาความลื่นไหลของ viewer ระหว่างการ render ที่ช้าจริงๆ คือรันการ render นั้นที่อื่น ซึ่งเป็นเหตุผลที่ THPDFBackgroundRenderer มีอยู่ในฐานะคลาสลูกของ TThread แทนที่จะเป็น callback หรือ timer

การตั้งค่าคิวคำขอสำหรับ viewer ที่ scroll ได้

THPDFBackgroundRenderer.Create รับ instance ของ THotPDF ที่โหลดอยู่และค่า DPI ที่คงที่ตลอดอายุของ renderer ตัวนั้น ดังนั้นทุกหน้าที่จัดคิวผ่าน instance เดียวจะ render ที่ความละเอียดเดียวกัน viewer ที่รองรับ zoom ต้องการ renderer ตัวใหม่ ไม่ใช่ property DPI ตัวใหม่ ทุกครั้งที่ระดับ zoom เปลี่ยน RequestPage เพิ่มดัชนีหน้าเข้าไปในคิวภายในและคืนค่าทันที มันไม่ render อะไรเองและไม่แตะ UI thread เลย Execute ซึ่งเป็นจุดเข้าของ TThread ที่สืบทอดมาที่ HotPDF รันทันทีที่คุณเรียก Start ดึงดัชนีทีละตัวจากด้านหน้าของคิวนั้น render มันผ่าน cache หน้าของเอกสาร และเก็บสำเนาที่ index ด้วยหมายเลขหน้า เพื่อให้ GetCachedBitmap ส่งมันกลับมาให้ได้ในภายหลัง

type
  TViewerForm = class(TForm)
    RenderPollTimer: TTimer;
    procedure RenderPollTimerTimer(Sender: TObject);
  private
    FDoc: THotPDF;
    FRenderer: THPDFBackgroundRenderer;
    FPendingPage: Integer;
    procedure RequestPageWindow(CenterPage: Integer);
  end;

procedure TViewerForm.RequestPageWindow(CenterPage: Integer);
var
  I: Integer;
begin
  if FRenderer <> nil then
  begin
    FRenderer.CancelAll;
    FRenderer.Free;
  end;
  FRenderer := THPDFBackgroundRenderer.Create(FDoc, 150);
  for I := CenterPage - 1 to CenterPage + 1 do
    if (I >= 0) and (I < FDoc.LoadedPageCount) then
      FRenderer.RequestPage(I);
  FPendingPage := CenterPage;
  FRenderer.Start;
end;

procedure TViewerForm.RenderPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  if FRenderer = nil then Exit;
  Bmp := FRenderer.GetCachedBitmap(FPendingPage);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

GetCachedBitmap คืนค่า nil จนกว่าสำเนาของหน้านั้นจะพร้อม ดังนั้นรูปแบบ poll-on-a-timer แบบข้างต้นก็เพียงพอแล้ว ไม่มี ready event แยกต่างหากให้ต้องต่อสาย HotPDF แก้ปัญหานี้ด้วยการเช็ค nil ธรรมดาแทนที่จะมี notification API ที่ใหญ่กว่า ส่วนถัดไปจะครอบคลุมว่า CancelAll และการเรียก Free นั้นทำอะไรจริงๆ เพราะทั้งคู่สำคัญเมื่อหน้าเริ่ม render ไม่เรียงลำดับ หรือเมื่อการ scroll เกิดเร็วกว่าที่คิวจะระบายทัน

ทางลัดเรียกครั้งเดียวสำหรับหน้าเดียว

THotPDF.RenderLoadedPageToBitmapAsync มีไว้สำหรับกรณีทั่วไปที่ต้องการยิงแค่หน้าเดียวโดยไม่ต้องแตะ THPDFBackgroundRenderer โดยตรง มันสร้าง renderer ภายในเอง เรียก RequestPage หนึ่งครั้ง เริ่ม thread แล้วคืน reference ของ TThread กลับให้ผู้เรียก ซึ่งเป็นเจ้าของและมีหน้าที่ free มันเอง การดึงผลลัพธ์วิ่งผ่าน THotPDF.GetLoadedCachedRenderedBitmap แทนที่จะเป็น GetCachedBitmap ของ renderer เอง เพราะ GetLoadedCachedRenderedBitmap อ่านจาก cache ที่ใช้ร่วมกันของเอกสารซึ่ง key ด้วยดัชนีหน้าและ DPI ซึ่งเป็น cache เดียวกับที่ RenderLoadedPageToBitmapCached และตัว prefetcher ในตัวใส่ข้อมูลไว้แล้ว หน้าที่ส่วนอื่นของ viewer เคย render ที่ DPI นั้นไว้แล้วสามารถคืนค่ากลับมาได้ทันที ก่อนที่ background thread ที่เพิ่งเริ่มจะถูก OS จัดตารางเวลาให้ทำงานด้วยซ้ำ

// A simpler alternative to the queue above, for one page at a time.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // waits if a prior page is still rendering
  FAsyncWorker := Pdf.RenderLoadedPageToBitmapAsync(PageIndex, 150);
  FPendingPage := PageIndex;
end;

procedure TViewerForm.AsyncPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  Bmp := Pdf.GetLoadedCachedRenderedBitmap(FPendingPage, 150);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

ยกเลิกหน้าที่จัดคิวไว้แล้วได้ไหม

CancelAll ลบเฉพาะงานที่ยังนั่งอยู่ในคิวเท่านั้น หน้าที่ HotPDF ดึงออกจากด้านหน้าคิวไปแล้วและส่งให้การเรียก render ของมันไปแล้ว จะทำงานต่อจนเสร็จ เพราะ THPDFBackgroundRenderer ไม่มีกลไกขัดจังหวะงานที่กำลังดำเนินอยู่แล้ว นั่นเป็นการแลกเปลี่ยนที่สมเหตุสมผลในทางปฏิบัติ การ render หน้าเดียวไม่ค่อยยาวนานพอที่จะทำให้การ preempt คุ้มกับความซับซ้อนที่เพิ่มขึ้น แต่การ scroll เร็วที่ยิง CancelAll ในทุก scroll event ก็ยังต้องจ่ายต้นทุนของหน้าเดียวที่กำลัง render อยู่กลางคันในขณะที่ยกเลิกแต่ละครั้งอยู่ดี เอกสารอ้างอิงอย่างเป็นทางการพูดตรงๆ เรื่องนี้ การ render ที่กำลังทำงานอยู่แล้วอาจเสร็จสิ้นก่อนที่ thread จะสิ้นสุด

Execute มีพฤติกรรมที่สองที่มองข้ามได้ง่าย loop จะออกทันทีที่พบว่าคิวว่างเปล่า มันไม่ idle รอให้งานใหม่มาถึง ดังนั้น instance ของ THPDFBackgroundRenderer จึงเป็น worker แบบ batch ที่ทำครั้งเดียว ไม่ใช่ background service แบบถาวร จัดคิวหน้าจำนวนหนึ่ง เรียก Start แล้วเมื่อหน้าสุดท้ายที่จัดคิวไว้ render เสร็จ OS thread ที่อยู่เบื้องหลังก็จบตัวเองไป การเรียก RequestPage อีกครั้งบน instance เดียวกันหลังจากที่ Execute ระบายคิวหมดไปแล้วจะไม่ทำให้มันเริ่มใหม่ ซึ่งเป็นเหตุผลตรงๆ ที่ RequestPageWindow ข้างต้นแทนที่ instance ของ renderer ทุกครั้งที่เรียก แทนที่จะพยายามป้อนงานให้ object เดียวที่มีอายุยาวนานต่อไปเรื่อยๆ

แตะ TBitmap จาก background thread ใน Delphi ปลอดภัยไหม

การแตะ TBitmap จาก background thread ปลอดภัยในการออกแบบของ HotPDF ตราบเท่าที่มีแค่ thread เดียวเท่านั้นที่ทำงานกับ instance ของบิตแมปตัวใดตัวหนึ่งในเวลาเดียวกัน และ THPDFBackgroundRenderer บังคับขอบเขตนี้เองแทนที่จะปล่อยให้เป็นหน้าที่ของผู้เรียก Execute render แต่ละหน้าภายใน render lock ของเอกสารเอง ซึ่งเป็น critical section เดียวกับที่การเรียก RenderLoadedPageToBitmapCached ทุกครั้งและตัว prefetcher ในตัวอย่าง PrefetchLoadedPages ใช้ร่วมกันอยู่แล้ว ดังนั้นการวาด GDI จริงสำหรับหน้าใดหน้าหนึ่งจะเกิดขึ้นบน thread เดียวเท่านั้นในแต่ละครั้ง และไม่มีทางซ้อนทับกับการ render อื่นของเอกสารนั้นเลย บิตแมปที่ได้เป็น object ที่ worker thread เป็นเจ้าของ ซึ่ง THPDFBackgroundRenderer ไม่เคยเผยแพร่ตรงให้ผู้เรียกเลย

GetCachedBitmap จะจัดสรร TBitmap ใหม่แทน แล้วเรียก Assign บนมันภายใต้ lock แยกต่างหากของ renderer เอง ดังนั้นการ copy จะเกิดขึ้นเสมอในขณะที่ Execute ถูกกันไม่ให้แทนที่ cache slot นั้นข้างใต้มันได้ thread ที่เรียกจะได้ข้อมูลพิกเซล ไม่ใช่ handle ต้นฉบับ การแยกนี้ก็เป็นเหตุผลที่ควรหลีกเลี่ยงการสร้าง rendering thread แบบกำหนดเองที่เรียกฟังก์ชัน render ของ HotPDF ตรงๆ โดยไม่ผ่าน THPDFBackgroundRenderer หรือ PrefetchLoadedPages เพราะการ render สองครั้งที่แข่งกันเข้าถึง cache ที่ใช้ร่วมกันและ object graph ของเอกสารที่โหลดเดียวกัน เป็นสถานการณ์ที่ locking ภายในของ HotPDF มีอยู่เพื่อป้องกันโดยเฉพาะ และคลาส background renderer ก็ให้ locking นั้นมาฟรีๆ แทนที่จะต้อง implement ซ้ำเอง

สิ่งนี้ต่างจาก page prefetch ในตัวของ HotPDF อย่างไร

PrefetchLoadedPages กับ THPDFBackgroundRenderer แก้ปัญหาที่เกี่ยวข้องกันแต่ต่างกัน PrefetchLoadedPages เมื่อได้ช่วงหน้ามา จะ render ย่านทั้งหมดนั้นเข้า cache ที่ใช้ร่วมกันของเอกสารโดยอัตโนมัติบน worker thread ของตัวเอง โดยไม่มี queue object ให้ผู้เรียกต้องสร้างหรือจัดการ THPDFBackgroundRenderer แลกความอัตโนมัตินั้นด้วยการควบคุม ผู้เรียกตัดสินใจเองว่าดัชนีหน้าไหนสำคัญและในลำดับใด และสามารถยกเลิกหน้าที่ยังจัดคิวอยู่ได้โดยไม่แตะช่วงที่ prefetcher ในตัวกำลังอุ่นอยู่ที่อื่น ทั้งคู่วิ่งผ่าน render lock เดียวกัน ดังนั้น viewer สามารถรัน PrefetchLoadedPages สำหรับกรณีปกติของสองสามหน้าถัดไป และหันไปใช้ THPDFBackgroundRenderer เฉพาะเมื่อมีบางอย่างนอกเหนือรูปแบบนั้นเกิดขึ้น เช่น แถบ thumbnail ที่กระโดดตรงไปยังหน้าที่ผู้ใช้เพิ่งคลิก

begin
  // PrefetchLoadedPages takes a 1-based "start-end" range string, while
  // RequestPage below stays 0-based like every other loaded-page index.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Reach for THPDFBackgroundRenderer only for a page outside that
  // window, such as a thumbnail the user just clicked.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

มีรายละเอียดวงจรชีวิตสองอย่างที่ควรนำติดตัวไปใช้ในโค้ด production cache ที่ใช้ร่วมกันทั้งเอกสารเบื้องหลัง RenderLoadedPageToBitmapCached ถูกจำกัดด้วย RenderCacheCapacity ซึ่งค่าเริ่มต้นคือแปดหน้า และจะเอา entry ที่ใช้น้อยที่สุดล่าสุดออกเมื่อเต็ม แต่รายการผลลัพธ์ของ instance THPDFBackgroundRenderer เองไม่มีขีดจำกัดแบบนั้น มันเก็บบิตแมปหนึ่งอันต่อดัชนีหน้าที่ต่างกันทุกตัวที่เคยถูกขอผ่าน instance นั้น จนกว่า instance เองจะถูก free ดังนั้น renderer ที่ถูกเก็บให้อยู่รอดตลอด session การ scroll ทั้งหมดที่ DPI สูง จะสะสมบิตแมปความละเอียดเต็มหนึ่งอันต่อหน้าที่ scroll ผ่านไปอย่างสบายๆ HotPDF ก็ไม่ได้ยกเลิก renderer ที่ผู้เรียกสร้างขึ้นเองโดยอัตโนมัติ แบบเดียวกับที่มันยกเลิก prefetcher ของตัวเองก่อนที่เอกสารจะโหลดหรือทำลายตัวเอง เพราะ instance ของ THPDFBackgroundRenderer ไม่เคยถูกลงทะเบียนไว้บน object THotPDF ที่มันชี้ไปเลย ดังนั้นโค้ดที่เรียกใช้ต้องยกเลิกและ free renderer ทุกตัวที่สร้างขึ้นกับเอกสารหนึ่งๆ ก่อนที่จะโหลดใหม่หรือ free เอกสารนั้น ซึ่งเป็นวินัยด้านลำดับเดียวกับที่ HotPDF ใช้กับ PrefetchLoadedPages ภายในตัวมันเอง

THPDFBackgroundRenderer เป็นชิ้นส่วนหนึ่งของ facade เอกสารที่โหลดอยู่ เบื้องหลังสถาปัตยกรรม viewer แบบ MVCของ HotPDF และมันจับคู่ได้เป็นธรรมชาติกับ workflow ระดับไฟล์ในDirect File API สำหรับ PDF ขนาดใหญ่เมื่อเอกสารที่กำลัง scroll เองก็ใหญ่เกินกว่าจะโหลดแบบสบายๆ ตั้งแต่แรก การ render เบื้องหลัง คิวคำขอ และ cache การ render ที่อธิบายในบทความนี้ล้วนเป็นส่วนหนึ่งของHotPDF Componentรุ่นมาตรฐานสำหรับ Delphi และ C++Builder