Technical Article

PDFium Delphi Viewer: Кеширане на рендирането и тактики за плавно мащабиране

Задръжте натиснат бутона за мащабиране в обикновен PDF визуализатор и наблюдавайте графиката на процесора (CPU). Еднократното натискане на контрола за автоматично повтаряне на мащаба задейства дузина или повече стъпки на мащабиране в секунда. Ако всяка стъпка стартира повторно рендиране с пълно качество на видимата страница, процесите на рендиране се натрупват по-бързо, отколкото завършват. Страницата се растеризира добре сама по себе си, например за 180 ms за сканиран A4 лист, но сега изпълнявате дузина рендирания от по 180 ms за задачи, които потребителят вече е подминал. Визуализаторът блокира, едно ядро на процесора се натоварва на 100% и докато екранът навакса, потребителят е спрял на ниво на мащабиране отпреди четири рендирания. Решението не е по-бърз растеризатор, а кеш, който връща готови страници незабавно, и цикъл на рендиране, готов да изостави текущата работа в момента, в който тя остарее.

PDFium Component ви предоставя необходимите компоненти и за двете и не се намесва в политиките на изпълнение. Получавате битмапи, собственост на извикващата страна, прогресивен рендиращ модул, който приема токен за анулиране, режими за напасване, които преизчисляват мащаба при промяна на размера, и извикване за разделяне на плочки (tiling) за страници, които са твърде големи за цялостно растеризиране. Това, което продуктът умишлено не предоставя, е самият кеш, тъй като правилната политика за изчистване (eviction policy) зависи от вашия изглед (viewport), лимита на паметта на платформата ви и начина, по който потребителите превъртат. Решението за правилната им настройка е ваше, а последиците от грешката са замръзване на интерфейса и изтичане на памет.

Къде отиват милисекундите и мегабайтите

Определете цифрово разходите, преди да проектирате каквото и да било. Страница с размер A4 при 96 DPI е приблизително 794 на 1123 пиксела, което е около 3.5 MB как 32-битов битмап. Мащабирайте до 200% и размерът се учетворява. При 400% на дисплей с висока разделителна способност (high-DPI) заделяте и запълвате един единствен битмап за страница от 50 до 60 MB, а визуализаторът с непрекъснато превъртане поддържа няколко активни страници едновременно. Разходите за растеризация следват изходните пиксели, така че всяко удвояване на мащаба грубо учетворява както времето за рендиране, така и консумацията на памет.

Две последствия произтичат директно от тази аритметика. Кеш, чийто ключ игнорира нивото на мащабиране, е безполезен, защото самото действие, което трябва да ускори (мащабирането), всеки път генерира нов битмап. Освен това неограниченият кеш ще доведе до изчерпване на адресното пространство на 32-битов процес точно при документи, където хората мащабират най-интензивно: плътни сканирания на документи за собственост, инженерни чертежи, едроформатни карти. Кешът трябва да бъде правилно индексиран с ключове и строго ограничен, като нито едно от двете не е опция по избор.

Какво влиза в ключа на кеша

Кеширан битмап е безопасен за повторна употреба само когато всички параметри, оформили неговите пиксели, съвпадат. Това означава номера на страницата, ефективното мащабиране (или съответно изходните размери в пиксели), ротацията, DPI на монитора и опциите за рендиране, които са били в сила при създаването му. Страница, рендирана с reAnnotations, е различно изображение от същата страница без тях, а черно-бялото рендиране чрез reGrayscale е съвсем различно. Изпуснете някой от тези параметри от ключа и грешките са предвидими: наслагване на анотация, което остава след като преглеждащият е изтрил коментара, или страница, която става размазана в момента, в който потребителят премести прозореца от екрана на лаптопа към външен 4K монитор и DPI се промени под остарелия битмап.

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);   // the cache now owns this bitmap
end;

При попадение в кеша резултатът се връща за микросекунди, което е и целта. По-трудният въпрос е какво се случва с битмапите, които отпадат от кеша, и това се оказва въпрос за тяхната собственост.

Кой освобождава битмапа

