技術記事

HotPDFによるDelphiのバックグラウンドPDF描画キュー

HotPDFのTHPDFBackgroundRendererクラスはTThreadを継承したクラスであり、読み込み済みのPDFページをワーカースレッド上でビットマップへ描画する。これにより、Delphiのビューアは、あるページがまだバックグラウンドでラスタライズされている間もスクロールと再描画を続けることができる。THPDFBackgroundRenderer.RequestPageはそのワーカースレッド向けにページインデックスをキューに入れ、CancelAllはまだ待機中のものをすべて破棄し、GetCachedBitmapは完成したビットマップを返す。呼び出し元はそれを所有し、解放しなければならない。印刷解像度で200ページのスキャン契約書をUIスレッドだけでスクロールすると、GDIが描画を終えるまでウィンドウが毎ページ送りのたびに一時停止する。これこそがTHPDFBackgroundRendererが取り除くために存在する、まさにその引っかかりである

そもそもなぜPDFページをバックグラウンドスレッドで描画するのか

バックグラウンドスレッドがその複雑さに見合う価値を持つのは、HotPDFのページレンダラーが誰も気づかないうちに戻ってくる安っぽいビットマップコピーではなく、本物のコンテンツストリームインタプリタだからである:それはPDF演算子を辿り、グラフィックステートのスタックを保持し、パス・画像・グリフをGDI経由でラスタライズする。これは読み込み済みPDFページをTBitmapへ描画するで扱われているのと同じエンジンである。この処理をスクロールや描画ハンドラの中で同期的に実行すると、呼び出しが戻るまでメッセージループのポンプが止まる。これこそがウィンドウがフリーズするということの実態である。描画呼び出しの中にApplication.ProcessMessagesを挟んでもこれは解決しない:メッセージキューは吐き出されるが、描画自体は依然として呼び出し元のスレッドを占有したままであり、そのため実際の作業は何も進んでいないのにウィンドウは古い内容をより速く再描画するだけになる。本当に遅い描画の間にビューアの応答性を保つ唯一の方法は、その描画をどこか別の場所で実行することである。THPDFBackgroundRendererがコールバックやタイマーではなくTThreadのサブクラスとして存在するのはそのためだ

スクロールするビューア向けにリクエストキューを設定する

THPDFBackgroundRenderer.Createは読み込み済みのTHotPDFインスタンスと、そのレンダラーの生涯にわたって固定されるDPIを受け取る。したがって1つのインスタンスを通じてキューに入れられたすべてのページは1つの解像度で描画される。ズームをサポートするビューアは、ズームレベルが変わるたびにDPIプロパティを新しくするのではなく、レンダラー自体を新しくする必要がある。RequestPageはページインデックスを内部キューに追加してすぐに戻る:それ自体は一切描画を行わず、UIスレッドには決して触れない。HotPDFがStartを呼ぶと実行される、継承されたTThreadのエントリポイントであるExecuteは、そのキューの先頭から一度に1つのインデックスを取り出し、文書のページキャッシュ経由でそれを描画し、後で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を返すため、上記のようなタイマーでポーリングするパターンで十分である。配線すべき別個のready用イベントは存在せず、HotPDFはより大きな通知APIではなく単純なnilチェックでこれを解決している。次の節ではCancelAllとそのFree呼び出しが実際に何をしているかを扱う。ページが順不同で描画され始めたり、スクロールがキューを空にできるより速く発生した場合、この両方が重要になるからだ

1ページだけのためのワンコールショートカット

THotPDF.RenderLoadedPageToBitmapAsyncは、THPDFBackgroundRendererに直接触れずに正確に1ページだけを発火させるという一般的なケースのために存在する:内部でレンダラーを構築し、RequestPageを一度呼び、スレッドを開始し、TThreadへの参照を呼び出し元に返す。呼び出し元はそれを所有し、解放する責任を負う。結果の取得はレンダラー自身のGetCachedBitmapではなくTHotPDF.GetLoadedCachedRenderedBitmapを経由する。なぜならGetLoadedCachedRenderedBitmapは、ページインデックスとDPIをキーとする文書の共有キャッシュを読み取るからだ。これはRenderLoadedPageToBitmapCachedと組み込みのプリフェッチャーがすでに埋めているのと同じキャッシュである——ビューアの他の部分がすでにそのDPIで描画したページであれば、たった今開始されたバックグラウンドスレッドがOSにスケジュールされる前でさえ、即座に戻ってくることができる

// 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を発火させる高速スクロールは、各キャンセルの瞬間に描画途中だった1ページ分のコストは依然として支払うことになる。公式リファレンスもこの点を率直に述べている:すでに実行中の描画はスレッドが終了する前に完了することがある

