Die meisten PDF-Seiten werden in wenigen Millisekunden gerastert, und man macht sich darüber nie Gedanken. Dann öffnet ein Benutzer eine technische Zeichnung im A1-Format, eine Seite vollgepackt mit Zehntausenden von Vektorstrichen, oder ein Poster, das mit Transparenzgruppen und weichen Masken überladen ist, und der einzelne Aufruf, der sie zeichnet, dauert plötzlich zwei oder drei Sekunden. Wenn dieser Aufruf auf dem UI-Thread läuft, wird das Fenster nicht mehr neu gezeichnet, die Titelleiste wird grau, und das Betriebssystem bietet an, die Anwendung zu beenden. Die Arbeit an sich ist legitim. Die Seite benötigt wirklich so lange. Der Mangel liegt darin, dass das Rendern ein einziger unteilbarer, blockierender Aufruf ist, ohne Möglichkeit zum Luftholen und ohne Möglichkeit zum Anhalten
In diesem Artikel geht es um genau eines dieser beiden Probleme: Das Abbrechen eines langen Renderns einer einzelnen Seite, ohne die Benutzeroberfläche einzufrieren. Der Benutzer hat auf die nächste Seite geklickt, gezoomt oder das Dokument geschlossen, und das aktuell laufende Rendering ist nun verschwendete Arbeit, die bei der nächsten Gelegenheit enden sollte, anstatt bis zum Schluss durchzulaufen. Das Glätten von Scrollen und Zoomen durch das Zwischenspeichern (Caching) dessen, was bereits gerastert wurde, ist ein separates Anliegen mit eigenem Design, das im am Ende verlinkten Begleitartikel behandelt wird. Hier geht es einzig um die Frage, wie man ein progressives Rendern dazu bringt, eine Abbruchanforderung schnell und sauber zu beantworten
Die API für progressives Rendering, die PDFium bereits mitliefert
PDFium hat das Problem des Einfrierens bereits vorhergesehen. Neben dem One-Shot-Aufruf FPDF_RenderPageBitmap bietet es eine progressive Variante an, die eine Seite in Arbeitsblöcke (Chunks) aufteilt. Sie rufen FPDF_RenderPageBitmap_Start einmal auf, um das Rendern auf ein Ziel-Bitmap einzurichten, und rufen dann wiederholt FPDF_RenderPage_Continue auf. Jedes Continue rastert ein begrenztes Stück und gibt einen Status zurück. FPDF_RENDER_TOBECONTINUED bedeutet, es gibt noch mehr zu tun, FPDF_RENDER_DONE bedeutet, die Seite ist fertig, und FPDF_RENDER_FAILED bedeutet, sie hat wegen eines Fehlers angehalten. Wenn die Schleife endet, rufen Sie FPDF_RenderPage_Close auf, um den seitenbezogenen progressiven Zustand freizugeben. Da die Kontrolle zwischen den Stücken an Ihren Code zurückgegeben wird, können Sie Nachrichten weiterleiten (Message Pumping), eine Fortschrittsanzeige aktualisieren oder prüfen, ob die Arbeit überhaupt noch gewünscht ist
Der Mechanismus, den PDFium bereitstellt, um zu entscheiden, wann nachgegeben (Yield) werden soll, ist ein Callback-Struct namens IFSDK_PAUSE. Sie übergeben es an Start und an jedes Continue. Nach jedem Stück ruft PDFium seinen Funktionszeiger NeedToPauseNow auf. Wenn dieser einen Wert ungleich Null zurückgibt, stoppt das aktuelle Continue vorzeitig und gibt die Kontrolle mit FPDF_RENDER_TOBECONTINUED zurück. Das Struct trägt außerdem ein version-Feld, das auf 1 gesetzt werden muss, und einen frei verwendbaren user-Zeiger, den PDFium niemals berührt und unverändert durchreicht. Dieser unberührte Zeiger ist der Dreh- und Angelpunkt des gesamten folgenden Designs
Pause als Abbrechen umfunktionieren
Die ursprüngliche Absicht von NeedToPauseNow ist das Time-Slicing. Geben Sie einen Wert ungleich Null zurück, wenn Ihr Frame-Budget aufgebraucht ist, geben Sie Null zurück, um weiter zu rendern, und PDFium pausiert, damit Sie etwas anderes tun können, bevor Sie dasselbe Rendering fortsetzen. Die PDFium-Komponente verwendet genau dasselbe Signal für ein anderes Verb wieder. Anstatt die Frage zu beantworten: „Soll ich pausieren und Sie fortsetzen lassen?“, beantwortet der Callback die Frage: „Wurde diese Arbeit abgebrochen?“. Die beiden Konzepte lassen sich sauber aufeinander abbilden, aufgrund dessen, was die Schleife tut, wenn sie das Flag sieht. Eine echte Pause erwartet ein späteres Continue; ein Abbruch nicht. Sobald die aufrufende Schleife bemerkt, dass das Token storniert wurde, schließt sie den Render-Kontext und ruft Continue nie wieder auf. Somit wird dieselbe Rückgabe ungleich Null, die PDFium als „Stoppe diesen Block“ liest, effektiv zu einem „Stoppe für immer“
Der Abbruch wird durch eine Schnittstelle (Interface) namens IPdfCancellationToken ausgedrückt, deren Eigenschaft IsCancelled von False auf True wechselt, wenn ein anderer Teil des Programms verlangt, dass das Rendern gestoppt wird. Die Brücke zwischen dieser Pascal-Schnittstelle und PDFiums C-Callback ist ein einzelner Zeiger. Die Schnittstellenreferenz des Tokens wird in IFSDK_PAUSE.user geschrieben, und ein statischer cdecl-Callback liest sie wieder aus und fragt sie ab. Das ist das klassische Problem, wenn man eine C-Bibliothek zurück in Pascal rufen lässt: Der Callback muss eine einfache Funktion mit C-Aufrufkonvention sein, keine Methode, denn PDFium speichert und ruft einen bloßen Funktionszeiger auf, der absolut nichts über Pascal-Objekte oder Self weiß
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;
Die Callback-Funktion gewinnt das Token zurück, indem sie pThis^.user wieder in den Schnittstellentyp umwandelt und IsCancelled liest. Nichts darin reserviert Speicher, setzt Sperren oder blockiert, was wichtig ist, da PDFium dies im Rendering-Thread nach jedem Abschnitt aufruft und jede hier geleistete Arbeit zu den Kosten des Renderings selbst hinzukommt. Die Absicherung gegen ein nil-Struct oder ein nil-user-Feld bedeutet, dass dieselbe Funktion selbst bei einem Rendering sicher installiert werden kann, dem nie ein echtes Token gegeben wurde
Das Token über die Schleife hinweg am Leben halten
Einen Schnittstellenzeiger in einen rohen Pointer und wieder zurück zu casten, ist der Ort, an dem Lebensdauer-Bugs geboren werden. Ein IInterface in Delphi ist referenzgezählt (Reference Counted), und der Zähler bewegt sich nur, wenn der Compiler sieht, dass eine Variable vom Schnittstellentyp zugewiesen wird. Würde man das Token ausschließlich als nackten Zeiger in IFSDK_PAUSE.user speichern, bliebe es dem Referenzzähler völlig verborgen. Wenn die einzige andere Referenz auf dieses Token den Gültigkeitsbereich (Scope) verlassen würde, während die Continue-Schleife noch läuft, würde das Objekt unter den Füßen des Callbacks freigegeben, und das nächste Stück würde einen Dangling Pointer dereferenzieren
Aus diesem Grund ist der Deskriptor ein Record, der zwei Dinge enthält, nicht nur eines. Das Feld Pause ist das Struct, das PDFium liest. Das Feld Token ist eine echte Schnittstellen-Typ-Referenz, die vom Compiler mitgezählt wird, und es existiert aus keinem anderen Grund, als das Token im Arbeitsspeicher festzunageln (zu pinnen), solange der Record lebt. Der Record ist eine lokale Variable auf dem Stack der Render-Routine, bleibt also für die gesamte Dauer der Schleife gültig und wird erst abgebaut, wenn die Routine beendet wird. Der nackte Zeiger in user und die gezählte Referenz in Token benennen dasselbe Objekt; das eine ist das, was PDFium lesen kann, das andere ist das, was verhindert, dass dieses Objekt abgeräumt (Collected) wird
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);
Den Render-Kontext schließen, egal wie die Schleife endet
Jeder Aufruf von FPDF_RenderPageBitmap_Start weist einen progressiven Zustand zu, den PDFium mit der Seite verknüpft, und dieser Zustand wird nur durch FPDF_RenderPage_Close wieder freigegeben. Es gibt drei Wege aus der Antriebsschleife heraus. Die Seite wird fertiggestellt und der letzte Status lautet FPDF_RENDER_DONE. Das Token löst aus und die Schleife wird vorzeitig beendet und meldet einen Abbruch. Irgendetwas schlägt fehl und der Status ist FPDF_RENDER_FAILED. Alle drei müssen Close aufrufen, und beim Abbruchpfad macht man am leichtesten Fehler, da die natürliche Form „Abbruch bemerkt, sofort raus hier“ dazu neigt, auf dem Weg zum Ausgang die Bereinigung zu überspringen. Bleibt Close unerreicht, leckt der seitenbezogene Zustand, und ein Viewer, der den Benutzer ein Rendern nach dem anderen abbrechen lässt, würde dieses Speicherleck bei jeder abgebrochenen Seite weiter anhäufen
Das robuste Konstrukt packt die Schleife und die Ergebniseinteilung in einen try-Block und das FPDF_RenderPage_Close in den dazugehörigen finally-Block. Das Ziel-Bitmap wird im selben Block zerstört. Ein Abbruch kann die Schleife durch ein frühes Exit verlassen und das finally wird dennoch ausgeführt. Es gibt also genau einen Ort, der den progressiven Zustand freigibt, und dieser kann unter keinen Umständen umgangen werden
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;
Die Schleife prüft das Token vor jedem Continue und verlässt sich zusätzlich auf den Callback im Inneren. Der Callback verkürzt den aktuellen Block; die Schleifenprüfung verhindert, dass der nächste überhaupt startet. Zusammen begrenzen sie die Zeit, die ein Abbruch braucht, um wirksam zu werden, auf ungefähr die Dauer eines einzigen Arbeitsblocks
Drei Ergebnisse, und was das Bitmap nach einem Abbruch enthält
Der öffentliche Einsprungpunkt (Entry Point) ist TPdf.RenderPageProgressive, und dieser gibt einen TPdfProgressiveStatus zurück, der entweder prsDone, prsCancelled oder prsFailed lautet. Die Werte spiegeln PDFiums FPDF_RENDER_*-Konstanten im Pascal-Idiom wider, integrieren den Abbruchfall jedoch als erstklassiges (First-Class) Ergebnis und nicht als Fehler
Der Punkt, der viele Entwickler irritiert, ist, was das Ziel-Bitmap nach prsCancelled enthält. Es ist nicht leer. PDFium rendert progressiv in dasselbe Bitmap, Stück für Stück. Wenn also ein Abbruch die Schleife stoppt, enthält das Bitmap all das, was bis zu diesem Moment gezeichnet wurde. Es ist ein unfertiges Bild: Einige Streifen sind fertig, der Rest zeigt immer noch die Füllfarbe. Ob dieses Teilergebnis nützlich ist, hängt vom Aufrufer ab. Ein Viewer, der im Begriff ist, das Bitmap ohnehin wegzuwerfen, weil der Benutzer woandershin navigiert hat, kann es einfach ignorieren. Ein Viewer, der eine kostengünstige Vorschau anzeigen möchte, kann es behalten. Was Sie jedoch nicht tun dürfen, ist anzunehmen, dass prsCancelled ein leeres oder undefiniertes Bitmap impliziert; es impliziert vielmehr einen wahrheitsgetreuen Schnappschuss eines unvollendeten Renderings
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;
Das Nil-Token und ein verzweigungsfreier Callback-Pfad
Abbruch ist Opt-in. Ein Aufrufer, der einfach nur progressives Rendering wegen des Vorteils der Nachrichtenverarbeitung (Message Pumping) möchte, ohne die Absicht, jemals abzubrechen, sollte in der Lage sein, nil für das Token zu übergeben. Der naive Weg, dies zu unterstützen, bestünde darin, überall im Callback und in der Schleife Prüfungen nach dem Muster „wurde ein Token übergeben?“ einzustreuen. Das bedeutet eine Verzweigung (Branch) bei jedem Block und einen Callback, der sowohl ein echtes Token als auch dessen Fehlen handhaben muss
Die Implementierung vermeidet das, indem sie ein Singleton substituiert, wenn der Aufrufer nichts übergibt. Ein nil-Token wird gegen PdfNoCancellationToken ausgetauscht, ein Interface, dessen IsCancelled stets auf False steht. Ab diesem Punkt haben der Callback und die Schleife in jedem Fall ein Token zum Abfragen, sodass keiner von beiden eine Nil-Prüfung oder einen speziellen Pfad benötigt. Das Niemals-Abbrechen-Token antwortet einfach immer mit False, der Callback gibt immer Null zurück, und das Rendering läuft exakt so bis zum Ende durch, wie es ein nicht-abbrechbares Rendering tun würde. Optionales Verhalten wird als Token modelliert, das nie feuert, anstatt als Fehlen eines Tokens, was den Hot Path einheitlich hält
// 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;
Die Form, die sich daraus ergibt, ist klein und es wert, noch einmal wiederholt zu werden, denn sie ist der wiederverwendbare Teil. Eine C-Bibliothek, die einen Callback unterstützt, gibt Ihnen genau einen Kanal, um den Zustand in diesen Callback zu übertragen: den undurchsichtigen (Opaque) User-Zeiger. Legen Sie eine gezählte Pascal-Interface-Referenz hinter diesen Zeiger, halten Sie eine zweite echte Referenz neben dem Struct am Leben, damit das Objekt nicht mitten im Aufruf abgeräumt werden kann, und lesen Sie das Interface in einer statischen cdecl-Funktion wieder aus. Wickeln Sie die gesamte Antriebsschleife in ein try und geben Sie den nativen Kontext im finally frei. Dasselbe Muster lässt sich auf jede progressive oder Callback-gesteuerte PDFium-Operation übertragen, bei der Pascal-Code die Kontrolle über die Lebensdauer behalten muss, während C einen Zeiger hält
Der Abbruch ist nur die eine Hälfte eines reaktionsschnellen Viewers. Die andere Hälfte besteht darin, Seiten, die Sie bereits gezeichnet haben, nicht neu zu rendern und Zoomen sowie Scrollen durch das Ausliefern zwischengespeicherter Bitmaps geschmeidig zu halten. Dies wird in unserem Artikel über Render-Caching und Zoom-Performance behandelt. Wie sich das abbrechbare Rendern zusammen mit Navigation, Auswahl und Suche in einen vollständigen Viewer einfügt, erfahren Sie unter Einen funktionsreichen PDF-Viewer mit der PDFium VCL-Komponente entwickeln. Das hier beschriebene progressive Rendering wird als Teil der PDFium-Komponente für Delphi und Lazarus ausgeliefert, zusammen mit den Lade-, Render- und Formular-APIs, die an anderer Stelle in diesem Blog behandelt werden