Halten Sie in einem naiven PDF-Viewer die Zoom-Taste gedrückt und beobachten Sie die CPU-Kurve. Ein einziger Druck auf ein Zoom-Bedienelement mit Tastenwiederholung löst ein Dutzend Zoom-Schritte pro Sekunde aus, und wenn jeder Schritt ein vollqualitatives erneutes Rendern der sichtbaren Seite anstößt, häufen sich die Renderaufträge schneller an, als sie fertig werden. Die Seite rastert für sich genommen problemlos, vielleicht 180 ms für einen A4-Scan, aber Sie führen jetzt ein Dutzend 180-ms-Renderdurchläufe für Arbeit aus, über die der Benutzer längst hinweg ist. Der Viewer blockiert, ein Kern liegt bei 100 %, und bis der Bildschirm aufholt, ist der Benutzer bei einer Zoomstufe stehen geblieben, die vier Renderdurchläufe zurückliegt. Die Abhilfe ist kein schnellerer Rasterizer. Sie ist ein Cache, der fertige Seiten sofort zurückgibt, und eine Renderschleife, die bereit ist, Arbeit in dem Moment aufzugeben, in dem sie veraltet
PDFium Component gibt Ihnen die Bausteine für beides und hält sich aus der Richtlinienentscheidung heraus. Sie bekommen Bitmaps im Besitz des Aufrufers, einen progressiven Renderer, der ein Cancellation-Token entgegennimmt, Fit-Modi, die den Zoom bei Größenänderungen neu berechnen, und einen Kachelaufruf für Seiten, die zu groß sind, um sie am Stück zu rastern. Was die Komponente bewusst nicht mitliefert, ist der Cache selbst, denn die richtige Verdrängungsstrategie hängt von Ihrem Viewport ab, von der Speicherobergrenze Ihrer Plattform und davon, wie Ihre Benutzer scrollen. Diese Entscheidung müssen Sie richtig treffen, und die Folgen einer falschen Entscheidung sind genau das Einfrieren und das Speicherleck
Wohin die Millisekunden und Megabyte gehen
Beziffern Sie die Kosten, bevor Sie irgendetwas entwerfen. Eine A4-Seite bei 96 DPI misst grob 794 mal 1123 Pixel, also etwa 3,5 MB als 32-Bit-Bitmap. Zoomen Sie auf 200 %, und das vervierfacht sich. Bei 400 % auf einem High-DPI-Display belegen und füllen Sie für eine einzige Seite eine Bitmap von 50 bis 60 MB, und ein Viewer mit fortlaufendem Bildlauf hält mehrere Seiten gleichzeitig im Speicher. Die Rasterkosten folgen den Ausgabepixeln, also vervierfacht jede Verdopplung des Zooms Renderzeit und Speicherbedarf ungefähr gemeinsam
Aus dieser Rechnung folgen unmittelbar zwei Dinge. Ein Cache, dessen Schlüssel die Zoomstufe ignoriert, ist wertlos, denn genau die Geste, die er beschleunigen soll, das Zoomen, erzeugt jedes Mal eine neue Bitmap. Und ein unbegrenzter Cache treibt einen 32-Bit-Prozess ausgerechnet bei den Dokumenten aus dem Adressraum, bei denen am stärksten gezoomt wird: dichte Grundbuchscans, technische Zeichnungen, großformatige Karten. Der Cache muss korrekt verschlüsselt und hart gedeckelt sein, und keines von beidem ist optional
Was in den Cache-Schlüssel gehört
Eine gecachte Bitmap darf nur dann wiederverwendet werden, wenn jede Eingabe, die ihre Pixel geformt hat, weiterhin übereinstimmt. Das heißt: die Seitennummer, der effektive Zoom (oder gleichwertig die Ausgabemaße in Pixeln), die Drehung, die Monitor-DPI und die Render-Optionen, die bei ihrer Erzeugung galten. Eine mit reAnnotations gerenderte Seite ist ein anderes Bild als dieselbe Seite ohne sie, und ein Graustufendurchlauf über reGrayscale ist noch einmal ein anderes. Lassen Sie eines davon aus dem Schlüssel weg, und die Fehler sind vorhersehbar: ein Annotations-Overlay, das bestehen bleibt, nachdem ein Prüfer den Kommentar gelöscht hat, oder eine Seite, die in dem Moment unscharf wird, in dem ein Benutzer das Fenster vom Notebook-Panel auf einen externen 4K-Monitor zieht und sich die DPI unter einer veralteten Bitmap ändert
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); // Der Cache besitzt diese Bitmap jetzt
end;
Bei einem Treffer kehrt das in Mikrosekunden zurück, und genau darum geht es. Die schwierigere Frage ist, was mit den Bitmaps geschieht, die aus dem Cache herausfallen, und das entpuppt sich als Frage danach, wem sie gehören
Wer die Bitmap freigibt
Die Funktionsform von RenderPage gibt eine TBitmap zurück, die dem Aufrufer gehört. Bei einem einmaligen Export ist dieser Besitz offensichtlich und leicht einzuhalten. In einem Cache wird daraus das mit Abstand häufigste Speicherleck in Delphi-PDF-Viewern, denn das Dictionary hält nun die einzige Referenz auf jede Bitmap, und ein einfaches TDictionary gibt Schlüssel und Werte nur dann für Sie frei, wenn es verwaltete Typen sind. Eine TBitmap ist keiner. Verdrängen Sie einen Eintrag, ohne Free aufzurufen, und die Pixel bleiben belegt, ohne dass irgendetwas auf sie zeigt
Dass das durchrutscht, liegt am zeitlichen Verlauf. Ein zehnminütiger Rauchtest zoomt nie genug verschiedene Seiten, um es zu bemerken; das Leck zeigt sich erst, nachdem jemand ein langes Dokument ein paar Stunden lang gescrollt und gezoomt hat, und dann hält der Prozess Hunderte verwaister Seiten-Bitmaps und die Maschine beginnt auszulagern. Deshalb gehört die Verdrängung in die erste Fassung des Cache, nicht in eine spätere. Deckeln Sie den Cache nach geschätzten Bytes, berechnet als Breite mal Höhe mal vier, verdrängen Sie die am längsten unbenutzten Seiten außerhalb des Viewports und des Prefetch-Fensters, und geben Sie jede Bitmap beim Entfernen frei. Für Zeichenvorgänge, die wirklich flüchtig sind, ersparen Ihnen die Überladungen, die in eine vom Aufrufer bereitgestellte TBitmap oder direkt auf einen HDC rendern, den Besitztanz vollständig. Die Druckvorschau ist der naheliegende Fall, da Sie jedes Blatt einmal rendern und das Cachen nichts einbringt
Progressives Rendern und ehrliches Abbrechen
Die einfachen RenderPage-Überladungen blockieren, bis die Seite fertig ist, und das ist genau das Verhalten, das Sie nicht wollen, während der Benutzer das Zoom-Bedienelement noch bewegt. Dafür greifen Sie zu RenderPageProgressive. Es nimmt ein IPdfCancellationToken entgegen und liefert eines von prsDone, prsCancelled oder prsFailed zurück. Das Verhaltensdetail, über das die Leute stolpern, ist, dass der Abbruch nicht sofort wirkt. Das Token wird an Chunk-Grenzen innerhalb des Renderns abgefragt, also wirkt ein Token, das Sie mitten in einem Chunk auslösen, erst dann, wenn dieser Chunk fertig ist. Auf einer komplexen Seite beträgt die Latenz zwischen Anfordern und Anhalten mehrere Dutzend Millisekunden. Planen Sie um diese Lücke herum, statt sie wegzuwünschen: Brechen Sie das vorherige Token in dem Moment ab, in dem ein neuer Zoomwert eintrifft, aber nehmen Sie nicht an, dass der alte Renderdurchlauf auf Zuruf sofort stoppt
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
Status: TPdfProgressiveStatus;
begin
if FTokenSource <> nil then
FTokenSource.Cancel; // Den vorherigen laufenden Renderdurchlauf aufgeben
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: ; // Durch eine neuere Anfrage überholt: stillschweigend verwerfen
prsFailed: ShowRenderFailure;
end;
end;
Während der Interaktion ist prsCancelled der Normalfall, nicht die Ausnahme. Die meisten Renderdurchläufe, die eine Zoom-Geste anstößt, werden überholt, bevor sie fertig sind, also behandeln Sie den Abbruch als Routine und verwerfen Sie das Ergebnis stillschweigend. Eine Render-Warteschlange, die jeden Abbruch als Warnung protokolliert, begräbt den einen Fehler, auf den es wirklich ankommt, unter Tausenden Zeilen Rauschen. Damit der Bildschirm nicht tot wirkt, während der echte Renderdurchlauf läuft, kombinieren Sie den progressiven Pfad mit einem billigen Platzhalter: Skalieren Sie die zuletzt gecachte Bitmap auf den neuen Zoom und zeigen Sie diese sofort an. Sie sieht ein bis zwei Zehntelsekunden lang weich aus, wirkt aber sofort, und sie verschafft dem vollqualitativen Renderdurchlauf die Zeit, die er braucht, um entweder fertig zu werden oder von der nächsten Geste abgebrochen zu werden
Der Fit-Modus, den der Zoom stillschweigend abschaltet
Die Eigenschaft FitMode eines Viewers, gesetzt auf pfmFitPage oder pfmFitWidth, berechnet den Zoom bei jeder Größenänderung neu, damit die Seite weiter passt, wenn sich das Fenster ändert. Der Haken ist, dass eine direkte Zuweisung an Zoom den FitMode auf pfmNone zurücksetzt. Als Standard ist das richtig: Wer bewusst 150 % eingetippt hat, will nicht, dass die nächste Fenstergrößenänderung das verwirft. Aber es überrascht jeden, der eine Vergrößern-Schaltfläche als Zoom := Zoom * 1.25 verdrahtet und dann nicht herausfindet, warum die Breitenanpassung nach dem ersten Klick nicht mehr reagiert. Wenn Ihre Symbolleiste sowohl expliziten Zoom als auch Fit-Modi anbietet, müssen Sie sich die letzte Fit-Wahl des Benutzers selbst merken und sie neu zuweisen, wenn er die Fit-Schaltfläche erneut drückt. Die Komponente stellt einen Modus nicht wieder her, den eine Zoom-Zuweisung gerade gelöscht hat, und das soll sie auch nicht
Ein Speicherbudget, das Sie verteidigen können
Ein Budget, das Sie aufschreiben können, ist ein Budget, für das Sie in einem Code-Review argumentieren können, also beginnen Sie mit einem konkreten Szenario. Nehmen wir an, der fortlaufende Bildlauf hält die sichtbare Seite plus je eine vorausgeladene Seite darüber und darunter, dazu eine Miniaturansichtsleiste. Bei 100 % auf einem 96-DPI-Display kommen diese drei Bitmaps in voller Größe auf jeweils etwa 3,5 MB, was nichts ist. Bei 300 % auf einem 4K-Display sind dieselben drei Bitmaps jeweils rund 30 MB, und das ist, bevor der Cache eine einzige historische Seite behalten hat. Das Wachstum steckt in der Geste, nicht im Dokument
Ein solider Standard für einen 32-Bit-Delphi-Prozess ist ein Bitmap-Budget von 256 MB mit LRU-Verdrängung. Auf 64 Bit können Sie mit dem physischen RAM skalieren, behalten Sie aber in jedem Fall eine harte Obergrenze, denn der Fehler, gegen den Sie sich absichern, ist nicht der Absturz Ihres Prozesses. Es ist die ganze Maschine, die ihre Auslagerungsdatei durchpflügt, während Ihr Viewer technisch weiterläuft und der Benutzer sich fragt, warum alles andere langsamer geworden ist. Eine harte Obergrenze scheitert vorhersehbar; ein unbegrenzter Cache scheitert, indem er den Desktop mit sich reißt. Miniaturansichten verdienen eine eigene Behandlung: Rendern Sie jede einmal in ihrer kleinen Zielgröße und halten Sie sie in einem separaten Pool, den die LRU-Logik nie anfasst. Eine Miniaturansicht von 120 Pixeln durch Herunterskalieren einer 60-MB-Vollseiten-Bitmap neu zu erzeugen, ist die verschwenderischste denkbare Art, eine Briefmarke herzustellen
Manche einzelnen Seiten schlagen jedes Budget. Eine technische Zeichnung im Format E oder eine große Karte, am Stück bei 400 % gerendert, ist eine Speicheranforderung von mehreren hundert Megabyte, und keine Verdrängungsstrategie macht das akzeptabel. Die Antwort ist dort, ganze Seiten gar nicht mehr zu rendern. RenderTile rastert nur den Bereich am Pixelversatz (Left, Top) innerhalb einer Seite, die gedanklich auf PageWidth mal PageHeight skaliert ist, sodass Sie nur das sichtbare Rechteck plus einen Rand von einer Kachel für flüssiges Verschieben rendern und die Kachelversätze zusammen mit dem Zoom in den Cache-Schlüssel aufnehmen. Halten Sie die Kachelmaße im gesamten Dokument fest. Feste Kacheln bedeuten, dass eine DPI-Änderung das gesamte Raster sauber ungültig macht, während variable Kacheln Sie sichtbaren Nähten zwischen Bereichen hinterherjagen lassen, die in leicht unterschiedlichen Maßstäben gerendert wurden
Zwei benachbarte Funktionen kommen unauffällig zu alldem hinzu. Farbfilterdurchläufe wie Graustufen oder Invertierung laufen nach dem Rendern und erzeugen jedes Mal eine zweite Bitmap in voller Größe, was den Speicherbedarf pro Seite in jeder Ansicht verdoppelt, die sie nutzt; diese Kosten behandelt die Farbfilterung für sehbehinderte Leser in Delphi-PDF-Viewern. Und ein Viewer, der Wörter während der Sprachausgabe hervorhebt, macht die gerenderte Ansicht bei jedem gesprochenen Wort ungültig, sodass das Zusammenspiel von Hervorhebungs-Neuzeichnungen und Sprechgeschwindigkeit mehr zählt, als es zunächst wirkt, wie in der Wort-für-Wort-Hervorhebung bei TTS beschrieben
Die Render-Überladungen, die progressiven Statuscodes und die Viewer-Komponente selbst sind auf der Produktseite von PDFium Component dokumentiert