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

Черга фонового рендерингу PDF у Delphi з HotPDF

Клас THPDFBackgroundRenderer у HotPDF — це нащадок TThread, що рендерить завантажені сторінки PDF у растрові зображення на робочому потоці, тож переглядач Delphi може продовжувати прокручуватися й перемальовуватися, поки сторінка ще растеризується у фоні. THPDFBackgroundRenderer.RequestPage ставить індекс сторінки в чергу для цього робочого потоку, CancelAll скидає все, що ще очікує, а GetCachedBitmap повертає готове растрове зображення, яким володіє викликач і яке той мусить звільнити. Прокрутіть двохсотсторінковий скан-контракт із роздільною здатністю для друку лише на потоці інтерфейсу — і кожне перегортання сторінки призупинить вікно, доки GDI не закінчить її малювати, а це саме те заїкання, заради усунення якого й існує THPDFBackgroundRenderer

Навіщо взагалі рендерити сторінки PDF на фоновому потоці?

Фоновий потік виправдовує свою складність, бо рендерер сторінок HotPDF — це справжній інтерпретатор потоку вмісту, а не дешеве копіювання растрового зображення, що повертається до того, як хтось помітить: він проходить оператори PDF, тримає стек графічного стану й растеризує контури, зображення та гліфи через GDI — той самий рушій, описаний у статті рендеринг завантажених сторінок PDF у TBitmap. Виконайте цю роботу синхронно всередині обробника прокрутки чи малювання — і цикл повідомлень перестає перекачувати повідомлення, доки виклик не повернеться, а саме це і є замерзлим вікном. Вставлення Application.ProcessMessages всередину виклику рендера цього не виправляє: воно дозволяє черзі повідомлень спорожнитися, але сам рендер усе одно володіє потоком, що викликає, тож вікно перемальовує застарілий вміст швидше, поки реальна робота нікуди не рухається. Єдиний спосіб зберегти чуйність переглядача під час справді повільного рендера — виконати цей рендер десь-інде, і саме тому THPDFBackgroundRenderer існує як підклас TThread, а не як колбек чи таймер

Налаштування черги запитів для переглядача, що прокручується

THPDFBackgroundRenderer.Create приймає завантажений екземпляр THotPDF та DPI, що залишається фіксованим упродовж усього життя цього рендерера, тож кожна сторінка, поставлена в чергу через один екземпляр, рендериться з однією роздільною здатністю; переглядачу, що підтримує масштабування, потрібен новий рендерер, а не нова властивість DPI, щоразу коли змінюється рівень масштабу. RequestPage додає індекс сторінки до внутрішньої черги й одразу повертається: він сам нічого не рендерить і ніколи не торкається потоку інтерфейсу. Execute — успадкована точка входу TThread, яку HotPDF запускає, щойно ви викликаєте Start, — витягує по одному індексу з початку цієї черги, рендерить його через кеш сторінок документа й зберігає копію, індексовану за сторінкою, щоб GetCachedBitmap міг пізніше її повернути

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 повертає nil, доки копія цієї сторінки не готова, тож шаблону з опитуванням за таймером, як вище, достатньо; окремої події готовності підключати не потрібно — HotPDF вирішує це простою перевіркою на nil замість масштабнішого API сповіщень. Наступний розділ описує, що насправді роблять CancelAll і той виклик Free, бо обидва важать, щойно сторінки починають рендеритися не по порядку або прокрутка відбувається швидше, ніж черга встигає спорожнюватися

Скорочення в один виклик для однієї сторінки

THotPDF.RenderLoadedPageToBitmapAsync існує для звичайного випадку — запустити рендер рівно однієї сторінки, не торкаючись THPDFBackgroundRenderer напряму: він внутрішньо конструює рендерер, викликає RequestPage один раз, запускає потік і повертає посилання на TThread викликачу, який ним володіє й відповідає за його звільнення. Отримання результату проходить через THotPDF.GetLoadedCachedRenderedBitmap, а не через власний GetCachedBitmap рендерера, бо GetLoadedCachedRenderedBitmap читає спільний кеш документа, ключем якого є сторінка та DPI, — той самий кеш, який уже наповнюють RenderLoadedPageToBitmapCached і вбудований попередній завантажувач — сторінка, яку якась інша частина переглядача вже відрендерила з цим DPI, може повернутися негайно, ще до того, як щойно запущений фоновий потік взагалі отримає час від ОС

// 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;

Чи можна скасувати сторінку, яка вже в черзі?

CancelAll видаляє лише завдання, що ще сидять у черзі; сторінка, яку HotPDF уже витягнув із початку й передав своєму виклику рендера, продовжує виконуватися до завершення, бо THPDFBackgroundRenderer не має механізму перервати роботу, що вже виконується. На практиці це розумний компроміс — рендер однієї сторінки рідко буває достатньо довгим, щоб виправдати додану складність витіснення, — але швидка прокрутка, що викликає CancelAll на кожну подію прокрутки, усе одно платить за ту одну сторінку, що якраз рендерилася в момент кожного скасування. Офіційний довідник прямо про це каже: рендеринг, що вже виконується, може завершитися до того, як потік завершиться

