Articol tehnic

Vizualizator PDFium Delphi: cache de randare și zoom fluid

Țineți apăsat butonul de zoom într-un vizualizator PDF naiv și priviți graficul CPU. O singură apăsare a unui control de zoom cu auto-repetare declanșează o duzină sau mai mulți pași de zoom pe secundă, iar dacă fiecare pas pornește o re-randare la calitate maximă a paginii vizibile, randările se acumulează mai repede decât se termină. Pagina se rasterizează perfect în izolare, poate 180 ms pentru o scanare A4, dar acum rulați o duzină de randări de 180 ms pentru o muncă pe care utilizatorul deja a depășit-o. Vizualizatorul se blochează, un nucleu rămâne fixat la 100%, iar până când ecranul recuperează, utilizatorul s-a oprit la un nivel de zoom de acum patru randări. Remediul nu este un rasterizator mai rapid. Este un cache care returnează instantaneu paginile terminate și o buclă de randare dispusă să abandoneze munca de îndată ce devine învechită

PDFium Component vă oferă piesele pentru ambele și rămâne în afara politicii. Primiți bitmap-uri deținute de apelant, un randator progresiv care acceptă un token de anulare, moduri de încadrare care recalculează zoom-ul la redimensionare și un apel de tiling pentru pagini prea mari pentru a fi rasterizate întregi. Ceea ce deliberat nu oferă este cache-ul în sine, deoarece politica corectă de evicțiune depinde de viewport-ul dvs., de plafonul de memorie al platformei dvs. și de modul în care derulează utilizatorii dvs. Această decizie vă aparține, iar consecințele unei rezolvări greșite sunt exact blocajul și scurgerea de memorie

Unde se duc milisecundele și megabyții

Puneți cifre pe cost înainte să proiectați ceva. O pagină A4 la 96 DPI are aproximativ 794 pe 1123 de pixeli, adică aproximativ 3,5 MB ca bitmap pe 32 de biți. Măriți la 200%, și asta se cvadruplează. La 400% pe un afișaj cu DPI ridicat, alocați și umpleți un singur bitmap de pagină de 50 până la 60 MB, iar un vizualizator cu derulare continuă păstrează mai multe pagini active simultan. Costul rasterizării urmărește pixelii de ieșire, așa că fiecare dublare a zoom-ului cvadruplează aproximativ atât timpul de randare, cât și memoria, împreună

Două consecințe decurg direct din acea aritmetică. Un cache a cărui cheie ignoră nivelul de zoom este inutil, deoarece exact gestul pe care trebuie să îl accelereze, zoom-ul, produce un bitmap nou de fiecare dată. Iar un cache nelimitat va epuiza spațiul de adrese al unui proces pe 32 de biți exact pe documentele unde oamenii măresc cel mai mult: scanări dense de acte de proprietate, desene de inginerie, hărți de format mare. Cache-ul trebuie să fie cheiat corect și plafonat ferm, iar niciuna dintre cele două nu este opțională

Ce aparține cheii de cache

Un bitmap din cache este sigur de refolosit doar atunci când fiecare intrare care i-a modelat pixelii încă se potrivește. Asta înseamnă numărul paginii, zoom-ul efectiv (sau echivalent, dimensiunile în pixeli de ieșire), rotația, DPI-ul monitorului și opțiunile de randare în vigoare la momentul producerii lui. O pagină randată cu reAnnotations este o imagine diferită de aceeași pagină fără ele, iar o trecere grayscale prin reGrayscale este din nou diferită. Omiteți oricare dintre acestea din cheie, iar erorile devin previzibile: un overlay de adnotare care persistă după ce un recenzor șterge comentariul, sau o pagină care devine neclară în clipa în care un utilizator trage fereastra de pe ecranul unui laptop pe un monitor extern 4K, iar DPI-ul se schimbă sub un bitmap învechit

Căutare în cache-ul de redare PDFium într-un vizualizator Delphi, în care cheia de cache combină pagina, zoom-ul, rotația, DPI-ul monitorului și opțiunile de redare, un hit returnează bitmap-ul în microsecunde, iar evacuarea eliberează fiecare bitmap pe care îl aruncă
Cheia de cache acoperă fiecare input care modelează pixelii, iar evacuarea eliberează bitmap-urile pe care le aruncă
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);   // cache-ul deține acum acest bitmap
end;

La un hit, aceasta returnează în microsecunde, ceea ce este exact scopul. Întrebarea mai grea este ce se întâmplă cu bitmap-urile care ies din cache, iar aceasta se dovedește a fi o întrebare despre cine le deține

Cine eliberează bitmap-ul

