대부분의 PDF 페이지는 몇 밀리초 만에 래스터화(rasterise)되며 사람들은 이를 의식하지 않습니다. 그러다 사용자가 A1 크기의 엔지니어링 도면, 수만 개의 벡터 선으로 채워진 페이지, 또는 투명도 그룹(transparency groups)과 부드러운 마스크(soft masks)로 가득 찬 포스터를 열면, 이를 그리는 단일 호출에 2~3초가 걸리게 됩니다. 만약 이 호출이 UI 스레드에서 실행되면 창은 다시 그리기를 멈추고, 제목 표시줄은 회색으로 변하며, 운영 체제는 응용 프로그램 종료 여부를 묻게 됩니다. 이 작업 자체는 정당합니다. 그 페이지는 정말 그만큼의 시간이 필요하기 때문입니다. 결함은 이 렌더링이 도중에 빠져나올 수도 없고 멈출 방법도 없는 하나의 나눌 수 없는 차단(blocking) 호출이라는 점입니다
이 글은 그 두 가지 문제 중 정확히 하나, 즉 UI를 정지시키지 않고 긴 단일 페이지 렌더링을 취소하는 방법에 대해 다룹니다. 사용자가 다음 페이지를 클릭했거나, 확대/축소를 했거나, 문서를 닫았을 때, 이미 진행 중인 렌더링은 낭비되는 작업이 되므로 끝까지 실행되는 대신 다음 기회에 종료되어야 합니다. 이미 래스터화된 것을 캐싱하여 스크롤과 확대를 부드럽게 만드는 것은 그 자체의 설계를 갖춘 별개의 문제이며, 이는 끝부분에 링크된 동반 기사에서 다룹니다. 여기에서의 유일한 질문은 단일 점진적 렌더링(progressive render)이 어떻게 취소 요청에 빠르고 깔끔하게 응답할 수 있도록 만드느냐는 것입니다
PDFium이 이미 제공하는 점진적 렌더링 API
PDFium은 UI가 정지되는 문제에 대비했습니다. 단발성 함수인 FPDF_RenderPageBitmap 외에도, 페이지를 작업 단위(chunks)로 나누는 점진적 변형을 제공합니다. 대상 비트맵에 렌더링을 설정하기 위해 한 번 FPDF_RenderPageBitmap_Start를 호출한 다음 FPDF_RenderPage_Continue를 반복적으로 호출합니다. 각각의 Continue는 제한된 범위를 래스터화하고 상태를 반환합니다. FPDF_RENDER_TOBECONTINUED는 해야 할 일이 더 남았음을 의미하고, FPDF_RENDER_DONE은 페이지가 완료되었음을, FPDF_RENDER_FAILED는 오류로 멈추었음을 의미합니다. 루프가 끝나면 FPDF_RenderPage_Close를 호출하여 페이지 단위의 점진적 상태(progressive state)를 해제합니다. 조각과 조각 사이에 여러분의 코드로 제어권이 돌아오기 때문에, 그사이에 메시지를 처리하거나 진행률 표시기를 업데이트하거나 작업을 계속 진행할지 확인할 수 있습니다
언제 양보할지(yield) 결정하기 위해 PDFium이 제공하는 메커니즘은 IFSDK_PAUSE라는 이름의 콜백 구조체입니다. 이를 Start와 모든 Continue에 전달합니다. 각 작업 단위(chunk) 이후에 PDFium은 구조체의 NeedToPauseNow 함수 포인터를 호출하며, 만약 이 함수가 0이 아닌 값을 반환하면, 현재의 Continue는 일찍 중단되고 FPDF_RENDER_TOBECONTINUED 상태와 함께 제어권을 반환합니다. 이 구조체는 반드시 1로 설정되어야 하는 version 필드와, PDFium이 건드리지 않고 그대로 통과시키는 자유 형식의 user 포인터를 함께 전달합니다. 이 건드리지 않은 포인터가 앞으로 이어질 설계의 핵심이 됩니다
일시 중지를 취소로 용도 변경하기
NeedToPauseNow의 원래 의도는 타임 슬라이싱(time-slicing)입니다. 프레임 예산이 소진되었을 때 0이 아닌 값을 반환하고 렌더링을 계속하려면 0을 반환하면, 동일한 렌더링을 재개하기 전에 다른 작업을 수행할 수 있도록 PDFium이 일시 중지합니다. PDFium Component는 이 동일한 신호를 다른 동작을 위해 재사용합니다. 콜백은 "일시 중지하고 나중에 재개하도록 할까?"라고 묻는 대신 "이 작업이 취소되었는가?"라고 대답합니다. 콜백이 플래그를 확인했을 때 루프가 어떤 동작을 하는지에 따라 이 두 가지가 깔끔하게 맞아떨어집니다. 진짜 일시 중지는 나중에 Continue가 올 것을 예상하지만, 취소는 그렇지 않습니다. 호출 루프가 토큰이 취소된 것을 관찰하면, 렌더 컨텍스트를 닫고 다시는 Continue를 호출하지 않으므로, PDFium이 "이 단위를 중단하라"로 읽는 동일한 0이 아닌 반환 값은 사실상 "영구히 중단하라"는 의미가 됩니다
취소는 IPdfCancellationToken 인터페이스를 통해 표현되며, 프로그램의 다른 부분에서 렌더링 중단을 요청할 때 이 인터페이스의 IsCancelled 속성이 false에서 true로 바뀝니다. 해당 파스칼 인터페이스와 PDFium의 C 콜백 사이를 연결하는 다리는 단일 포인터입니다. 토큰의 인터페이스 참조는 IFSDK_PAUSE.user에 기록되고, 정적 cdecl 콜백이 이를 다시 읽어내어 질의합니다. 이것은 C 라이브러리가 다시 파스칼을 호출하게 만드는 전형적인 문제로, PDFium은 파스칼 객체나 Self에 대해 아무것도 모르는 순수 함수 포인터를 저장하고 호출하므로, 이 콜백은 메서드가 아닌 C 호출 규약(cdecl)을 따르는 순수 함수여야 합니다
type
TPdfProgressivePause = record
Pause: IFSDK_PAUSE; // PDFium이 이것을 읽습니다; .user는 토큰을 보관합니다
Token: IPdfCancellationToken; // 강력한 참조(strong ref)가 토큰을 살아있게 유지합니다
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; // 0이 아닌 값: PDFium이 이 단위를 중단합니다
end;
콜백은 pThis^.user를 다시 인터페이스 타입으로 캐스팅하여 토큰을 복구하고 IsCancelled를 읽습니다. 이 안에서는 아무것도 할당하거나 락(lock)을 걸거나 차단(block)하지 않는데, 이는 PDFium이 각 단위(chunk) 이후마다 렌더링 스레드에서 이를 호출하며 여기서 수행되는 모든 작업은 렌더링 자체의 비용에 더해지기 때문에 중요합니다. nil 구조체나 nil user 필드에 대한 보호(guard)는 실제 토큰이 주어지지 않은 렌더링에 동일한 함수를 설치하더라도 안전함을 의미합니다
루프가 실행되는 동안 토큰을 살려두기
인터페이스 포인터를 원시 Pointer를 통해 캐스팅하고 다시 되돌리는 과정은 수명 주기(lifetime) 버그가 생기는 지점입니다. Delphi의 IInterface는 참조 횟수가 계산되며(reference counted), 컴파일러가 인터페이스 타입 변수가 할당되는 것을 볼 수 있을 때만 그 수가 변합니다. 토큰을 IFSDK_PAUSE.user 내부의 단순 포인터로만 저장하면 참조 카운터로부터 이 토큰이 완전히 숨겨집니다. 만약 그 토큰에 대한 유일한 다른 참조가 Continue 루프가 아직 실행 중인 동안 스코프(scope)를 벗어나면, 이 객체는 콜백 밑에서 해제될 것이고 다음번 작업 단위(chunk)는 허상 포인터(dangling pointer)를 역참조하게 됩니다
이것이 바로 기술자(descriptor)가 하나가 아닌 두 개를 보관하는 레코드인 이유입니다. Pause 필드는 PDFium이 읽는 구조체입니다. Token 필드는 컴파일러가 카운트하는 실제 인터페이스 타입 참조이며, 오직 이 레코드가 살아있는 동안 토큰을 메모리에 고정하기 위한 목적으로만 존재합니다. 이 레코드는 렌더링 루틴의 스택에 있는 지역 변수이므로, 루프가 진행되는 내내 유효하며 루틴이 종료될 때만 해체됩니다. user의 순수 포인터와 Token의 참조 횟수가 계산된 참조는 동일한 객체를 가리킵니다. 하나는 PDFium이 읽을 수 있는 것이고 다른 하나는 그 객체가 수집(collected)되는 것을 막는 것입니다
var
Pause: TPdfProgressivePause;
EffectiveToken: IPdfCancellationToken;
begin
// ... EffectiveToken 선택 ...
// 먼저 강력한 참조를 설정한 후, .user를 통해 동일한 객체를 PDFium에 발행합니다.
Pause.Token := EffectiveToken;
Pause.Pause.version := 1;
Pause.Pause.NeedToPauseNow := ProgressivePauseCallback;
Pause.Pause.user := Pointer(EffectiveToken);
루프가 어떻게 끝나든 렌더 컨텍스트를 닫기
FPDF_RenderPageBitmap_Start의 모든 호출은 PDFium이 페이지와 연관시키는 점진적 상태(progressive state)를 할당하며, 그 상태는 오직 FPDF_RenderPage_Close에 의해서만 해제됩니다. 구동 루프(drive loop)를 빠져나오는 방법은 세 가지가 있습니다. 페이지가 완료되어 마지막 상태가 FPDF_RENDER_DONE인 경우, 토큰이 발동되어 취소를 보고하며 루프를 일찍 빠져나오는 경우, 무언가 실패하여 상태가 FPDF_RENDER_FAILED인 경우입니다. 세 가지 경우 모두 반드시 Close를 호출해야 하며, "취소를 확인하고 빠져나오는" 자연스러운 형태는 나가는 길에 정리를 건너뛰기 쉽기 때문에 취소 경로가 버그를 내기 가장 쉽습니다. Close를 호출하지 않으면 페이지 단위 상태가 누수되며, 사용자가 연달아 렌더링을 취소할 수 있는 뷰어는 중단된 모든 페이지에서 그 누수를 누적하게 될 것입니다
견고한 형태는 루프와 결과 분류를 try 안에 두고 FPDF_RenderPage_Close를 그에 맞는 finally 안에 넣는 것입니다. 대상 비트맵도 동일한 블록 안에서 파괴됩니다. 취소는 조기 Exit를 통해 루프를 벗어날 수 있으며 이 경우에도 finally는 실행되므로, 점진적 상태를 해제하는 곳은 단 한 곳뿐이며 우회할 수 없습니다
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
// Start가 할당한 점진적 상태를 해제합니다; 모든 경로에서 필수적입니다.
FPDF_RenderPage_Close(FPage);
FPDFBitmap_Destroy(PdfBmp);
end;
루프는 내부의 콜백에 의존하는 것뿐만 아니라 각 Continue 전에도 토큰을 점검합니다. 콜백은 현재 단위를 단축시키고, 루프 확인은 다음 단위가 시작되는 것을 막습니다. 둘이 합쳐 취소가 발효되는 데 걸리는 시간을 대략 작업 단위 하나가 걸리는 시간으로 제한합니다
세 가지 결과, 그리고 취소 후 비트맵의 상태
공개된 진입점(entry point)은 TPdf.RenderPageProgressive이며, prsDone, prsCancelled, 또는 prsFailed 중 하나인 TPdfProgressiveStatus를 반환합니다. 이 값들은 파스칼 관용구 안에서 PDFium의 FPDF_RENDER_* 상수를 반영하지만, 취소된 경우를 오류가 아닌 일급(first-class) 결과로서 받아들입니다
사람들이 걸려 넘어지는 지점은 prsCancelled 이후 대상 비트맵이 무엇을 포함하느냐입니다. 비트맵은 비어 있지 않습니다. PDFium은 한 단위가 끝날 때마다 동일한 비트맵에 점진적으로 렌더링하므로, 취소가 루프를 멈출 때 비트맵은 그 순간까지 그려진 이미지를 간직합니다. 이는 부분적인 이미지입니다. 어떤 띠(bands)는 그려져 있고, 나머지는 여전히 채우기 색상(fill colour)을 보여줍니다. 이 부분적인 결과가 유용한지 여부는 호출자에게 달려 있습니다. 사용자가 다른 곳으로 이동하여 곧 비트맵을 버릴 예정인 뷰어는 단순히 이를 무시할 수 있습니다. 저비용 미리 보기를 보여주고 싶은 뷰어는 이를 유지할 수 있습니다. 결코 해서는 안 될 가정은 prsCancelled가 비어 있거나 정의되지 않은 비트맵을 암시한다고 생각하는 것입니다. 이는 완료되지 않은 렌더링에 대한 진실된 스냅샷(snapshot)을 의미합니다
var
Bmp: TBitmap;
Token: IPdfCancellationToken;
Status: TPdfProgressiveStatus;
begin
Bmp := TBitmap.Create;
try
// 토큰은 취소되지 않은 상태로 시작합니다; 진행 중인 렌더링을 중단하려면
// 다른 곳(UI 액션, 탐색 이벤트)에서 Token.IsCancelled를 전환(flip)합니다.
Status := Pdf.RenderPageProgressive(Bmp, 0, 0, PageW, PageH, Token);
case Status of
prsDone: Image1.Picture.Assign(Bmp); // 완전히 렌더링됨
prsCancelled: ; // 부분적인 비트맵, 보통은 버려짐
prsFailed: ShowMessage('Render failed');
end;
finally
Bmp.Free;
end;
end;
nil 토큰과 분기 없는 콜백 경로
취소 기능은 선택 사항(opt-in)입니다. 중단할 의도 없이 단지 메시지 처리(message-pumping)의 이점만을 위해 점진적 렌더링을 원하는 호출자는 토큰에 nil을 전달할 수 있어야 합니다. 이를 지원하는 순진한 방법은 콜백과 루프 전체에 "토큰이 제공되었다면"이라는 검사를 흩뿌리는 것인데, 이는 모든 작업 단위(chunk)에서 분기(branch)가 발생하며 콜백이 실제 토큰이 있는 경우와 없는 경우를 모두 처리해야 함을 의미합니다
구현은 호출자가 아무것도 전달하지 않았을 때 싱글톤(singleton)을 대체함으로써 이를 피합니다. nil 토큰은 IsCancelled가 항상 거짓인 PdfNoCancellationToken 인터페이스로 교체됩니다. 그 시점부터 콜백과 루프는 모든 경우에 질의할 토큰을 갖게 되므로 어느 쪽도 nil 검사나 특별한 경로를 필요로 하지 않습니다. 절대 취소되지 않는 토큰은 그저 항상 거짓(false)을 대답하고 콜백은 항상 0을 반환하며 렌더링은 취소 불가능한 렌더링과 정확히 똑같이 완료될 때까지 실행됩니다. 선택적 동작을 토큰의 부재가 아닌 결코 발동되지 않는 토큰으로 모델링함으로써 집중 경로(hot path)를 균일하게 유지합니다
// nil -> 절대 취소되지 않는 싱글톤이므로, 호출자가 취소를 선택했든
// 안 했든 간에 콜백 경로는 동일합니다.
if AToken <> nil then
EffectiveToken := AToken
else
EffectiveToken := PdfNoCancellationToken;
드러난 이 구조는 작고 재사용할 수 있는 부분이므로 다시 언급할 가치가 있습니다. 콜백을 지원하는 C 라이브러리는 콜백 안으로 상태를 전달할 수 있는 단 하나의 채널인 불투명한 사용자 포인터(opaque user pointer)를 제공합니다. 참조 횟수 계산이 있는 파스칼 인터페이스 참조를 그 포인터 뒤에 놓고 해당 구조체 옆에 살아있는 두 번째 실제 참조를 유지하여 호출 도중에 객체가 수집되지 않도록 한 다음, 정적 cdecl 함수 내부에서 인터페이스를 다시 읽어내세요. 전체 구동 루프를 try로 감싸고 finally 안에서 네이티브 컨텍스트를 해제하세요. C 라이브러리가 포인터를 쥐고 있는 동안 파스칼 코드가 수명 주기를 지속해서 통제해야 하는 모든 점진적 혹은 콜백 기반의 PDFium 작업에 똑같은 템플릿이 적용됩니다
취소는 반응형 뷰어의 절반일 뿐입니다. 나머지 절반은 이미 그린 페이지를 다시 렌더링하지 않고 캐시된 비트맵을 제공하여 확대와 스크롤을 부드럽게 유지하는 것으로, 이는 렌더 캐시와 줌 성능에 대한 기사에서 다룹니다. 탐색, 선택, 검색과 나란히 취소 가능한 렌더링이 어떻게 완전한 뷰어에 들어맞는지는 PDFium VCL Component를 사용해 기능이 풍부한 PDF 뷰어 구축하기를 참조하세요. 여기서 설명된 점진적 렌더링은 이 블로그 다른 곳에서 다룬 로드, 렌더링, 폼(form) API와 함께 Delphi와 Lazarus용 PDFium Component의 일부로 제공됩니다