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が来るタイミングが変更されたので、これへの対応方法を変える必要がある。以下、にて。