A legtöbb PDF-oldal néhány ezredmásodperc alatt raszterizálódik, és sosem gondol rá. Aztán egy felhasználó megnyit egy A1-es mérnöki rajzot, egy olyan oldalt, amely tele van több tízezer vektoros vonallal, vagy egy átlátszósági csoportokkal (transparency groups) és lágy maszkokkal (soft masks) zsúfolt posztert, és az az egyetlen hívás, amely megfesti, két-három másodpercet is igénybe vesz. Ha ez a hívás az UI szálon fut, az ablak nem frissül, a címsor kiszürkül, és az operációs rendszer felajánlja az alkalmazás bezárását. A munka legitim. Az oldalnak tényleg ennyi időre van szüksége. A hiba az, hogy a megjelenítés egyetlen oszthatatlan (indivisible) blokkoló hívás, amelyből nem lehet feljönni levegőért, és nincs mód a megállítására
Ez a cikk pontosan az egyikről szól a fenti két probléma közül: egy hosszú, egyoldalas megjelenítés megszakításáról a felhasználói felület (UI) lefagyasztása nélkül. A felhasználó a következő oldalra kattintott, nagyított, vagy bezárta a dokumentumot, és a folyamatban lévő megjelenítés (render in flight) immár kárba veszett munka, amelynek a következő adandó alkalommal be kellene fejeződnie, ahelyett, hogy végigfutna. A görgetés (scroll) és a nagyítás (zoom) simítása a már raszterizált elemek gyorsítótárazásával (caching) egy különálló kérdés a maga sajátos kialakításával, amellyel a cikk végén linkelt kapcsolódó cikk foglalkozik. Itt az egyetlen kérdés az, hogy hogyan lehet egy progresszív megjelenítést rávenni arra, hogy gyorsan és tisztán reagáljon a megszakítási kérésre
A progresszív renderelési API, amelyet a PDFium már tartalmaz
A PDFium előre látta a probléma lefagyasztó felét. Az egyszeri (one-shot) FPDF_RenderPageBitmap mellett elérhetővé tesz egy progresszív változatot is, amely egy oldalt kisebb munkafolyamatokra (chunks) oszt. Egyszer hívja meg a FPDF_RenderPageBitmap_Start függvényt a célbittérképhez tartozó megjelenítés beállításához, majd ismételten a FPDF_RenderPage_Continue függvényt. Minden egyes Continue egy behatárolt szeletet (slice) raszterizál és egy állapotot ad vissza. Az FPDF_RENDER_TOBECONTINUED azt jelenti, hogy van még tennivaló, a FPDF_RENDER_DONE azt, hogy az oldal kész, az FPDF_RENDER_FAILED pedig azt, hogy hibával állt le. Amikor a ciklus véget ér, meghívja az FPDF_RenderPage_Close-t, hogy felszabadítsa az oldalankénti (per-page) progresszív állapotot. Mivel a vezérlés (control) visszatér a kódjához a szeletek között, kiküldheti az üzeneteket, frissítheti a folyamatjelzőt, vagy ellenőrizheti, hogy a munkára még mindig szükség van-e
A PDFium által biztosított mechanizmus az átadás (yield) idejének eldöntésére egy IFSDK_PAUSE nevű visszahívási struktúra (callback struct). Ezt átadja a Start-nak és minden egyes Continue-nak. Minden egyes rész (chunk) után a PDFium meghívja a saját NeedToPauseNow függvénymutatóját, és ha az nem nulla értéket (non-zero value) ad vissza, az aktuális Continue idő előtt leáll, és az FPDF_RENDER_TOBECONTINUED értékkel adja vissza a vezérlést. A struktúra hordoz egy version mezőt is, amelyet 1-re kell állítani, és egy kötetlen (free-form) user mutatót (pointer), amelyet a PDFium soha nem érint, és érintetlenül enged át. Ez az érintetlen mutató a sarkalatos pontja a következő kialakításnak (design)
A szüneteltetés átdolgozása megszakításra
A NeedToPauseNow eredeti célja az időszeletelés (time-slicing). Adjon vissza egy nem nulla értéket, ha a képkocka-keret (frame budget) kimerült, adjon vissza nullát, ha folytatni kívánja a megjelenítést, a PDFium pedig szünetet tart, hogy valami mást csinálhasson, mielőtt folytatná (resume) ugyanazt a renderelést. A PDFium Component ugyanazt a jelet használja fel újra egy másik igére. Ahelyett, hogy azt válaszolná, hogy "szüneteltessem-e és hagyjam, hogy folytasd", a visszahívás (callback) arra válaszol, hogy "megszakították-e ezt a munkát". A kettő tisztán leképezi egymást amiatt, amit a ciklus tesz, amikor meglátja a jelzőt. Egy igazi szünet (pause) egy későbbi Continue-t vár; egy megszakítás (cancel) nem. Amint a hívó ciklus (calling loop) észleli, hogy a token megszakítva lett, bezárja a renderelési környezetet (render context), és soha többé nem hívja a Continue-t, tehát ugyanaz a nem nulla visszatérés, amelyet a PDFium úgy olvas, hogy "állítsd le ezt a részt (chunk)", a gyakorlatban "állítsd le végleg"-gé válik
A megszakítás (cancellation) egy interfészen (IPdfCancellationToken) keresztül fejeződik ki, amelynek IsCancelled tulajdonsága hamisról igazra vált, amikor a program egy másik része kéri a megjelenítés leállítását. A híd ez a Pascal-interfész és a PDFium C-s visszahívása (callback) között egyetlen mutató. A token interfész-hivatkozása bekerül az IFSDK_PAUSE.user mezőbe, és egy statikus cdecl visszahívás olvassa ki onnan és kérdezi le. Ez egy klasszikus problémája annak, hogy hagyjuk a C-könyvtárat visszahívni a Pascalba: a visszahívásnak egy egyszerű függvénynek (plain function) kell lennie C-s hívási konvencióval (C calling convention), nem pedig egy metódusnak, mert a PDFium egy csupasz (bare) függvénymutatót tárol és hív meg, amely semmit sem tud a Pascal objektumokról vagy a Self-ről
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;
A visszahívás (callback) visszanyeri a tokent a pThis^.user visszaalakításával (casting) az interfész típusra, és kiolvassa az IsCancelled értéket. Semmi sem foglal memóriát benne, nem zárol és nem is blokkol, ami számít, mert a PDFium minden rész (chunk) után meghívja a megjelenítő szálon (rendering thread), és bármely itt végzett munka hozzáadódik a megjelenítés tényleges költségéhez (cost). A nil struktúra vagy egy nil user mező elleni védelem azt jelenti, hogy ugyanazt a függvényt biztonságosan lehet telepíteni még olyan renderelés esetén is, amelyhez soha nem adtak igazi tokent
A token életben tartása a cikluson keresztül
Egy interfész mutató (interface pointer) átalakítása (casting) egy nyers (raw) Pointer-en keresztül oda és vissza – ez az a pont, ahol az élettartammal kapcsolatos hibák (lifetime bugs) születnek. Az IInterface a Delphiben referenciaszámlált (reference counted), és a szám csak akkor mozdul el, amikor a fordító (compiler) lát egy interfész típusú változót hozzárendelve. Ha a tokent csak puszta mutatóként (bare pointer) tárolnánk az IFSDK_PAUSE.user belsejében, az teljesen elrejtené a referenciaszámláló elől. Ha az erre a tokenre vonatkozó egyetlen másik hivatkozás kikerülne a hatókörből, miközben a Continue ciklus még futna, az objektum felszabadulna a visszahívás (callback) alól, és a következő részlet (chunk) már egy lógó mutatót (dangling pointer) dereferálna
Ez az oka annak, hogy a leíró (descriptor) egy rekord (record), amely nem egy, hanem két dolgot tartalmaz. A Pause mező az a struktúra (struct), amelyet a PDFium olvas. A Token mező egy valódi, interfész típusú hivatkozás (reference), amelyet a fordító számol, és nem létezik más okból, mint hogy rögzítse a tokent a memóriában, amíg a rekord él. A rekord egy helyi változó (local variable) a renderelési rutin veremében (stack), így a ciklus teljes időtartama alatt érvényes marad, és csak a rutin kilépésekor épül le (torn down). A user-ben lévő csupasz mutató és a Token-ben lévő számlált hivatkozás (counted reference) ugyanazt az objektumot nevezi meg; az egyik az, amit a PDFium olvasni tud, a másik az, ami megakadályozza, hogy az objektumot begyűjtsék (collected)
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);
A megjelenítési kontextus lezárása, függetlenül a ciklus végétől
Az FPDF_RenderPageBitmap_Start minden hívása lefoglal egy progresszív állapotot, amelyet a PDFium az oldalhoz társít, és ezt az állapotot csak a FPDF_RenderPage_Close szabadítja fel. Háromféleképpen lehet kijutni a hajtó ciklusból (drive loop). Az oldal befejeződik, és az utolsó állapot FPDF_RENDER_DONE lesz. A token billen, és a ciklus korán kilép megszakítást (cancellation) jelentve. Valami meghibásodik, és az állapot FPDF_RENDER_FAILED lesz. Mindháromnak a Close-t kell meghívnia, és a megszakítási utat lehet a legkönnyebben elrontani, mert a "látom a megszakítást, kilépek" (see cancel, break out) természetes formája hajlamos kihagyni a tisztítást (cleanup) a kijárat (exit) felé vezető úton. Ha a Close elérhetetlen marad, akkor az oldalankénti (per-page) állapot kiszivárog (leak), és egy olyan megjelenítő (viewer), amely lehetővé teszi a felhasználó számára, hogy egymás után szakítsa meg a megjelenítéseket, minden egyes leállított oldalon halmozná a memóriaszivárgást (leak)
A robusztus alak (robust shape) a ciklust és az eredményosztályozást (result classification) egy try belsejébe helyezi, a FPDF_RenderPage_Close-t pedig az ehhez illeszkedő finally-ba. A célbittérképet (destination bitmap) ugyanabban a blokkban semmisítik meg. A megszakítás kivezethet a ciklusból egy korai Exit-en keresztül is, a finally blokk akkor is lefut, így pontosan egyetlen hely van, amely felszabadítja a progresszív állapotot, és ezt nem lehet megkerülni
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;
A ciklus minden egyes Continue előtt ellenőrzi a tokent, valamint a benne lévő visszahívásra (callback) támaszkodik. A visszahívás lerövidíti az aktuális részt (chunk); a ciklusellenőrzés (loop check) megállítja a következő elkezdését. Együttesen ők korlátozzák azt, hogy a megszakítás nagyjából egy részletnyi idő (one chunk) alatt érvénybe lépjen
Három kimenetel, és az, hogy mit tartalmaz a bittérkép megszakítás után
A nyilvános belépési pont a TPdf.RenderPageProgressive, és egy TPdfProgressiveStatus-t ad vissza, amely prsDone, prsCancelled, vagy prsFailed lehet. Az értékek a PDFium FPDF_RENDER_* állandóit (constants) tükrözik a Pascal idiómában, de a megszakítási esetet is magukba foglalják első osztályú eredményként (first-class result), nem pedig hibaként
Ami meglepi az embereket az az, hogy a célbittérkép (destination bitmap) mit tartalmaz a prsCancelled után. Nem üres (blank). A PDFium progresszíven jelenít meg ugyanabba a bittérképbe részletről (chunk) részletre, így amikor egy megszakítás leállítja a ciklust, a bittérkép azt tartalmazza, ami addig a pillanatig fel lett festve, vagyis egy részleges képet (partial image): néhány sáv kész, a többi még mindig a kitöltőszínt (fill colour) mutatja. Az, hogy ez a részleges eredmény hasznos-e, a hívótól (caller) függ. Egy megjelenítő (viewer), amely épp arra készül, hogy eldobja a bittérképet, mert a felhasználó máshová navigált, egyszerűen figyelmen kívül hagyhatja. Egy olyan megjelenítő, amely egy alacsony költségű előnézetet (low-cost preview) akar mutatni, megtarthatja. Amit semmiképp sem szabad feltételeznie, az az, hogy a prsCancelled egy üres vagy definiálatlan bittérképet von maga után; hanem egy befejezetlen megjelenítés valósághű pillanatfelvételét (snapshot)
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;
A nil token és az elágazásmentes visszahívási út
A megszakítás opcionális (opt-in). Egy olyan hívó (caller), aki csak a progresszív renderelést akarja az üzenet-szivattyúzás (message-pumping) előnyei miatt, és nem áll szándékában a megszakítás, annak képesnek kell lennie nullát (nil) átadni tokenként. A naiv módja ennek támogatására az, hogy a visszahívásban (callback) és a ciklusban (loop) "ha egy tokent adtak át" (if a token was supplied) ellenőrzéseket szórnak szét, ami minden résznél (chunk) elágazást jelent, valamint egy olyan visszahívást, amelynek egyszerre kell kezelnie egy valós tokent és annak hiányát
A megvalósítás ezt úgy kerüli el, hogy egy egykét (singleton) helyettesít be, amikor a hívó semmit sem ad át. A nil tokent kicserélik a PdfNoCancellationToken-re, egy interfészre, amelynek IsCancelled állapota mindig hamis (false). Ettől a ponttól kezdve a visszahívásnak és a ciklusnak minden esetben van egy lekérdezhető tokenje, így egyiknek sincs szüksége a nil-ellenőrzésre, és nincs szükség külön elérési útra sem. A soha-meg-nem-szakadó token egyszerűen mindig hamissal (false) válaszol, a visszahívás mindig nullát ad vissza, és a renderelés pontosan úgy lefut a végéig, ahogyan egy meg nem szakítható tenné. Az opcionális viselkedést nem a token hiányaként modellezik, hanem olyan tokenként, amely soha nem lép működésbe, így tartva egységesen (uniform) a forró útvonalat (hot path)
// 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;
A kialakult forma kicsi, és érdemes újra megfogalmazni, mert ez az újrahasználható része. Egy visszahívást (callback) támogató C könyvtár pontosan egy csatornát (channel) biztosít az állapot átadására a visszahívásba, az átlátszatlan (opaque) user mutatót (pointer). Helyezzen egy számlált (counted) Pascal interfész-hivatkozást a mutató mögé, tartson életben egy második valódi hivatkozást a struktúra (struct) mellett, hogy az objektumot ne lehessen menet közben begyűjteni (collected mid-call), és olvassa vissza az interfészt egy statikus cdecl függvényen belül. Csomagolja be (wrap) a teljes hajtó ciklust (drive loop) egy try blokkba, és szabadítsa fel a natív kontextust a finally-ban. Ugyanez a sablon (template) átvihető bármelyik progresszív vagy visszahívás-vezérelt (callback-driven) PDFium-műveletre, ahol a Pascal-kódnak meg kell tartania az irányítást az élettartam (lifetime) felett, miközben a C egy mutatót tart a kezében
A megszakítás (cancellation) csupán az egyik fele a reszponzív (responsive) megjelenítőnek. A másik fele az, hogy ne renderelje újra azokat az oldalakat, amelyeket már megrajzolt, és a görgetés és a nagyítás egyenletességét is őrizze meg a gyorsítótárazott (cached) bittérképek kiszolgálásával; ezt a renderelés gyorsítótárazásáról és a zoomolás teljesítményéről szóló cikkünk mutatja be. Hogy a megszakítható (cancellable) megjelenítés miként illeszkedik egy teljes értékű megjelenítőbe a navigáció, a kiválasztás és a keresés mellé, tekintse meg a funkciókban gazdag PDF-megjelenítő felépítése a PDFium VCL-komponenssel című anyagot. Az itt leírt progresszív renderelés a Delphi és Lazarus rendszerekhez készült PDFium Component részeként érkezik a betöltő (loading), a megjelenítő (rendering) és az űrlap (form) API-k mellett, amelyek a blogon máshol is megtalálhatók