Technical Article

Delphi PDFium čitač: Keširanje iscrtavanja i taktike glatkog zumiranja

Držite pritisnuto dugme za zumiranje u jednostavnom PDF čitaču i posmatrajte grafikom CPU-a. Jedan pritisak na kontrolu zumiranja sa automatskim ponavljanjem ispaljuje desetak ili više koraka zumiranja u sekundi, i ako svaki korak pokrene iscrtavanje vidljive stranice u punom kvalitetu, iscrtavanja se gomilaju brže nego što se završavaju. Stranica se dobro rasterizuje u izolaciji, možda 180 ms za A4 skeniranje, ali sada pokrećete desetak iscrtavanja od 180 ms u odnosu na posao koji je korisnik već prošao. Čitač se zaključava, jezgro se zakucava na 100%, a dok se ekran ažurira, korisnik se zaustavio na nivou zumiranja od pre četiri iscrtavanja. Lek nije brži rasterizator. To je keš koji trenutno vraća završene stranice i petlja iscrtavanja koja je spremna da napusti posao onog trenutka kada postane zastareo.

PDFium komponenta vam predaje delove za oba i ostaje van politike. Dobijate bitmape u vlasništvu pozivaoca, progresivni render koji prihvata token otkazivanja, režime uklapanja koji ponovo izračunavaju zum pri promeni veličine i poziv za popločavanje (tiling) za stranice koje su prevelike da bi se rasterizovale cele. Ono što namerno ne pruža jeste sam keš, jer ispravna politika izbacivanja zavisi od vašeg prikaza, memorijskog plafona vaše platforme i načina na koji vaši korisnici skroluju. Ta odluka je na vama da je ispravno donesete, a posledice pogrešne odluke su upravo zamrzavanje i curenje memorije.

Gde odlaze milisekunde i megabajti

Postavite brojeve na cenu pre nego što bilo šta dizajnirate. A4 stranica na 96 DPI je otprilike 794 sa 1123 piksela, oko 3.5 MB kao 32-bitna bitmapa. Zumirajte na 200% i to se učetvorostručuje. Na 400% na ekranu visoke rezolucije (high-DPI) alocirate i popunjavate bitmapu jedne stranice od 50 do 60 MB, a čitač sa neprekidnim skrolovanjem drži nekoliko stranica aktivnim odjednom. Trošak rasterizacije prati izlazne piksele, tako da svako dupliranje zumiranja otprilike učetvorostručuje i vreme iscrtavanja i memoriju zajedno.

Dve posledice proizilaze direktno iz te aritmetike. Keš čiji ključ ignoriše nivo zumiranja je bezvredan, jer upravo pokret koji treba da ubrza, zumiranje, svaki put proizvodi novu bitmapu. Pored toga, neograničeni keš će pokrenuti 32-bitni proces van adresnog prostora upravo na dokumentima gde ljudi najviše zumiraju: gusti skenovi vlasničkih listova, inženjerski crteži, mape velikog formata. Keš mora biti ispravno ključevan i čvrsto ograničen, i nijedno od toga nije opciono.

Št pripada ključu keša

Keširanu bitmapu je bezbedno ponovo koristiti samo kada se svaki unos koji je oblikovao njene piksele i dalje poklapa. To znači broj stranice, efektivni zum (ili ekvivalentno dimenzije izlaznog piksela), rotacija, DPI monitora i opcije iscrtavanja koje su bile na snazi kada je proizvedena. Stranica iscrtana sa reAnnotations je drugačija slika od iste stranice bez njih, a sivi prolaz kroz reGrayscale je ponovo drugačiji. Ispustite bilo koji od ovih iz ključa i greške su predvidljive: prekrivač anotacije koji se zadržava nakon što pregledač obriše komentar, ili stranica koja postaje zamućena čim korisnik prevuče prozor sa ekrana laptopa na spoljni 4K monitor i DPI se promeni ispod zastarele bitmape.

function TPageCache.Acquire(Pdf: TPdf; PageNo: Integer; ZoomPct: Single;
  Rotation: TRotation; Opts: TRenderOptions): TBitmap;
var
  Key: string;
begin
  Key := Format('%d|%.0f|%d|%d|%d',
    [PageNo, ZoomPct, Ord(Rotation), Screen.PixelsPerInch, OptionsMask(Opts)]);
  if FBitmaps.TryGetValue(Key, Result) then
    Exit;

  Pdf.PageNumber := PageNo;
  Result := Pdf.RenderPage(0, 0, OutputWidth(PageNo, ZoomPct),
    OutputHeight(PageNo, ZoomPct), Rotation, Opts);
  FBitmaps.Add(Key, Result);   // the cache now owns this bitmap
