Техническая статья

Очередь фоновой отрисовки PDF в Delphi с HotPDF

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

Зачем вообще отрисовывать страницы PDF в фоновом потоке?

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

Настройка очереди запросов для прокручиваемого просмотрщика

THPDFBackgroundRenderer.Create принимает загруженный экземпляр THotPDF и DPI, которое остаётся неизменным на протяжении всей жизни этого рендерера, так что каждая страница, поставленная в очередь через один экземпляр, отрисовывается с одним разрешением; просмотрщику, поддерживающему масштабирование, при каждом изменении уровня масштаба нужен новый рендерер, а не новое значение свойства DPI. RequestPage добавляет индекс страницы во внутреннюю очередь и немедленно возвращает управление: сам он ничего не отрисовывает и никогда не касается UI-потока. 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