DPIの呪縛を解く:PerMonitorV2におけるWindows Formsの動的再設計術
高解像度ディスプレイが標準となり、マルチモニター環境において「スケーリング設定が異なるディスプレイ間をウィンドウが移動する」という事象は、もはや例外ではない。しかし、多くのWindows Formsアプリケーションは、この物理的な変化に耐えうる設計になっていない。
スケーリングが変更された瞬間にコントロールが重なり、フォントが滲み、あるいはウィンドウが極端に小さくなる。これらは単なる「表示のバグ」ではない。OSによるDPI仮想化(DPI Virtualization)と、アプリケーションのレンダリングロジックの間の「認識の乖離」が生み出す、アーキテクチャ上の欠陥である。
本稿では、レガシーなWinForms環境において、いかにして`PerMonitorV2`を掌握し、高解像度環境下でも一分の隙もないUIを実現するか、その極限の知見を詳解する。
1. 根本原因:DPI仮想化の罠
Windowsは、アプリケーションがDPIに対応していない場合、OS側で「それっぽく」拡大表示を行う。これが「ボケ」の正体である。ビットマップベースの描画を無理やり引き伸ばしているため、エッジが失われるのだ。
真の解決策は、アプリケーション自身が「現在のDPI値」を自覚し、スケーリングの変化を検知した瞬間に、すべての描画リソース(Font, Image, Size, Location)を再計算して再構築することにある。
2. 第一の要件:マニフェストによる「覚悟」の表明
コードを書く前に、アプリケーションに対して「私はDPIを自律的に制御する」という宣言をしなければならない。これを行わない限り、Windowsはアプリケーションに適切なメッセージを送らない。
プロジェクトに `app.manifest` を追加し、以下の設定を確実に適用すること。`PerMonitorV2` は、Windows 10 (1703) 以降で導入された、最も洗練されたDPIモードである。
<!-- app.manifest -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- PerMonitorV2 を指定することで、モニター移動時の DPI 変更通知を受け取れるようになる -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
3. 第二の要件:`WM_DPICHANGED` の捕捉
スケーリングが変更された際、Windowsは対象ウィンドウに対して `WM_DPICHANGED` メッセージを投げる。WinFormsの標準的な `OnResize` イベントでは、この情報の深淵には辿り着けない。我々は `WndProc` をオーバーライドし、低レイヤーのメッセージループへ介入する必要がある。
以下に、その基盤となるアーキテクチャを示す。
高度なDPI制御用ベースクラスの実装
単なるフォームではなく、すべてのフォームの親となる `BaseDpiForm` を設計することが、大規模システムにおける保守性を担保する唯一の道である。
Imports System.Runtime.InteropServices
Imports System.Drawing
''' <summary>
''' DPI変更を動的に検知し、レイアウトを再構築するための基底クラス。
''' プロフェッショナルな業務アプリケーションにおけるUIの整合性を保証する。
''' </summary>
Public MustInherit Class BaseDpiForm
Inherits Form
' Windows API 定数
Private Const WM_DPICHANGED As Integer = &H8 {
' DPI変更時に渡される構造体
<StructLayout(LayoutKind.Sequential)>
Private Structure DPI_CHANGE_PARAMS
Public Rect As RECT
Public NewDpiX As Integer
Public NewDpiY As Integer
End Structure
<StructLayout(LayoutKind.Sequential)>
Private Structure RECT
Public Left As Integer
Public Top As Integer
Public Right As Integer
Public Bottom As Integer
End Structure
''' <summary>
''' ウィンドウプロシージャのオーバーライド。
''' OSからの低レイヤーメッセージを監視する。
''' </summary>
Protected Overrides Sub WndProc(ByRef m As Message)
If m.Msg = WM_DPICHANGED Then
' DPI変更パラメータの抽出
Dim params As DPI_CHANGE_PARAMS = Marshal.PtrToStructure(Of DPI_CHANGE_PARAMS)(m.LParam)
' 新しいDPIに基づき、ウィンドウのサイズと位置をOSが推奨するものへ即座に適合させる
' これを行わないと、スケーリング変更時にウィンドウが不自然な位置に残留する
Me.SetWindowPos(IntPtr.Zero, 0, params.Rect.Left, params.Rect.Top,
params.Rect.Right - params.Rect.Left,
params.Rect.Bottom - params.Rect.Top,
0)
' レイアウト再計算のトリガー
OnDpiChanged(params.NewDpiX)
End If
MyBase.WndProc(m)
End Sub
<DllImport("user32.dll")>
Private Shared Function SetWindowPos(hWnd As IntPtr, hWndInsertAfter As IntPtr, X As Integer, Y As Integer, Width As Integer, Height As Integer, uFlags As UInteger) As Boolean
End Function
''' <summary>
''' DPI変更時に呼び出される抽象メソッド。
''' 派生クラスでは、ここでコントロールの再配置とリソースの再生成を行う。
''' </summary>
''' <param name="newDpi">新しいDPI値(例: 96, 120, 144...)</param>
Protected MustOverride Sub OnDpiChanged(newDpi As Integer)
End Class
4. 第三の要件:リソースのライフサイクル管理と再計算ロジック
ここが最も重要なフェーズである。`OnDpiChanged` が呼ばれた際、我々は単にコントロールを大きくするのではない。「元の解像度(Base DPI)から、現在のDPIへの比率」を算出し、すべての属性を再計算しなければならない。
ここで、多くのエンジニアが犯す致命的なミスがある。それは 「現在のサイズに対して倍率を掛けてしまう」 ことだ。これを繰り返すと、浮動小数点の計算誤差が蓄積し(Rounding Error)、レイアウトが徐々にズレていく。必ず「基準となる解像度」を保持し、そこからの絶対的な比率で計算せよ。
実装例:動的なフォントとレイアウトの再構築
Public Class MainDashboardForm
Inherits BaseDpiForm
' 基準となるDPI(通常は 96 = 100%)
Private Const BaseDpi As Integer = 96
' 基準となるフォント(設計時のフォント)
Private ReadOnly _baseFont As New Font("Segoe UI", 10)
Protected Overrides Sub OnDpiChanged(newDpi As Integer)
' スケール比率の算出
Dim scaleFactor As Double = CDbl(newDpi) / BaseDpi
' 1. フォントの再生成
' 重要: 古いフォントオブジェクトは必ず Dispose しなければならない。
' GDI+リソースのリークは、長時間稼働する業務システムにおいて致命的なメモリ不足を招く。
UpdateFonts(scaleFactor)
' 2. コントロールの再配置
' Scale() メソッドは累積誤差を生むため、設計時の基準値から再計算するのが理想的。
' ここでは簡略化のため、コントロールのプロパティを直接スケーリングする例を示す。
RescaleControls(scaleFactor)
' 3. 再描画の強制
Me.Invalidate()
End Sub
Private Sub UpdateFonts(scale As Double)
' 全コントロールを走査し、フォントを更新
' 再帰的に処理を行うことで、パネル内のコントロールも漏らさず更新する
ApplyFontToControls(Me, scale)
End Sub
Private Sub ApplyFontToControls(parent As Control, scale As Double)
' 新しいフォントサイズの計算
Dim newSize As Single = CSng(_baseFont.Size * scale)
' フォントの差し替え
' 注意: 既存のフォントをDisposeするプロセスを忘れないこと
Dim oldFont As Font = parent.Font
parent.Font = New Font(parent.Font.FontFamily, newSize, parent.Font.Style)
oldFont.Dispose()
For Each ctrl As Control In parent.Controls
ApplyFontToControls(ctrl, scale)
Next
End Sub
Private Sub RescaleControls(scale As Double)
' ここでは、コントロールの Size, Location, Margin, Padding を再計算する。
' 実際の実装では、設計時の「絶対座標」を保持しておく仕組み(Dictionary等)を併用するのが最も堅牢である。
' 今回は概念を示すための簡易実装とする。
' Windows Formsの標準的なScale機能を使用する場合でも、
' 累積誤差を防ぐために、BaseDpiからの比率を意識した設計が必要である。
End Sub
Protected Overrides Sub OnLoad(e As EventArgs)
MyBase.OnLoad(e)
' 初期化時に基準フォントを設定
Me.Font = _baseFont
End Sub
End Class
5. アーキテクトの視点:メモリとパフォーマンスの最適化
この実装には、シニアエンジニアが留意すべき「重み」がある。
1. GDI+ オブジェクトの明示的解放:
`UpdateFonts` 内で `oldFont.Dispose()` を実行している点は、単なる作法ではない。DPI変更が頻繁に起こるマルチモニター環境において、これを怠れば、アプリケーションは数時間のうちに GDI ハンドルを使い果たし、描画不能に陥る。
2. 再帰処理の計算コスト:
`ApplyFontToControls` は再帰的にコントロールを走査する。コントロール数が数千規模に及ぶ複雑な画面では、この処理がUIスレッドをブロックする可能性がある。その場合は、描画更新を一時停止(`SuspendLayout` / `ResumeLayout`)し、処理の原子性を確保すること。
3. 画像の扱い:
フォントだけでなく、画像(`ImageList` や `BackgroundImage`)も同様の扱いが必要である。高DPI環境では、低解像度のビットマップを拡大して表示してはならない。各スケーリングレベルに対応した高解像度版の画像リソースをあらかじめ用意しておき、`OnDpiChanged` 内で切り替える設計が、真のプロフェッショナリズムである。
結論
Windows FormsにおけるDPI対応は、単なる「見た目の調整」ではない。それは、OSのメッセージング機構、グラフィックス・サブシステム、そしてメモリ管理のすべてを理解し、制御下に置くという、高度なエンジニアリングの挑戦である。
`PerMonitorV2` を正しく実装したアプリケーションは、どのような解像度のディスプレイに持ち込まれても、その存在感を失わない。これこそが、堅牢な業務システムを構築する者が到達すべき、一つの到達点である。
