Повечето PDF страници се растаризират за няколко милисекунди и вие никога не се замисляте за това. След това потребител отваря инженерен чертеж във формат A1, страница, пълна с десетки хиляди векторни щрихи, или плакат, претъпкан с групи за прозрачност и меки маски, и единственото извикване, което го рисува, отнема две или три секунди. Ако това извикване се изпълнява в основната нишка, прозорецът спира да се прерисува, заглавната лента посивява и операционната система предлага да убие приложението. Работата е легитимна. Страницата наистина се нуждае от толкова време. Дефектът е, че рендирането е едно неделимо блокиращо извикване без начин да се поеме въздух и без начин да се спре
Тази статия е точно за един от тези два проблема: отмяна на дълго рендиране на една страница без замразяване на потребителския интерфейс. Потребителят е щракнал на следващата страница, или е мащабирал, или е затворил документа, и рендирането в полет вече е загубена работа, която трябва да приключи при първа възможност, вместо да се изпълнява докрай. Изглаждането на превъртането и мащабирането чрез кеширане на вече растаризираното е отделна грижа със собствен дизайн, обхваната в придружаващата статия, свързана в края. Тук единственият въпрос е как да накараме едно прогресивно рендиране да отговори на заявка за отмяна бързо и чисто
API-то за прогресивно рендиране, което PDFium вече доставя
PDFium предвиди замразяващата половина на проблема. Наред с еднократното FPDF_RenderPageBitmap, той излага прогресивен вариант, който разделя страницата на порции работа. Извиквате FPDF_RenderPageBitmap_Start веднъж, за да настроите рендирането спрямо целево растерно изображение, след което извиквате FPDF_RenderPage_Continue многократно. Всяко Continue растаризира ограничен отрязък и връща състояние. FPDF_RENDER_TOBECONTINUED означава, че има още за правене, FPDF_RENDER_DONE означава, че страницата е завършена, а FPDF_RENDER_FAILED означава, че е спряла при грешка. Когато цикълът приключи, извиквате FPDF_RenderPage_Close, за да освободите прогресивното състояние на страницата. Тъй като контролът се връща към вашия код между отрязъците, можете да изпомпвате съобщения, да актуализирате индикатор за напредък или да проверите дали работата все още е желана
Механизмът, който PDFium предоставя за решаване кога да се отстъпи, е структура за обратно извикване (callback struct), наречена IFSDK_PAUSE. Подавате го на Start и на всяко Continue. След всяка порция PDFium извиква нейния указател към функция NeedToPauseNow, и ако той върне ненулева стойност, текущото Continue спира рано и връща контрола обратно с FPDF_RENDER_TOBECONTINUED. Структурата носи и поле version, което трябва да бъде зададено на 1, и свободен указател user, който PDFium никога не докосва и предава непокътнат. Този непокътнат указател е цялата панта на дизайна, който следва
Преназначаване на паузата като отмяна
Първоначалното намерение на NeedToPauseNow е разделяне на времето. Връща ненулева стойност, когато бюджетът на кадъра ви е изразходван, връща нула, за да продължи рендирането, и PDFium прави пауза, за да можете да направите нещо друго, преди да възобновите същото рендиране. PDFium Component използва повторно същия сигнал за различен глагол. Вместо да отговаря "трябва ли да направя пауза и да ви позволя да възобновите", обратното извикване отговаря "отменена ли е тази работа". Двете се картографират една върху друга чисто заради това, което цикълът прави, когато види флага. Истинската пауза очаква по-късно Continue; отмяната не. След като извикващият цикъл забележи, че токенът е отменен, той затваря контекста за рендиране и никога повече не извиква Continue, така че същото ненулево връщане, което PDFium чете като "спри тази порция", се превръща на практика в "спри завинаги"
Отмяната се изразява чрез интерфейс, IPdfCancellationToken, чието свойство IsCancelled се обръща от false на true, когато друга част от програмата поиска спиране на рендирането. Мостът между този Pascal интерфейс и C обратното извикване на PDFium е един единствен указател. Референцията към интерфейса на токена е записана в IFSDK_PAUSE.user, а статично cdecl обратно извикване я чете обратно и я запитва. Това е класическият проблем да се позволи на C библиотека да се обади обратно в Pascal: обратното извикване трябва да бъде обикновена функция със C конвенция за извикване, а не метод, защото PDFium съхранява и извиква гол указател към функция, който не знае нищо за обекти на Pascal или 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;
Обратното извикване възстановява токена, като предава (cast) pThis^.user обратно към типа на интерфейса и чете IsCancelled. Нищо в него не разпределя памет, не заключва и не блокира, което има значение, защото PDFium го извиква в нишката за рендиране след всяка порция и всяка работа, свършена тук, се добавя към цената на самото рендиране. Защитата срещу nil структура или nil поле user означава, че същата функция е безопасна за инсталиране дори на рендиране, на което никога не е бил даден истински токен
Поддържане на токена жив през цикъла
Предаването (casting) на интерфейсен указател през суров Pointer и обратно е мястото, където се раждат бъгове с продължителността на живота. IInterface в Delphi е с отчитане на референции (reference counted), и броят се движи само когато компилаторът може да види как се присвоява променлива от интерфейсен тип. Съхраняването на токена единствено като гол указател вътре в IFSDK_PAUSE.user би го скрило напълно от брояча на референции. Ако единствената друга референция към този токен излезе извън обхват, докато цикълът Continue все още се изпълняваше, обектът щеше да бъде освободен под обратното извикване и следващата порция щеше да дереференцира висящ указател (dangling pointer)
Ето защо дескрипторът е запис, съдържащ две неща, а не едно. Полето Pause е структурата, която PDFium чете. Полето Token е истинска референция от интерфейсен тип, която компилаторът брои, и то съществува само по една причина: да прикрепи токена в паметта толкова дълго, колкото живее записът. Записът е локална променлива в стека на рутината за рендиране, така че остава валиден за цялата продължителност на цикъла и се разрушава само когато рутината излезе. Голият указател в user и отчитаната референция в Token назовават един и същ обект; едното е това, което PDFium може да чете, а другото е това, което предпазва обекта от събиране (garbage collection)
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);
Затваряне на контекста за рендиране, независимо как завършва цикълът
Всяко извикване на FPDF_RenderPageBitmap_Start разпределя прогресивно състояние, което PDFium асоциира със страницата, и това състояние се освобождава само от FPDF_RenderPage_Close. Има три пътя за изход от цикъла. Страницата завършва и последното състояние е FPDF_RENDER_DONE. Токенът се задейства и цикълът излиза рано, отчитайки отмяна. Нещо се проваля и състоянието е FPDF_RENDER_FAILED. И трите трябва да извикат Close, а пътят на отмяната е най-лесният за объркване, защото естествената форма на "виж отмяна, излез" има тенденция да пропуска почистването по пътя си към изхода. Оставянето на Close недостигнато води до изтичане на състоянието на страницата, и програма за преглед, която позволява на потребителя да отменя рендиране след рендиране, би натрупала това изтичане при всяка прекратена страница
Здравата форма поставя цикъла и класификацията на резултатите вътре в try и FPDF_RenderPage_Close в съвпадащото finally. Целевото растерно изображение се унищожава в същия блок. Отмяната може да напусне цикъла чрез ранно Exit и finally все пак се изпълнява, така че има точно едно място, което освобождава прогресивното състояние, и то не може да бъде заобиколено
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;
Цикълът проверява токена преди всяко Continue, както и разчита на обратното извикване вътре в него. Обратното извикване съкращава текущата порция; проверката на цикъла спира започването на следващата. Заедно те ограничават колко време отнема отмяната да влезе в сила до приблизително продължителността на една порция
Три резултата и какво съдържа растерното изображение след отмяна
Публичната входна точка е TPdf.RenderPageProgressive и тя връща TPdfProgressiveStatus, което е едно от prsDone, prsCancelled или prsFailed. Стойностите отразяват константите FPDF_RENDER_* на PDFium в идиома на Pascal, но сгъват случая на отмяна като първокласен резултат, а не като грешка
Моментът, който хваща хората неподготвени, е какво съдържа целевото растерно изображение след prsCancelled. Не е празно. PDFium рендира прогресивно в едно и също растерно изображение порция след порция, така че когато отмяната спре цикъла, растерното изображение съдържа каквото е било нарисувано до този момент, което е частично изображение: някои ленти са готови, а останалите все още показват цвета на запълване. Дали този частичен резултат е полезен зависи от извикващия. Програма за преглед, която е на път да изхвърли растерното изображение, защото потребителят е навигирал другаде, може просто да го игнорира. Програма за преглед, която иска да покаже визуализация с ниска цена, може да го запази. Това, което не трябва да правите, е да приемате, че prsCancelled предполага празно или недефинирано растерно изображение; предполага истинска моментна снимка на недовършено рендиране
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;
Нилевият (nil) токен и път за обратно извикване без разклонения
Отмяната е по желание. Извикващ, който просто иска прогресивно рендиране заради предимството на изпомпването на съобщения, без намерение да прекъсва, трябва да може да подаде nil за токена. Наивният начин да се поддържа това е да се разпръснат проверки "ако е предоставен токен" през обратното извикване и цикъла, което означава разклонение при всяка порция и обратно извикване, което трябва да обработва както истински токен, така и липсата му
Имплементацията избягва това чрез заместване със сингълтън, когато извикващият не подава нищо. Токен nil се разменя за PdfNoCancellationToken, интерфейс, чийто IsCancelled е винаги false. От този момент нататък обратното извикване и цикълът имат токен за заявка във всеки случай, така че нито едно от двете не се нуждае от nil проверка и нито едното няма нужда от специален път. Токенът never-cancel просто винаги отговаря false, обратното извикване винаги връща нула, и рендирането се изпълнява докрай точно както би го направило неотменимо рендиране. Поведението по избор се моделира като токен, който никога не се задейства, а не като липса на токен, което поддържа горещия път еднообразен
// 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;
Формата, която се появява, е малка и си струва да се повтори, защото тя е частта за многократна употреба. С библиотека, която поддържа обратно извикване, ви дава точно един канал за предаване на състояние в това обратно извикване, непрозрачния потребителски указател. Поставете отчитана препратка към интерфейс на Pascal зад този указател, запазете втора истинска препратка жива до структурата, така че обектът да не може да бъде събран в средата на извикването, и прочетете интерфейса обратно вътре в статична cdecl функция. Обвийте целия задвижващ цикъл в try и освободете родния контекст в finally. Същият шаблон се пренася към всяка прогресивна или управлявана от обратно извикване операция на PDFium, където кодът на Pascal трябва да запази контрола върху живота, докато C държи указател
Отмяната е само едната половина на отзивчивата програма за преглед. Другата половина е да не прерисувате страници, които вече сте нарисували, и да поддържате мащабирането и превъртането гладки чрез обслужване на кеширани растерни изображения, което е обхванато в нашата статия за кеширане на рендирането и производителност на мащабиране. За това как анулируемото рендиране се вписва в пълна програма за преглед заедно с навигация, избор и търсене, вижте изграждане на богата на функции програма за преглед на PDF с PDFium VCL компонента. Прогресивното рендиране, описано тук, се доставя като част от компонента PDFium за Delphi и Lazarus заедно с API-тата за зареждане, рендиране и формуляри, разгледани на други места в този блог