.NET 7 WinForms C# での高DPI/PerMonitorV2 対応(仮)

2023/04/01 (今日はエイプリルフールですが、ネタではありません。)

正直なところこの方面には全く疎いので、大いに割り引いてご覧ください。
C# に初めて手を出し、わからないなりに試行錯誤して、とりあえずのところまでのレポートです。
「いまさら WinForms でやるの??」という議論は大ありなのですが、まあそれは横に置いておいて。
.NET 7 ではそのままでも高DPI/PerMonitorV2 対応は十分実用的なレベルになっているようですが、やはり細かいところで困る場合もあり、気づきを記録しておきたいと思います。ただし、あくまで、これを書いている現在(2023/04/01、Microsoft Visual Studio Community 2022 Version 17.5.3)の情報です。
なお、過去の .NET 6 での試行のレポートはこちら 。.NET 6 ではいろいろと不具合があって困ったものでした。.NET 6 にあったあからさまな不具合は、.NET 7 ではほとんど解消されているようです。
[2023/12/16] 最新の .NET 8 での試行のレポートはこちら

参考文献は文中に明記しています。感謝いたします。


まずは結論

結論:フォームの DpiChanged イベントで、フォーム上のすべてのコントロール(フォーム自身を含む)に対して、フォントサイズを、単精度浮動小数点の丸めを考慮しつつ再計算して、そのフォントサイズ値を持つ新しいフォントを生成して適用する。

結論だけ知りたければ、この先は読む必要はありません。


検証(対策前)

準備作業:新規プロジェクトの生成

このページの最後のスクショ集を参照。
「Windows フォームアプリ .NET Windows フォーム (WinForms) アプリを…」を選択。プラットフォームで .NET 7 を選ぶ。
最初に表示すべきフォームのプロパティで AutoScaleMode を DPI モードにする。
csproj ファイルにおいて PropertyGroup セクション内のすでにある内容のあと(PropertyGroup 終了タグの前)に <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>追記する 。 .NET 7 ではこれで PerMonitorV2 の動作になる。

Visual Studio 統合開発環境では PerMonitorV2 指定をするとフォームデザイナーが頻繁にバグるので、フォームデザインするときは都度コメントアウト無効化するとよいようです。

  1. プロジェクトを開いていれば、そのフォームデザイナータブをすべて終了。他のソースファイルのタブや、Visual Studio 統合開発環境は起動したままで良い。
  2. プライマリディスプレイの表示スケール倍率と、Visual Studio 統合開発環境を表示させているディスプレイの表示スケール倍率ディスプレイを、どちらも 100% に設定する。
  3. <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode> をコメントアウトした状態でいったんビルドする。
  4. フォームデザイナータブを開き直す(フォームのソースコードの右クリック→デザイナーの表示)。
  5. ただし、たまに復旧できない場合があり、その場合は Visual Studio 統合開発環境をいったん終了してもう一度プロジェクトを開く。

コントロール上のフォントサイズの問題

.NET 7 WinForms の ApplicationHighDpiMode PerMonitorV2 で唯一目立つ問題点として、DPI 変更時のフォントサイズの再計算がうまくいっていないようです。DPI 変更のパターンによっては、ボタンなどコントロールの文字のサイズが期待する大きさよりも少し大きくなり、小さいボタンの文字が一部がはみ出して欠落することが、時々あります。

テストコードは以下のとおり。フォームデザイン関係はこのページの最後のスクショ集を参照してください。

namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        int dpi0;
        public Form1()
        {
            InitializeComponent();
            dpi0 = this.DeviceDpi;
        }

        private void button1_Click(object sender, EventArgs e)
        {
            textBox1.Text = button1.Font.ToString()
                + "\r\n"
                + "dpi0=" + dpi0.ToString()
                + "\r\n"
                + "dpi_now=" + this.DeviceDpi.ToString();
        }
    }
}

結果のスクリーンショットだけ貼っておきます。以下、Scale_P はプライマリディスプレイの表示スケール倍率とします。セカンダリディスプレイの表示スケール倍率は 100% 固定で、そこに Visual Studio 開発環境ウインドウを配置して作業しています。デバッグ実行ではウインドウはプライマリディスプレイで開始されます。

  1. Scale_P = 100% でデバッグ実行開始(プライマリディスプレイにて)
  2. Scale_P を 125% に変更
  3. Scale_P = 125% でデバッグ実行開始(プライマリディスプレイにて)
  4. Scale_P を 100% に変更

ボタンに表示されている「新規」の文字の大きさに注目してください。最もわかりやすいのは「規」の文字の旁「見」がレンダリングされたビットマップパターンが違っているところです。この例では辛うじて表示が保たれていますが、これはボタンが1個だけしかないためのようです。複数のボタンを設けていて、当該ボタンからフォーカスが外れると、表示されるのが「新」だけになってしまうなどレイアウトがダメになる場合があるようです(Yu Gothic UI, 9pt では button1.Size を 40, 22 にすると再現できます)。
次に、テキストボックスに表示させている情報に注目してください。7.2 が期待されるものが 7.2000003 になっています。
.NET WinForms のフォントサイズは単精度浮動小数点型です。浮動小数点数内部表現シミュレーター によると IEEE754 32bit float では 7.2 = 0x40e66666 = 7.199999809265137、7.2000003 = 0x40e66667 = 7.200000286102295 でこれは仮数部 1 違い、すなわち 1 ulp の違いです。
こんなことになってしまうのは、たぶん、コントロールのフォントをDPIに応じて何かしら計算する処理のうち単精度浮動小数点演算の途中のどこかで何らかの丸めが入っているか、そうでなければ部分的に1000倍した整数などでの疑似固定小数点演算が混ざっているか(フォームのドット数とかコントロールの Margin プロパティとかは「整数」しか有り得ないのでそういう残骸コードがある?)、あたりではないかと思います。また、タイミング的に、その途中過程で切り上げ丸めを入れた数値でフォントサイズを生成してしまっていて、その後の補正を忘れているとかも、有り得るかもしれません(7.2 と 7.2000003 とでレンダリング結果がここまで違うのは流石におかしい)。流石にそこまでの詳細は調べていません。


