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);
}
完全なコードはここからダウンロードできる。

0 コメント :