砂漠の旅人(たびと)

UNIX / MS-DOS 時代から電脳砂漠を旅しています

【Win32API】.NET 6 C# でマルチディスプレイの解像度を変更する

こんにちは、たびとです。

前回に引き続き、今回も Win32 API を使ったプログラムを作ります。 また、.NET Framework ではなく、.NET 6 を使ったソースコードを作っていきます。

前回同様にコンソールアプリを使って作ります。 マルチディスプレイの判定には、WinForms の System.Windows.Forms.Screen.AllScreens を利用します。 ただし、普通にやってもアセンブリを登録できないので、ひと手間が必要になります。

この記事の対象者

  • ディスプレイの解像度を変更するアプリを作ってみたい方
  • Win32 API を用いて Windows を制御することに興味のある方
  • .NET 6 で Win32 API アプリを開発してみたい方

Win32 API .NET 6 で実装する

Win32 API を使うことだけを考えると、.NET Framework を選択した方がいいと思います。 しかし、プロジェクトとして複数のアプリがあり、他のアプリが .NET 6(.NET Core) を使っている場合、 共通ライブラリを作ったりするため、.NET 6 への統一化が進められることでしょう。 そうした場合に備えて、.NET 6 での実装を知っておくのも良いと考えます。

ディスプレイ解像度の Win32API

ディスプレイ解像度の変更を実装するには、Win32 APIEnumDisplayDevicesChangeDisplaySettingsEx を使います。 詳細は、以下を参照してください。

learn.microsoft.com

learn.microsoft.com

この Win32 APIC# で実装するには、DllImport 属性を使って定義する必要があります。 また、Win32 API に指定する引数や構造体も合わせて定義していきます。

コンソールアプリで WinForms を使えるようにする

適当にコンソールアプリを作って、依存関係を見ると下記のように Microsoft.NETCore.App のみが追加されています。

コンソールアプリの依存関係

このままでは、WinForms を使えないため、プロジェクトをアンロードします。

プロジェクトのアンロード

アンロードすると、プロジェクトファイルが編集できるようになります。

プロジェクトのアンロード済み

WinForms が使えるようにプロジェクトファイルを編集します。

プロジェクトファイルの編集

コピー用にテキストも掲載しておきます。

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
      <TargetFramework>net6.0-windows</TargetFramework>
      <ImplicitUsings>enable</ImplicitUsings>
      <UseWindowsForms>true</UseWindowsForms>
      <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

編集が終わったら、プロジェクトを再度読み込みます。

プロジェクトの再読み込み

Microsoft.WindowsDesktop.App.WindowsForms が追加されて、WinFoms が利用できるようになりました。

WinFoms が追加された

ソースコード

Visual Studio 2022 の新規プロジェクトから、コンソールアプリ(Linux, macOS がある方)を選択し、適当な名前を付け、 .NET 6 (長期的なサポート) を選択します。上述のように、WinFoms が利用できるようにプロジェクトを編集後、Program.cs に以下のソースコードをコピペして完了です。

using System.Runtime.InteropServices;

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int EnumDisplayDevices(
    [MarshalAs(UnmanagedType.LPWStr), In] string? lpDevice,
    UInt32 iDevNum,
    ref DISPLAY_DEVICE lpDisplayDevice,
    UInt32 dwFlags);

[DllImport("user32.dll", CharSet = CharSet.Unicode)]
static extern int ChangeDisplaySettingsEx(
    [MarshalAs(UnmanagedType.LPWStr), In] string lpszDevicename,
    ref DEVMODE lpDevMode,
    IntPtr hwnd,
    UInt32 dwflags,
    IntPtr lParam);

// dmFields
const int DM_PELSWIDTH = 0x080000;          // dmPelsWidth(解像度:横)を設定
const int DM_PELSHEIGHT = 0x100000;         // dmPelsHeight(解像度:縦)を設定

// dwflags
const int CDS_UPDATEREGISTRY = 0x00000001;  // レジストリ変更
const int CDS_TEST = 0x00000002;            // テストモード
const int CDS_FULLSCREEN = 0x00000004;      // フルスクリーン

// Windows(OS)判定
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
    Console.Error.WriteLine("Windows で実行してください。");
    return;
}

// 各スクリーン情報の格納
var screens = Screen.AllScreens.Select((n, index) => new { index, n }).ToDictionary(p => p.index, p => p.n);
foreach (var kv in screens)
{
    Console.WriteLine($"Display:{kv.Key} - {kv.Value.Bounds}");
}

var displayNumber = 0U;         // ディスプレイ番号(0開始)
var displayWidth = 3840U;       // ディスプレイ幅
var displayHeight = 2160U;      // ディスプレイ高さ

if (!screens.ContainsKey((int)displayNumber))
{
    Console.Error.WriteLine("ディスプレイ番号が見つかりません。");
    return;
}

// ディスプレイ情報の取得
var pDisplayDevice = new DISPLAY_DEVICE();
pDisplayDevice.cbSize = (uint)Marshal.SizeOf(pDisplayDevice);
if (0 == EnumDisplayDevices(null, displayNumber, ref pDisplayDevice, 0))
{
    Console.Error.WriteLine("ディスプレイ情報の取得に失敗しました。");
    return;
}

