しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

CsWin32 で Win32 API や COM を使ったアプリケーション開発を効率化する

これまで P/Invoke 定義はドキュメントやヘッダーファイルから手書きすることが多かったのですが、最近は必要な Win32 API や COM インターフェースが多くなってきたので CsWin32 を使って自動生成しています。

CsWin32 自体は win32metadata というプロジェクトの成果物となる winmd ファイルから、必要なものだけを Source Generator で生成するツールになっています。

生成してほしい Win32 API と COM インターフェースに関係する構造体と定数も自動生成されるので、依存関係を自分でチェックする必要もないのが最高です。

詳しい説明やセットアップ方法は公式の GitHub リポジトリに任せて、ここからは実際に CsWin32 を使ってアプリケーションを開発する際の注意点と効率化のポイントを書いていきます。

CsWin32 用のプロジェクトを別途作る

手書きで P/Invoke 定義を書いていた時は出来るだけポインターを使わないように書いてきたと思いますが、CsWin32 ではパフォーマンスと効率が重視されているので結構ポインターが出てくることが多いです。

ポインターを含んだコードをビルドするには unsafe を追加する必要があるので、アプリケーションから使う場合には意識したくない部分です。なので以下のように CsWin32 と unsafe なコードはクラスライブラリから切り出してしまうとスッキリします。

プロジェクトの RootNamespaceWindows.Win32 にしておけば追加のコードも同じ名前空間に定義できるので、自動生成される partial クラスにメソッドを追加する際にも便利です。

最大のメリットはメインプロジェクトでビルドエラーが発生しても、CsWin32 と Source Generator はクラスライブラリに閉じているので P/Invoke 周りの定義が巻き込まれずに済むことです。ビルドエラーになると Source Generator で生成されたコードもエラー扱いになるのがストレスフルでした。

PWSTR な引数は Span<char> に置き換える

Win32 API を使っていると頻発するパターンとして、呼び出し側が必要サイズのメモリを確保して、そのポインターを API に渡すことで結果を受け取るという挙動があります。

特に文字列を受け取る場合には StringBuilder を使うことが多かったのですが、最近では非推奨になっているので別の方法を使う必要があります。

ドキュメントにはポインターや char の配列を使う例がありますが、今の時代では Span<T> を使って解決するのがベストです。P/Invoke 定義に直接渡すことは出来ませんが、fixed を使ってポインターを取得できるので CsWin32 との相性が良いです。

例として AssocQueryString に対して Span<T> を使うオーバーロードを追加します。

引数にある pszOut が結果が書き込まれるポインターになるので、これを Span<T> に書き換えたオーバーロードとして以下のような定義を追加します。

public static unsafe HRESULT AssocQueryString(uint flags, ASSOCSTR str, string pszAssoc, string pszExtra, Span<char> pszOut, ref uint pcchOut)
{
    fixed (char* pszOutLocal = pszOut)
    {
        return AssocQueryString(flags, str, pszAssoc, pszExtra, new PWSTR(pszOutLocal), ref pcchOut);
    }
}

やっていることは難しくなく、単純に Span<T> のポインターを固定して渡しているだけです。

定義したメソッドを呼び出すサンプルは以下のようになります。この例では C# 拡張子に関連づいたアプリケーションのパスを取得しています。

// stackalloc で書き込み先のメモリを確保して Span<T> に入れる
Span<char> pszOut = stackalloc char[260];

// 確保したメモリのサイズ
uint pcchOut = 260;

PInvoke.AssocQueryString(ASSOCF.ASSOCF_NOTRUNCATE, ASSOCSTR.ASSOCSTR_EXECUTABLE, ".cs", null, pszOut, ref pcchOut);

// string は \0 があっても無視してサイズ分確保してしまうので \0 で切る
var result = new string(pszOut.TrimEnd('\0'));

最後に文字列化する必要がある分 StringBuilder の時よりパッと見は冗長に見えますが、こちらの方が圧倒的にパフォーマンスが優れています。メモリ確保に stackalloc が使えるので効率的でもあります。

ちなみに PWSTR には AsSpan というメソッドが用意されているので、逆方向の場合は unsafe コードを使うことなく安全に扱えるようになっています。

COM オブジェクトは特定の型にキャストして返す

CsWin32 はデフォルトで COM オブジェクトのマーシャリングが有効化されているので、COM オブジェクトを返す関数はマーシャリング対応のオーバーロードが同時に生成されますが、型が object 固定になってしまうので使い勝手がよくありません。

そこで以下のようなオーバーロードを追加すると、COM インターフェースを指定するだけで IID の解決と、適切な型へのキャスト済みオブジェクトを受け取れるようになります

public static HRESULT SHCreateItemFromParsingName<T>(string pszPath, System.Com.IBindCtx pbc, out T ppv)
{
    var hr = SHCreateItemFromParsingName(pszPath, pbc, typeof(T).GUID, out var o);
    ppv = (T)o;
    return hr;
}

メソッドを呼び出す際に out 引数で型を明示的に指定すれば、キャスト不要でとてもスッキリします。

