【テクニカル・上級編】Windows FormsにおけるDPI変更動的検知(PerMonitorV2対応):マルチモニター移動時やWindows 10/11のスケーリング変更に伴うレイアウト崩れとフォント滲みを完全防止する – Visual Basic (VB / VB.NET)解析バイブル

スポンサーリンク
スポンサーリンク

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` を正しく実装したアプリケーションは、どのような解像度のディスプレイに持ち込まれても、その存在感を失わない。これこそが、堅牢な業務システムを構築する者が到達すべき、一つの到達点である。

タイトルとURLをコピーしました