HotPDF의 THPDFBackgroundRenderer 클래스는 워커 스레드에서 로드된 PDF 페이지를 비트맵으로 렌더링하는 TThread 서브클래스로, 페이지가 백그라운드에서 아직 래스터화되는 동안에도 Delphi 뷰어가 계속 스크롤하고 다시 그릴 수 있게 한다. THPDFBackgroundRenderer.RequestPage는 페이지 인덱스를 그 워커 스레드용 큐에 넣고, CancelAll은 아직 대기 중인 항목을 모두 버리며, GetCachedBitmap은 완성된 비트맵을 호출자에게 돌려주는데 그 소유권과 해제 책임은 호출자에게 있다. UI 스레드 하나로 200페이지짜리 스캔 계약서를 인쇄 해상도로 스크롤해 보면 GDI가 다 그릴 때까지 페이지를 넘길 때마다 창이 멈추는데, 바로 이 버벅임을 없애기 위해 THPDFBackgroundRenderer가 존재한다
PDF 페이지를 왜 굳이 백그라운드 스레드에서 렌더링하는가?
백그라운드 스레드가 그만한 복잡성을 감수할 가치가 있는 이유는 HotPDF의 페이지 렌더러가 아무도 눈치채기 전에 돌아오는 값싼 비트맵 복사가 아니라 진짜 콘텐츠 스트림 인터프리터이기 때문이다: PDF 오퍼레이터를 순회하고, 그래픽 상태 스택을 유지하며, 로드된 PDF 페이지를 TBitmap으로 렌더링하기에서 다루는 것과 동일한 엔진인 GDI를 통해 경로, 이미지, 글리프를 래스터화한다. 이 작업을 스크롤이나 페인트 핸들러 안에서 동기적으로 실행하면 호출이 반환될 때까지 메시지 루프가 펌핑을 멈추는데, 이것이 바로 창이 멈추는 실체다. 렌더링 호출 안에 Application.ProcessMessages를 넣는다고 해도 이 문제는 해결되지 않는다: 메시지 큐는 비워지지만 렌더링 자체는 여전히 호출 스레드를 점유하고 있으므로, 실제 작업은 전혀 진전이 없는 채로 창은 그저 낡은 내용을 더 빨리 다시 그리게 될 뿐이다. 진짜로 느린 렌더링 동안 뷰어의 반응성을 유지하는 유일한 방법은 그 렌더링을 다른 곳에서 실행하는 것이며, 이것이 THPDFBackgroundRenderer가 콜백이나 타이머가 아니라 TThread 서브클래스로 존재하는 이유다
스크롤 뷰어를 위한 요청 큐 설정하기
THPDFBackgroundRenderer.Create는 로드된 THotPDF 인스턴스와 그 렌더러의 전체 수명 동안 고정되는 DPI를 받으므로, 하나의 인스턴스를 통해 큐에 들어간 모든 페이지는 하나의 해상도로 렌더링된다. 확대를 지원하는 뷰어는 확대율이 바뀔 때마다 새 DPI 속성이 아니라 새 렌더러가 필요하다. RequestPage는 페이지 인덱스를 내부 큐에 추가하고 즉시 반환한다: 그 자체로는 아무 렌더링도 하지 않고 UI 스레드를 전혀 건드리지 않는다. Start를 호출하면 실행되는, HotPDF가 상속받아 실행하는 TThread 진입점인 Execute는 그 큐의 맨 앞에서 인덱스를 하나씩 꺼내 문서의 페이지 캐시를 통해 렌더링하고, 페이지별로 색인된 사본을 저장해 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을 반환하므로, 위와 같은 타이머 폴링 패턴만으로 충분하다. 별도로 연결해야 할 준비 완료 이벤트는 없으며, HotPDF는 더 큰 알림 API 대신 단순한 nil 검사로 이 문제를 해결한다. 다음 절에서는 CancelAll과 그 Free 호출이 실제로 무엇을 하는지 다루는데, 페이지가 순서를 벗어나 렌더링되기 시작하거나 스크롤이 큐가 비워지는 속도보다 빠르게 일어나면 둘 다 중요해지기 때문이다
단일 페이지를 위한 원콜 지름길
THotPDF.RenderLoadedPageToBitmapAsync는 THPDFBackgroundRenderer를 직접 건드리지 않고 정확히 페이지 하나만 발사하는 흔한 경우를 위해 존재한다: 내부적으로 렌더러를 생성하고, RequestPage를 한 번 호출하고, 스레드를 시작한 뒤, TThread 참조를 호출자에게 반환하는데 그 소유권과 해제 책임은 호출자에게 있다. 결과를 가져올 때는 렌더러 자체의 GetCachedBitmap이 아니라 THotPDF.GetLoadedCachedRenderedBitmap을 거치는데, GetLoadedCachedRenderedBitmap은 페이지 인덱스와 DPI를 키로 하는 문서의 공유 캐시를 읽기 때문이다. 이는 RenderLoadedPageToBitmapCached와 내장 프리페처가 이미 채워두는 것과 동일한 캐시로, 뷰어의 다른 부분이 이미 그 DPI로 렌더링해 둔 페이지라면 방금 시작된 백그라운드 스레드가 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가 이미 맨 앞에서 꺼내 렌더링 호출에 넘긴 페이지는 완료까지 계속 진행하는데, THPDFBackgroundRenderer에는 이미 진행 중인 작업을 중단시키는 메커니즘이 없기 때문이다. 실무적으로 이는 합리적인 절충이다 — 페이지 하나의 렌더링은 선점을 도입할 만큼 길게 걸리는 경우가 드물다 — 하지만 스크롤 이벤트마다 CancelAll을 발사하는 빠른 스크롤은 여전히 각 취소 시점에 렌더링 중이던 페이지 한 장의 비용을 치른다. 공식 레퍼런스도 이를 명확히 밝히고 있다: 이미 실행 중인 렌더링은 스레드가 종료되기 전에 완료될 수 있다
Execute에는 놓치기 쉬운 두 번째 동작이 있다: 루프는 큐가 비어 있음을 발견하는 즉시 종료하며, 새 작업이 도착하기를 유휴 상태로 기다리지 않는다. 따라서 THPDFBackgroundRenderer 인스턴스는 지속적인 백그라운드 서비스가 아니라 일회성 배치 워커다 — 페이지 몇 개를 큐에 넣고 Start를 호출하면, 큐에 들어간 마지막 페이지가 렌더링된 뒤 그 밑의 OS 스레드는 스스로 끝난다. Execute가 이미 큐를 비운 뒤에 같은 인스턴스에 RequestPage를 다시 호출해도 재시작되지 않는데, 이것이 위의 RequestPageWindow가 하나의 장수 객체에 계속 작업을 먹이려 하는 대신 매 호출마다 렌더러 인스턴스를 교체하는 이유다
Delphi에서 백그라운드 스레드로부터 TBitmap을 건드리는 것은 안전한가?
HotPDF의 설계에서 특정 비트맵 인스턴스를 한 번에 스레드 하나만 다룬다면 백그라운드 스레드에서 TBitmap을 건드리는 것은 안전하며, THPDFBackgroundRenderer는 그 경계를 호출자에게 맡기지 않고 스스로 강제한다. Execute는 각 페이지를 문서 자체의 렌더 락 안에서 렌더링하는데, 이는 모든 RenderLoadedPageToBitmapCached 호출과 내장 PrefetchLoadedPages 프리페처가 이미 공유하는 것과 동일한 임계 구역이다. 그래서 특정 페이지에 대한 실제 GDI 그리기는 항상 정확히 한 번에 하나의 스레드에서만 일어나고 그 문서의 다른 렌더링과 절대 겹치지 않는다. 결과로 나오는 비트맵은 워커 스레드가 소유하는 객체이며, THPDFBackgroundRenderer는 이를 호출자에게 직접 공개하지 않는다
대신 GetCachedBitmap은 완전히 새로운 TBitmap을 할당하고 렌더러 자체의 별도 락 아래에서 그것에 Assign을 호출한다. 그래서 그 복사는 항상 Execute가 그 아래에서 캐시 슬롯을 교체하지 못하도록 막힌 상태에서 일어난다 — 호출 스레드는 원본 핸들이 아니라 픽셀 데이터를 받는다. 이 분리는 또한 THPDFBackgroundRenderer나 PrefetchLoadedPages를 거치지 않고 HotPDF의 렌더링 함수를 직접 호출하는 커스텀 렌더링 스레드를 직접 만드는 것을 자제해야 하는 이유이기도 하다: 같은 로드된 문서의 공유 캐시와 객체 그래프를 두고 렌더링 두 개가 경쟁하는 상황이야말로 HotPDF의 내부 락이 막으려는 정확한 시나리오이며, 백그라운드 렌더러 클래스는 그 락을 재구현하지 않고도 공짜로 얻게 해준다
이것은 HotPDF의 내장 페이지 프리페치와 어떻게 다른가?
PrefetchLoadedPages와 THPDFBackgroundRenderer는 관련되어 있지만 서로 다른 문제를 해결한다: PrefetchLoadedPages는 페이지 범위가 주어지면 호출자가 만들거나 관리할 큐 객체 없이, 자신의 워커 스레드에서 자동으로 그 이웃 영역 전체를 공유 문서 캐시에 렌더링한다. THPDFBackgroundRenderer는 그 자동화 대신 제어권을 준다 — 호출자가 정확히 어떤 페이지 인덱스가 중요한지, 어떤 순서로 처리할지 결정하고, 내장 프리페처가 다른 곳에서 미리 데우고 있는 범위를 건드리지 않으면서 여전히 큐에 있는 것만 취소할 수 있다. 둘 다 동일한 렌더 락을 거치므로, 뷰어는 일반적인 '다음 몇 페이지' 상황에는 PrefetchLoadedPages를 실행하고, 사용자가 방금 클릭한 페이지로 썸네일 스트립이 곧바로 건너뛰는 것처럼 그 패턴을 벗어나는 상황이 생길 때만 THPDFBackgroundRenderer를 사용하면 된다
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;
운영 코드에 반영할 가치가 있는 생명주기 세부사항 두 가지가 있다. RenderLoadedPageToBitmapCached 뒤에 있는 문서 전역 캐시는 RenderCacheCapacity(기본값 8페이지)로 제한되며 가득 차면 가장 오래 사용되지 않은 항목을 축출하지만, THPDFBackgroundRenderer 인스턴스 자체의 결과 목록에는 그런 제한이 없다 — 그 인스턴스를 통해 요청된 적 있는 서로 다른 페이지 인덱스마다 비트맵 하나씩을 인스턴스 자체가 해제될 때까지 계속 보유한다. 그래서 고DPI로 전체 스크롤 세션 동안 유지되는 렌더러는 스크롤해 지나간 페이지마다 전체 해상도 비트맵을 기꺼이 계속 쌓아둔다. 또한 HotPDF는 문서가 로드되기 전에 자신의 프리페처를 취소하거나 스스로를 파괴하는 것과 같은 방식으로 호출자가 만든 렌더러를 자동으로 취소하지 않는다. THPDFBackgroundRenderer 인스턴스는 그것이 가리키는 THotPDF 객체에 결코 등록되지 않기 때문이다 — 따라서 호출 측 코드는 문서를 다시 로드하거나 해제하기 전에 그 문서를 대상으로 만든 모든 렌더러를 취소하고 해제해야 하며, 이는 HotPDF가 내부적으로 PrefetchLoadedPages에 적용하는 것과 동일한 순서 규율이다
THPDFBackgroundRenderer는 HotPDF의 MVC 뷰어 아키텍처 뒤에 있는 로드된 문서 파사드의 한 조각이며, 스크롤 중인 문서 자체가 처음부터 가볍게 로드하기에는 너무 큰 경우 대용량 PDF를 위한 Direct File API의 파일 수준 워크플로와 자연스럽게 짝을 이룬다. 여기서 설명한 백그라운드 렌더링, 요청 큐, 렌더 캐시는 모두 Delphi와 C++Builder용 HotPDF 컴포넌트 표준판에 포함되어 있다