Technisch artikel

Achtergrond-PDF-renderwachtrij in Delphi met HotPDF

HotPDF's klasse THPDFBackgroundRenderer is een afstammeling van TThread die geladen PDF-pagina's op een werkerthread naar bitmaps rendert, zodat een Delphi-viewer kan blijven scrollen en hertekenen terwijl een pagina nog op de achtergrond wordt gerasteriseerd. THPDFBackgroundRenderer.RequestPage zet een pagina-index in de wachtrij voor die werkerthread, CancelAll laat vallen wat nog wacht, en GetCachedBitmap geeft een voltooide bitmap terug waarvan de aanroeper eigenaar is en die deze moet vrijgeven. Scroll een contract van tweehonderd gescande pagina's op afdrukresolutie alleen op de UI-thread, en elke paginaomslag pauzeert het venster totdat GDI klaar is met tekenen, precies de hapering die THPDFBackgroundRenderer moet wegnemen

Waarom PDF-pagina's überhaupt op een achtergrondthread renderen?

Een achtergrondthread verdient zijn complexiteit omdat HotPDF's paginarenderer een echte inhoudsstroom-interpreter is, geen goedkope bitmapkopie die terugkeert voordat iemand het merkt: het doorloopt PDF-operatoren, houdt een grafische-statusstapel bij, en rasteriseert paden, afbeeldingen en glyphs via GDI, dezelfde engine die wordt behandeld in het renderen van geladen PDF-pagina's naar een TBitmap. Voer dat werk synchroon uit binnen een scroll- of tekenhandler, en de berichtenlus stopt met pompen totdat de aanroep terugkeert, en dat is precies wat een bevroren venster is. Application.ProcessMessages binnen de renderaanroep plaatsen lost dit niet op: het laat de berichtenwachtrij leeglopen, maar het renderen zelf blijft de aanroepende thread bezetten, dus tekent het venster verouderde inhoud sneller opnieuw terwijl het echte werk nergens vordert. De enige manier om een viewer responsief te houden tijdens een werkelijk trage render is dat renderwerk ergens anders uit te voeren, en dat is waarom THPDFBackgroundRenderer bestaat als een TThread-subklasse in plaats van een callback of een timer

Een verzoekwachtrij opzetten voor een scrollende viewer

THPDFBackgroundRenderer.Create neemt de geladen THotPDF-instantie en een DPI die gedurende de hele levensduur van die renderer vast blijft, dus elke pagina die via één instantie in de wachtrij wordt gezet, wordt op één resolutie gerenderd; een viewer die zoom ondersteunt, heeft een nieuwe renderer nodig, geen nieuwe DPI-eigenschap, telkens wanneer het zoomniveau verandert. RequestPage voegt een pagina-index toe aan een interne wachtrij en keert onmiddellijk terug: het rendert zelf niets en raakt de UI-thread nooit aan. Execute, het overgeërfde TThread-toegangspunt dat HotPDF uitvoert zodra u Start aanroept, haalt telkens één index van de voorkant van die wachtrij, rendert deze via de paginacache van het document, en slaat een kopie op geïndexeerd op pagina, zodat GetCachedBitmap deze later kan teruggeven

type
  TViewerForm = class(TForm)
    RenderPollTimer: TTimer;
    procedure RenderPollTimerTimer(Sender: TObject);
  private
    FDoc: THotPDF;
    FRenderer: THPDFBackgroundRenderer;
    FPendingPage: Integer;
    procedure RequestPageWindow(CenterPage: Integer);
  end;

procedure TViewerForm.RequestPageWindow(CenterPage: Integer);
var
  I: Integer;
begin
  if FRenderer <> nil then
  begin
    FRenderer.CancelAll;
    FRenderer.Free;
  end;
  FRenderer := THPDFBackgroundRenderer.Create(FDoc, 150);
  for I := CenterPage - 1 to CenterPage + 1 do
    if (I >= 0) and (I < FDoc.LoadedPageCount) then
      FRenderer.RequestPage(I);
  FPendingPage := CenterPage;
  FRenderer.Start;
end;

procedure TViewerForm.RenderPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  if FRenderer = nil then Exit;
  Bmp := FRenderer.GetCachedBitmap(FPendingPage);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

