Windows Forms高DPI地獄の平定:AutoScaleMode.DpiとPerMonitorV2対応の極意
レガシーシステムの延命とモダナイゼーションの狭間で、我々Windows Formsエンジニアが直面する最大の壁――それは高DPI環境(4Kディスプレイ、拡大率150%や200%の混在、そしてマルチモニター間の移動)における「レイアウトの崩れ」と「フォントの滲み」である。
`.NET Framework`の初期から存在するWindows Formsは、本質的にDPI非依存で設計されたWin32 APIの薄いラッパーに過ぎない。そのため、何も考えずに作られたアプリケーションを現代の高DPI環境で動かすと、拡大縮小の過程でビットマップが引き伸ばされ、文字はボケ、カスタム描画したコントロールの座標はズタズタになる。
本稿では、この物理的・アーキテクチャ的な限界を突破し、`AutoScaleMode.Dpi`の挙動の裏側と、`PerMonitorV2`による真の動的DPI対応をVB.NETで完全制御するための極限の知見を授ける。
—
1. なぜ高DPIで文字が滲み、レイアウトが破綻するのか
敵を知るためには、まずWindowsのDPIスケーリングの歴史と闇を知らなければならない。
1. System DPI (DPI Unaware / System Aware):
プロセス起動時のメインモニターのDPIを基準に全体をスケーリングする。異なるDPIのサブモニターに移動すると、Windowsが強制的にビットマップを引き伸ばす(Bitmap Stretch)ため、文字やアイコンが盛大にボケる。
2. Per-Monitor DPI (PerMonitorV1):
モニターごとのDPI変更を検知できるが、ウィンドウ全体の再スケーリング処理が開発者任せであり、特にカスタムコントロールの描画ロジックにおいて破綻が多発した。
3. Per-Monitor V2 (PerMonitorV2 – Windows 10 1703以降):
非クライアント領域(タイトルバーやメニュー)も含めてOSが自動スケーリングし、WM_DPICHANGEDメッセージが子ウィンドウやコントロールの隅々にまで伝播する、現時点の終着点である。
VB.NET(Windows Forms)でこのPerMonitorV2の恩恵を完全に受けるには、マニフェストレベルでの宣言と、ランタイム側での泥臭いイベントハンドリングの両輪が不可欠となる。
—
2. アプリケーションマニフェストによるPerMonitorV2の強制
まず、コードを書く以前の問題として、EXEのマニフェストでOSに対して「我々は高DPIを完璧に理解している」と宣言しなければならない。
プロジェクトに `app.manifest` を追加し、`
—
3. VB.NETコードによるDPIスケーリングの完全制御とフォント滲み対策
標準の `AutoScaleMode.Font` は、ユーザーがOSのシステムフォントサイズを変更した場合には強いが、物理モニターのDPI変更(PerMonitorV2)に対しては無力だ。
ここでは、フォームのスケール変更イベントをフックし、カスタム描画部分やGDI+フォントのレンダリング品質をコードで担保する実装パターンを示す。
実装コード:PerMonitorV2対応基底フォーム
Imports System.Drawing
Imports System.Drawing.Text
Imports System.Windows.Forms
Public Class HighDpiForm
Inherits Form
‘ 現在のDPIスケール倍率(初期値は96DPI = 1.0)
Private _currentScaleFactor As Single = 1.0F
Public Sub New()
MyBase.New()
‘AutoScaleModeは必ずDpiに設定する
Me.AutoScaleMode = AutoScaleMode.Dpi
End Sub
”’
”’
Protected Overrides Sub OnDpiChanged(e: DpiChangedEventArgs)
MyBase.OnDpiChanged(e)
‘ 新しいDPIからスケール倍率を算出 (96が基準)
_currentScaleFactor = CSng(e.DeviceDpi) / 96.0F
‘ ログやデバッグ出力でDPI変更を確認
System.Diagnostics.Debug.WriteLine($”DPI Changed: {e.DeviceDpi}, Scale: {_currentScaleFactor}”)
‘ 子コントロール群へのカスタムスケーリング伝播が必要な場合はここに記述
Me.Invalidate(True) ‘ 再描画を強制
End Sub
”’
”’
Protected Overrides Sub OnPaint(e: PaintEventArgs)
‘ アンチエイリアスとクリアタイプの設定(フォントの視認性を極限まで高める)
e.Graphics.TextRenderingHint = TextRenderingHint.ClearTypeGridFit
e.Graphics.SmoothingMode = Drawing2D.SmoothingMode.AntiAlias
MyBase.OnPaint(e)
‘ 【極秘知見】高DPI環境でのカスタム描画ロジック
‘ 座標やペンの太さは必ずスケールファクターを乗算してハードコーディングを避ける
Using pen As New Pen(Color.FromArgb(0, 120, 215), CSng(2.0 _currentScaleFactor))
e.Graphics.DrawRectangle(pen, Rectangle.Round(RectangleF.op_Implicit(New RectangleF(10 _currentScaleFactor, 10 _currentScaleFactor, 200 _currentScaleFactor, 100 _currentScaleFactor))))
End Using
End Sub
End Class
—
4. メモリ最適化とGDI+リソースの明示的解放
高DPI対応で最も恐ろしいのは、レイアウト崩れではなく「GDIリソースリークによるアプリケーションの突然死」である。
`Paint` イベントや動的なDPI変更のたびに `Font`、`Brush`、`Pen`、`Bitmap` オブジェクトを生成し、GC(ガベージコレクタ)に解放を任せていると、マルチモニター間を数回ドラッグ&ドロップしただけで `System.ComponentModel.Win32Exception (0x80004005): IDragDrop ロジックのエラー` や `GDI+ で汎エラーが発生しました。` が発生してプロセスがクラッシュする。
鉄則:IDisposableなオブジェクトは `Using` で即時破棄せよ
カスタム描画を行うコントロールを作成する場合、描画キャッシュ用の `Bitmap` やスケーリングされた `Font` は、フィールドとして保持し、DPI変更時や破棄時に確実に明示的解放(Dispose)を行わなければならない。
Public Class HighDpiCustomPanel
Inherits Panel
Private _scaledFont As Font
Private _cachedBitmap As Bitmap
Private _lastDpi As Integer = 96
Public Sub New()
MyBase.New()
Me.DoubleBuffered = True
UpdateResources(96)
End Sub
‘ DPI変更や初期化時にリソースを再生成
Private Sub UpdateResources(targetDpi As Integer)
‘ 既存リソースの明示的解放(メモリリークの根絶)
If _scaledFont IsNot Nothing Then
_scaledFont.Dispose()
End If
If _cachedBitmap IsNot Nothing Then
_cachedBitmap.Dispose()
End If
Dim scale As Single = targetDpi / 96.0F
‘ スケールに応じたフォントを安全に生成
_scaledFont = New Font(“Meiryo UI”, 9.0F scale, FontStyle.Regular)
‘ 描画用オフスクリーンバッファの再構築
_cachedBitmap = New Bitmap(CInt(Me.Width scale), CInt(Me.Height scale))
_lastDpi = targetDpi
.End Sub
Protected Overrides Sub OnDpiChangedAfterParent(e: EventArgs)
MyBase.OnDpiChangedAfterParent(e)
‘ 親からのDPI変更通知を受けてリソースを更新
Dim currentDpi = Me.DeviceDpi
If currentDpi <> _lastDpi Then
UpdateResources(currentDpi)
Me.Invalidate()
End If
End Sub
Protected Overrides Sub Dispose(disposing As Boolean)
If disposing Then
‘ マネージドリソースの解放
If _scaledFont IsNot Nothing Then _scaledFont.Dispose()
If _cachedBitmap IsNot Nothing Then _cachedBitmap.Dispose()
End If
MyBase.Dispose(disposing)
End Sub
End Class
—
5. チーフアーキテクトからの提言
レガシーなWindows Formsシステムにおいて、高DPI対応とPerMonitorV2の導入は、単なる「見た目の修正」ではない。それはアプリケーションの寿命を決定づける構造改革である。
1. ハードコードされたピクセル値を排除せよ: `Width = 150` のようなコードはすべて、`CSng(150 scaleFactor)` または `Anchor / Dock` / `TableLayoutPanel` による動的レイアウトへ置き換えること。
2. GDIオブジェクトの寿命を支配せよ: `Graphics.DrawString` や `New Pen()` をイベントハンドラーの直書きで乱発しないこと。必ず `Using` ブロックを使用するか、キャッシュ機構を構築してDisposeを徹底する。
この領域の最適化を怠ったシステムは、4Kディスプレイが当たり前になった現代のオフィス環境において、ユーザーにストレスを与え続け、やがて「使えないレガシーゴミ」として淘汰される運命にある。
プロフェッショナルであれば、コードの隅々にまでスケーリングとメモリ管理の哲学を宿させ、環境の変化に微動だにしない強靭なUIを構築しなくてはならない。
