2013/07/04

Per-Monitor DPIに備える

Windows 8.1 PreviewにおけるDPIの変化に関して。

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. コーディング

基本情報としては以下のとおり。
この中でキーになるのは以下のもの。
  • DPIの変化を知らせるウィンドウメッセージ: WM_DPICHANGED
  • モニターごとのDPIを取得するAPI: GetDpiForMonitor
  • アプリがDPI Awareであることを示すマニフェストの拡張: 「Per Monitor」と「True/PM」
で、現時点で出ている情報は基本的にC++向けなので、これをVBで書いたWindowsフォームアプリから試してみた。環境はWindows 8.1 Preview上のVisual Studio 2012 Expressで、対象のフレームワークを.NET Framework 4 Client ProfileとしたWindowsフォームアプリをVisual Basicで作成。

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 Enum
MonitorFromWindowの第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を呼び出しているようなので同じ結果になる。

この対応策としては以下の二通り。
  1. 対応しているOSとしてWindows 8.1をマニフェストに追加する。
  2. Win32のVerifyVersionInfoを使う。
VerifyVersionInfoはOSに関係なく使えるが、こちらから提示したバージョンに対して正否を返してくるAPIなので、少しだけ面倒。一方、マニフェストへの追加はこの問題と関係なくやるだろうし、この方が正攻法、かつコード量もごく少なくて済む。

具体的には、以下のようなもの。
<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

2 コメント :

匿名 さんのコメント...

次期OfficeもModernUI版としてリリースすると公式に示したように
MSの動きを見ていると、てっきりModernUIのみ高DPI環境を整備して、
DesktopUIはあくまでも既存アプリケーションとの互換性維持の為に残すだけで
高DPI対応は事実上放置に近いのではないのかと思ってましたが・・・
そういう訳でもないんですかね?スタンスはっきりして欲しいなぁ。

EMO さんのコメント...

シノフスキーが辞めた前後で路線に修正があったのかなと推測してますが、元々大画面のモニターで使うデスクトップでWindowsストアアプリは無理があったんだろうと思います。