Технічна стаття

Переглядач PDFium Delphi: кеш рендерингу та тактика плавного масштабування

Затисніть кнопку масштабування в наївному переглядачі PDF і подивіться на графік CPU. Одне натискання елемента керування масштабом з автоповтором генерує десяток чи більше кроків масштабування за секунду, і якщо кожен крок запускає повторний рендер видимої сторінки в повній якості, рендери накопичуються швидше, ніж завершуються. Сторінка растеризується чудово ізольовано, можливо, за 180 мс для скану A4, але тепер ви запускаєте десяток рендерів по 180 мс проти роботи, яку користувач уже залишив позаду. Переглядач зависає, одне ядро прибивається до 100%, і на той час, коли екран наздоганяє, користувач уже зупинився на рівні масштабу чотири рендери тому. Ліки — не швидший растеризатор. Це кеш, який повертає готові сторінки миттєво, і цикл рендеру, готовий покинути роботу тієї миті, коли вона стає застарілою

PDFium Component вручає вам деталі для обох і не втручається в політику. Ви отримуєте бітмапи, якими володіє викликач, прогресивний рендерер, що бере токен скасування, режими припасування, які перераховують масштаб при зміні розміру, і виклик тайлування для сторінок, задовеликих для растеризації цілком. Чого він навмисно не надає — це самого кешу, бо правильна політика витіснення залежить від вашого вʼюпорту, стелі пам'яті вашої платформи й того, як прокручують ваші користувачі. Це рішення ви маєте прийняти правильно самі, а наслідки помилки тут — саме зависання й витік

Куди йдуть мілісекунди й мегабайти

Виразіть вартість у числах, перш ніж щось проєктувати. Сторінка A4 при 96 DPI — це приблизно 794 на 1123 пікселі, близько 3.5 МБ як 32-бітний бітмап. Збільште до 200%, і це почетверяється. При 400% на дисплеї з високою DPI ви виділяєте й заповнюєте один бітмап сторінки на 50–60 МБ, а переглядач із безперервним прокручуванням тримає кілька сторінок живими одночасно. Вартість растеризації відстежує вихідні пікселі, тож кожне подвоєння масштабу приблизно вчетверо збільшує і час рендеру, і пам'ять разом

Дві наслідки випливають прямо з цієї арифметики. Кеш, чий ключ ігнорує рівень масштабу, — марний, бо саме той жест, який він мав прискорювати, масштабування, щоразу створює новий бітмап. А необмежений кеш вичерпає адресний простір 32-бітного процесу саме на тих документах, де люди масштабують найактивніше: щільні скани документів на землю, інженерні креслення, великоформатні карти. Кеш має бути правильно ключований і твердо обмежений, і жодне з цих двох не опційне

Що має входити до ключа кешу

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

Пошук у кеші рендерингу PDFium у переглядачі Delphi: ключ кешу поєднує сторінку, масштаб, обертання, 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);   // тепер кеш володіє цим бітмапом
end;

При влучанні це повертається за мікросекунди, і в цьому весь сенс. Складніше питання — що трапляється з бітмапами, які випадають із кешу, і воно виявляється питанням про те, хто ними володіє

Хто звільняє бітмап

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

Причина, з якої це прослизає, — це час. Десятихвилинний димовий тест ніколи не масштабує достатньо різних сторінок, щоб це помітити; витік проявляє себе лише після того, як хтось кілька годин прокручував і масштабував довгий документ, і на цей момент процес тримає сотні осиротілих бітмапів сторінок, а машина починає свопитись. Ось чому витіснення має бути в першій версії кешу, а не в пізнішій. Обмежуйте кеш за оціненими байтами, обчисленими як ширина на висоту на чотири, витісняйте найдовше не використовувані сторінки, що лежать поза вʼюпортом і вікном попереднього завантаження, і звільняйте кожен бітмап при видаленні. Для малювань, які справді минущі, перевантаження, що рендерять у наданий викликачем TBitmap або прямо на HDC, дозволяють повністю пропустити танець з володінням. Попередній перегляд друку — очевидний випадок, оскільки ви рендерите кожен аркуш один раз, і кешування тут нічого не дає

Прогресивний рендер і чесне скасування

Звичайні перевантаження RenderPage блокують, доки сторінка не завершиться, а це саме та поведінка, яку ви не хочете, поки користувач ще рухає елемент керування масштабом. Для цього ви сягаєте по RenderPageProgressive. Він бере IPdfCancellationToken і повертає один з prsDone, prsCancelled або prsFailed. Поведінкова деталь, яка ловить людей, — те, що скасування не миттєве. Токен опитується на межах чанків усередині рендеру, тож токен, який ви сигналізуєте посеред чанка, набуває чинності лише коли той чанк завершиться. На складній сторінці затримка між запитом і зупинкою сягає десятків мілісекунд. Проєктуйте з урахуванням цього розриву, а не бажаючи, щоб його не було: скасовуйте попередній токен тієї миті, коли надходить нове значення масштабу, але не припускайте, що старий рендер зупиняється тієї ж миті, коли ви про це просите

Хронологія прогресивного рендерингу PDFium у Delphi: кожен новий запит масштабу скасовує попередній токен, скасування сідає на межі чанка, витіснені рендери повертають prsCancelled, а фінальна спроба повертає prsDone
Кожен новий запит масштабу скасовує попередній токен рендеру, а скасування осідає на межі шматка
procedure TViewerForm.RequestRender(TargetZoom: Single);
var
  Status: TPdfProgressiveStatus;