対策:フォントサイズの修正

フォームの DpiChanged イベントにおいて、そのフォーム上にあるすべてのコントロール(フォーム自身を含む)のフォントを、正しいサイズのものに設定しなおします。とりあえず、その処理は独自に作成した ListUpAllSubControls.fixAllFontSizeOfControls(this) というメソッドであると仮定し、その具体的な処理内容は次の段落で述べます。

private void Form1_DpiChanged(object sender, DpiChangedEventArgs e)
{
    ListUpAllSubControls.fixAllFontSizeOfControls(this);
}

この処理はプログラム内のすべてのフォームで必要になるので、共通化のため、独立したソースファイルにしたものをプロジェクトに追加するのが良さそうです。それを ListUpAllSubControls という静的メンバのみのクラスにしてみました。ただし C# に慣れていないので業界での標準的なコーディング流儀ではないかもしれませんがご了承ください(そもそも私は職業プログラマーではありません)。どなたかプロのプログラマーさんに、このページに代わるものを正しい流儀で広く公開していただけるとありがたいです。
「すべてのコントロールを列挙し、その一つ一つに対して何らかの処理を行う」ことについては、http://stackoverflow.com/questions/15186828/loop-through-all-controls-on-a-form-even-those-in-groupboxes が非常に参考になりました(感謝)。たくさんの実装例があるが正直理解しきれていません。最も簡単そうな実装をコピペしてしまいました。

namespace WinFormsApp1
{
    internal class ListUpAllSubControls
    {
        // LINQ版
        // http://stackoverflow.com/questions/15186828/loop-through-all-controls-on-a-form-even-those-in-groupboxes
        public static List AllSubControls(Control control)
        {
            var list = control.Controls.OfType().ToList();
            var deep = list.Where(o => o.Controls.Count > 0).SelectMany(AllSubControls).ToList();
            list.AddRange(deep);
            return list;
        }

        public static void fixAllFontSizeOfControls(Control control)
        {
            // フォントサイズ修正
            // (デザインサイズが 9pt のとき PerMonitorV2 で 125% のディスプレイから起動したのち 100% に変更/移動すると
            // 7.2 となるべきものが 7.2000003 など微妙に大きな値になってそれが .NET 7 WinForms ランタイムのどこかで切り上げられて
            // テキストがコントロールからはみ出すことがあるのを修正)
            var allctrls = AllSubControls(control);
            foreach (var ctrl in allctrls)
            {
                ctrl.Font = new Font(ctrl.Font.Name, (float)Math.Round(ctrl.Font.Size, 3, MidpointRounding.ToZero));
            }
        }
    }
}

ボタン文字列は少しくらい小さくしてでも全部表示してくれるほうが、GUI としてはありがたい、というスタンスです。Math.Round で小数第二位~第三位くらいで丸めておくのがポイントのようです。
ちなみに Math.Round メソッドの「丸めと単精度浮動小数点値」 の解説に基づき、念のため Decimal 型へのキャストを行った例( MidpointRounding.ToZero を併用せず)も試してみましたが、今回の 9pt を 7.2pt に縮小したい例の限りでは、キャストを行わなくても(暗黙の float → double 変換であっても)結果は変わらず問題ありませんでした。Decimal 型は 128bit でありそれへのキャストは結構重たい処理ではないかと思いますので、暗黙の float → double 変換で MidpointRounding.ToZero を併用するのがいいのかなと思います。
MidpointRounding.ToZero を省いてデフォルトの四捨五入で済ませても、問題は無いようですが、このあたりは、ありとあらゆるパターンを調べてはいませんので、限界はあると思います。

これで、ほぼ完全に、期待する結果が得られるようになりました。

  1. Scale_P = 100% でデバッグ実行開始(プライマリディスプレイにて)
  2. Scale_P を 125% に変更
  3. Scale_P = 125% でデバッグ実行開始(プライマリディスプレイにて)
  4. Scale_P を 100% に変更

蛇足 検証時の Visual Studio スクショ集







WinFormsApp1.csproj ファイルは以下のとおり。前述のとおり <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode> をフォームデザイン時はコメントアウト・いったんそれでビルドし、最終的に欲しい実行ファイルをビルドする時は有効にします。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net7.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <UseWindowsForms>true</UseWindowsForms>
    <ImplicitUsings>enable</ImplicitUsings>
    <!--
    -->
    <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
  </PropertyGroup>

</Project>

略歴


トップページ / 計算機関係の雑多なこと / .NET 7 WinForms C# での高DPI/PerMonitorV2 対応(仮)
このサイトについて・著者連絡先