Функционалната форма на RenderPage връща TBitmap, който е собственост на извикващия код. При еднократен експорт тази собственост е очевидна и лесна за управление. Вътре в кеша обаче това се превръща в най-честата причина за изтичане на памет в Delphi PDF визуализаторите, тъй като речникът съдържа единствената препратка към всеки битмап, а стандартният TDictionary освобождава ключове и стойности автоматично само ако те са управлявани типове. TBitmap не е такъв тип. Извадете запис от кеша, без да извикате Free, и пикселите остават заделени в паметта, без нищо да сочи към тях.

Причината това да се пропуска е времето. Десетминутен бърз тест никога не мащабира достатъчно различни страници, за да се забележи проблемът. Изтичането се проявява едва след като някой е превъртал и мащабирал дълъг документ в продължение на два часа, при което процесът задържа стотици осиротели битмапи на страници и машината започва да използва виртуална памет. Ето защо механизмът за изчистване на паметта трябва да бъде част от първата версия на кеша, а не от някоя по-късна. Ограничете кеша по прогнозни байтове (изчислени като ширина по височина по четири), премахнете най-рядко използваните страници (LRU), които се намират извън видимата зона и прозореца за предварително зареждане, и освобождавайте всеки битмап при премахването му. За изчертавания, които са наистина временни, претоварванията, които рендират в предоставен от потребителя TBitmap или директно върху HDC, ви позволяват напълно да избегнете сложната схема със собствеността върху обектите. Предварителният преглед преди печат е очевиден пример, тъй като рендирате всеки лист само веднъж и кеширането му не носи никакви ползи.

Прогресивно рендиране и коректно анулиране

Стандартните претоварвания на RenderPage блокират изпълнението до завършване на страницата, което е точно поведението, което искате да избегнете, докато потребителят все още движи контрола за мащабиране. За тази цел се използва RenderPageProgressive. Методът приема IPdfCancellationToken и връща една от стойностите prsDone, prsCancelled или prsFailed. Поведенческият детайл, който често изненадва разработчиците, е че анулирането не е мигновено. Токенът се проверява на границите между отделните блокове в процеса на рендиране, така че токен, който подадете в средата на блок, влиза в сила едва след като този блок приключи. При сложна страница времето между заявката за спиране и реалното прекратяване може да достигне десетки милисекунди. Проектирайте логиката около този промеждутък, вместо да се надявате той да не съществува: анулирайте предходния токен в момента, в който пристигне нова стойност за мащаб, но не приемайте, че старото рендиране спира на секундата.

procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // abandon the previous in-flight render
  FTokenSource := TPdfCancellationTokenSource.New;  // FPdfAsync unit

  Status := Pdf.RenderPageProgressive(FBackBuffer, 0, 0,
    FBackBuffer.Width, FBackBuffer.Height, FTokenSource.Token,
    ro0, [reAnnotations]);

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // superseded by a newer request: drop silently
    prsFailed:    ShowRenderFailure;
  end;
end;

По време на интерактивно мащабиране prsCancelled е нормалният резултат, а не изключение. Повечето рендирания, стартирани от жест за мащабиране, ще бъдат заменени преди да завършат, така че третирайте анулирането като рутинно събитие и пренебрегвайте резултата тихо. Опашка от рендирания, която записва всяко анулиране като предупреждение, ще погребе единствената наистина важна грешка под хиляди редове излишен шум. За да предотвратите екрана да изглежда замръзнал, докато тече реалното рендиране, комбинирайте прогресивния път с временен заместител: мащабирайте предходния кеширан битмап до новия размер и го покажете веднага. Той ще изглежда леко размазан за сто или двеста милисекунди, но ще се усеща като мигновен отговор и ще осигури на висококачественото рендиране необходимото време да завърши или да бъде анулирано от следващото действие.

Режимът на напасване, който мащабирането тихо изключва