Forma de funcție a RenderPage returnează un TBitmap pe care apelantul îl deține. Într-un export dintr-o singură lovitură, această deținere este evidentă și ușor de respectat. Într-un cache, devine cea mai comună scurgere de memorie din vizualizatoarele PDF Delphi, deoarece dicționarul deține acum singura referință către fiecare bitmap, iar un TDictionary simplu vă eliberează cheile și valorile doar dacă acestea sunt tipuri gestionate. Un TBitmap nu este. Evacuați o intrare fără să apelați Free, iar pixelii rămân alocați fără nimic care să indice spre ei

Motivul pentru care asta trece neobservat este cronologia. Un test de fum de zece minute nu mărește niciodată suficiente pagini distincte pentru a observa; scurgerea se arată abia după ce cineva a derulat și a mărit un document lung timp de câteva ore, moment în care procesul deține sute de bitmap-uri de pagină orfane, iar mașina începe să pagineze memoria. De aceea evicțiunea aparține primei versiuni a cache-ului, nu uneia ulterioare. Plafonați cache-ul după bytes estimați, calculați ca lățime înmulțită cu înălțime înmulțită cu patru, evacuați paginile cel mai puțin recent folosite care se află în afara viewport-ului și a ferestrei de prefetch, și eliberați fiecare bitmap pe măsură ce îl eliminați. Pentru desenări cu adevărat tranzitorii, supraîncărcările care randează într-un TBitmap furnizat de apelant sau direct pe un HDC vă permit să evitați complet dansul deținerii. Previzualizarea de tipărire este cazul evident, deoarece randați fiecare coală o singură dată, iar stocarea ei în cache nu aduce niciun beneficiu

Randare progresivă și anulare onestă

Supraîncărcările simple ale RenderPage blochează până când pagina este terminată, ceea ce este exact comportamentul pe care nu îl doriți în timp ce utilizatorul încă mișcă controlul de zoom. Pentru asta apelați la RenderPageProgressive. Aceasta primește un IPdfCancellationToken și returnează unul dintre prsDone, prsCancelled sau prsFailed. Detaliul comportamental care încurcă lumea este că anularea nu este instantanee. Token-ul este verificat la limitele de bloc din interiorul randării, așa că un token pe care îl semnalați la mijlocul unui bloc are efect abia când acel bloc se termină. Pe o pagină complexă, latența dintre cerere și oprire ajunge la zeci de milisecunde. Proiectați ținând cont de acest decalaj, în loc să sperați că nu există: anulați token-ul anterior în clipa în care sosește o nouă valoare de zoom, dar nu presupuneți că randarea veche se oprește în clipa în care o cereți

Cronologie a redării progresive PDFium în Delphi, în care fiecare cerere nouă de zoom anulează token-ul anterior, anularea aterizează la o graniță de segment, redările depășite returnează prsCancelled, iar încercarea finală returnează prsDone
Fiecare cerere nouă de zoom anulează token-ul de randare anterior, iar anularea aterizează la o frontieră de chunk
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandonează randarea anterioară aflată în desfășurare
  FTokenSource := TPdfCancellationTokenSource.New;  // unitatea FPdfAsync

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

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // înlocuit de o cerere mai nouă: se ignoră silențios
    prsFailed:    ShowRenderFailure;
  end;
end;

În timpul interacțiunii, prsCancelled este rezultatul normal, nu cel excepțional. Majoritatea randărilor pe care le pornește un gest de zoom vor fi înlocuite înainte să se termine, așa că tratați anularea ca fiind de rutină și ignorați rezultatul silențios. O coadă de randare care jurnalizează fiecare anulare ca avertisment va îngropa singurul eșec care chiar contează sub mii de linii de zgomot. Pentru a împiedica ecranul să pară blocat cât timp rulează randarea reală, combinați calea progresivă cu un înlocuitor ieftin: scalați bitmap-ul anterior din cache la noul zoom și prezentați-l imediat. Arată neclar pentru o sută sau două de milisecunde, dar se citește ca instantaneu și cumpără randării la calitate maximă timpul de care are nevoie fie să se termine, fie să fie anulată de următorul gest

Modul de încadrare pe care zoom-ul îl dezactivează în tăcere

