2013/09/08

スクリーン座標とPer-Monitor DPI

Per-Monitor DPI環境下でウィンドウのスクリーン座標を取得する際に気づいたことがあったので、検証してみた。

1. 検証アプリ


少し前にWindows 8.1 PreviewのPer-Monitor DPI環境下でDPI仮想化(DPI Virtualization)のかかったウィンドウのスクリーン座標を正確に取得できないことがあるのに気づいたので、検証アプリを作ってみた。
  • 上2つの"Chaser"は下の"Runner"のスクリーン座標を追いかけるためのアプリで、
    • "Process Name"にあるプロセス名のプロセスを見つけて、そのウィンドウの座標をWin32のGetWindowRect関数を使って表示する("GetWindowRect Left"がウィンドウの左上角のX座標、"GetWindowRect Top"が同じくY座標)。
    • "Process DPI Awareness"はそのプロセスのDPI Awarenessを示す。
    • タイトルバーにはこのChaser自身のDPI Awarenessを表示。

  • 下の"Runner"はドラッグして動かしてみるためのアプリで、
    • Runner自身の座標をWin32のGetWindowRect関数とForm.Locationプロパティを使って表示する(これらは常に一致する、はず)。
    • "Return Zero"はスクリーンの原点(左上角)にウィンドウを移動させる。原点はいかなる場合でも一致することの確認用。
    • "Move One Right"はウィンドウをドラッグによらず、右に座標1つ分だけずらす。
    • タイトルバーにはこのRunnerのDPI Awarenessを表示。
このChaserとRunnerはそれぞれ同じコード(下記参照)で、マニフェストファイルでDPI Awarenessだけ変えたものを用意した。

2. 検証結果


モニターは、1680x1050(メイン)と1024x768(セカンド)のマルチモニター。

これを元の96DPIの環境から、Per-Monitor DPI環境(メインは120DPIになり、セカンドは96DPIのまま)に設定後、サインアウトまたは再起動する前の、新しいDPIがまだデスクトップに反映されてない状態で、Chaser (Per-Monitor Aware)とChaser (System Aware)を起動しておく。

ここでRunner (System Aware)を起動し、メインからセカンドに移る直前まで動かした状態が以下。
DpiLocTest 96(120)-96 System Aware 1

正確な座標を示しているのはChaser (Per-Monitor Aware)で、Runner (System Aware)はDPI仮想化がかかっていて、拡大された分だけ座標は小さな値になっている。Chaser (System Aware)はRunner (System Aware)と同じで、正確でない。なお、Chaser (System Aware)にDPI仮想化がかかっているが、検証には関係ない。

このまま右にドラッグするとセカンドに移るが、敢えてMove One Rightで座標1つ分だけずらすとこうなる。
DpiLocTest 96(120)-96 System Aware 2

Runner (System Aware)のウィンドウが左上に飛んでいる。「何これ?」という感じだが、DPI仮想化の効果が切れ、座標も元に戻って、そこに移動している。三者の示す座標は同じ。普通にウィンドウを動かしている限りは陥らない状態だが、システム的にはこうなるということ。

この状態、あるいは最初の状態からドラッグして右に動かし、セカンドの左端に合わせたところ。
DpiLocTest 96(120)-96 System Aware 3

メインの幅は1680なので、三者とも座標は正確。ということは、Runner (System Aware)とChaser (System Aware)にとっては、座標が大きくジャンプしたことになる。その間の座標はどこに行ったかというと、上の状態のとおり。

さて、次にRunner (Per-Monitor Aware)を起動して、同じようにメインからセカンドに移る直前まで動かす。
DpiLocTest 96(120)-96 Per-Monitor Aware 1

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は正確な座標を示しているが、Chaser (System Aware)は先ほどと同じく小さくなった値を示している。

同じようにMove One Rightで座標1つ分だけずらす。
DpiLocTest 96(120)-96 Per-Monitor Aware 2

Runner (Per-Monitor Aware)はそのまま波乱なく移動。Chaser (System Aware)は値がジャンプした。

このままセカンドの左端まで動かす。
DpiLocTest 96(120)-96 Per-Monitor Aware 3

三者とも正確な座標で一致。

これで結論は見えてきたが、確認のため一旦サインアウトして新しいDPIがデスクトップに反映された状態にし、同じようにChaser (Per-Monitor Aware)とChaser (System Aware)を起動しておく。

まずRunner (System Aware)を起動。
DpiLocTest 120-96 System Aware 1

