C#
WPF
dpi
高DPI

WPFアプリを高DPI対応にしよう!

More than 1 year has passed since last update.


概要

 タイトル通りの記事です。高DPIとアプリケーションとの関係については、次のページが分かりやすいでしょう。

 ASCII.jp:Windowsと高DPIディスプレイ【その1】 8までのDPIスケーリング (1/2)|Windows Info

 ASCII.jp:Windowsと高DPIディスプレイ【その2】 8.1では異なるDPIを設定可 (1/2)|Windows Info

 ASCII.jp:Windows 10+高解像度ディスプレイでのアプリのボケはRS2で解消される|Windows Info

 ASCII.jp:Windows 10+高解像度ディスプレイでのアプリのボケはRS2で解消される【その2】|Windows Info

 また、高DPIに対応するための選択肢は複数あり、状況に応じてどれかを選ぶことができます。上から順にだんだん面倒なものとなっています。


  • 選択肢1:何もしない

  • 選択肢2:モダンな解決策

  • 選択肢3:泥臭い解決策

※今回の記事はVisual Studio 2015 Update 3を前提としています

※今回使用したコードの動作確認はWindows 10 Anniversary Updateを使用しました

※今回使用したコードを置いたリポジトリ→https://github.com/YSRKEN/HighDpiSample


選択肢1:OS任せにする

 非常に単純な方法ですが、大抵の環境では問題ないでしょう。

 Windows Vista以降だとDPI仮想化機能がありますので、未対応アプリケーションでもとりあえず使用できます。

 いざとなれば、ファイルのプロパティから「高DPI設定では画面のスケーリングを無効にする」を選択することでDPI仮想化をキャンセルすることも可能です。

image

 ただ、これでは根本的な解決にはなっていません。

 高DPI対応がunawareなWindows Formsアプリは言うまでもないとして、System AwareなWPFもDPI仮想化から逃れられないからです。Windows 10 Creator's Updateでは仮想DPIが賢くなったのでこの方法でも解決しますが、もっと早く実装しろよとしか言いようがありませんね!!

 ASCII.jp:Windows 10+高解像度ディスプレイでのアプリのボケはRS2で解消される|Windows Info

 ASCII.jp:Windows 10+高解像度ディスプレイでのアプリのボケはRS2で解消される【その2】|Windows Info


選択肢2:最新のOSとライブラリを使用する

 冒頭で挙げた記事にもあるように、Windowsにおける高DPI対応は徐々に進んできました。

 そして、Windows 10 Anniversary Update以降では、僅かな修正だけで高DPI対応が完了します

 具体的な方法については、次のページが参考になります。

 .Net 4.6.2以降でのWPFのPer-Monitor DPI対応 - SourceChord


選択肢3:真面目に高DPI対応する

 実装する手順としては、次のようになります。


  • ステップ1:Per-Monitor DPIに対応したアプリであることを明示する

  • ステップ2:指定したDPIに合わせて画面をリサイズするルーチンを書く

  • ステップ3:起動時にディスプレイのDPIを取得し、上記リサイズルーチンを叩く

  • ステップ4:WM_DPICHANGEDを取得し、そのタイミングでリサイズルーチンを叩く

 ここでWM_DPICHANGEDは、設定でDPIを変更した際や、DPIが異なるディスプレイ間をウィンドウが移動した際に飛んでくるウィンドウメッセージです。後者の場合、ウィンドウの半分以上が他方に移った際に飛んでくることに注意しましょう。


ステップ1:Per-Monitor Awareを明示

 まず、プロジェクトにアプリケーションマニフェストファイルを追加します。

image

 次に、application > windowsSettings > dpiAwareと要素を辿っていって、その内容をtrue/PMとします。この部分はデフォルトでtrueとなっており、更にコメントアウトまでされていますが、ここを切り替えることで高DPIへの対応状況が変化します。ちなみに参考文献ではTrue/PMと書かれていますが、大文字と小文字は区別していないので好みで決めましょう。

 ……ただ、ここをTrue/PMにするということは、単にDPI仮想化を使用しないと宣言したに過ぎません。つまり、後に触れる対策を何も行わなかった場合、前述の「高DPI設定では~」を有効にしたのと同じ表示になるということです。

記述
高DPIへの対応状況
起動時に高DPIだった場合
起動後に切り替わった場合

false
unaware
ボケる
ボケる

true
System Aware
ボケない
ボケる

true/PM
Per-Monitor Aware
ボケない
ボケない


ステップ2:リサイズルーチン

 例えば「96dpiを120dpiに変更する」とした場合、ウィンドウおよび内部のオブジェクトの大きさを1.25倍にする必要があります。

 ウィンドウをリサイズするには、ウィンドウのFrameworkElement.WidthFrameworkElement.Heightを変更すればいいでしょう。

 オブジェクトをリサイズするには、1つづつWidthHeightをいじっても構いませんが、より簡潔な手段として、XAML上でScaleTransform属性を設定する方法があります。

<DockPanel>

<DockPanel.LayoutTransform>
<ScaleTransform ScaleX="{Binding ScaleX}" ScaleY="{Binding ScaleY}"/>
</DockPanel.LayoutTransform>
<!-- ここにリサイズしたいオブジェクトを置く -->
</DockPanel>

 上記コードをご覧ください。ScaleTransformにはリサイズの働きがあり、この場合はDockPanel内の全オブジェクトのサイズがリサイズされます。ScaleXおよびScaleY属性は、Data Bindingを使って叩くのがスマートでしょう。

 ScaleTransformの外側にあるLayoutTransformは、リサイズする際の挙動を設定するためのものです。詳しくは次のページをご覧ください。

 RenderTransformプロパティとLayoutTransformプロパティの違い - Yuya Yamaki’s blog (id:Yamaki / @yamaki00)

 ……ちなみに、最初は「Window.LayoutTransformを弄ればいいのでは」と思って書いたところ、リサイズした際にレイアウトが思いっきり崩れましたorz