GetCachedBitmap geeft nil terug totdat de kopie van die pagina klaar is, dus is een poll-op-een-timer-patroon zoals hierboven voldoende; er is geen apart gereed-event om aan te sluiten, HotPDF lost dit op met een eenvoudige nil-controle in plaats van een grotere meldings-API. Het volgende deel behandelt wat CancelAll en die Free-aanroep daadwerkelijk doen, omdat beide ertoe doen zodra pagina's buiten volgorde beginnen te renderen of een scroll sneller gebeurt dan de wachtrij kan leeglopen

De één-aanroep-snelkoppeling voor een enkele pagina

THotPDF.RenderLoadedPageToBitmapAsync bestaat voor het gangbare geval van precies één pagina afvuren zonder THPDFBackgroundRenderer rechtstreeks aan te raken: het construeert de renderer intern, roept RequestPage één keer aan, start de thread, en geeft de TThread-referentie terug aan de aanroeper, die er eigenaar van is en verantwoordelijk is voor het vrijgeven ervan. Het resultaat ophalen loopt via THotPDF.GetLoadedCachedRenderedBitmap in plaats van de eigen GetCachedBitmap van de renderer, omdat GetLoadedCachedRenderedBitmap de gedeelde cache van het document leest, gesleuteld op pagina-index en DPI, dezelfde cache die RenderLoadedPageToBitmapCached en de ingebouwde prefetcher al vullen — een pagina die een ander deel van de viewer al op die DPI heeft gerenderd, kan onmiddellijk terugkomen, voordat de zojuist gestarte achtergrondthread door het OS zelfs maar is ingepland

// A simpler alternative to the queue above, for one page at a time.
procedure TViewerForm.RequestSinglePage(PageIndex: Integer);
begin
  if FAsyncWorker <> nil then
    FAsyncWorker.Free; // waits if a prior page is still rendering
  FAsyncWorker := Pdf.RenderLoadedPageToBitmapAsync(PageIndex, 150);
  FPendingPage := PageIndex;
end;

procedure TViewerForm.AsyncPollTimerTimer(Sender: TObject);
var
  Bmp: TBitmap;
begin
  Bmp := Pdf.GetLoadedCachedRenderedBitmap(FPendingPage, 150);
  if Bmp <> nil then
  begin
    PageImage.Picture.Bitmap.Assign(Bmp);
    Bmp.Free;
  end;
end;

Kunt u een pagina annuleren die al in de wachtrij staat?

CancelAll verwijdert alleen taken die nog in de wachtrij staan; een pagina die HotPDF al van de voorkant heeft gehaald en aan zijn renderaanroep heeft doorgegeven, loopt door tot voltooiing, omdat THPDFBackgroundRenderer geen mechanisme heeft om werk dat al bezig is te onderbreken. Dat is in de praktijk een redelijke afweging — een enkele paginarender is zelden lang genoeg om preemptie de moeite van de extra complexiteit waard te maken — maar een snelle scroll die bij elke scrollgebeurtenis CancelAll afvuurt, betaalt nog steeds voor welke ene pagina zich op het moment van elke annulering midden in het renderen bevond. De officiële referentie is hier direct over: reeds lopend renderen kan voltooien voordat de thread eindigt

Execute heeft een tweede, gemakkelijk over het hoofd te zien gedrag: de lus stopt zodra deze de wachtrij leeg aantreft, het staat niet stil en wacht niet op meer werk. Een THPDFBackgroundRenderer-instantie is daarom een eenmalige batchwerker, geen permanente achtergronddienst — zet een handvol pagina's in de wachtrij, roep Start aan, en zodra de laatst in de wachtrij gezette pagina is gerenderd, eindigt de onderliggende OS-thread vanzelf. RequestPage opnieuw aanroepen op diezelfde instantie nadat Execute de wachtrij al heeft leeggemaakt, herstart deze niet, en dat is precies waarom RequestPageWindow hierboven bij elke aanroep de rendererinstantie vervangt in plaats van te proberen één langlevend object te blijven voeden

Is het veilig om een TBitmap vanuit een achtergrondthread aan te raken in Delphi?

Een TBitmap vanuit een achtergrondthread aanraken is veilig in HotPDF's ontwerp zolang slechts één thread ooit tegelijk op een gegeven bitmapinstantie werkt, en THPDFBackgroundRenderer handhaaft die grens in plaats van deze aan de aanroeper over te laten. Execute rendert elke pagina binnen de eigen renderlock van het document, dezelfde kritieke sectie die elke RenderLoadedPageToBitmapCached-aanroep en de ingebouwde PrefetchLoadedPages-prefetcher al delen, dus het daadwerkelijke GDI-tekenen voor een gegeven pagina gebeurt op precies één thread tegelijk en overlapt nooit met een andere render van dat document. De resulterende bitmap is een object in eigendom van de werkerthread dat THPDFBackgroundRenderer nooit rechtstreeks aan een aanroeper publiceert

