Windows FormsにおけるDPI変更動的検知:DynamicDPI対応でモニター移動時のレイアウト崩れとフォント滲みを完全に防止する
我が国の中堅・大企業における基幹クライアントアプリケーションの多くは、未だにWindows Forms(VB.NET)の堅牢な基盤の上で稼働している。VBAによるマクロの限界を超え、真のデスクトップアプリケーションとして構築されたこれらのシステムは、業務の根幹を支え続けている。
しかし、昨今のハードウェア環境の変化、すなわち4K・8Kの高解像度ディスプレイや、複数モニター環境(マルチディスプレイ)の常態化は、レガシーなWindows Formsアーキテクチャに深刻な矛盾突きつけている。
異なるDPI(Dots Per Inch)を持つディスプレイ間でウィンドウをドラッグ移動させた際、突如として発生する「UIの縮退・肥大化」「フォントの醜悪な滲み(Bitmap Scalingの弊害)」「レイアウトの致命的な破綻」。この現象に頭を悩ませていない現場のアーキテクトはもはやいないだろう。
本稿では、Windows FormsにおけるPer-Monitor DPI V2(Dynamic DPI)を完全に掌握し、モニター移動時のレイアウト崩れとフォント滲みを完全に根絶するための極限の知見を、実装コードと共に解き明かす。
—
1. なぜレイアウトは崩れ、フォントは滲むのか:根本原因の解剖
Windows Formsのデフォルト動作は、アプリケーション起動時のメインモニターのDPIに依存する(System DPI Awareness)。これを別DPIのモニターへ移動させた場合、Windows OSは力技でウィンドウ全体をビットマップ拡大・縮小(Bitmap Stretch)させる。これが「フォントの滲み」と「ボケ」の正体である。
さらに、コントロールの相対位置やマージンがハードコードされている、あるいはコンテナのレイアウトエンジン(`FlowLayoutPanel` や `TableLayoutPanel`)が適切にスケーリングイベントを捕捉できていない場合、レイアウトは不可逆的に崩壊する。
真の解決策は、OSからのDPI変更通知(`WM_DPICHANGED`)を低レベルで傍受し、フォームとその子孫コントロール群のフォントメトリクスと座標系をプログラム側で動的に再計算・再構築することにある。
—
2. アーキテクチャ設計:Per-Monitor V2の宣言とメッセージフック
まずは、アプリケーションがHigh DPIに対応していることをOSとCLRに強制しなければならない。
現代のWindows Forms(.NET Framework 4.8以降 または .NET 6/7/8)では、`app.manifest` においてPer-Monitor V2を有効化することが大前提となる。
マニフェスト設定 (`app.manifest`)
しかし、マニフェストだけでは不十分だ。複雑なカスタムコントロールやサードパーティ製グリッドを使用している場合、OSのデフォルトスケーリングだけでは微細な座標の丸め誤差(Rounding Error)が蓄積し、UIが破綻する。
ここで、`WndProc`をオーバーライドし、Windows APIからの `WM_DPICHANGED` メッセージを直接ハンドリングする極限の実装を導入する。
—
3. 実装コード:DynamicDPI完全対応フォーム基底クラス
以下のコードは、モニター移動時に発生するDPI変更をミリ秒単位で検知し、フォントの再生成、コントロールの再スケーリング、そしてちらつき(Flickering)を排除するダブルバッファリング制御を統合した、現場即応型の基底フォーム(`ExBaseForm`)である。
Imports System.Runtime.InteropServices
Imports System.Drawing
Imports System.Windows.Forms
Public Class ExBaseForm
Inherits Form
‘ Win32 API 定数
Private Const WM_DPICHANGED As Integer = &H02E0
Private Shared Function SetProcessDpiAwarenessContext(value As IntPtr) As Boolean
End Function
Public Sub New()
MyBase.New()
‘ 描画負荷を極限まで抑え、スケーリング時のちらつきを防止
Me.SetStyle(ControlStyles.OptimizedDoubleBuffer Or
ControlStyles.AllPaintingInWmPaint Or
ControlStyles.ResizeRedraw, True)
End Sub
”’
”’
Protected Overrides Sub WndProc(ByRef m As Message)
Select Case m.Msg
Case WM_DPICHANGED
‘ m.WParamの下位ワードに新しいDPIが入っている
Dim newDpi As Integer = LoWord(m.WParam)
Dim suggestedRect As RECT = CType(Marshal.PtrToStructure(m.LParam, GetType(RECT)), RECT)
‘ OSが提案する新しいウィンドウ矩形を適用
Me.SuspendLayout()
ApplyDpiScaling(newDpi)
Me.ResumeLayout(True)
‘ ウィンドウの位置とサイズをOSの提案通りに強制設定
Me.SetBounds(suggestedRect.Left, suggestedRect.Top,
suggestedRect.Right – suggestedRect.Left,
suggestedRect.Bottom – suggestedRect.Top,
BoundsSpecified.All)
m.Result = IntPtr.Zero
Exit Sub
End Select
MyBase.WndProc(m)
End Sub
”’
”’
Private Sub ApplyDpiScaling(newDpi As Integer)
Dim scalingFactor As Single = newDpi / 96.0F ‘ 96 DPIを基準(100%)とする
‘ フォーム自体のフォントをスケーリング(滲みを防ぐため新規GDI+フォントオブジェクトを生成)
Dim currentFontName As String = Me.Font.Name
Dim currentFontSizePt As Single = (Me.Font.Size 96.0F) / Me.DeviceDpi ‘ 現在の論理サイズを算出
Dim newFontSizePt As Single = currentFontSizePt (C1.SI(newDpi) / 96.0F) ‘ ※簡易スケーリング計算
‘ メモリリークを防ぐため、旧フォントの破棄は慎重に行う必要があるが、
‘ Control.Fontプロパティのセッターは内部で適切に処理する。
Me.Font = New Font(currentFontName, Me.Font.Size (newDpi / CSng(Me.DeviceDpi)), Me.Font.Style)
‘ 子コントロール群の再帰的スケーリング
ScaleControlsRecursive(Me, newDpi)
End Sub
”’
”’
Private Sub ScaleControlsRecursive(parent As Control, newDpi As Integer)
Dim scaleFactor As Single = newDpi / CSng(Me.DeviceDpi)
For Each ctrl As Control In parent.Controls
ctrl.SuspendLayout()
‘ 座標とサイズのスケーリング(丸め誤差を排除するため整数演算を維持)
ctrl.Left = CInt(ctrl.Left scaleFactor)
ctrl.Top = CInt(ctrl.Top scaleFactor)
ctrl.Width = CInt(ctrl.Width scaleFactor)
ctrl.Height = CInt(ctrl.Height scaleFactor)
‘ フォントのスケーリング
If ctrl.Font IsNot Nothing Then
ctrl.Font = New Font(ctrl.Font.Name, ctrl.Font.Size scaleFactor, ctrl.Font.Style)
End If
‘ コンテナコントロールの場合は再帰処理
If ctrl.HasChildren Then
ScaleControlsRecursive(ctrl, newDpi)
End If
ctrl.ResumeLayout(False)
Next
End Sub
‘ WParamから下位ワード(DPI値)を取り出すヘルパー
Private Function LoWord(wParam As IntPtr) As Integer
Return CInt(wParam.ToInt64() And &HFFFFL)
End Function
Private Structure RECT
Public Left As Integer
Public Top As Integer
Public Right As Integer
Public Bottom As Integer
End Structure
End Class
—
4. チーフアーキテクトが指摘する「メモリ管理とパフォーマンスの罠」
上記のコードを実務投入するにあたり、シニアエンジニアとして看過できないのが「フォントオブジェクトの乱造によるGDIリソース枯渇(GDI Handles Leak)」である。
VB.NETのガベージコレクション(GC)はマネージメモリの回収には神のごとき働きをするが、GDI+オブジェクト(`System.Drawing.Font` や `Bitmap`、`Pen` など)が内部で保持するアンマネージドなGDIハンドルの回収は、GCの気まぐれに依存する。
ユーザーが頻繁にウィンドウを4KモニターとフルHDモニターの間で往復させた場合、毎回のスケーリング処理で数千個の `Font` インスタンスが生成され、OSのGDIハンドル上限(デフォルトでセッションあたり10,000〜)を容易に突破、アプリケーションが突如として「描画不能(OutOfMemoryExceptionのGDI版)」に陥る。
対策:フォントキャッシュと明示的破棄(IDisposableの徹底)
大規模システムにおいては、フォントを都度 `New` するのではなく、DPI値ごとにキャッシュされたフォントインスタンスを引く、あるいは古いフォントを明示的に `Dispose` するファクトリーパターンの導入が不可欠となる。
‘ プライベートなフォントキャッシュ機構の例
Private Shared _fontCache As New Dictionary(Of String, Font)
Private Function GetCachedFont(familyName As String, emSize As Single, style As FontStyle, dpi As Integer) As Font
Dim key As String = $”{familyName}_{emSize}_{style}_{dpi}”
If Not _fontCache.ContainsKey(key) Then
_fontCache(key) = New Font(familyName, emSize, style)
End If
Return _fontCache(key)
End Function
※アプリケーション終了時に `_fontCache` の全要素をループして `Dispose()` を呼び出すクリーンアップ処理を忘れてはならない。
—
5. レガシー環境・VBAシステム連携時の留意点
もし、このWindows Formsアプリケーションが、Excel VBAや旧来のCOMコンポーネント、あるいは外部C++プロセスから呼び出される形(`ShowDialog` や親ウィンドウのハンドル指定)で稼働している場合、スケーリングの起点が親プロセス側に奪われる現象が発生する。
COM相互運用環境下では、親ウィンドウ(Excel等)がHigh DPIに対応していない場合、子ウィンドウであるVB.NETのフォームもまた、レガシーな仮想DPI空間に閉じ込められ、どれだけコードを最適化しても滲みが発生する。
この壁を突破するには、エントリポイント(MainメソッドまたはCOM公開クラスの初期化時)において、明示的にプロセス全体のDPI認識コンテキストをコードから強制設定する必要がある。
Public Shared Sub Main()
‘ アプリケーション起動の極めて初期段階でPer-Monitor V2を強制適用
Try
SetProcessDpiAwarenessContext(CType(-3, IntPtr)) ‘ DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
Catch
‘ フォールバック処理(古いOS環境への配慮)
End Try
Application.EnableVisualStyles()
Application.SetCompatibleTextRenderingDefault(False)
Application.Run(New ExBaseForm())
End Sub
—
6. 結言
Windows Formsは過去の遺物ではない。適切にWindows APIを調教し、メモリのライフサイクルをコントロール下におくならば、現代の最先端ハードウェア環境(4K、マルチモニター、動的DPI変更)においても、Webアプリや最新のUIフレームワークに匹敵する堅牢性と美しさを維持し続けることができる。
モニター移動時のレイアウト崩れとフォント滲みは、もはや「仕様」として諦めるべきものではない。本稿で示した低レベルメッセージフックと厳格なリソース管理を実装基盤に組み込むことで、あなたの管理するレガシーシステムは、次の10年も現役として組織の血液を循環させ続けるだろう。
技術に妥協なし。コードの隅々にまでエンジニアの魂を宿せ。