end;

Pri pogotku ovo se vraća u mikrosekundama, što je i poenta. Teže pitanje je šta se dešava sa bitmapama koje ispadnu iz keša, a to se ispostavlja kao pitanje o tome ko ih poseduje.

Ko oslobađa bitmapu

Funkcijski oblik RenderPage vraća TBitmap koji pozivalac poseduje. U jednokratnom izvozu to vlasništvo je očigledno i lako ga je poštovati. Unutar keša to postaje najčešće curenje memorije u Delphi PDF čitačima, jer rečnik sada drži jedinu referencu na svaku bitmapu, a običan TDictionary oslobađa ključeve i vrednosti za vas samo ako su u pitanju upravljani tipovi. TBitmap to nije. Izbacite unos bez pozivanja Free i pikseli ostaju alocirani sa ničim što ukazuje na njih.

Razlog zašto ovo prolazi jeste tajming. Desetominutni brzi test nikada ne zumira dovoljno različitih stranica da bi se to primetilo; curenje se pokazuje tek nakon što je neko skrolovao i zumirao dugačak dokument par sati, u kom trenutku proces drži stotine siročadi bitmapa stranica i mašina počinje da koristi virtuelnu memoriju (page file). Zato izbacivanje pripada prvoj verziji keša, a ne nekoj kasnijoj. Ograničite keš procenjenim bajtovima, izračunatim kao širina pomnožena sa visinom pomnožena sa četiri, izbacite najmanje nedavno korišćene stranice koje se nalaze izvan prikaza i prozora za prethodno učitavanje, i oslobodite svaku bitmapu kada je uklonite. Za iscrtavanja koja su zaista prolazna, preopterećenja koja iscrtavaju u TBitmap koji obezbeđuje pozivalac ili direktno na HDC omogućavaju vam da potpuno preskočite ples oko vlasništva. Pregled pre štampanja je očigledan slučaj, pošto svaku stranicu iscrtavate jednom i njeno keširanje ne donosi ništa.

Progresivno iscrtavanje i pošteno otkazivanje

Obična preopterećenja funkcije RenderPage blokiraju rad dok se stranica ne završi, što je upravo ponašanje koje ne želite dok korisnik još uvek pomera kontrolu zumiranja. Za to posežete za RenderPageProgressive. Ono prima IPdfCancellationToken i vraća jedno od prsDone, prsCancelled ili prsFailed. Detalj ponašanja koji hvata ljude jeste da otkazivanje nije trenutno. Token se proverava na granicama delova (chunks) unutar iscrtavanja, tako token koji signalizirate u sredini dela stupa na snagu tek kada se taj deo završi. Na složenoj stranici, latencija između traženja i zaustavljanja iznosi desetine milisekundi. Dizajnirajte oko tog jaza umesto da se nadate da ne postoji: otkažite prethodni token čim stigne nova vrednost zumiranja, ali nemojte pretpostavljati da se staro iscrtavanje zaustavlja onog trenutka kada to zatražite.

procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandon the previous in-flight render
  FTokenSource := TPdfCancellationTokenSource.New;  // FPdfAsync unit

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // superseded by a newer request: drop silently
    prsFailed:    ShowRenderFailure;
  end;
end;

Tokom interakcije, prsCancelled je normalan ishod, a ne izuzetan. Većina iscrtavanja koja pokret zumiranja započne biće zamenjena pre nego što se završe, pa otkazivanje tretirajte kao rutinu i tiho odbacite rezultat. Red za iscrtavanje koji beleži svako otkazivanje kao upozorenje zatrpaće jedan neuspeh koji je zapravo važan pod hiljadama linija šuma. Da ekran ne bi izgledao mrtvo dok se pokreće stvarni render, uparite progresivnu putanju sa jeftinom zamenom: skalirajte prethodnu keširanu bitmapu na novi zum i to prikažite odmah. Izgleda meko sto ili dve milisekunde, ali se očitava kao trenutno, i kupuje iscrtavanju punog kvaliteta vreme koje mu je potrebno da se završi ili da bude otkazano sledećim pokretom.

Režim uklapanja koji zumiranje tiho isključuje