ここまでは三者とも一致して正確。

Move One Rightで座標1つ分だけずらす。
DpiLocTest 120-96 System Aware 2

またRunner (System Aware)のウィンドウが飛んでいる。その座標をChaser (Per-Monitor Aware)は正確に捉えているが、Runner (System Aware)とChaser (System Aware)はそのまま右にずれたかのように認識している。ウィンドウにはDPI仮想化が縮小する方向にかかっている。

セカンドの左端まで動かす。
DpiLocTest 120-96 System Aware 3

Chaser (Per-Monitor Aware)は正確だが、Runner (System Aware)の座標はDPI仮想化で縮小した分だけ大きな値になっている。Chaser (System Aware)も同様。

最後にRunner (Per-Monitor Aware)を起動。
DpiLocTest 120-96 Per-Monitor Aware 1

ここまでは三者とも一致。

Move One Rightで座標1つ分だけずらす。
DpiLocTest 120-96 Per-Monitor Aware 2

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は至って正確だが、Chaser (System Aware)は座標がジャンプした。

セカンドの左端まで動かす。
DpiLocTest 120-96 Per-Monitor Aware 3

Chaser (Per-Monitor Aware)とRunner (Per-Monitor Aware)は正確で、Runner (Per-Monitor Aware)にDPI仮想化もかかっていないが(当然)、Chaser (System Aware)の座標は大きくなった値のままずれている。

3. まとめ


以上から、とりあえずの結論。
  1. アプリのDPI AwarenessがPer-Monitor Awareに設定されていない場合、Per-Monitor DPI環境下ではそのプロセスで実行したGetWindowsRect関数が正確な値を返さないことがある。これは対象のプロセスが自分のアプリか他のアプリか(そして、その、他のアプリがPer-Monitor Awareに設定されているか否か)には関係ない。

  2. これは対象のアプリにDPI仮想化がかかっているか否かにも直接関係ない(この存在を示唆するものだとは思うが)。

  3. GetWindowsRect関数以外でも同様のことは起こり得る(GetClientRect関数でも起こることは確認)。

  4. したがって、ケースバイケースだが、自分のアプリのUIをPer-Monitor DPIに合わせて積極的に変えたりしない場合でも、スクリーン座標に関係する処理があればPer-Monitor Awareに設定した方が安全、と思われる。
初めはバグかとも思ったが、Per-Monitor Awareに設定すれば問題は起きないので、まあ仕様なのかと。RTMで変わるかもしれないが。

[追記]

Windows 8.1 RTMで試した結果も同じだったので、これが仕様らしいと確定。ついでに、Previewでは動作の怪しかったSetProcessDpiAwarenessが正しく機能するようになっていた。

4. コード


検証アプリは.NET Framework 4.5のWindowsフォームアプリで、Visual Studio 2013 Previewで作成。言語はC#。

Chaserの主要部分。
private Timer timerChaser;
private Process processRunner = null;

private void ChaserForm_Load(object sender, EventArgs e)
{
    timerChaser = new Timer();
    timerChaser.Tick += new System.EventHandler(this.CheckRunner);
    timerChaser.Interval = 400;
    timerChaser.Enabled = true;
}

private void CheckRunner(object sender, EventArgs e)
{
    if (String.IsNullOrWhiteSpace(this.textBox_ProcessName.Text)) return;

    GetProcess(this.textBox_ProcessName.Text);

    CheckLocation();
    CheckDpiAwareness();
}

// Get process of Runner.
private void GetProcess(string nameRunner)
{
    if (!String.IsNullOrWhiteSpace(nameRunner))
    {
        try
        {
            processRunner = Process.GetProcessesByName(nameRunner).FirstOrDefault();
        }
        catch (Exception ex)
        {
            Debug.WriteLine("Failed to get process. " + ex.Message);
        }
    }
}