Executeには見落としやすい2つ目の挙動がある:ループはキューが空であることを見つけた瞬間に終了し、新しい作業が到着するのを待ってアイドル状態になることはない。したがってTHPDFBackgroundRendererのインスタンスは常駐するバックグラウンドサービスではなく、使い捨てのバッチワーカーである——一握りのページをキューに入れ、Startを呼び、最後にキューに入れたページが描画されればその下にあるOSスレッドは自動的に終了する。Executeがすでにキューを空にした後で同じインスタンスに対してRequestPageを再度呼んでもそれは再起動しない。これがまさに、上記のRequestPageWindowが1つの長生きするオブジェクトに供給し続けようとするのではなく、呼び出しごとにレンダラーインスタンスを置き換える理由である

Delphiでバックグラウンドスレッドから TBitmap に触れるのは安全か

特定のビットマップインスタンスに対して一度に一つのスレッドしか操作しない限り、バックグラウンドスレッドからTBitmapに触れることはHotPDFの設計上安全であり、THPDFBackgroundRendererはその境界を呼び出し元任せにするのではなく自ら強制する。Executeは文書自身の描画ロックの内側で各ページを描画する。これは、あらゆるRenderLoadedPageToBitmapCached呼び出しと組み込みのPrefetchLoadedPagesプリフェッチャーがすでに共有しているのと同じクリティカルセクションである。そのため、あるページに対する実際のGDI描画は一度に正確に1つのスレッドでのみ発生し、その文書の別の描画と重なることは決してない。結果として生じるビットマップはワーカースレッドが所有するオブジェクトであり、THPDFBackgroundRendererはそれを呼び出し元に直接公開することはない

代わりにGetCachedBitmapは真新しいTBitmapを割り当て、レンダラー自身の別個のロックの下でそれに対してAssignを呼ぶ。そのため、コピーは常にExecuteがそのキャッシュスロットを下から差し替えることをブロックされている間に行われる——呼び出し元のスレッドが受け取るのはピクセルデータであり、決して元のハンドルではない。この分離はまた、THPDFBackgroundRendererPrefetchLoadedPagesを経由せずにHotPDFの描画関数を直接呼び出すカスタム描画スレッドを自分で作ることを控えるべき理由でもある:同じ読み込み済み文書の共有キャッシュとオブジェクトグラフに対して競合する2つの描画は、まさにHotPDFの内部ロックが防ぐために存在するシナリオであり、バックグラウンドレンダラークラスはそのロックを再実装することなく無償で与えてくれる

これはHotPDFの組み込みページプリフェッチとどう違うのか

PrefetchLoadedPagesTHPDFBackgroundRendererは関連するが異なる問題を解決する: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;

本番コードに持ち込む価値のあるライフサイクル上の詳細が2つある。RenderLoadedPageToBitmapCachedの背後にある文書全体のキャッシュはRenderCacheCapacity(既定8ページ)によって上限が設けられており、満杯になると最も長く使われていないエントリを追い出すが、THPDFBackgroundRendererインスタンス自身の結果リストにはそのような上限がない——そのインスタンスを通じて要求された個別のページインデックスごとに1つのビットマップを、そのインスタンス自体が解放されるまで保持し続ける。そのため、高DPIでスクロールセッション全体にわたって生かし続けられたレンダラーは、スクロールを通過したページごとにフル解像度のビットマップを喜んで蓄積していく。またHotPDFは、文書がロードされる前や自身が破棄される前に自身のプリフェッチャーをキャンセルするのと同じようには、呼び出し元が作成したレンダラーを自動的にキャンセルすることはない。なぜならTHPDFBackgroundRendererのインスタンスは、それが指すTHotPDFオブジェクトに登録されることが決してないからだ——したがって呼び出し側のコードは、その文書を再読み込みまたは解放する前に、その文書に対して構築されたすべてのレンダラーをキャンセルし解放しなければならない。これはHotPDFが内部でPrefetchLoadedPagesに適用しているのと同じ順序の規律である

THPDFBackgroundRendererは、HotPDFのMVCビューアアーキテクチャの背後にある読み込み済み文書ファサードの一部品であり、スクロールされている文書自体がそもそも気軽に読み込むには大きすぎる場合には大きなPDF向けDirect File APIのファイルレベルのワークフローと自然に組み合わさる。ここで説明したバックグラウンド描画、リクエストキュー、描画キャッシュはいずれも、DelphiおよびC++Builder向け標準HotPDFコンポーネントの一部である