Свойството FitMode на визуализатора, настроено на pfmFitPage или pfmFitWidth, преизчислява мащаба при всяка промяна на размера, така че страницата да продължи да съответства на прозореца. Уловката е, че задаването на Zoom директно нулира FitMode обратно на pfmNone. По подразбиране това е правилно: потребител, който изрично е задал 150%, не иска следващата промяна на размера на прозореца да премахне тази настройка. Това обаче изненадва всеки, който свърже бутон за приближаване как Zoom := Zoom * 1.25 и след това не може да разбере защо напасването по ширина е спряло да реагира след първото кликване. Ако вашата лента с инструменти предлага както изрично мащабиране, така и режими на напасване, трябва сами да запомните последния избор на потребителя за напасване и да го присвоите отново, когато той натисне съответния бутон. Компонентът няма да възстанови режим, който присвояването на мащаб току-що е изчистило, и не се предполага да го прави.

Бюджет на паметта, който можете да защитите

Бюджет, който можете да разпишете черно на бяло, е бюджет, който можете да защитите при преглед на кода, така че започнете с конкретен сценарий. Да кажем, че непрекъснатото превъртане поддържа видимата страница плюс одна предварително заредена страница отгоре и отдолу, заедно с лента с миниатюри (thumbnails). При 100% на 96-DPI дисплей тези три битмапа в пълен размер са по около 3.5 MB всеки, което е пренебрежимо малко. При 300% на 4K дисплей същите три битмапа са по около 30 MB всеки, и това е преди кешът да е запазил дори една предишна страница. Нарастването е в самото действие на потребителя, а не в документа.

Разумна настройка по подразбиране за 32-битов Delphi процес е лимит от 256 MB за битмапи при LRU изчистване. При 64-битови процеси можете да мащабирате според физическата RAM памет, но въпреки това запазете твърд таван, защото сривът, от който се предпазвате, не е просто затваряне на вашия процес. Става въпрос за това цялата машина да започне интензивно да записва във виртуалната памет на диска, докато вашият визуализатор технически продължава да работи, а потребителят се чуди защо всичко останало е станало изключително бавно. Твърдият лимит спира работата предвидимо, докато неограниченият кеш се срива, блокирайки цялата операционна система. Миниатюрите заслужават отделно отношение: рендирайте всяка от тях веднъж в нейния малък целеви размер и я пазете в отделен пул, който LRU логиката не докосва. Повторното генериране на 120-пикселова миниатюра чрез намаляване на мащаба на 60 MB битмап за цяла страница е най-неефективният възможен начин за създаване на такова изображение.

Някои отделни страници могат да надхвърлят всеки бюджет. Инженерен чертеж с размер E или голяма карта, рендирани изцяло при 400%, представляват заделяне на стотици мегабайти и никаква политика за освобождаване на памет не прави това приемливо. Решението в този случай е да спрете рендирането на цели страници. RenderTile растеризира само областта с пикселно отместване (Left, Top) в рамките на страница, мащабирана по подразбиране до PageWidth на PageHeight. Така рендирате само видимия правоъгълник плюс поле от една плочка около него за плавно панорамиране и добавяте отместванията на плочките в ключа на кеша заедно с мащаба. Поддържайте размерите на плочките фиксирани в целия документ. Фиксираните плочки означават, че промяната на DPI изчиства чисто цялата мрежа, докато променливите плочки ще ви накарат да се справяте с видими шевове между области, рендирани в леко различни мащаби.

Две допълнителни функции тихо усложняват този процес. Филтрите за цветове, като черно-бяло изображение или инверсия, се изпълняват след рендиране и всеки път създават втори битмап в пълен размер, удвоявайки консумацията на памет на страница за всеки изглед, който ги използва. Тези аспекти са разгледани в материала за филтриране на цветовете за потребители с отслабено зрение в Delphi PDF визуализатори. Освен това визуализатор, който осветява думите по време на преобразуване на текст в реч (TTS), инвалидира рендирания изглед при всяка произнесена дума, така че взаимодействието между преначертаването на маркировката и темпото на речта е по-важно, отколкото изглежда на пръв поглед, както е описано в статията за маркиране на думи при TTS в Delphi.

Претоварените методи за рендиране, статус кодовете за прогресивно изпълнение и самият компонент за визуализация са документирани на продуктовата страница на PDFium Component.