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

Дисковый кэш PDF-просмотрщика и DPI для каждого монитора в Delphi

Любой встроенный PDF-просмотрщик обычно страдает от двух проблем: большие документы перерисовываются с нуля при каждом запуске, а text становится размытым, как только окно перетаскивают на монитор с высоким DPI. Библиотека losLab PDF Library решает обе проблемы в своем элементе управления TPDFlibViewer: свойство DiskCacheFolder активирует межсессионный дисковый кэш страниц, привязанный к отпечатку документа, а свойство ScreenDPI, опирающееся на функцию GetDpiForWindow и обработчик сообщения WM_DPICHANGED, обеспечивает элементу управления полноценную поддержку DPI для каждого монитора

Обе проблемы заслуживают серьезного внимания, так как пользователи замечают их мгновенно. Сканированный договор на 400 страниц, прокрутка которого вчера стала доступна только через восемь секунд, сегодня снова открывается те же восемь секунд, хотя в самом файле ничего не изменилось. А на рабочем столе с мониторами разного разрешения просмотрщик, выполняющий рендериринг с системным значением 96 DPI основного монитора, выглядит там отлично, но становится заметно размытым на соседней 4K-панели. В этой статье мы разберем, как TPDFlibViewer решает каждую из этих проблем, а также почему архитектурные решения были приняты именно таким образом. Если вы еще не знакомы с самим элементом управления, начните с обзора интерактивного элемента управления PDF-просмотрщиком, а затем возвращайтесь к изучению слоя оптимизации производительности

Как кэшировать отрисованные страницы PDF между сессиями в Delphi?

Укажите папку в свойстве TPDFlibViewer.DiskCacheFolder, и элемент управления начнет сохранять отрисованные растровые изображения страниц на диск, благодаря чему недавно просмотренный документ будет открываться мгновенно без повторного рендеринга. Структура кэша на диске выглядит следующим образом: <Папка>\PDFlibPas-PageCache\<отпечаток документа>\p<страница>_z<код масштаба>.bin — по одной подпапке на каждый документ и по одному файлу на комбинацию страницы и масштаба. Все остальное происходит автоматически: нет необходимости вызывать API кэша или принудительно очищать его

uses
  PDFlibViewer;

procedure TMainForm.FormCreate(Sender: TObject);
begin
  FViewer := TPDFlibViewer.Create(Self);
  FViewer.Parent := Self;
  FViewer.Align := alClient;
  // One line turns on the cross-session page cache
  FViewer.DiskCacheFolder := 'C:\ProgramData\MyApp\PageCache';
  FViewer.LoadFromFile('C:\Contracts\master-agreement.pdf', '');
end;

Счетчик DiskCacheHits показывает, сколько операций рендеринга страниц сэкономил дисковый кэш в текущей сессии, что делает прирост производительности измеримым, а не просто субъективным. При прокрутке документа, который вы закрыли и открыли заново, каждая страница, отображаемая без фазы рендеринга, увеличивает значение этого счетчика

Почему ключом кэша является уникальный отпечаток, а не номер версии

Отпечаток документа представляет собой 64-битный хэш FNV-1a от строки путь|размер|время последней записи, записанный в виде шестнадцати шестнадцатеричных цифр. Эта тройка параметров составляет всю стратегию инвалидации кэша: при изменении файла меняется его размер или время записи, меняется хэш, и просмотрщик просто начинает использовать другую подпапку кэша. Устаревшая папка больше никогда не используется и со временем удаляется механизмом LRU-очистки. Здесь нет протокола тегов версий, который нужно поддерживать, нет логики сравнения меток времени, которая могла бы дать сбой, и исключена ситуация, когда старый растр отображается для измененного документа

Это та же идея "идентификации по содержимому в качестве ключа кэша", которую используют современные системы сборки, и она оправдывает себя, устраняя потенциальные точки сбоя. Кэш, сохраняющий файлы под простым именем документа, должен явно определять изменения, а любая явная проверка — это баг, поджидающий крайний случай: файл, восстановленный из резервной копии со старой меткой времени, сохранение без изменения размера или сравнение путей с учетом регистра на одной машине и без него на другой. Хэширование всех трех сигналов в имя папки делает неактуальность кэша структурно невозможной, избавляя от необходимости процедурных проверок

Почему угол поворота намеренно исключен из ключа кэша на диске

Имя файла кэша кодирует номер страницы и масштаб, и ничего больше — угол поворота вида намеренно отсутствует. Дисковый кэш всегда хранит неповернутые байты рендеринга; когда кэшированная страница загружается при повернутом виде, пиксели переориентируются в памяти уже после декодирования. Если бы поворот был частью дискового ключа, пользователь, перебирающий четыре угла обзора, записывал бы одну и ту же страницу на диск четыре раза, увеличивая объем кэша в четыре раза ради нулевой выгоды, поскольку поворот — это быстрая перестановка пикселей по сравнению с полным циклом отрисовки

Кэш растровых изображений в оперативной памяти использует составной ключ Round(Zoom * 1000) * 4 + Rotation div 90, так как там повернутый растр — это именно то, что нужно обработчику отрисовки. Фоновый поток предварительной выборки следует тому же принципу: он всегда выполняет рендеринг неповернутых страниц и возвращает простые коды масштаба, а основной поток применяет поворот при обработке результатов — то самое правило "основной поток владеет кэшем", описанное в статье о параллельном рендеринге страниц и потокобезопасности. Исключение поворота из всех слоев ниже оперативной памяти означает наличие одного канонического рендеринга на страницу и масштаб везде, где хранение обходится дорого