ステップ3:ディスプレイのDPIを取得

 手順としては、ディスプレイのハンドルを取得してからディスプレイのDPIを取得することになります。

 そのため、それぞれの操作のために、どうしてもWinAPIを叩く必要があります。

 また、WinAPIに渡す引数も用意するため、行数が嵩む嵩む……。

// MonitorFromWindowが返したディスプレイの種類

public enum MonitorDefaultTo { Null, Primary, Nearest }
// GetDpiForMonitorが返したDPIの種類
enum MonitorDpiType { Effective, Angular, Raw, Default = Effective }
// NativeMethods
class NativeMethods {
// ウィンドウハンドルから、そのウィンドウが乗っているディスプレイハンドルを取得
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern IntPtr MonitorFromWindow(IntPtr hwnd, MonitorDefaultTo dwFlags);
// ディスプレイハンドルからDPIを取得
[DllImport("SHCore.dll", CharSet = CharSet.Unicode, PreserveSig = false)]
public static extern void GetDpiForMonitor(IntPtr hmonitor, MonitorDpiType dpiType, ref uint dpiX, ref uint dpiY);
}
// 現在のディスプレイにおけるDPIを取得する
Dpi GetDpi() {
// 当該ウィンドウののハンドルを取得する
var helper = new WindowInteropHelper(this);
var hwndSource = HwndSource.FromHwnd(helper.Handle);
// ウィンドウが乗っているディスプレイのハンドルを取得する
var hmonitor = NativeMethods.MonitorFromWindow(hwndSource.Handle, MonitorDefaultTo.Nearest);
// ディスプレイのDPIを取得する
uint dpiX = Dpi.Default.X;
uint dpiY = Dpi.Default.Y;
NativeMethods.GetDpiForMonitor(hmonitor, MonitorDpiType.Default, ref dpiX, ref dpiY);
return new Dpi(dpiX, dpiY);
}

 さらに、実行時にこれを使用する場合、Windowのコンストラクタで実行すると例外が飛びます

 そのため、OnSourceInitializedメソッド内で使用することを推奨します。

// 初期化直後の処理

protected override void OnSourceInitialized(EventArgs e) {
base.OnSourceInitialized(e);
// 最初にDPIを取得する
ResizeWindowByDpi(GetDpi());
}


ステップ4:DPIの変更を取得

 ウィンドウメッセージを拾うには、ウィンドウプロシージャを用意する必要があります。

 ただ、ウィンドウプロシージャを用意した後そちらにウィンドウメッセージが来るようにフックするルーチンも書く必要があります。

 下記におけるOnSourceInitialized内が「フックするルーチン」、WindProc内が「ウィンドウプロシージャ」となります。

// DPI変更時に飛んでくるウィンドウメッセージ

enum WindowMessage { DpiChanged = 0x02E0 }
// フックするルーチン
protected override void OnSourceInitialized(EventArgs e) {
base.OnSourceInitialized(e);
// ウィンドウメッセージを取得する
var helper = new WindowInteropHelper(this);
var source = HwndSource.FromHwnd(helper.Handle);
source.AddHook(new HwndSourceHook(WndProc));
}
// ウィンドウプロシージャ
IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) {
if(msg == (int)WindowMessage.DpiChanged) {
// wParamの下位16bit・上位16bitがそれぞれX・Y方向のDPIを表している
var dpiX = (uint)wParam & 0xFFFF; //下位16bit
var dpiY = (uint)wParam >> 16; //上位16bit
ResizeWindowByDpi(new Dpi(dpiX, dpiY));
handled = true;
}
return IntPtr.Zero;
}

※この選択肢における参考資料:

 Windows 8.1 で加わった Per-Monitor DPI と WPF での対応方法

 WPFでウィンドウメッセージを処理する


備考

 薄々感づいている方もいらっしゃるかと思いますが、上記のステップ1~ステップ4は少し雑な実装です。

 ステップ4でWM_DPICHANGEDメッセージを受け取るのは「ウィンドウの半分以上が他方に移った」際ですので、その瞬間にリサイズしてしまうと、場合によってはリサイズルーチンが延々と呼ばれ続けることになってしまいます。

image

 真面目に対策すると、「振動しないようにウィンドウの左上座標を動かす」か「完全にウィンドウが移りきった際にリサイズする」かなのですが、どういった実装になるかの詳しいコードは次のページが詳しいです。

 WindowsフォームとPer-Monitor DPI(続)


各選択肢でどう表示が変化するのか


選択肢1

 WPFアプリケーションはデフォルトでSystem Awareですので、起動時に100%表示(96dpi)だった場合や、

image

起動時に高DPI対応だった場合は綺麗に表示できます。

image

 ただし、System AwareはPer-Monitor DPIに対応していませんので、起動時に100%表示だったのを途中で変更した場合や、DPIが異なるディスプレイに移した場合はボケて表示されてしまいます。

image


選択肢2

 完璧です。ただ、なんでビルドしたexe(画像右)と違って開発環境(画面左)はボケてるんだよとツッコみたくなりますが。

image


選択肢3

 当然完璧ですが、なかなかに面倒なので、1つテンプレとなるクラス・XAMLを作成し、継承して使いまわすのがベターでしょうね。

image