PInvoke.SHCreateItemFromParsingName("...", null, out IShellItem? shellItem);

殆どのケースでは自動的にマーシャリング用のオーバーロードが追加されますが、たまにマーシャリングされておらずポインタを直接返すメソッドしか生成されないことがあります。

最近だと SHGetPropertyStoreFromParsingName という関数は void**out void* のオーバーロードしか生成されませんでした。そういった場合は以下のようなオーバーロードを追加すると使いやすくなります。

public static unsafe HRESULT SHGetPropertyStoreFromParsingName<T>(string pszPath, System.Com.IBindCtx pbc, GETPROPERTYSTOREFLAGS flags, out T ppv)
{
    var hr = SHGetPropertyStoreFromParsingName(pszPath, pbc, flags, typeof(T).GUID, out var o);
    ppv = (T)Marshal.GetUniqueObjectForIUnknown(new IntPtr(o));
    return hr;
}

返ってきた COM オブジェクトへのポインタを Marshal.GetUniqueObjectForIUnknown を呼び出して RCW で包みます。あとはキャストするだけで欲しいオブジェクトを得ることが出来ます。

preserveSigMethods は指定した方が分かりやすい

CsWin32 のコード生成に関する設定は NativeMethods.json に書くことになっていて、デフォルトでも大体いい感じの設定になっているのですが preserveSigMethods だけは設定した方が扱いやすくなります。

具体的には HRESULT を自動的に COMException に変換するかどうかという設定なのですが、HRESULT のまま判定した方が例外のハンドリングを行うよりコードがスッキリしますし、例外が必要な場合は HRESULTThrowOnFailure メソッドが用意されているので同等の処理は簡単に書けます。

if (PInvoke.SHCreateItemFromParsingName("...", null, out IShellItem? shellItem).Succeeded)
{
    // IShellItem の作成に成功した場合はここにくる

    // RCW の解放もスコープが決まるのでわかりやすい
    Marshal.ReleaseComObject(shellItem);
}

// COMException が必要なら ThrowOnFailure を明示的に呼び出す
PInvoke.SHCreateItemFromParsingName("...", null, out IShellItem? shellItem).ThrowOnFailure();

1 つぐらいの呼び出しならともかく、これが複数の COM オブジェクトが必要な場合は例外ハンドリングがかなりカオスになるので、あえて HRESULT のまま使うのが簡単です。

可能な限りハンドル系は SafeHandle として扱う

Win32 API を使っている限り避けては通れないのが HWNDHBITMAP といったハンドル系です。あらゆる場面で必要になりますが CsWin32 は自動的に SafeHandle を実装したクラスを生成してくれます。

今回はメッセージフックを行うために SetWindowsHookEx を呼び出す例を挙げてみます。

通常なら HHOOK を返しているのを IntPtr に置き換えて P/Invoke 定義を用意するケースですが、CsWin32 では UnhookWindowsHookExSafeHandle というクラスを自動生成してくれるので、以下のようなシンプルなコードでフックの解放まで行えます。

// この場合は GetModuleHandle も SafeHandle を返している
var hook = PInvoke.SetWindowsHookEx(_idHook, HookProc, PInvoke.GetModuleHandle((string)null!), 0);

// Close メソッドが UnhookWindowsHookEx を呼び出してくれる
hook.Close();

ハンドルであれば自動的にオーバーロード含めて生成されるので Bitmap 周りを扱う場合にも便利です。ただし WPF の世界に持ち込む際には DangerousGetHandle を呼び出す必要があります。

ARM64 向けのビルド時に一部の定義が生成されない問題

時々ですが Win32 API には 32bit と 64bit で構造体のサイズが変わるというケースが存在します。

このようなケースに CsWin32 自体は問題なく対応されているのですが、ARM64 向けに関しては以下のような警告が出て、特定の定義が生成されない問題があります。

ちなみに x86 と x64 では問題ないので Issue を作成したところ、MSBuild 側の問題であることが発覚しました。ARM64 向けの場合のみ PlatformTarget が定義されずに正しくコード生成が行われないようです。

いつかは MSBuild 側で対応されるはずですが、それまでは CsWin32 のプロジェクトファイルに以下のような定義を追加することで、欠けた PlatformTarget を補って正しくコード生成出来るようになります。

  <PropertyGroup Condition=" '$(_PlatformWithoutConfigurationInference)' == 'ARM64' ">
    <PlatformTarget Condition=" '$(PlatformTarget)' == '' ">ARM64</PlatformTarget>
  </PropertyGroup>

最後に ARM64 向けにビルドしている人がほぼ居ないことが発覚してしまった感がありますが、CsWin32 を使うと P/Invoke での面倒な定義を自動化かつ整理された名前空間で利用できるようになるのでお勧めです。

Win32 API や COM 自体が膨大すぎるので、生成されたコードが正しく動作しないという問題も頻発するのですが、GitHub に Issue を立てれば解決出来るものも多いので改善に貢献していきたいですね。