Tenete premuto il pulsante di zoom in un viewer PDF ingenuo e guardate il grafico della CPU. Una sola pressione di un controllo di zoom con ripetizione automatica genera una dozzina o più di passi di zoom al secondo, e se ogni passo avvia un nuovo rendering a piena qualità della pagina visibile, i rendering si accumulano più in fretta di quanto si completino. La pagina si rasterizza bene se presa da sola, forse 180 ms per una scansione A4, ma adesso state eseguendo una dozzina di rendering da 180 ms su un lavoro che l'utente ha già superato. Il viewer si blocca, un core resta inchiodato al 100%, e quando lo schermo recupera il ritardo l'utente si è fermato a un livello di zoom di quattro rendering fa. La cura non è un rasterizzatore più veloce. È una cache che restituisce le pagine finite all'istante e un ciclo di rendering disposto ad abbandonare il lavoro nel momento in cui diventa obsoleto
PDFium Component vi consegna i pezzi per entrambe le cose e resta fuori dalla politica. Ottenete bitmap di proprietà del chiamante, un renderer progressivo che accetta un token di cancellazione, modalità di adattamento che ricalcolano lo zoom al ridimensionamento e una chiamata a piastrelle per le pagine troppo grandi da rasterizzare intere. Ciò che deliberatamente non fornisce è la cache in sé, perché la politica di sfratto giusta dipende dal vostro viewport, dal tetto di memoria della vostra piattaforma e da come i vostri utenti scorrono. Quella decisione tocca a voi e le conseguenze di sbagliarla sono esattamente il blocco e la perdita di memoria
Dove finiscono i millisecondi e i megabyte
Mettete dei numeri sul costo prima di progettare qualsiasi cosa. Una pagina A4 a 96 DPI è circa 794 per 1123 pixel, all'incirca 3,5 MB come bitmap a 32 bit. Portate lo zoom al 200% e la cifra quadruplica. Al 400% su un display high-DPI state allocando e riempiendo una singola bitmap di pagina da 50 a 60 MB, e un viewer a scorrimento continuo tiene vive più pagine contemporaneamente. Il costo di rasterizzazione segue i pixel in uscita, quindi ogni raddoppio dello zoom quadruplica all'incirca sia il tempo di rendering sia la memoria
Da quell'aritmetica discendono due conseguenze immediate. Una cache la cui chiave ignora il livello di zoom non vale nulla, perché proprio il gesto che dovrebbe accelerare, lo zoom, produce ogni volta una bitmap nuova. E una cache senza limiti esaurirà lo spazio di indirizzamento di un processo a 32 bit esattamente sui documenti su cui la gente zooma di più: scansioni catastali dense, disegni tecnici, mappe di grande formato. La cache deve avere una chiave corretta e un tetto fermo, e nessuna delle due cose è facoltativa
Che cosa appartiene alla chiave di cache
Una bitmap in cache è sicura da riusare soltanto quando ogni input che ne ha formato i pixel corrisponde ancora. Questo significa il numero di pagina, lo zoom effettivo (o in modo equivalente le dimensioni in pixel dell'output), la rotazione, i DPI del monitor e le opzioni di rendering in vigore quando è stata prodotta. Una pagina renderizzata con reAnnotations è un'immagine diversa dalla stessa pagina senza di esse, e una passata in scala di grigi attraverso reGrayscale è diversa ancora. Togliete anche uno solo di questi elementi dalla chiave e i bug sono prevedibili: un overlay di annotazione che resta dopo che un revisore ha cancellato il commento, oppure una pagina che diventa sfocata nell'istante in cui un utente trascina la finestra dal pannello del portatile a un monitor 4K esterno e i DPI cambiano sotto una bitmap obsoleta
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); // ora la cache possiede questa bitmap
end;
In caso di hit questo ritorna in microsecondi, ed è tutto il punto della faccenda. La domanda più difficile è che cosa succede alle bitmap che escono dalla cache, e si rivela essere una domanda su chi le possiede
Chi libera la bitmap
La forma a funzione di RenderPage restituisce una TBitmap di proprietà del chiamante. In una esportazione una tantum quella proprietà è ovvia e facile da rispettare. Dentro una cache diventa la perdita di memoria più comune in assoluto nei viewer PDF Delphi, perché ora il dizionario detiene l'unico riferimento a ogni bitmap, e un semplice TDictionary libera chiavi e valori per voi solo se sono tipi gestiti. Una TBitmap non lo è. Sfrattate una voce senza chiamare Free e i pixel restano allocati senza nulla che li punti
Il motivo per cui questo passa inosservato è il tempo. Un test di fumo di dieci minuti non zooma mai abbastanza pagine distinte da accorgersene; la perdita si manifesta solo dopo che qualcuno ha scorso e zoomato un documento lungo per un paio di ore, e a quel punto il processo trattiene centinaia di bitmap di pagina orfane e la macchina inizia a paginare. Ecco perché lo sfratto appartiene alla prima versione della cache, non a una successiva. Limitate la cache per byte stimati, calcolati come larghezza per altezza per quattro, sfrattate le pagine usate meno di recente che stanno fuori dal viewport e dalla finestra di prefetch, e liberate ogni bitmap mentre la rimuovete. Per i disegni davvero transitori, gli overload che renderizzano dentro una TBitmap fornita dal chiamante o direttamente su un HDC vi permettono di saltare del tutto la danza della proprietà. L'anteprima di stampa è il caso ovvio, dato che renderizzate ogni foglio una volta sola e metterlo in cache non porta nulla
Rendering progressivo e cancellazione onesta
Gli overload semplici di RenderPage bloccano fino a quando la pagina non è finita, che è esattamente il comportamento che non volete mentre l'utente sta ancora muovendo il controllo dello zoom. Per quello ricorrete a RenderPageProgressive. Accetta un IPdfCancellationToken e restituisce uno fra prsDone, prsCancelled e prsFailed. Il dettaglio di comportamento che coglie in fallo è che la cancellazione non è istantanea. Il token viene interrogato ai confini dei blocchi dentro il rendering, quindi un token che segnalate a metà di un blocco ha effetto solo quando quel blocco finisce. Su una pagina complessa la latenza fra la richiesta e l'arresto arriva a decine di millisecondi. Progettate attorno a quella distanza invece di sperare che sparisca: cancellate il token precedente nell'istante in cui arriva un nuovo valore di zoom, ma non date per scontato che il vecchio rendering si fermi nel momento in cui glielo chiedete
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
Status: TPdfProgressiveStatus;
begin
if FTokenSource <> nil then
FTokenSource.Cancel; // abbandona il rendering precedente in corso
FTokenSource := TPdfCancellationTokenSource.New; // unit FPdfAsync
Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
ro0, [reAnnotations]);
case Status of
prsDone: PresentBackBuffer;
prsCancelled: ; // superato da una richiesta più recente: scarta
prsFailed: ShowRenderFailure;
end;
end;
Durante l'interazione, prsCancelled è l'esito normale, non quello eccezionale. La maggior parte dei rendering avviati da un gesto di zoom sarà superata prima di finire, quindi trattate la cancellazione come routine e scartate il risultato in silenzio. Una coda di rendering che registra ogni cancellazione come avviso seppellirà l'unico fallimento che conta davvero sotto migliaia di righe di rumore. Per evitare che lo schermo sembri morto mentre il rendering vero è in corso, abbinate il percorso progressivo a un sostituto economico: scalate la bitmap precedente in cache al nuovo zoom e presentate subito quella. Sembra morbida per un centinaio di millisecondi o due, ma si legge come istantanea, e regala al rendering a piena qualità il tempo di cui ha bisogno per finire o per essere cancellato dal gesto successivo
La modalità di adattamento che lo zoom disattiva in silenzio
La proprietà FitMode di un viewer, impostata a pfmFitPage o pfmFitWidth, ricalcola lo zoom a ogni ridimensionamento in modo che la pagina continui ad adattarsi al variare della finestra. Il problema è che assegnare direttamente Zoom riporta FitMode a pfmNone. Come impostazione predefinita è corretto: un utente che ha digitato deliberatamente 150% non vuole che il ridimensionamento successivo della finestra glielo butti via. Ma sorprende chiunque colleghi un pulsante di ingrandimento come Zoom := Zoom * 1.25 e poi non riesca a capire perché adatta alla larghezza abbia smesso di rispondere dopo il primo clic. Se la vostra barra degli strumenti offre sia zoom esplicito sia modalità di adattamento, dovete ricordare voi stessi l'ultima scelta di adattamento dell'utente e riassegnarla quando preme di nuovo il pulsante di adattamento. Il componente non ripristinerà una modalità che una assegnazione di zoom ha appena azzerato, e non deve farlo
Un budget di memoria difendibile
Un budget che potete mettere per iscritto è un budget che potete difendere in una code review, quindi partite da uno scenario concreto. Ipotizziamo che lo scorrimento continuo tenga la pagina visibile più una pagina precaricata sopra e una sotto, accanto a una striscia di miniature. Al 100% su un display a 96 DPI quelle tre bitmap a dimensione piena pesano circa 3,5 MB ciascuna, cioè nulla. Al 300% su un display 4K le stesse tre bitmap sono all'incirca 30 MB ciascuna, e questo prima che la cache abbia trattenuto una sola pagina storica. La crescita sta nel gesto, non nel documento
Un default solido per un processo Delphi a 32 bit è un budget di 256 MB di bitmap con sfratto LRU. Su 64 bit potete scalare con la RAM fisica, ma tenete comunque un tetto rigido, perché il guasto da cui vi state proteggendo non è il crash del vostro processo. È l'intera macchina che frusta il file di paging mentre il vostro viewer tecnicamente continua a girare e l'utente si chiede perché tutto il resto sia rallentato. Un tetto rigido fallisce in modo prevedibile; una cache senza limiti fallisce portandosi dietro il desktop. Le miniature meritano un trattamento proprio: renderizzate ciascuna una volta sola alla piccola dimensione di destinazione e tenetela in un pool separato che la logica LRU non tocca mai. Rigenerare una miniatura da 120 pixel riducendo una bitmap di pagina intera da 60 MB è il modo più sprecone possibile di produrre un francobollo
Alcune singole pagine sconfiggono qualsiasi budget. Un disegno tecnico in formato E o una mappa grande renderizzati interi al 400% sono un'allocazione da diverse centinaia di megabyte, e nessuna politica di sfratto la rende accettabile. La risposta lì è smettere di renderizzare pagine intere. RenderTile rasterizza soltanto la regione all'offset in pixel (Left, Top) dentro una pagina scalata idealmente a PageWidth per PageHeight, quindi renderizzate solo il rettangolo visibile più un margine di una piastrella attorno per una panoramica fluida, e inserite gli offset delle piastrelle nella chiave di cache accanto allo zoom. Tenete fisse le dimensioni delle piastrelle in tutto il documento. Piastrelle fisse significano che un cambio di DPI invalida l'intera griglia in modo pulito, mentre piastrelle variabili vi lasciano a rincorrere cuciture visibili fra regioni renderizzate a scale leggermente diverse
Due funzionalità adiacenti aggiungono in silenzio qualcosa a tutto questo. Le passate di filtro colore come la scala di grigi o l'inversione girano dopo il rendering e producono ogni volta una seconda bitmap a dimensione piena, raddoppiando l'impronta per pagina di qualsiasi vista che le usi; quel costo è oggetto di filtri colore per ipovedenti nei viewer PDF Delphi. E un viewer che evidenzia le parole durante la sintesi vocale invalida la vista renderizzata a ogni parola pronunciata, quindi l'interazione fra i ridisegni dell'evidenziazione e la velocità di lettura conta più di quanto sembri a prima vista, come descritto in evidenziazione TTS parola per parola
Gli overload di rendering, i codici di stato progressivi e il componente viewer stesso sono documentati nella pagina di prodotto di PDFium Component