// Check location of Runner.
private void CheckLocation()
{
    try
    {
        if ((processRunner != null) &&
            (processRunner.MainWindowHandle != IntPtr.Zero))
        {
            W32.RECT rct;

            bool result = W32.GetWindowRect(processRunner.MainWindowHandle, out rct);

            if (result)
            {
                this.textBox_GetWindowRectLeft.Text = rct.Left.ToString();
                this.textBox_GetWindowRectTop.Text = rct.Top.ToString();
            }
            else
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        else
        {
            this.textBox_GetWindowRectLeft.Text = "";
            this.textBox_GetWindowRectTop.Text = "";
        }
    }
    catch (Exception ex)
    {
        this.textBox_GetWindowRectLeft.Text = "Failed";
        this.textBox_GetWindowRectTop.Text = "Failed";

        Debug.WriteLine("Failed to get window rectangle. " + ex.Message);
    }
}

// Check DPI awareness of Runner.
private void CheckDpiAwareness()
{
    if (!OsVersion.IsEightOneOrNewer()) return;

    try
    {
        if ((processRunner != null) &&
            (processRunner.Handle != IntPtr.Zero))
        {
            W32.PROCESS_DPI_AWARENESS awareness;

            int result = W32.GetProcessDpiAwareness(processRunner.Handle, out awareness);

            if (result == 0) // If S_OK
            {
                this.textBox_DpiAwareness.Text = DpiAwareness.NameAwareness(awareness);
            }
            else
            {
                throw new Exception(result.ToString());
            }
        }
    }
    catch (Exception ex)
    {
        this.textBox_DpiAwareness.Text = "Failed";

        Debug.WriteLine("Failed to get DPI awareness. " + ex.Message);
    }
}
Runnerの主要部分。
private void RunnerForm_Move(object sender, EventArgs e)
{
    CheckLocation();

    this.textBox_LocationX.Text = this.Location.X.ToString();
    this.textBox_LocationY.Text = this.Location.Y.ToString();
}

// Check location of this Runner.
private void CheckLocation()
{
    IntPtr handleThis = Process.GetCurrentProcess().MainWindowHandle;

    if (handleThis != IntPtr.Zero)
    {
        try
        {
            W32.RECT rct;

            bool result = W32.GetWindowRect(handleThis, out rct);

            if (result)
            {
                this.textBox_GetWindowRectLeft.Text = rct.Left.ToString();
                this.textBox_GetWindowRectTop.Text = rct.Top.ToString();
            }
            else
            {
                throw new Win32Exception(Marshal.GetLastWin32Error());
            }
        }
        catch (Exception ex)
        {
            this.textBox_GetWindowRectLeft.Text = "Failed";
            this.textBox_GetWindowRectTop.Text = "Failed";

            Debug.WriteLine("Failed to get window rectangle. " + ex.Message);
        }
    }
}

private void button_ReturnZero_Click(object sender, EventArgs e)
{
    this.Location = Point.Empty;
}

private void button_MoveOneRight_Click(object sender, EventArgs e)
{
    this.Location = new Point(this.Location.X + 1, this.Location.Y);
}
完全なコードはここからダウンロードできる。

2013/09/02

WindowsフォームとPer-Monitor DPI

Windows 8.1のPer-Monitor DPIに対応するための基本情報を元に、実際にWindowsフォームアプリを対応させる作業に関して。なお、Windows 8.1 Previewに基づくものなので、RTMでは変更があり得る。

[追記] RTMでOS側に変更があったので、続を加えて再構成した。

1. 3つのDPI


Windowsフォームのスケーリングに関する出発点としては以下を参照。この中の「現在の自動スケーリングのサポート」は.NET Framework 2.0の場合として書かれているが、自分が試した範囲では.NET Framework 4.0でも同じだった。
これを踏まえて要点をまとめると、以下の3つのDPIを考慮する必要がある。
  • デザイン時のDPI

    概要 : Visual StudioのデザイナーでUIをデザインしたとき(最後にデザイナーで変更を加えたとき)のDPI。FormのAutoScaleDimensionsプロパティの値。下手な訳では意味が不明瞭になるので、MSDNの説明を引けば、"this property will be set by the Windows Forms designer to the value your monitor is currently using"で、"the AutoScaleDimensions property represents the design-time reference dimensions of the design environment for the current control"となる。

    取得方法 : デザイン時のDPIが100%なら96。実行時にはAutoScaleDimensionsはPerformAutoScaleメソッドによりCurrentAutoScaleDimensionsと同じ値になるので(AutoScaleModeプロパティがDpiの場合)、実際のデザイン時の値は取得できない。あえて実行時に取得するなら対象Formをインスタンス化だけして読み取るなど。Visual Studioで直接確認するには、対象Formのデザイナーファイルを見ればいい。

  • 実行時のDPI(以下、仮に「共通DPI」)

    概要 : 実行時の、全モニター共通のDPI。従来のDPIといえば、これのこと。FormのCurrentAutoScaleDimensionsプロパティの値。MSDNの説明を引けば、"the current run-time dimensions of the screen"で、"The CurrentAutoScaleDimensions property represents the reference dimensions on the current screen"となる。

    取得方法 : CurrentAutoScaleDimensionsから取得。Win32のGetDeviceCapsからLOGPIXELSXまたはLOGPIXELSYフラグで取得したものと、自分が試した範囲では同じだった。CurrentAutoScaleDimensionsが内部的に参照しているものも出所は同じではないかと思う。

  • 実行時のDPI(Per-Monitor DPI用)(以下、仮に「個別DPI」)

    概要 : 実行時の、個別モニターのDPI。Windows 8.1以降のみ。96、120、144、192の4段階(現在のところ)。

    取得方法 : Win32のGetDpiForMonitorから取得するか、WM_DPICHANGEDが来たときは、それに含まれているものを見る。
従来のWindowsフォームの高DPI対応(System DPI Aware)では、基本的にFormのAutoScaleModeをDpiに設定するだけでよかった。これには内部的にデザイン時のDPIと実行時の共通DPIが使われている。

で、Per-Monitor DPI環境での対応(Per-Monitor DPI Aware)はどうかとなると、BUILD 2013のプレゼンテーション(Making Your Desktop Apps Shine on High-DPI Displays)の際、Windowsフォームの対応方法について質問した人が素気なく却下されたのを見るに、Microsoftが何か用意してくれそうな感じではないので、実行時の個別DPIを見て自分で実装することになる。

2. 実装


以下は、従来どおりAutoScaleModeをDpiに設定して、その上にPer-Monitor DPI対応を重ねる形で実装する方法。つまり、CLRにSystem DPI Aware対応をさせた上でPer-Moniter DPI対応を加えることになる。AutoScaleModeをNoneにして、全ての場合のスケーリングを自分で実装する方がシンプルになるかもしれないが、副作用がよく分からなかったので。言語はVB。

2.1 コントロールの位置・サイズ


冒頭のMSDNの説明にあるとおり、PerformAutoScaleは内部的にScaleメソッドを使っている。これは親コントロールから子コントロールに波及していくので、一番の親であるFormにScaleメソッドを使えばいい。この引数にはその前の状態からの倍率を入れる。

例えば、デザイン時のDPIが96、実行時の共通DPIが120、個別DPIが144の場合、アプリの起動時に自動的にPerformAutoScaleで96から120へ1.25倍の拡大が行われる。そこから個別DPIへは120から144へ1.2倍に拡大する必要があるので、以下のようにSizeF構造体にしてScaleを実行する。
Me.Scale(New SizeF(1.2F, 1.2F))
実行中に個別DPIが変わったときは、その前の状態から現在の個別DPIへの倍率を同様に適用すればいい。

ちなみに、これをPerformAutoScaleで行うことはできない。というのは、PerformAutoScaleは内部的にCurrentAutoScaleDimensionsを参照してこれに合わせるメソッドになっていて、CurrentAutoScaleDimensionsはReadOnlyプロパティなので外部から変更できないので。

2.2. フォント


フォントの方は結構トリッキー。

フォントサイズの倍率


第一に、フォントサイズを直接指定はできない。どういうことかというと、コード内で指定されたサイズから、デザイン時のDPIから実行時の共通DPIへの倍率で自動的に拡大されたサイズで表示される。

例えば、デザイン時のDPIが96で実行時の共通DPIが120の場合、コード内で9ポイントと指定されたフォントは1.25倍に拡大されて11.25ポイントで表示される。ここでサイズを参照しても9ポイントのまま。つまり、この表示のスケーリングはサイレントに行われ、コードから直接感知できない。

さらに、コントロールのPerformAutoScaleは基本的にアプリの起動時に行われるだけだが、このフォントのスケーリング効果は実行中ずっと続いている。RPGに例えるなら、基本ステータスは変わらないまま、ずっと拡大のバフ(魔法効果)がかかっているようなもの。

したがって、目的のサイズで表示させるには、このサイレントに行われるスケーリングの倍率を差し引いた倍率で拡大してコード内で指定する必要がある。といっても難しいことはなくて、実行時の共通DPIから個別DPIへの倍率をサイズに掛ければいいだけ。これが1.2倍の場合、以下のようにFontを設定し直す。
Me.Font = New Font(Me.Font.FontFamily, 
                   Me.Font.Size * 1.2F, 
                   Me.Font.Style)
実行中に個別DPIが変わったときは、コントロールと同じくその前の状態から現在の個別DPIへの倍率を適用すればいい。

対象とするコントロール


第二に、フォント設定の対象とするコントロールの問題がある。デザイン時にコントロールのフォント設定をしていない場合(デザイナーでFontプロパティが太字でない状態)、実行時には親コントロールのフォント設定が使われる。したがって、全フォントが同じであればFormのフォント設定だけすればよく、実行時にサイズを変えるときもそれだけを対象にすれば足りる。

が、実際のアプリではそういうわけにも行かないと思うので、以下のようなことが起こる。
  • 個別のコントロールによってフォント設定をした場合、そのコントロールは親コントロールのフォント設定には影響されない(当然)。よって、実行時にサイズを変えるにはそのコントロールを直接対象にする必要がある。

  • ならば、Form中の全コントロールを対象に舐めるようにフォント設定していけばいいかといえば、そうも行かない。デザイン時にフォント設定されていないコントロールには、親コントロールにかけられたスケーリングと直接かけられたスケーリングの両方の効果がかかってしまう。例えば、親コントロールのフォント設定で1.2倍にした後で、子コントロールのフォント設定で1.2倍にすると、合わせて1.44倍の拡大がかかる。
これを回避するには、うまく対象のコントロールを抽出する必要がある(デザイン時に全コントロールに個別にフォント設定する方法は避けたい)。

大まかには以下の流れになると思うが、これ一発でという方法はたぶんない。
  1. Form中のコントロールを配列かコレクションの形で再帰的に取得する(必要であれば、世代を限定する)。

  2. 取得した配列かコレクションからLINQなどでコントロールの型や特定の条件によって抽出する。
例として、Form中のButtonのみを個別にフォント設定している場合。GetChildInControlは引数のコントロールを親とする全コントロールを取得するメソッドで、この中からButtonの型で抽出している。
For Each b As Button In GetChildInControl(Me).OfType(Of Button)()
    b.Font = New Font(b.Font.FontFamily, 
                      b.Font.Size * 1.2F, 
                      b.Font.Style)
Next
別に難しくはないが、うまくやろうとすると頭を使わないといけないという意味で面倒な部分。

2.3 動的なコントロール


実行中に動的にコントロールを作成したり、位置・サイズを変更したりする場合は、
  • 位置・サイズについては、デザイン時の位置・サイズをコード内に持っておいて、デザイン時のDPIから個別DPIへの倍率を掛けるのが簡単だと思う。

  • フォントについては、同じくデザイン時のフォントをコード内に持っておいて、実行時の共通DPIから個別DPIへの倍率を掛ければいい。
掛ける倍率が違うのに注意。

なお、文字表示に必要なコントロールのサイズを求めるのにTextRenderer.MeasureTextメソッドを使うことがあると思うが、これは表示のスケーリングがかかった状態のサイズが取得できるので、とくに手を加える必要はない。ただし、一旦これでサイズを設定した後に個別DPIが変わってScaleメソッドをかけた場合、新しいDPIで取得したTextRenderer.MeasureTextのサイズと微妙にずれが出てくるので、再設定した方がいいかもしれない。

ちなみに、タイトルバーのアイコンと通知領域のアイコンについては、表示される大きさが共通DPIに従って固定されているようなので、変更する意味はとりあえずない。

2.4 正しくスケーリングされないコントロール


一部のコントロールはScaleメソッドを実行しても正しくスケーリングされない。自分が直接知っている例は以下のもの。
  • TextBoxをMultilineにしたときの高さ
  • ListViewをDetailsにしたときのColumnHeaderの幅
弥縫策的には個別にスケーリングした値を与えてやれば済むが、問題を修正したカスタムコントロールを作成してもいいかもしれない。

ということで、TextBoxの高さの方は色々試してみたが、オーバーロードできるメソッドではなかなか難しくて、諦めた(SetBoundsCoreメソッドを使えば何とかなるかもしれない)。が、実はこれにはデザイン時にできる簡単な回避策があって、Panelなどのコンテナコントロールに入れてDockプロパティをFillにすればいい。Panelは正しくスケーリングされるので、中のTextBoxのサイズも正しくスケーリングされるようになる。

ちなみに、これらはScaleメソッドの問題なので、従来の高DPI対応でも発生する。

3. WM_DPICHANGEDの変更


Windows 8.1 RTMではWM_DPICHANGEDが来るタイミングが変更されたので、これへの対応方法を変える必要がある。以下、続にて。

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