Większość stron PDF rasteryzuje się w kilka milisekund i nigdy o tym nie myślisz. Potem użytkownik otwiera rysunek techniczny formatu A1, stronę napakowaną dziesiątkami tysięcy obrysów wektorowych, lub plakat pełen grup przezroczystości i masek miękkich, i pojedyncze wywołanie, które ją maluje, zajmuje dwie lub trzy sekundy. Jeśli to wywołanie działa na wątku UI, okno przestaje się odświeżać, pasek tytułu szarzeje, a system operacyjny proponuje zabicie aplikacji. Praca jest uzasadniona. Strona naprawdę potrzebuje tyle czasu. Wadą jest to, że renderowanie to jedno niepodzielne blokujące wywołanie bez możliwości oddechu i bez możliwości zatrzymania
Ten artykuł dotyczy dokładnie jednego z tych dwóch problemów: anulowania długiego renderowania pojedynczej strony bez zamrażania UI. Użytkownik kliknął następną stronę, powiększył lub zamknął dokument, a renderowanie w locie to teraz zmarnowana praca, która powinna skończyć się przy najbliższej okazji zamiast biec do końca. Wygładzanie przewijania i powiększania przez buforowanie już rasteryzowanych elementów to osobna kwestia z własnym projektem, omówiona w powiązanym artykule na końcu. Tutaj jedyne pytanie brzmi, jak sprawić, by jedno progresywne renderowanie odpowiedziało na żądanie anulowania szybko i czysto
Progresywny API renderowania dostarczany przez PDFium
PDFium przewidział połowę problemu związanego z zamrażaniem. Obok jednorazowego FPDF_RenderPageBitmap udostępnia wariant progresywny, który dzieli stronę na porcje pracy. Wywołujesz FPDF_RenderPageBitmap_Start raz, aby skonfigurować renderowanie na docelową bitmapę, a następnie wielokrotnie wywołujesz FPDF_RenderPage_Continue. Każde Continue rasteryzuje ograniczony wycinek i zwraca status. FPDF_RENDER_TOBECONTINUED oznacza, że jest więcej do zrobienia, FPDF_RENDER_DONE oznacza, że strona jest skończona, a FPDF_RENDER_FAILED oznacza zatrzymanie z powodu błędu. Gdy pętla się kończy, wywołujesz FPDF_RenderPage_Close, aby zwolnić stan progresywny per-strona. Ponieważ sterowanie wraca do twojego kodu między wycinkami, możesz pompować komunikaty, aktualizować wskaźnik postępu lub sprawdzać, czy praca jest nadal potrzebna
Mechanizmem, który PDFium dostarcza do decydowania, kiedy ustępować, jest struct callback o nazwie IFSDK_PAUSE. Przekazujesz go do Start i do każdego Continue. Po każdej porcji PDFium wywołuje wskaźnik funkcji NeedToPauseNow, a jeśli zwraca on wartość niezerową, bieżące Continue zatrzymuje się wcześnie i przekazuje sterowanie z powrotem z FPDF_RENDER_TOBECONTINUED. Struct zawiera również pole version, które musi być ustawione na 1, oraz dowolny wskaźnik user, którego PDFium nigdy nie dotyka i przekazuje bez zmian. Ten niedotknięty wskaźnik jest całym zawiasem poniższego projektu
Repurposing pauzy jako anulowania
Pierwotnym zamiarem NeedToPauseNow jest time-slicing. Zwróć wartość niezerową, gdy twój budżet klatki jest wyczerpany, zwróć zero, aby kontynuować renderowanie, a PDFium pauzuje, żebyś mógł zrobić coś innego przed wznowieniem tego samego renderowania. PDFium Component ponownie używa tego samego sygnału dla innego polecenia. Zamiast odpowiadać na pytanie "czy powinienem pauzować i pozwolić ci wznowić", callback odpowiada na pytanie "czy ta praca została anulowana". Te dwa mapują się na siebie czysto ze względu na to, co robi pętla, gdy widzi flagę. Prawdziwa pauza oczekuje późniejszego Continue; anulowanie nie. Gdy pętla wywołująca stwierdzi, że token jest anulowany, zamyka kontekst renderowania i nigdy więcej nie wywołuje Continue, więc ten sam niezerowy zwrot, który PDFium odczytuje jako "zatrzymaj tę porcję", staje się w efekcie "zatrzymaj na dobre"
Anulowanie jest wyrażone przez interfejs, IPdfCancellationToken, którego właściwość IsCancelled zmienia się z false na true, gdy jakaś inna część programu poprosi o zatrzymanie renderowania. Mostem między tym interfejsem Pascal a callbackiem C PDFium jest pojedynczy wskaźnik. Referencja interfejsu tokenu jest zapisywana do IFSDK_PAUSE.user, a statyczny callback cdecl odczytuje go z powrotem i odpytuje. To klasyczny problem pozwalania bibliotece C wywołania zwrotnego do Pascala: callback musi być zwykłą funkcją z konwencją wywołania C, nie metodą, ponieważ PDFium przechowuje i wywołuje zwykły wskaźnik funkcji, który nic nie wie o obiektach Pascala ani Self
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;
Callback odzyskuje token przez rzutowanie pThis^.user z powrotem na typ interfejsu i odczytuje IsCancelled. Nic w nim nie alokuje, nie blokuje ani nie czeka, co ma znaczenie, ponieważ PDFium wywołuje go na wątku renderowania po każdej porcji i każda praca wykonana tutaj jest dodawana do kosztu samego renderowania. Ochrona przed zerowym structem lub zerowym polem user oznacza, że ta sama funkcja jest bezpieczna do zainstalowania nawet dla renderowania, któremu nigdy nie nadano prawdziwego tokenu
Utrzymywanie tokenu przy życiu przez całą pętlę
Rzutowanie wskaźnika interfejsu przez surowy Pointer i z powrotem to miejsce, gdzie rodzą się błędy czasu życia. IInterface w Delphi jest zliczany referencyjnie, a licznik przesuwa się tylko wtedy, gdy kompilator może zobaczyć przypisywaną zmienną typowaną jako interfejs. Przechowywanie tokenu wyłącznie jako surowego wskaźnika wewnątrz IFSDK_PAUSE.user ukryłoby go całkowicie przed licznikiem referencji. Gdyby jedyna inna referencja do tego tokenu wyszła poza zakres podczas trwania pętli Continue, obiekt zostałby zwolniony spod callbacku, a następna porcja dereferencjonowałaby wiszący wskaźnik
Dlatego deskryptor to rekord zawierający dwie rzeczy, a nie jedną. Pole Pause to struct, który odczytuje PDFium. Pole Token to prawdziwa referencja typowana jako interfejs, którą kompilator zlicza, i istnieje z jednego tylko powodu: przypięcia tokenu w pamięci na tak długo, jak żyje rekord. Rekord jest zmienną lokalną na stosie rutyny renderowania, więc pozostaje ważny przez cały czas trwania pętli i jest niszczony dopiero gdy rutyna kończy pracę. Surowy wskaźnik w user i zliczona referencja w Token nazywają ten sam obiekt; jedna jest tym, co może odczytać PDFium, druga tym, co zapobiega zebraniu tego obiektu
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);
Zamykanie kontekstu renderowania niezależnie od tego, jak kończy się pętla
Każde wywołanie FPDF_RenderPageBitmap_Start alokuje stan progresywny, który PDFium kojarzy ze stroną, i ten stan jest zwalniany tylko przez FPDF_RenderPage_Close. Są trzy wyjścia z pętli napędowej. Strona kończy i ostatni status to FPDF_RENDER_DONE. Token odpala i pętla wychodzi wcześnie zgłaszając anulowanie. Coś zawodzi i status to FPDF_RENDER_FAILED. Wszystkie trzy muszą wywołać Close, a ścieżka anulowania jest najłatwiejszą do pomylenia, bo naturalna forma "widzę anulowanie, przerywam" ma tendencję do pomijania sprzątania po drodze do wyjścia. Pozostawienie Close nieosiągniętym wycieka stan per-strona, a przeglądarka, która pozwala użytkownikowi anulować renderowanie za renderowaniem, gromadziłaby ten wyciek na każdej przerwanej stronie
Solidna forma umieszcza pętlę i klasyfikację wyników wewnątrz try, a FPDF_RenderPage_Close w pasującym finally. Docelowa bitmapa jest niszczona w tym samym bloku. Anulowanie może wyjść z pętli przez wczesne Exit i finally nadal działa, więc jest dokładnie jedno miejsce, które zwalnia stan progresywny i nie można go ominąć
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;
Pętla sprawdza token przed każdym Continue oraz polega na callbacku wewnątrz niego. Callback skraca bieżącą porcję; sprawdzenie pętli powstrzymuje następną przed uruchomieniem. Razem ograniczają czas, jaki anulowanie potrzebuje, by zacząć działać, do mniej więcej czasu trwania jednej porcji
Trzy wyniki i co bitmapa zawiera po anulowaniu
Publiczny punkt wejścia to TPdf.RenderPageProgressive, który zwraca TPdfProgressiveStatus będący jednym z prsDone, prsCancelled lub prsFailed. Wartości odzwierciedlają stałe PDFium FPDF_RENDER_* w idiomie Pascala, ale składają przypadek anulowania jako wynik pierwszej klasy zamiast błędu
Punkt, który zaskakuje ludzi, to co docelowa bitmapa zawiera po prsCancelled. Nie jest pusta. PDFium renderuje progresywnie do tej samej bitmapy porcja po porcji, więc gdy anulowanie zatrzymuje pętlę, bitmapa zawiera wszystko, co zostało namalowane do tego momentu, co jest obrazem częściowym: niektóre pasy gotowe, reszta nadal pokazuje kolor wypełnienia. Czy ten częściowy wynik jest użyteczny, zależy od wywołującego. Przeglądarka, która ma zamiar wyrzucić bitmapę, bo użytkownik przeszedł gdzie indziej, może po prostu ją zignorować. Przeglądarka, która chce pokazać tani podgląd, może ją zachować. Czego nie wolno robić, to zakładać, że prsCancelled implikuje pustą lub niezdefiniowaną bitmapę; implikuje wierne migawkę niedokończonego renderowania
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;
Token nil i ścieżka callbacku bez rozgałęzień
Anulowanie jest opt-in. Wywołujący, który chce tylko progresywnego renderowania dla korzyści pompowania komunikatów, bez zamiaru przerywania, powinien móc przekazać nil dla tokenu. Naiwnym sposobem na obsługę tego jest rozsypanie sprawdzeń "jeśli dostarczono token" przez callback i pętlę, co oznacza rozgałęzienie na każdej porcji i callback, który musi obsługiwać zarówno prawdziwy token, jak i jego brak
Implementacja unika tego przez podstawienie singletonu, gdy wywołujący nic nie przekazuje. Token nil jest zamieniony na PdfNoCancellationToken, interfejs, którego IsCancelled jest zawsze false. Od tego momentu callback i pętla mają token do odpytania w każdym przypadku, więc żaden nie potrzebuje sprawdzenia nil i żaden nie potrzebuje specjalnej ścieżki. Token nigdy-nie-anuluj po prostu zawsze odpowiada false, callback zawsze zwraca zero, a renderowanie biegnie do końca dokładnie tak jak nieanulowalny. Opcjonalne zachowanie jest modelowane jako token, który nigdy nie odpala, zamiast jako brak tokenu, co utrzymuje gorącą ścieżkę jednolitą
// 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;
Kształt, który się wyłania, jest mały i wart powtórzenia, bo to część wielokrotnego użytku. Biblioteka C, która obsługuje callback, daje ci dokładnie jeden kanał do przekazania stanu do tego callbacku - nieprzezroczysty wskaźnik user. Umieść zliczoną referencję interfejsu Pascal za tym wskaźnikiem, trzymaj drugą prawdziwą referencję żywą obok structu, żeby obiekt nie mógł zostać zebrany w trakcie wywołania, i odczytaj interfejs z powrotem wewnątrz statycznej funkcji cdecl. Owijaj całą pętlę napędową w try i zwalniaj natywny kontekst w finally. Ten sam szablon przenosi się na każdą progresywną lub sterowaną callbackiem operację PDFium, gdzie kod Pascala musi pozostać w kontroli czasu życia, podczas gdy C trzyma wskaźnik
Anulowanie to tylko połowa responsywnej przeglądarki. Druga połowa to nie re-renderowanie stron, które już narysowałeś, i utrzymywanie płynności powiększenia i przewijania przez serwowanie buforowanych bitmap, co jest omówione w naszym artykule o buforowaniu renderowania i wydajności powiększenia. Jak anulowalne renderowanie pasuje do kompletnej przeglądarki wraz z nawigacją, zaznaczaniem i wyszukiwaniem, opisano w artykule budowanie bogatej w funkcje przeglądarki PDF z komponentem PDFium VCL. Progresywne renderowanie opisane tutaj jest dostarczane jako część PDFium Component dla Delphi i Lazarus wraz z API ładowania, renderowania i formularzy opisanymi gdzie indziej na tym blogu