// 指定された解像度に変更できるかを確認する
var name = pDisplayDevice.DeviceName;
var pDevMode = new DEVMODE();
pDevMode.dmSize = (UInt16)Marshal.SizeOf(pDevMode);
pDevMode.dmFields = DM_PELSWIDTH | DM_PELSHEIGHT;
pDevMode.dmPelsWidth = displayWidth;
pDevMode.dmPelsHeight = displayHeight;
if (0 != ChangeDisplaySettingsEx(name, ref pDevMode, IntPtr.Zero, CDS_TEST, IntPtr.Zero))
{
    Console.Error.WriteLine($"ディスプレイ {pDisplayDevice.DeviceName} は情指定のサイズに変更できません。");
    return;
}

// 指定された解像度に変更する
//Change
if (0 != ChangeDisplaySettingsEx(pDisplayDevice.DeviceName, ref pDevMode, IntPtr.Zero, CDS_UPDATEREGISTRY | CDS_FULLSCREEN, IntPtr.Zero))
{
    Console.Error.WriteLine($"ディスプレイ {pDisplayDevice.DeviceName} のサイズ変更に失敗しました。");
    return;
}
Console.WriteLine($"ディスプレイ {pDisplayDevice.DeviceName} の解像度を({displayWidth},{displayHeight})に変更しました。");

// DISPLAY_DEVICE 構造体
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct DISPLAY_DEVICE
{
    public UInt32 cbSize;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
    public string DeviceName;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
    public string DeviceString;
    public UInt32 StateFlags;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
    public string DeviceID;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 128)]
    public string DeviceKey;
}

// DEVMODE 構造体
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct DEVMODE
{
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
    public string dmDeviceName;
    public UInt16 dmSpecVersion;
    public UInt16 dmDriverVersion;
    public UInt16 dmSize;
    public UInt16 dmDriverExtra;
    public UInt32 dmFields;
    public UInt16 dmOrientation;
    public UInt16 dmPaperSize;
    public UInt16 dmPaperLength;
    public UInt16 dmPaperWidth;
    public UInt16 dmScale;
    public UInt16 dmCopies;
    public UInt16 dmDefaultSource;
    public UInt16 dmPrintQuality;
    public UInt16 dmColor;
    public UInt16 dmDuplex;
    public UInt16 dmYResolution;
    public UInt16 dmTTOption;
    public UInt16 dmCollate;
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
    public string dmFormName;
    public UInt16 dmUnusedPadding;
    public UInt16 dmBitsPerPel;
    public UInt32 dmPelsWidth;
    public UInt32 dmPelsHeight;
    public UInt32 dmDisplayFlags;
    public UInt32 dmDisplayFrequency;
}

ポイント

特に難しい箇所はないと思います。 ディスプレイの情報を Dictionary に変換するラムダ式は、 よくある使い方なので初見の方は覚えておくといいと思います。

  1. すべてのディスプレイ情報を出力します。
  2. ディスプレイ 0 (0開始) の情報が取得してあるのかを確認します。
  3. ChangeDisplaySettingsEx で変更可能なサイズなのかを確認します。
  4. ChangeDisplaySettingsEx で指定のサイズに変更します。

WSL2 Ubuntu で実行するとどうなるのか

WSL2 Ubuntuソースコードをコピーしてビルドすると、ビルドエラーが発生しました。 やはり、WinFoms があるとダメなようです。

$ dotnet build
Microsoft (R) Build Engine version 17.0.1+b177f8fa7 for .NET
Copyright (C) Microsoft Corporation. All rights reserved.

  Determining projects to restore...
  All projects are up-to-date for restore.
/usr/lib/dotnet/sdk/6.0.113/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.targets(1191,3): error MSB4019: The imported project "/usr/lib/dotnet/sdk/6.0.113/Sdks/Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.NET.Sdk.WindowsDesktop.targets" was not found. Confirm that the expression in the Import declaration ";/usr/lib/dotnet/sdk/6.0.113/Sdks/Microsoft.NET.Sdk/targets/../../Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.NET.Sdk.WindowsDesktop.targets" is correct, and that the file exists on disk. [/home/tabito/devcpp/ScreenSize/ScreenSize/ScreenSize.csproj]

Build FAILED.

/usr/lib/dotnet/sdk/6.0.113/Sdks/Microsoft.NET.Sdk/targets/Microsoft.NET.Sdk.targets(1191,3): error MSB4019: The imported project "/usr/lib/dotnet/sdk/6.0.113/Sdks/Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.NET.Sdk.WindowsDesktop.targets" was not found. Confirm that the expression in the Import declaration ";/usr/lib/dotnet/sdk/6.0.113/Sdks/Microsoft.NET.Sdk/targets/../../Microsoft.NET.Sdk.WindowsDesktop/targets/Microsoft.NET.Sdk.WindowsDesktop.targets" is correct, and that the file exists on disk. [/home/tabito/devcpp/ScreenSize/ScreenSize/ScreenSize.csproj]
    0 Warning(s)
    1 Error(s)

Time Elapsed 00:00:00.65

まとめ

今回も Win32 API + .NET 6 の簡単なアプリを実装してみました。 以前、同様のプログラムを ANSI 版で実装していたことはあったのですが、 Unicode 版で作ってみると正常に動作させるまでに意外と苦戦してしましました。

また、今回のように WinForms を組み込むと WSL2 Ubuntu ではビルドに失敗することが理解できました。 そのうち、GUI アプリが Linux 上でも動作するようになると面白いですね。

では、皆さん、よい旅を。