GetCachedBitmap wijst in plaats daarvan een gloednieuwe TBitmap toe en roept Assign erop aan onder de eigen aparte lock van de renderer, dus de kopie gebeurt altijd terwijl Execute geblokkeerd wordt om die cacheslot eronder te vervangen — de aanroepende thread krijgt pixeldata, nooit de originele handle. Die scheiding is ook de reden om te weerstaan een eigen renderthread te bouwen die HotPDF's renderfuncties rechtstreeks aanroept zonder via THPDFBackgroundRenderer of PrefetchLoadedPages te gaan: twee renders die racen tegen dezelfde gedeelde caches en objectgraaf van het geladen document is precies het scenario dat HotPDF's interne locking moet voorkomen, en de achtergrondrendererklasse geeft u die locking gratis in plaats van deze opnieuw te implementeren

Hoe verschilt dit van HotPDF's ingebouwde paginaprefetch?

PrefetchLoadedPages en THPDFBackgroundRenderer lossen verwante maar verschillende problemen op: PrefetchLoadedPages rendert, gegeven een paginabereik, die hele buurt automatisch naar de gedeelde documentcache op zijn eigen werkerthread, zonder wachtrijobject dat de aanroeper hoeft aan te maken of te beheren. THPDFBackgroundRenderer ruilt die automatisering in voor controle — de aanroeper beslist precies welke pagina-indices ertoe doen en in welke volgorde, en kan de pagina's die nog in de wachtrij staan annuleren zonder het bereik aan te raken dat de ingebouwde prefetcher elders aan het opwarmen is. Beide lopen via dezelfde renderlock, dus een viewer kan PrefetchLoadedPages gebruiken voor het gewone geval van de volgende paar pagina's en alleen naar THPDFBackgroundRenderer grijpen wanneer iets buiten dat patroon opduikt, zoals een miniaturenbalk die rechtstreeks springt naar een pagina waar de gebruiker net op heeft geklikt

begin
  // PrefetchLoadedPages takes a 1-based "start-end" range string, while
  // RequestPage below stays 0-based like every other loaded-page index.
  Pdf.PrefetchLoadedPages(Format('%d-%d', [CenterPage + 1, CenterPage + 5]), 150);

  // Reach for THPDFBackgroundRenderer only for a page outside that
  // window, such as a thumbnail the user just clicked.
  FRenderer := THPDFBackgroundRenderer.Create(Pdf, 150);
  FRenderer.RequestPage(ClickedThumbnailPage);
  FRenderer.Start;
end;

Twee levenscyclusdetails zijn de moeite waard om mee te nemen in productiecode. De documentbrede cache achter RenderLoadedPageToBitmapCached is begrensd door RenderCacheCapacity, standaard acht pagina's, en verwijdert de minst recent gebruikte vermelding zodra deze vol is, maar de eigen resultatenlijst van een THPDFBackgroundRenderer-instantie kent geen dergelijke limiet — deze houdt één bitmap per unieke pagina-index bij die ooit via die instantie is aangevraagd totdat de instantie zelf wordt vrijgegeven, dus een renderer die levend wordt gehouden voor een hele scrollsessie op hoge DPI zal gretig één bitmap op volledige resolutie per gescrolde pagina accumuleren. HotPDF annuleert een door de aanroeper gemaakte renderer ook niet automatisch op de manier waarop het zijn eigen prefetcher annuleert voordat een document wordt geladen of zichzelf vernietigt, aangezien een THPDFBackgroundRenderer-instantie nooit wordt geregistreerd op het THotPDF-object waarnaar het verwijst — dus de aanroepende code moet elke renderer die tegen een document is gebouwd, annuleren en vrijgeven voordat dat document opnieuw wordt geladen of vrijgegeven, dezelfde volgordediscipline die HotPDF intern toepast op PrefetchLoadedPages

THPDFBackgroundRenderer is één onderdeel van de gevel voor geladen documenten achter HotPDF's MVC-viewerarchitectuur, en het combineert van nature met de bestandsniveau-workflows in de Direct File API voor grote PDF's wanneer het document dat wordt gescrold zelf te groot is om terloops te laden. Achtergrondrendering, verzoekwachtrijen, en de rendercache die hier worden beschreven, maken allemaal deel uit van de standaard HotPDF-component voor Delphi en C++Builder