概要
タイトル通りの記事です。高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仮想化をキャンセルすることも可能です。
ただ、これでは根本的な解決にはなっていません。
高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を明示
まず、プロジェクトにアプリケーションマニフェストファイルを追加します。
次に、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
属性を設定する方法があります。
<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
メッセージを受け取るのは「ウィンドウの半分以上が他方に移った」際ですので、その瞬間にリサイズしてしまうと、場合によってはリサイズルーチンが延々と呼ばれ続けることになってしまいます。
真面目に対策すると、「振動しないようにウィンドウの左上座標を動かす」か「完全にウィンドウが移りきった際にリサイズする」かなのですが、どういった実装になるかの詳しいコードは次のページが詳しいです。
WindowsフォームとPer-Monitor DPI(続)
各選択肢でどう表示が変化するのか
選択肢1
WPFアプリケーションはデフォルトでSystem Awareですので、起動時に100%表示(96dpi)だった場合や、
起動時に高DPI対応だった場合は綺麗に表示できます。
ただし、System AwareはPer-Monitor DPIに対応していませんので、起動時に100%表示だったのを途中で変更した場合や、DPIが異なるディスプレイに移した場合はボケて表示されてしまいます。
選択肢2
完璧です。ただ、なんでビルドしたexe(画像右)と違って開発環境(画面左)はボケてるんだよとツッコみたくなりますが。
選択肢3
当然完璧ですが、なかなかに面倒なので、1つテンプレとなるクラス・XAMLを作成し、継承して使いまわすのがベターでしょうね。