Proprietatea FitMode a unui vizualizator, setată la pfmFitPage sau pfmFitWidth, recalculează zoom-ul la fiecare redimensionare, astfel încât pagina continuă să se încadreze pe măsură ce fereastra se schimbă. Capcana este că atribuirea directă a Zoom readuce FitMode la pfmNone. Ca valoare implicită, asta este corect: un utilizator care a tastat deliberat 150% nu vrea ca următoarea redimensionare a ferestrei să arunce asta. Dar surprinde pe oricine conectează un buton de mărire ca Zoom := Zoom * 1.25 și apoi nu poate să-și dea seama de ce încadrarea pe lățime a încetat să răspundă după primul clic. Dacă bara dvs. de instrumente oferă atât zoom explicit, cât și moduri de încadrare, trebuie să vă amintiți dvs. înșivă ultima alegere de încadrare a utilizatorului și să o reatribuiți când acesta apasă din nou butonul de încadrare. Componenta nu va restaura un mod pe care o atribuire de zoom tocmai l-a șters, și nici nu ar trebui să o facă

Un buget de memorie pe care îl puteți apăra

Un buget pe care îl puteți scrie este un buget pentru care puteți argumenta într-un code review, așa că plecați de la un scenariu concret. Să spunem că derularea continuă păstrează pagina vizibilă plus câte o pagină pre-încărcată deasupra și dedesubt, alături de o bandă de miniaturi. La 100% pe un afișaj de 96 DPI, acele trei bitmap-uri la dimensiune completă ajung la aproximativ 3,5 MB fiecare, ceea ce nu înseamnă nimic. La 300% pe un afișaj 4K, aceleași trei bitmap-uri sunt de aproximativ 30 MB fiecare, iar asta înainte ca cache-ul să fi reținut vreo pagină istorică. Creșterea se află în gest, nu în document

Aritmetica memoriei bitmap PDFium pentru un vizualizator Delphi, în care fiecare dublare a zoom-ului quadruplează memoria paginii, derularea continuă ține trei pagini vii, un buget LRU limitat apără cache-ul, iar RenderTile tratează desenele supradimensionate
Fiecare dublare de zoom cvadruplează memoria bitmap, astfel încât cache-ul are nevoie de un plafon dur și de dale pentru paginile supradimensionate

O valoare implicită rezonabilă pentru un proces Delphi pe 32 de biți este un buget de bitmap-uri de 256 MB sub evicțiune LRU. Pe 64 de biți puteți scala odată cu RAM-ul fizic, dar păstrați totuși un plafon dur, deoarece eșecul de care vă protejați nu este blocarea procesului dvs. Este întreaga mașină care își zdruncină fișierul de pagină în timp ce vizualizatorul dvs. rulează tehnic în continuare, iar utilizatorul se întreabă de ce a încetinit totul. Un plafon dur eșuează previzibil; un cache nelimitat eșuează luând desktopul cu el. Miniaturile merită propriul tratament: randați fiecare o singură dată la dimensiunea ei mică țintă și păstrați-o într-un pool separat pe care logica LRU nu îl atinge niciodată. Regenerarea unei miniaturi de 120 de pixeli prin micșorarea unui bitmap de pagină întreagă de 60 MB este cel mai risipitor mod posibil de a produce un timbru poștal

Unele pagini singulare depășesc orice buget. Un desen de inginerie de dimensiune E sau o hartă mare randată integral la 400% este o alocare de sute de megabytes, iar nicio politică de evicțiune nu face asta acceptabil. Răspunsul acolo este să încetați să randați pagini întregi. RenderTile rasterizează doar regiunea de la offset-ul în pixeli (Left, Top) în interiorul unei pagini scalate teoretic la PageWidth pe PageHeight, așa că randați doar dreptunghiul vizibil plus o marjă de o singură țiglă (tile) în jurul lui pentru panoramare fluidă, iar offset-urile de țiglă le includeți în cheia de cache alături de zoom. Păstrați dimensiunile țiglelor fixe pe tot documentul. Țiglele fixe înseamnă că o schimbare de DPI invalidează întreaga grilă curat, în timp ce țiglele variabile vă lasă să urmăriți cusături vizibile între regiuni randate la scări ușor diferite

Două funcționalități adiacente se adaugă în tăcere la toate acestea. Trecerile de filtru de culoare precum grayscale sau inversarea rulează după randare și produc de fiecare dată un al doilea bitmap la dimensiune completă, dublând amprenta per pagină a oricărei vizualizări care le folosește; acest cost face obiectul filtrării de culoare pentru deficiențe de vedere în vizualizatoare PDF Delphi. Iar un vizualizator care evidențiază cuvintele în timpul conversiei text-vorbire invalidează vizualizarea randată la fiecare cuvânt rostit, așa că interacțiunea dintre redesenările de evidențiere și viteza vorbirii contează mai mult decât pare la prima vedere, așa cum este tratat în evidențierea TTS cuvânt cu cuvânt

Supraîncărcările de randare, codurile de stare progresive și componenta de vizualizare în sine sunt documentate pe pagina de produs a PDFium Component