1. 背景
Windows 8.1ではマルチモニターにしているときに、モニターごとに違うDPIで使えるようになった。これはユーザーがDPI(scaling level)を大ざっぱに指定するとOSの方で自動的に設定するもので、個別に手動設定できるわけではない。具体的には、「ディスプレイ」設定でスライダーをSmallerからLargerの間で動かして指定する(無段階ではない)。
下にある「Let me choose one scaling level for all my displays」をチェックすれば直接DPIを指定できるようになるが(これも無段階ではない)、その場合DPIは共通になるので、モニターごとに変えることはできない。
どうせならモニターごとに全て手動設定できるようにして欲しい気もするが、推測すれば、大多数のユーザーにとっては自動設定の方がよいと判断した、あるいは細かいカスタマイズを許すとUIの動作検証で死ぬ、という事情があるのかなと思う。アプリを作る側としても後者は無視できない問題なので。
ともかく、デスクトップアプリはこうしたモニターごとに違うDPIに対応できるように、というのがMicrosoftからの宿題になる。
2. コーディング
基本情報としては以下のとおり。
- BUILD 2013におけるプレゼンテーション(Making Your Desktop Apps Shine on High-DPI Displays)
- ペーパー(Writing DPI-Aware Desktop Applications in Windows 8.1 Preview)
- サンプルコード(DPI Tutorial sample)
- DPIの変化を知らせるウィンドウメッセージ: WM_DPICHANGED
- モニターごとのDPIを取得するAPI: GetDpiForMonitor
- アプリがDPI Awareであることを示すマニフェストの拡張: 「Per Monitor」と「True/PM」
WM_DPICHANGED
このウィンドウメッセージのID番号はhiyohiyoさんに教えていただいて0x02E0と分かったので(Visual Studio 2013 PreviewのSDK中のWinUser.hにある)、後はLOWORDとHIWORDのマクロをメソッド化して作成。
Protected Overrides Sub WndProc(ByRef m As Message) MyBase.WndProc(m) Const WM_DPICHANGED As Integer = &H2E0 '0x02E0 from WinUser.h If (m.Msg = WM_DPICHANGED) Then 'wParam Dim lo As Single = GetLoWord(m.WParam.ToInt32) Dim hi As Single = GetHiWord(m.WParam.ToInt32) Me.TextBox_LoWord.Text = lo.ToString() Me.TextBox_HiWord.Text = hi.ToString() 'lParam Dim r As RECT = CType(Marshal.PtrToStructure(m.LParam, GetType(RECT)), RECT) Me.TextBox_Position.Text = String.Format("{0},{1}", r.top, r.left) Me.TextBox_Size.Text = String.Format("{0}x{1}", r.bottom - r.top, r.right - r.left) End If End Sub Private Function GetLoWord(dword As Int32) As Int16 Return Convert.ToInt16(dword And &HFFFF) End Function Private Function GetHiWord(dword As Int32) As Int16 Return Convert.ToInt16(dword >> 16) End Function <StructLayout(LayoutKind.Sequential)> Private Structure RECT Private left As Integer Private top As Integer Private right As Integer Private bottom As Integer End Structure
GetDpiForMonitor
これはまずアプリのウィンドウハンドルを取得し、次に現在のモニターのハンドルをMonitorFromWindowをP/Invokeで実行して取得し、その後にGetDpiForMonitorをP/Invokeで実行する流れで作成。
Private Sub Button_GetDpi_Click(sender As Object, e As EventArgs) Handles Button_GetDpi.Click 'Get handle to this window. Dim windowHandle As IntPtr = Process.GetCurrentProcess().MainWindowHandle 'Get handle to monitor that contains this window. Dim monitorHandle As IntPtr = MonitorFromWindow(windowHandle, MONITOR_DEFAULTTOPRIMARY) 'Get DPI (If the OS is not Windows 8.1 or newer, calling GetDpiForMonitor will cause exception). Dim dpiX As UInteger Dim dpiY As UInteger Dim result As Integer Try result = GetDpiForMonitor(monitorHandle, Monitor_DPI_Type.MDT_Default, dpiX, dpiY) Catch ex As Exception result = 1 'Not S_OK (= 0) End Try If (result = 0) Then 'If S_OK (= 0) Me.TextBox_dpiX.Text = dpiX.ToString() Me.TextBox_dpiY.Text = dpiY.ToString() Else Me.TextBox_dpiX.Text = "Failed" Me.TextBox_dpiY.Text = "Failed" End If End Sub <DllImport("user32.dll", SetLastError:=True)> Private Shared Function MonitorFromWindow(ByVal hwnd As IntPtr, ByVal dwFlags As Integer) As IntPtr End Function Private Const MONITORINFOF_PRIMARY As Integer = &H1 Private Const MONITOR_DEFAULTTONEAREST As Integer = &H2 Private Const MONITOR_DEFAULTTONULL As Integer = &H0 Private Const MONITOR_DEFAULTTOPRIMARY As Integer = &H1 <DllImport("Shcore.dll", SetLastError:=True)> Private Shared Function GetDpiForMonitor(ByVal hmonitor As IntPtr, ByVal dpiType As Monitor_DPI_Type, ByRef dpiX As UInteger, ByRef dpiY As UInteger) As Integer End Function Private Enum Monitor_DPI_Type As Integer MDT_Effective_DPI = 0 MDT_Angular_DPI = 1 MDT_Raw_DPI = 2 MDT_Default = MDT_Effective_DPI End EnumMonitorFromWindowの第2引数とGetDpiForMonitorの第2引数の選択は要検討。なお、GetDpiForMonitorは「Shcore.dll」のない環境では当然ながら例外を起こす。
DPI Awareのマニフェスト
プロジェクトにマニフェストファイルを追加し、そこに<dpiAware>を記述。
<asmv3:application xmlns:asmv3="urn:schemas-microsoft-com:asm.v3"> <asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings"> <dpiAware>Per Monitor</dpiAware> </asmv3:windowsSettings> </asmv3:application>ここの値を「Per Monitor」(ハイフンなし)か「True/PM」にしないとWM_DPICHANGEDは来ない模様。
3. テスト
このアプリの見た目は以下のとおりで、コード中の記述との対応関係は見れば分かると思う。「Get DPI」のボタンは手動でGetDpiForMonitorを実行するもの。最初にDPI120のモニター上にある状態。
これをドラッグしてDPI96のモニターに移すとこうなる。
さらにドラッグしてDPI120のモニターに戻すとこうなる。
まずは成功で、見ているとアプリのウィンドウがモニターの境界を半分過ぎたぐらいでWM_DPICHANGEDが来ている感じ。これで問題なしかというとそうでもなくて、スライダーを動かして「適用」したときにDPIが変わっても反応がなかったり、一旦サインアウトしないと効かないこともあったりして、「あれー?」というところはある。
ともあれ、以上のように割と少ない手間で実装はできると思う。その上でDPIに応じたUIを作り込むのはまた別問題だけど。
[追記] Windows 8.1のバージョンの取得
Windows 8.1のバージョン番号は6.3で、Windows 8の6.2からマイナー番号が1つ上がっている。これはverコマンドでも確認できる。
が、.NETのSystem.Environment.OSVersionで見ても、Win32のGetVersionExで見てもバージョンは「6.2.9200」と返ってくるので、「ベータだからか~」と思っていた(念のため、Visual Studio 2013 Previewでアプリを作成し、Visual Studioの中から実行するとちゃんとWindows 8.1のバージョンが返ってくる。ただし、同じアプリをVisual Studioの外から実行するとダメ)。
実際はそういうことではなくて、意図的なものだった。
GetVersionExはあえて古いバージョンを返すように変更されたということで、System.Environment.OSVersionについて言及はないが、内部的にGetVersionExを呼び出しているようなので同じ結果になる。
この対応策としては以下の二通り。
- 対応しているOSとしてWindows 8.1をマニフェストに追加する。
- Win32のVerifyVersionInfoを使う。
具体的には、以下のようなもの。
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1"> <application> <!-- Windows 7 --> <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/> <!-- Windows 8 --> <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/> <!-- Windows 8.1 --> <supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/> </application> </compatibility>これでSystem.Environment.OSVersionも、GetVersionExも普通に「6.3.9431」を返してくるようになった。というわけで、OSのバージョンを見てPer-Monitor DPI対応を切り替えることができる。
一応VerifyVersionInfoを使った方も。Windows 8.1以降か否かを判別可能。
Private Sub CheckEightOneOrNewer() 'Set expected OS version. Dim osvi As New OSVERSIONINFOEX() osvi.dwMajorVersion = 6 'Major version number osvi.dwMinorVersion = 3 'Minor version number osvi.dwOSVersionInfoSize = Convert.ToUInt32(Marshal.SizeOf(osvi)) 'Set condition mask (equal to or newer than designated OS version). Dim cm As UInt64 = 0 cm = VerSetConditionMask(cm, VER_MAJORVERSION, VER_GREATER_EQUAL) cm = VerSetConditionMask(cm, VER_MINORVERSION, VER_GREATER_EQUAL) 'Perform VerifyVersionInfo. Dim result As Boolean = VerifyVersionInfoW(osvi, VER_MAJORVERSION Or VER_MINORVERSION, cm) If (result = True) Then MessageBox.Show("OS is Windows 8.1 or newer.") Else If (Marshal.GetLastWin32Error() = 1150) Then 'If ERROR_OLD_WIN_VERSION MessageBox.Show("OS is older than Windows 8.1.") Else MessageBox.Show("Failed to check OS version.") End If End If End Sub <DllImport("kernel32", SetLastError:=True)> Private Shared Function VerifyVersionInfoW(ByVal osvi As OSVERSIONINFOEX, ByVal dwTypeMask As UInt32, ByVal dwlConditionMask As UInt64) As <MarshalAs(UnmanagedType.Bool)> Boolean End Function <DllImport("kernel32", SetLastError:=True)> Private Shared Function VerSetConditionMask(ByVal dwlConditionMask As UInt64, ByVal dwTypeBitMask As UInt32, ByVal dwConditionMask As Byte) As UInt64 End Function <StructLayout(LayoutKind.Sequential)> Private Structure OSVERSIONINFOEX Public dwOSVersionInfoSize As UInt32 Public dwMajorVersion As UInt32 Public dwMinorVersion As UInt32 Public dwBuildNumber As UInt32 Public dwPlatformId As UInt32 <MarshalAs(UnmanagedType.ByValTStr, SizeConst:=128)> Public szCSDVersion As String Public wServicePackMajor As UInt16 Public wServicePackMinor As UInt16 Public wSuiteMask As UInt16 Public wProductType As Byte Public wReserved As Byte End Structure Private VER_MINORVERSION As UInt32 = &H1 Private VER_MAJORVERSION As UInt32 = &H2 Private VER_BUILDNUMBER As UInt32 = &H4 Private VER_PLATFORMID As UInt32 = &H8 Private VER_SERVICEPACKMINOR As UInt32 = &H10 Private VER_SERVICEPACKMAJOR As UInt32 = &H20 Private VER_SUITENAME As UInt32 = &H40 Private VER_PRODUCT_TYPE As UInt32 = &H80 Private VER_EQUAL As Byte = 1 Private VER_GREATER As Byte = 2 Private VER_GREATER_EQUAL As Byte = 3 Private VER_LESS As Byte = 4 Private VER_LESS_EQUAL As Byte = 5 Private VER_AND As Byte = 6 Private VER_OR As Byte = 7