begin
  if FTokenSource <> nil then
    FTokenSource.Cancel;           // покинути попередній рендер, що вже виконується
  FTokenSource := TPdfCancellationTokenSource.New;  // модуль FPdfAsync

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

  case Status of
    prsDone:      PresentBackBuffer;
    prsCancelled: ;                // замінено новішим запитом: відкинути тихо
    prsFailed:    ShowRenderFailure;
  end;
end;

Під час взаємодії prsCancelled — це нормальний результат, а не винятковий. Більшість рендерів, які запускає жест масштабування, будуть замінені, перш ніж завершаться, тож ставтеся до скасування як до рутини й тихо відкидайте результат. Черга рендеру, яка логує кожне скасування як попередження, поховає той один збій, що справді важливий, під тисячами рядків шуму. Щоб екран не виглядав мертвим, поки триває справжній рендер, поєднайте прогресивний шлях із дешевою заміною: масштабуйте попередній кешований бітмап до нового рівня і покажіть його негайно. Він виглядатиме розмито протягом сотні-другої мілісекунд, але читається як миттєвий, і це купує рендеру повної якості час, потрібний, щоб або завершитися, або бути скасованим наступним жестом

Режим припасування, який масштаб тихо вимикає

Властивість FitMode переглядача, встановлена на pfmFitPage чи pfmFitWidth, перераховує масштаб при кожній зміні розміру, тож сторінка продовжує припасовуватися при зміні вікна. Заковика в тому, що пряме присвоєння Zoom скидає FitMode назад до pfmNone. Як типова поведінка це правильно: користувач, який навмисно ввів 150%, не хоче, щоб наступна зміна розміру вікна це відкинула. Але це дивує кожного, хто підключає кнопку збільшення як Zoom := Zoom * 1.25, а потім не може зрозуміти, чому припасування за шириною перестало реагувати після першого кліку. Якщо ваша панель інструментів пропонує і явний масштаб, і режими припасування, вам доведеться самостійно пам'ятати останній вибір припасування користувача й перепризначати його, коли він знову натисне кнопку припасування. Компонент не відновить режим, який щойно очистило присвоєння масштабу, і не повинен цього робити

Бюджет пам'яті, який можна відстояти

Бюджет, який можна записати, — це бюджет, який можна відстояти на code review, тож почніть із конкретного сценарію. Скажімо, безперервне прокручування тримає видиму сторінку плюс по одній попередньо завантаженій сторінці вище й нижче, разом зі смугою мініатюр. При 100% на дисплеї 96 DPI ці три повнорозмірні бітмапи складають приблизно 3.5 МБ кожен, що є нічим. При 300% на дисплеї 4K ті самі три бітмапи — приблизно 30 МБ кожен, і це до того, як кеш зберіг хоч одну історичну сторінку. Зростання — у жесті, а не в документі

Арифметика пам'яті бітмапів PDFium для переглядача Delphi: кожне подвоєння масштабу чотирикратно збільшує пам'ять сторінки, неперервна прокрутка тримає живими три сторінки, обмежений бюджет LRU обороняє кеш, а RenderTile обслуговує завеликі малюнки
Кожне подвоєння масштабу вчетверо збільшує бітову пам'ять, тож кешові потрібні жорстка стеля та плитки для надмірних сторінок

Розумний варіант за замовчуванням для 32-бітного процесу Delphi — бюджет бітмапів 256 МБ під витісненням LRU. На 64-бітних ви можете масштабувати з фізичною RAM, але тримайте тверду стелю в будь-якому разі, бо збій, від якого ви захищаєтеся, — це не крах вашого процесу. Це коли вся машина починає товкти свій файл підкачки, поки ваш переглядач технічно продовжує працювати, а користувач дивується, чому все інше сповільнилося. Тверда межа провалюється передбачувано; необмежений кеш провалюється, забираючи з собою весь робочий стіл. Мініатюри заслуговують на власний підхід: рендерте кожну один раз у її малому цільовому розмірі й тримайте в окремому пулі, якого логіка LRU ніколи не торкається. Регенерація 120-пікселної мініатюри шляхом зменшення 60-мегабайтного повносторінкового бітмапа — найрозтратніший можливий спосіб виробити поштову марку

Деякі окремі сторінки перемагають будь-який бюджет. Інженерне креслення формату E або велика карта, відрендерена цілком при 400%, — це виділення на кілька сотень мегабайтів, і жодна політика витіснення не робить це прийнятним. Відповідь тут — перестати рендерити цілі сторінки. RenderTile растеризує лише область за піксельним зміщенням (Left, Top) у межах сторінки, умовно масштабованої до PageWidth на PageHeight, тож ви рендерите лише видимий прямокутник плюс поле в один тайл навколо нього для плавного панорамування, і вписуєте зміщення тайла в ключ кешу поряд із масштабом. Тримайте розміри тайла фіксованими по всьому документу. Фіксовані тайли означають, що зміна DPI чисто інвалідує всю сітку, тоді як змінні тайли залишають вас ганятися за видимими швами між регіонами, відрендереними в трохи різних масштабах

Дві суміжні функції тихо додаються до всього цього. Проходи кольорових фільтрів, такі як відтінки сірого чи інверсія, виконуються після рендеру й щоразу виробляють другий повнорозмірний бітмап, подвоюючи відбиток на сторінку будь-якого перегляду, що їх використовує; ця вартість — тема статті фільтрування кольору для слабкозорих у переглядачах PDF на Delphi. А переглядач, що підсвічує слова під час синтезу мовлення, інвалідує відрендерений перегляд на кожному вимовленому слові, тож взаємодія між перемальовуванням підсвітки й темпом мовлення важить більше, ніж здається на перший погляд, як описано у пословній підсвітці TTS

Перевантаження рендеру, коди прогресивного статусу й сам компонент переглядача документовані на сторінці продукту для PDFium Component