Как LRU-очистка ограничивает размер папки кэша

Кэш хранит папки максимум для десяти документов и удаляет наименее используемые (least recently used) при открытии новых файлов. Актуальность отслеживается с помощью файла-маркера last-used.marker в папке каждого документа, метка времени которого обновляется как при записи, так и при чтении из кэша, поэтому документ, который вы просто читаете повторно, остается "горячим" без перезаписи. Папка, принадлежащая открытому в данный момент документу, защищена от удаления, поэтому очистка никогда не удалит страницы прямо из-под активного экрана просмотра

Каждая дисковая операция в кэше — расчет отпечатка, создание папок, обновление маркеров, чтение и запись страниц, очистка — перехватывает собственные исключения. Это осознанное архитектурное решение, а не небрежность: кэш является вспомогательным ускорителем, и переполненный диск, папка только для чтения или блокировка антивирусом должны лишь возвращать просмотрщик к обычному рендерингу "как раньше", но ни в коем случае не приводить к ошибке отображения страницы. Худшее, к чему может привести ошибка ввода-вывода в кэше, — это промах кэша (cache miss)

procedure TMainForm.FormClose(Sender: TObject; var Action: TCloseAction);
begin
  // How many render passes did the disk cache save this session?
  Log(Format('Disk cache hits: %d', [FViewer.DiskCacheHits]));
end;

Как сделать PDF-просмотрщик адаптированным к DPI в Delphi?

TPDFlibViewer направляет все вычисления, зависящие от DPI, через одно поле, и эта архитектура с единой точкой изменения делает поддержку индивидуального DPI мониторов легко реализуемой. Код разметки, предварительной выборки и рендеринга обращается к единому значению FScreenDPI; при масштабе 100% одна точка PDF соответствует ScreenDPI / 72 пикселям. Сеттер общедоступного свойства ScreenDPI очищает кэш растров, перестраивает разметку, обновляет диапазоны прокрутки и инициирует перерисовку окна, поэтому изменение DPI сводится к одному присваиванию, на которое все потребители реагируют автоматически. Эта же пиксельная математика имеет значение при выводе на принтер вместо монитора — тема, подробно описанная в статье о предварительном просмотре и рендеринге в контексте устройства

Определение масштаба происходит в переопределенном методе CreateWnd, а не в конструкторе, поскольку функции GetDpiForWindow требуется валидный дескриптор окна, которого в конструкторе еще нет. До вызова CreateWnd элемент управления хранит устаревшее значение по умолчанию 96, чтобы математика разметки оставалась корректной. Сама функция GetDpiForWindow относится к Windows 10 1607+ API, поэтому элемент управления связывает ее динамически через GetProcAddress и откатывается к GetDeviceCaps(LOGPIXELSY) на старых системах — модуль по-прежнему загружается везде, а старые версии Windows просто сообщают системный DPI

Что происходит при перемещении окна на 4K-монитор?

Windows отправляет сообщение WM_DPICHANGED, когда окно с поддержкой DPI для каждого монитора пересекает границу экрана с другим коэффициентом масштабирования, и TPDFlibViewer обрабатывает его напрямую. Обработчик считывает новое значение DPI из младшего слова wParam, присваивает его через метод SetScreenDPI (который сбрасывает ставший неверным кэш растров и пересчитывает разметку страниц), а затем принимает предложенный прямоугольник окна, переданный Windows в параметре lParam, вызывая SetWindowPos, что сохраняет логический размер клиентской области стабильным при перемещении. Пользователь видит, как документ четко перерисовывается в новом масштабе, вместо размытого растрового изображения, растянутого операционной системой

// Normally you never touch ScreenDPI: CreateWnd detects the host monitor
// and WM_DPICHANGED tracks moves. Override it only for special targets,
// e.g. rendering the layout as if for a 150% display:
FViewer.ScreenDPI := 144;  // clears the bitmap cache and rebuilds layout

Ограничения, о которых следует знать перед выпуском продукта

Три практических ограничения заслуживают места в вашей архитектурной оценке. Во-первых, сообщение WM_DPICHANGED приходит только в том случае, если процесс согласился на это: манифест вашего приложения должен декларировать поддержку DPI для каждого монитора (per-monitor v2 в современных версиях Windows), что в Delphi IDE настраивается в параметрах приложения (Application options). Процесс с поддержкой только системного DPI никогда не получит это сообщение, и элемент управления будет выполнять рендеринг с тем DPI, который он определил при создании окна. Во-вторых, дисковый кэш меняет дисковое пространство на скорость — растры страниц для десяти документов на разных уровнях масштабирования занимают реальный объем памяти, поэтому направляйте DiskCacheFolder в каталог, рост которого вас не пугает, а не в перемещаемый профиль пользователя (roaming profile). В-третьих, папка кэша требует прав на запись для текущего пользователя; без них просмотрщик продолжит работать молча с нулевым числом попаданий — проверяйте DiskCacheHits при тестировании, если подозреваете, что кэш не задействуется

Вместе эти две функции повышают воспринимаемое качество приложения для активной работы с документами сильнее, чем большинство других оптимизаций рендеринга: документы, к которым возвращаются ваши пользователи, открываются мгновенно, а изображение остается четким на любом мониторе, где бы ни оказалось окно. Обе возможности доступны как обычные свойства TPDFlibViewer в losLab PDF Library, поэтому их внедрение — это вопрос одного вечера настройки свойств, а не разработки собственного движка рендеринга