Většina stránek PDF se rasterizuje za několik milisekund a nikdy o tom nepřemýšlíte. Pak uživatel otevře inženýrský výkres formátu A1, stránku napěchovanou desítkami tisíc vektorových tahů nebo plakát přeplněný skupinami průhledností a měkkými maskami a jediné volání, které ji vykresluí, trvá dvě nebo tři sekundy. Pokud toto volání běží v UI vlákně, okno se přestane překreslovat, záhlaví zešedne a operační systém nabídne ukončení aplikace. Práce je to legitimní. Stránka opravdu potřebuje tak dlouho. Nedostatkem je, že vykreslování je jedno nedělitelné blokující volání, ze kterého se nelze vynořit pro vzduch a nelze ho nijak zastavit
Tento článek je přesně o jednom z těchto dvou problémů: jak zrušit dlouhé vykreslování jedné stránky bez zamrznutí UI. Uživatel klikl na další stránku, přiblížil nebo zavřel dokument a běžící vykreslování je nyní zbytečná práce, která by měla skončit při nejbližší příležitosti namísto toho, aby proběhla až do konce. Vyhlazení posouvání a přibližování pomocí mezipaměti toho, co již bylo rasterizováno, je samostatnou záležitostí s vlastním designem, které se věnuje doprovodný článek s odkazem na konci. Zde je jedinou otázkou to, jak zajistit, aby jedno progresivní vykreslování odpovědělo na požadavek na zrušení rychle a čistě
API pro progresivní vykreslování, které PDFium již dodává
PDFium polovinu problému se zamrznutím předvídalo. Vedle jednorázového FPDF_RenderPageBitmap nabízí progresivní variantu, která rozdělí stránku na kusy práce. Jednou zavoláte FPDF_RenderPageBitmap_Start pro nastavení vykreslování do cílové bitmapy, a poté opakovaně voláte FPDF_RenderPage_Continue. Každé Continue rasterizuje ohraničený řez a vrací stav. FPDF_RENDER_TOBECONTINUED znamená, že zbývá ještě nějaká práce, FPDF_RENDER_DONE znamená, že stránka je dokončena, a FPDF_RENDER_FAILED znamená, že se operace zastavila s chybou. Když smyčka skončí, zavoláte FPDF_RenderPage_Close k uvolnění progresivního stavu pro danou stránku. Vzhledem k tomu, že se mezi řezy řízení vrací do vašeho kódu, můžete zpracovávat zprávy, aktualizovat indikátor postupu nebo zkontrolovat, zda je práce stále žádoucí
Mechanismem, který PDFium poskytuje pro rozhodnutí, kdy má ustoupit, je struktura zpětného volání nazvaná IFSDK_PAUSE. Předáte ji do Start a do každého Continue. Po každém kusu práce zavolá PDFium její ukazatel na funkci NeedToPauseNow, a pokud ten vrátí nenulovou hodnotu, aktuální Continue se předčasně zastaví a předá řízení zpět s FPDF_RENDER_TOBECONTINUED. Struktura také obsahuje pole version, které musí být nastaveno na 1, a ukazatel volného formátu user, kterého se PDFium nikdy nedotkne a propouští jej nedotčený. Tento nedotčený ukazatel je celým pantem následujícího designu
Změna účelu pauzy na zrušení
Původním záměrem NeedToPauseNow je dělení času (time-slicing). Vrátíte nenulovou hodnotu, když je váš rozpočet na snímek vyčerpán, vrátíte nulu, aby se pokračovalo ve vykreslování, a PDFium se pozastaví, abyste mohli dělat něco jiného před pokračováním stejného vykreslování. Komponenta PDFium Component znovu využívá tento stejný signál pro jiné sloveso. Místo odpovědi na otázku "mám to pozastavit a nechat vás pokračovat" odpovídá toto zpětné volání na "byla tato práce zrušena". Obě tyto možnosti na sebe čistě navazují díky tomu, co dělá smyčka, když uvidí daný příznak. Skutečná pauza očekává pozdější Continue; zrušení nikoliv. Jakmile volající smyčka zpozoruje, že je token zrušen, zavře kontext vykreslování a Continue už nikdy nezavolá, takže stejná nenulová návratová hodnota, kterou PDFium čte jako "zastav tento kus práce", se ve skutečnosti stane "zastav to nadobro"
Zrušení je vyjádřeno prostřednictvím rozhraní IPdfCancellationToken, jehož vlastnost IsCancelled se přepne z nepravdy na pravdu, když nějaká jiná část programu požádá o zastavení vykreslování. Mostem mezi tímto rozhraním v Pascalu a céčkovským zpětným voláním v PDFium je jediný ukazatel. Reference na rozhraní tokenu je zapsána do IFSDK_PAUSE.user a statické zpětné volání cdecl ji přečte zpět a dotáže se na ni. Toto je klasický problém, jak nechat knihovnu v C volat zpět do Pascalu: zpětné volání musí být obyčejná funkce s volací konvencí C, nikoli metoda, protože PDFium uchovává a spouští holý ukazatel na funkci, který nic neví o objektech v Pascalu ani o 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;
Zpětné volání získá token zpět přetypováním pThis^.user zpět na typ rozhraní a přečte IsCancelled. Nic v něm nealokuje, nezamyká ani neblokuje, na čemž záleží, protože PDFium jej volá ve vykreslovacím vlákně po každém kusu práce a jakákoli práce, která by se zde udělala, se přidá k nákladům na samotné vykreslování. Ochrana proti nil struktuře nebo nil poli user znamená, že tutéž funkci je bezpečné nainstalovat i na vykreslování, kterému nikdy nebyl přidělen skutečný token
Udržení tokenu naživu napříč smyčkou
Přetypování ukazatele rozhraní přes surový Pointer a zpět je místem, kde se rodí chyby s dobou života. IInterface v Delphi používá počítání referencí a počet se mění pouze tehdy, když kompilátor vidí, že se přiřazuje proměnná s typem rozhraní. Uložení tokenu výhradně jako holého ukazatele do IFSDK_PAUSE.user by jej zcela skrylo před počítadlem referencí. Pokud by jediná další reference na tento token vypadla z rozsahu platnosti, zatímco by smyčka Continue stále běžela, objekt by se uvolnil pod rukama zpětného volání a další kus práce by dereferencoval visící ukazatel
Proto je deskriptor záznamem, který drží dvě věci, nikoliv jednu. Pole Pause je struktura, kterou čte PDFium. Pole Token je skutečná reference s typem rozhraní, kterou kompilátor počítá, a existuje z jediného důvodu: aby připíchlo token do paměti po celou dobu existence záznamu. Záznam je lokální proměnná na zásobníku vykreslovací rutiny, takže zůstává v platnosti po celou dobu trvání smyčky a je zrušen až při opuštění rutiny. Holý ukazatel v user a započítaná reference v Token ukazují na stejný objekt; jeden je to, co může číst PDFium, a druhý je ten, který brání sběru tohoto objektu
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);
Zavření kontextu vykreslování bez ohledu na to, jak smyčka skončí
Každé volání FPDF_RenderPageBitmap_Start alokuje progresivní stav, který PDFium spojuje se stránkou, a tento stav se uvolní pouze pomocí FPDF_RenderPage_Close. Ze smyčky existují tři cesty ven. Stránka se dokončí a poslední stav je FPDF_RENDER_DONE. Token se aktivuje a smyčka předčasně skončí a ohlásí zrušení. Něco selže a stav je FPDF_RENDER_FAILED. Všechny tři musí zavolat Close, a cesta pro zrušení je tou, na které se dá nejsnadněji udělat chyba, protože přirozený vzor "vidím zrušení, přerušuji" má tendenci přeskočit na cestě k východu úklid. Ponechání Close nedosažitelného znamená únik stavu pro stránku, a prohlížeč, který uživateli umožňuje rušit jedno vykreslování za druhým, by tento únik hromadil na každé přerušené stránce
Robustní vzor vkládá smyčku a klasifikaci výsledku do bloku try a FPDF_RenderPage_Close do odpovídajícího finally. Cílová bitmapa je zničena ve stejném bloku. Zrušení může opustit smyčku přes předčasný Exit a blok finally se stále spustí, takže existuje přesně jedno místo, které uvolňuje progresivní stav a nelze ho obejít
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;
Smyčka kontroluje token před každým Continue a spoléhá se i na zpětné volání uvnitř. Zpětné volání zkracuje aktuální kus práce; kontrola ve smyčce zastavuje spuštění dalšího. Společně omezují dobu, za jakou zrušení nabude účinnosti, na zhruba délku jednoho kusu práce
Tři výsledky a to, co bitmapa obsahuje po zrušení
Veřejným vstupním bodem je TPdf.RenderPageProgressive a vrací TPdfProgressiveStatus, což je jedna z hodnot prsDone, prsCancelled nebo prsFailed. Tyto hodnoty zrcadlí konstanty FPDF_RENDER_* v idiomu Pascalu, ale zahrnují případ zrušení jako výsledek první třídy namísto chyby
Moment, který lidi chytá do pasti, je to, co cílová bitmapa obsahuje po prsCancelled. Není prázdná. PDFium vykresluje progresivně do stejné bitmapy kus za kusem, takže když zrušení zastaví smyčku, bitmapa obsahuje to, co bylo namalováno do onoho okamžiku, což je částečný obraz: některé pruhy jsou hotové, zbytek stále ukazuje barvu výplně. Zda je tento částečný výsledek užitečný, závisí na volajícím. Prohlížeč, který se chystá bitmapu zahodit, protože uživatel přešel jinam, ji může prostě ignorovat. Prohlížeč, který chce ukázat levný náhled, si ji může ponechat. Co nesmíte udělat, je předpokládat, že prsCancelled implikuje prázdnou nebo nedefinovanou bitmapu; implikuje to pravdivý snímek nedokončeného vykreslování
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;
Nil token a cesta zpětného volání bez větvení
Zrušení je volitelné. Volající, který chce pouze progresivní vykreslování z důvodu výhody zpracovávání zpráv a nemá v úmyslu jej přerušit, by měl mít možnost předat pro token nil. Naivní způsob, jak to podpořit, by byl rozptýlit kontroly "pokud byl dodán token" napříč zpětným voláním a smyčkou, což znamená větvení při každém kusu práce a zpětné volání, které musí zvládnout jak skutečný token, tak jeho absenci
Implementace se tomu vyhýbá tím, že dosadí singleton, když volající nepředá nic. Token s hodnotou nil je nahrazen za PdfNoCancellationToken, což je rozhraní, jehož IsCancelled je vždy nepravda. Od tohoto okamžiku má zpětné volání a smyčka k dispozici token pro dotazování v každém případě, takže ani jedno z toho nepotřebuje kontrolu na nil a ani nepotřebuje speciální cestu. Token "nikdy nezrušuj" zkrátka vždy odpovídá nepravda, zpětné volání vždy vrací nulu a vykreslování doběhne do konce přesně tak, jako by to udělalo nezrušitelné. Volitelné chování je modelováno jako token, který se nikdy nespustí, spíše než jako absence tokenu, což udržuje nejvytěžovanější cestu jednotnou
// 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;
Vzor, který z toho vyplývá, je malý a stojí za to ho zopakovat, protože se jedná o znovu použitelnou část. Knihovna C, která podporuje zpětné volání, vám dává přesně jeden kanál pro předání stavu do tohoto zpětného volání, a to neprůhledný uživatelský ukazatel. Dejte za tento ukazatel referenci na rozhraní v Pascalu s počítáním referencí, udržujte naživu druhou skutečnou referenci vedle struktury tak, aby objekt nemohl být odstraněn v průběhu volání, a přečtěte si rozhraní zpět uvnitř statické funkce cdecl. Zabalte celou řídicí smyčku do try a uvolněte nativní kontext ve finally. Stejná šablona se přenáší na jakoukoliv progresivní operaci v PDFium nebo operaci řízenou zpětným voláním, kde musí kód v Pascalu zůstat pod kontrolou doby života, zatímco C drží ukazatel
Zrušení je pouze jednou polovinou responzivního prohlížeče. Druhou polovinou je nevykreslování stránek, které jste již nakreslili, a udržování plynulého přibližování a posouvání poskytováním bitmap z mezipaměti, čemuž se věnuje náš článek o mezipaměti vykreslování a výkonu přiblížení. Jak zrušitelné vykreslování zapadá do kompletního prohlížeče vedle navigace, výběru a vyhledávání naleznete v článku Vytvoření funkcemi nabitého prohlížeče PDF s VCL komponentou PDFium. Zde popsané progresivní vykreslování je součástí PDFium Component pro Delphi a Lazarus, společně s API pro načítání, vykreslování a formuláře pokrytými jinde na tomto blogu