Svojstvo čitača FitMode, postavljeno na pfmFitPage ili pfmFitWidth, ponovo izračunava zum pri svakoj promeni veličine tako da stranica nastavlja da se uklapa kako se prozor menja. Caka je u tome što direktno dodeljivanje Zoom vraća FitMode nazad na pfmNone. Kao podrazumevano ponašanje to je ispravno: korisnik koji je namerno ukucao 150% ne želi da sledeća promena veličine prozora to odbaci. Ali to iznenađuje svakoga ko poveže dugme za zumiranje kao Zoom := Zoom * 1.25 i onda ne može da shvati zašto je uklapanje po širini prestalo da reaguje nakon prvog klika. Ako vaša linija alatki nudi i eksplicitni zum i režime uklapanja, morate sami da zapamtite korisnikov poslednji izbor uklapanja i ponovo ga dodelite kada ponovo pritisne dugme za uklapanje. Komponenta neće vratiti režim koji je dodela zumiranja upravo očistila, niti bi trebala.

Budžet memorije koji možete odbraniti

Budžet koji možete napisati je budžet za koji se možete boriti u pregledu koda, pa počnite od konkretnog scenarija. Recimo da neprekidno skrolovanje drži vidljivu stranicu plus jednu unapred učitanu stranicu iznad i ispod, uporedo sa trakom sličica. Na 100% na ekranu od 96 DPI te tri bitmape pune veličine iznose oko 3.5 MB svaka, što je ništa. Na 300% na 4K ekranu iste tri bitmape su otprilike 30 MB svaka, i to pre nego što je keš zadržao ijednu istorijsku stranicu. Rast je u pokretu, a ne u dokumentu.

Razuman podrazumevani izbor za 32-bitni Delphi proces jeste budžet od 256 MB za bitmape pod LRU izbacivanjem. Na 64-bitnom sistemu možete skalirati sa fizičkom memorijom, ali bez obzira na to zadržite čvrst plafon, jer neuspeh od kojeg se branite nije rušenje vašeg procesa. To je situacija u kojoj cela mašina počinje da koristi virtuelnu memoriju (page file) dok vaš čitač tehnički nastavlja da radi, a korisnik se pita zašto je sve ostalo usporilo. Čvrsto ograničenje otkazuje predvidljivo; neograničeni keš propada tako što povlači ceo desktop sa sobom. Sličice (thumbnails) zaslužuju sopstveni tretman: iscrtajte svaku jednom u njenoj maloj ciljnoj veličini i držite je u posebnom bazenu koji LRU logika nikada ne dodiruje. Regenerisanje sličice od 120 piksela smanjivanjem bitmape cele stranice od 60 MB jeste najrasipniji mogući način za proizvodnju sličice.

Neke pojedinačne stranice poraze bilo koji budžet. Inženjerski crtež E-veličine ili velika mapa iscrtana u celosti na 400% jeste alokacija od više stotina megabajta, i nijedna politika izbacivanja to ne čini prihvatljivim. Odgovor je da prestanete sa iscrtavanjem celih stranica. RenderTile rasterizuje samo region na piksel pomeraju (Left, Top) unutar stranice koja je teoretski skalirana na PageWidth sa PageHeight, tako da iscrtavate samo vidljivi pravougaonik plus marginu od jedne pločice (tile) oko njega za glatko pomeranje, i pomeraje pločica slažete u ključ keša uporedo sa zumom. Držite dimenzije pločica fiksnim u celom dokumentu. Fiksne pločice znače da promena DPI-ja čisto invalida celu mrežu, dok promenljive pločice ostavljaju vidljive šavove između regiona iscrtanih u blago različitim skalama.

Dve susedne funkcije tiho doprinose svemu ovome. Prolazi filtera boja kao što su siva skala ili inverzija pokreću se nakon iscrtavanja i svaki put proizvode drugu bitmapu pune veličine, duplirajući otisak po stranici svakog prikaza koji ih koristi; taj trošak je tema filtriranja boja za slabovide za Delphi PDF čitače. Takođe, čitač koji ističe reči tokom teksta u govor invalidira iscrtani prikaz pri svakoj izgovorenoj reči, tako da je interakcija između ponovnih iscrtavanja isticanja i brzine govora važnija nego što se na prvi pogled čini, kao što je pokriveno u isticanju reč po reč pri čitanju naglas.

Preopterećenja iscrtavanja, progresivni statusni kodovi i sama komponenta čitača dokumentovani su na stranici proizvoda za PDFium Component.