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

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

　[ASCII.jp：Windowsと高DPIディスプレイ【その1】 8までのDPIスケーリング (1/2)｜Windows Info](http://ascii.jp/elem/000/000/901/901046/index.html)
　[ASCII.jp：Windowsと高DPIディスプレイ【その2】 8.1では異なるDPIを設定可 (1/2)｜Windows Info](http://ascii.jp/elem/000/000/905/905248/)
　[ASCII.jp：Windows 10＋高解像度ディスプレイでのアプリのボケはRS2で解消される｜Windows Info](http://ascii.jp/elem/000/001/457/1457365/)
　[ASCII.jp：Windows 10＋高解像度ディスプレイでのアプリのボケはRS2で解消される【その2】｜Windows Info](http://ascii.jp/elem/000/001/461/1461607/)

　また、高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](https://qiita-image-store.s3.amazonaws.com/0/29802/e91212e0-d776-4def-19e4-7a75603e7a14.png)

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

　[ASCII.jp：Windows 10＋高解像度ディスプレイでのアプリのボケはRS2で解消される｜Windows Info](http://ascii.jp/elem/000/001/457/1457365/)
　[ASCII.jp：Windows 10＋高解像度ディスプレイでのアプリのボケはRS2で解消される【その2】｜Windows Info](http://ascii.jp/elem/000/001/461/1461607/)

# 選択肢2：最新のOSとライブラリを使用する
　冒頭で挙げた記事にもあるように、Windowsにおける高DPI対応は徐々に進んできました。
　そして、Windows 10 Anniversary Update以降では、**僅かな修正だけで高DPI対応が完了します**。
　具体的な方法については、次のページが参考になります。

　[.Net 4.6.2以降でのWPFのPer-Monitor DPI対応 - SourceChord](http://sourcechord.hatenablog.com/entry/2016/12/14/081112)

# 選択肢3：真面目に高DPI対応する
　実装する手順としては、次のようになります。

- ステップ1：Per-Monitor DPIに対応したアプリであることを明示する
- ステップ2：指定したDPIに合わせて画面をリサイズするルーチンを書く
- ステップ3：起動時にディスプレイのDPIを取得し、上記リサイズルーチンを叩く
- ステップ4：[WM_DPICHANGED](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dn312083(v=vs.85).aspx)を取得し、そのタイミングでリサイズルーチンを叩く

　ここで[WM_DPICHANGED](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dn312083(v=vs.85).aspx)は、設定でDPIを変更した際や、DPIが異なるディスプレイ間をウィンドウが移動した際に飛んでくるウィンドウメッセージです。後者の場合、**ウィンドウの半分以上が他方に移った際に飛んでくる**ことに注意しましょう。

## ステップ1：Per-Monitor Awareを明示
　まず、**プロジェクトにアプリケーションマニフェストファイルを追加**します。

![image](https://qiita-image-store.s3.amazonaws.com/0/29802/580ca137-1142-fe17-ef59-7a31d9d12e7c.png)

　次に、`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.Width`と`FrameworkElement.Height`を変更すればいいでしょう。
　オブジェクトをリサイズするには、1つづつ`Width`と`Height`をいじっても構いませんが、より簡潔な手段として、XAML上で`ScaleTransform`属性を設定する方法があります。

```xml
<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)](http://d.hatena.ne.jp/Yamaki/20070222/1172107092)

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

## ステップ3：ディスプレイのDPIを取得
　手順としては、**ディスプレイのハンドルを取得**してから**ディスプレイのDPIを取得する**ことになります。
　そのため、それぞれの操作のために、どうしてもWinAPIを叩く必要があります。
　また、WinAPIに渡す引数も用意するため、行数が嵩む嵩む……。

```csharp
// 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`メソッド内で使用することを推奨します。

```csharp
// 初期化直後の処理
protected override void OnSourceInitialized(EventArgs e) {
	base.OnSourceInitialized(e);
	// 最初にDPIを取得する
	ResizeWindowByDpi(GetDpi());
}
```

## ステップ4：DPIの変更を取得
　ウィンドウメッセージを拾うには、ウィンドウプロシージャを用意する必要があります。
　ただ、ウィンドウプロシージャを用意した後そちらにウィンドウメッセージが来るようにフックするルーチンも書く必要があります。
　下記における`OnSourceInitialized`内が「フックするルーチン」、`WindProc`内が「ウィンドウプロシージャ」となります。

```csharp
// 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 での対応方法](http://grabacr.net/archives/1132)
　[WPFでウィンドウメッセージを処理する](http://akinoware.blogspot.jp/2012/02/wpf.html)

## 備考
　薄々感づいている方もいらっしゃるかと思いますが、上記のステップ1～ステップ4は少し雑な実装です。
　ステップ4で`WM_DPICHANGED`メッセージを受け取るのは「ウィンドウの半分以上が他方に移った」際ですので、その瞬間にリサイズしてしまうと、場合によっては**リサイズルーチンが延々と呼ばれ続ける**ことになってしまいます。
![image](https://qiita-image-store.s3.amazonaws.com/0/29802/8bcba0c4-9400-6edf-a525-68ff2027ff67.png)

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

　[WindowsフォームとPer-Monitor DPI（続）](http://8thway.blogspot.jp/2013/10/winforms-per-monitor-dpi-aggendum.html)

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

## 選択肢1
　WPFアプリケーションはデフォルトでSystem Awareですので、起動時に100%表示(96dpi)だった場合や、
![image](https://qiita-image-store.s3.amazonaws.com/0/29802/64b958eb-27b7-24bf-bafe-bb01ae6f2c4f.png)
起動時に高DPI対応だった場合は綺麗に表示できます。
![image](https://qiita-image-store.s3.amazonaws.com/0/29802/70475e30-74d5-1012-5825-e0f2c263d73f.png)
　ただし、System AwareはPer-Monitor DPIに対応していませんので、起動時に100%表示だったのを途中で変更した場合や、DPIが異なるディスプレイに移した場合はボケて表示されてしまいます。
![image](https://qiita-image-store.s3.amazonaws.com/0/29802/e82e7b9d-4c00-2689-8c79-22f347b7ef66.png)
## 選択肢2
　完璧です。ただ、**なんでビルドしたexe(画像右)と違って開発環境(画面左)はボケてるんだよ**とツッコみたくなりますが。
![image](https://qiita-image-store.s3.amazonaws.com/0/29802/1a0510f1-6662-a132-7341-fac207fe1534.png)
## 選択肢3
　当然完璧ですが、**なかなかに面倒**なので、1つテンプレとなるクラス・XAMLを作成し、継承して使いまわすのがベターでしょうね。
![image](https://qiita-image-store.s3.amazonaws.com/0/29802/090d80d4-cf61-fbfb-fd5c-f4e78f9cb568.png)