Execute має другу, легко пропустиму поведінку: цикл завершується, щойно виявляє порожню чергу, він не простоює в очікуванні нової роботи. Тому екземпляр THPDFBackgroundRenderer — це одноразовий пакетний робітник, а не постійна фонова служба: поставте в чергу кілька сторінок, викличте Start, і щойно останню сторінку в черзі відрендерять, базовий потік ОС завершиться сам. Повторний виклик RequestPage на тому самому екземплярі після того, як Execute уже спорожнив чергу, не перезапускає його, і саме тому RequestPageWindow вище замінює екземпляр рендерера на кожному виклику замість того, щоб намагатися й далі годувати один довгоживучий об'єкт

Чи безпечно торкатися TBitmap із фонового потоку в Delphi?

Торкатися TBitmap із фонового потоку безпечно в архітектурі HotPDF, доки з даним екземпляром растрового зображення в кожен момент часу працює лише один потік, і THPDFBackgroundRenderer примусово забезпечує цю межу замість того, щоб залишати це на відкуп викликачу. Execute рендерить кожну сторінку всередині власного блокування рендера документа — тієї самої критичної секції, яку вже спільно використовують кожен виклик RenderLoadedPageToBitmapCached та вбудований попередній завантажувач PrefetchLoadedPages, тож фактичне малювання GDI для даної сторінки в кожен момент часу відбувається рівно на одному потоці й ніколи не перетинається з іншим рендером того самого документа. Результатне растрове зображення — це об'єкт, яким володіє робочий потік і який THPDFBackgroundRenderer ніколи не публікує напряму викликачу

GetCachedBitmap натомість виділяє цілком нове TBitmap і викликає на ньому Assign під власним окремим блокуванням рендерера, тож копіювання завжди відбувається, поки Execute заблоковано від заміни цього слота кешу під ним — потік, що викликає, отримує дані пікселів, ніколи не оригінальний дескриптор. Це розділення — також причина утримуватися від власноруч зробленого потоку рендерингу, що викликає функції рендерингу HotPDF напряму, оминаючи THPDFBackgroundRenderer чи PrefetchLoadedPages: два рендери, що змагаються за спільні кеші та граф об'єктів того самого завантаженого документа, — це саме той сценарій, для запобігання якому існує внутрішнє блокування HotPDF, а клас фонового рендерера дає це блокування безкоштовно замість того, щоб реалізовувати його заново

Чим це відрізняється від вбудованого попереднього завантаження сторінок HotPDF?

PrefetchLoadedPages і THPDFBackgroundRenderer вирішують споріднені, але різні проблеми: PrefetchLoadedPages, отримавши діапазон сторінок, автоматично рендерить усю цю околицю в спільний кеш документа на власному робочому потоці, без об'єкта черги, який мав би створювати чи керувати ним викликач. THPDFBackgroundRenderer обмінює цю автоматизацію на контроль — викликач вирішує, які саме індекси сторінок важливі й у якому порядку, і може скасувати ті, що ще в черзі, не торкаючись жодного діапазону, який вбудований попередній завантажувач прогріває деінде. Обидва проходять через те саме блокування рендера, тож переглядач може виконувати PrefetchLoadedPages для звичайного випадку «наступні кілька сторінок» і звертатися до THPDFBackgroundRenderer лише тоді, коли трапляється щось поза цим шаблоном, наприклад смуга мініатюр, що переходить прямо до сторінки, яку користувач щойно клацнув

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;

Дві деталі життєвого циклу варто перенести в промисловий код. Загальний для всього документа кеш за RenderLoadedPageToBitmapCached обмежений RenderCacheCapacity, за замовчуванням вісім сторінок, і витісняє найдавніше використаний запис, щойно заповнюється, але власний список результатів екземпляра THPDFBackgroundRenderer такого обмеження не має — він тримає по одному растровому зображенню на кожен окремий індекс сторінки, коли-небудь запитаний через цей екземпляр, доки сам екземпляр не звільнять, тож рендерер, що залишається живим на весь сеанс прокрутки з високим DPI, охоче накопичить по одному повнороздільчому растровому зображенню на кожну прокручену сторінку. HotPDF також не скасовує створений викликачем рендерер автоматично так, як скасовує власний попередній завантажувач перед завантаженням документа чи власним знищенням, оскільки екземпляр THPDFBackgroundRenderer ніколи не реєструється на об'єкті THotPDF, на який він вказує, — тож код, що викликає, мусить скасовувати й звільняти кожен рендерер, побудований проти документа, перед перезавантаженням чи звільненням цього документа, — та сама дисципліна порядку, яку HotPDF внутрішньо застосовує до PrefetchLoadedPages

THPDFBackgroundRenderer — одна з частин фасаду завантаженого документа за архітектурою MVC переглядача HotPDF, і він природно поєднується з робочими процесами на рівні файлів у статті Direct File API для великих PDF, коли документ, що прокручується, сам по собі надто великий, щоб його завантажувати без цього. Фоновий рендеринг, черги запитів та кеш рендера, описані тут, — усі частина стандартного компонента HotPDF для Delphi та C++Builder