4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unityのスタンドアロンプレイヤーのウィンドウハンドルを高い精度かつ自由なタイミングで取得する

Posted at

はじめに

Windowsではウィンドウに対して様々なことを行うためには「ウィンドウハンドル」というウィンドウの識別子が必要になるのですが、UnityはPlayer、Editorの両方にウィンドウハンドルの取得を行う機能を提供していません。

実装について

方針を立てる

今回は、Windows用のスタンドアロンプレイヤーのメインウィンドウのウィンドウハンドルを取得することを目標とします。

  1. WINAPIのEnumWindowsを使用して、現在システムに存在するトップレベルのウィンドウを列挙する
  2. 列挙されたウィンドウを生成したプロセスのIDを取得し、自身(スタンドアロンプレイヤー)のプロセスIDと比較する
  3. スタンドアロンプレイヤーのプロセスが(直接的または間接的に)生成したウィンドウの中から、特定のウィンドウクラスで生成されたウィンドウであるものを抜き出す(このウィンドウがメインウィンドウ)

手順2までを手法とする先駆者(Unity上での自ウィンドウハンドル取得方法5種)が存在しましたが、この時点ではメインウィンドウの候補が複数存在する(UnityのC#から記述できる中で一番早い実行タイミングであるSubsystemRegistrationの時点でも、既に3個のウィンドウが存在する)ため、何らかの追加判定が必要になります。
また、今回行う手法に近い、手順2でスタンドアロンプレイヤーのプロセスが生成したウィンドウに対して、ウィンドウスタイルを使用して判定する手法を用いた先駆者([Unity]アプリのウインドウハンドルを確実に取得する方法)も存在したのですが、ウィンドウが表示されるタイミングはBeforeSplashScreenの直前であるため、ウィンドウが表示される前にウィンドウに対して何かを施したい場合には使うことができません(当然、表示される前のウィンドウにはWS_VISIBLE (0x10000000)フラグが立っていないため)。

そこで今回は、手段2でスタンドアロンプレイヤーのプロセスが生成したウィンドウに対して、ウィンドウのクラス名を使用してメインウィンドウの判定を行います。

なぜクラス名を使うのか

状況によって変化しない値を用いて判定を行うことで、結果の確実性を上げるためです。
他にウィンドウの識別情報として挙げられるものとしては

  • タイトル名(プロジェクトやAPIを使った書き換えなどで変化する)
  • 表示状態(ウィンドウが1個のアプリケーションであれば有効だが、先述の通り表示前のウィンドウを検知できない)
  • 親ウィンドウの有無(単体で動作している場合は有効だが、この記事の手法5のように別のアプリケーションに埋め込むと親が存在することになるため検知できない)

などが考えられますが、このように正常時でもタイミング次第で検知できないものばかりになります。

逆に、ウィンドウのクラスは生成したら基本的には破棄されるまで変わることがないと考えてもよいため、今回はこれを採用しました。

今回使用するAPIをC#から呼び出せるようにする

kernel32.dllのAPI

Kernel32Wrapper.cs
// Windowsの機能を呼び出すコードであるため、Windows以外ではコンパイルされないようにする
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
using System.Runtime.InteropServices;

// kernel32.dllの関数を呼び出せるようにするラッパークラス
internal static class Kernel32Wrapper
{
    // 現在のプロセスIDを取得する
    [DllImport("kernel32.dll", CallingConvention = CallingConvention.Winapi)]
    internal static extern uint GetCurrentProcessId();
}
#endif

user32.dllのAPI

User32Wrapper.cs
// Windowsの機能を呼び出すコードであるため、Windows以外ではコンパイルされないようにする
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
using System.Runtime.InteropServices;
using System.Text;

// user32.dllの関数を呼び出せるようにするラッパークラス
internal static class User32Wrapper
{
    // EnumWindowsのコールバック用のデリゲート型
    // (32ビット符号なし整数値1個をパラメーターとして渡す用に引数を変更した版)
    [UnmanagedFunctionPointer(CallingConvention.Winapi)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal delegate bool UInt32EnumWindowsProc(nint windowHandle, ref uint value);


    // ウィンドウを列挙する
    // (32ビット符号なし整数値1個をパラメーターとして渡す用に引数を変更した版)
    [DllImport("user32.dll", CallingConvention = CallingConvention.Winapi)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static extern bool EnumWindows(UInt32EnumWindowsProc callback, ref uint value);

    // ウィンドウを生成したスレッドとプロセスのIDを取得する
    [DllImport("user32.dll", CallingConvention = CallingConvention.Winapi)]
    internal static extern uint GetWindowThreadProcessId(nint windowHandle, out uint processId);

    // ウィンドウのクラス名を取得する
    [DllImport("user32.dll", EntryPoint = "GetClassNameW", CharSet = CharSet.Unicode, CallingConvention = CallingConvention.Winapi)]
    internal static extern int GetClassName(nint windowHandle,
        [MarshalAs(UnmanagedType.LPWStr)] StringBuilder className, int maxCharCount);
}
#endif

メインウィンドウのウィンドウハンドルを取得する

ウィンドウハンドルを取得するコード
WindowHandleGetter.cs
using AOT;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using UnityEngine;

// Unityのスタンドアロンプレイヤーのウィンドウハンドルを取得するクラス
public static class WindowHandleGetter
{
// 取得処理はWindowsのスタンドアロンプレイヤーでのみ実行されるため、
// Windows以外の環境ではコンパイルされないようにする
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
    // スタンドアロンプレイヤーのウィンドウに割り当てられているクラス名
    private const string UnityWindowClassName = "UnityWndClass";
    // ウィンドウのクラス名取得用のバッファの大きさ
    private const int WindowClassNameBufferCapacity = 16;

    // ウィンドウのクラス名取得用のバッファ
    private static StringBuilder windowClassNameBuffer = null;
#endif

    // 初期化が済んでいるか
    private static bool isInitialized = false;
    // スタンドアロンプレイヤーのメインウィンドウのウィンドウハンドル
    private static nint mainWindowHandle = 0;
    // ウィンドウハンドルの値が有効か
    private static bool isHandleValid = false;
    
    // スタンドアロンプレイヤーのメインウィンドウのウィンドウハンドル
    public static nint MainWindowHandle
    {
        get
        {
            // 必要な場合は初期化処理を実行する
            Initialze();

            // ウィンドウハンドルの値が有効であればウィンドウハンドルを返す
            return isHandleValid ? mainWindowHandle : 0;
        }
    }

    // ウィンドウハンドルの値が有効か
    public static bool IsHandleValid
    {
        get
        {
            // 必要な場合は初期化処理を実行する
            Initialze();

            // ウィンドウハンドルの値が有効かを返す
            return isHandleValid;
        }
    }


    // 必要があれば初期化処理を実行する
    private static void Initialze()
    {
        // 既に初期化されている場合は何もしない
        if (isInitialized) return;

        // 変数を初期化する
        isHandleValid = false;

// 取得処理はWindowsのスタンドアロンプレイヤーでのみ実行されるため、
// Windows以外の環境ではコンパイルされないようにする
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
        // Windowsで実行されている場合
        if (Application.platform == RuntimePlatform.WindowsPlayer)
        {
            // Windowsのスタンドアロンプレイヤーで実行されている場合
            // 自身のプロセスIDを取得する
            uint selfProcessId = Kernel32Wrapper.GetCurrentProcessId();

            // バッファを準備する
            windowClassNameBuffer = new(WindowClassNameBufferCapacity);

            // ウィンドウを列挙してメインウィンドウを探す
            User32Wrapper.EnumWindows(GetWindowHandleCallback, ref selfProcessId);

            // バッファの参照をクリアする
            windowClassNameBuffer = null;
        }
#endif
        
        // 初期化済みフラグを立てる
        isInitialized = true;
    }

// 取得処理はWindowsのスタンドアロンプレイヤーでのみ実行されるため、
// Windows以外の環境ではコンパイルされないようにする
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
    // ウィンドウハンドル取得処理の列挙用のコールバック
    [MonoPInvokeCallback(typeof(User32Wrapper.UInt32EnumWindowsProc))]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static bool GetWindowHandleCallback(nint windowHandle, ref uint selfProcessId)
    {
        if (User32Wrapper.GetWindowThreadProcessId(windowHandle, out var processId) == 0 ||
            selfProcessId != processId)
        {
            // ウィンドウのプロセスIDが自身のプロセスIDと異なる場合は次のウィンドウの判定に進む
            return true;
        }

        if (IsWindowClassUnityStandalonePlayer(windowHandle))
        {
            // ウィンドウがスタンドアロンプレイヤーのメインウィンドウである場合はウィンドウハンドルを保持する
            mainWindowHandle = windowHandle;

            // ウィンドウハンドルの値が有効であるフラグを立てる
            isHandleValid = true;

            // 列挙を終了する
            return false;
        }

        // ウィンドウがスタンドアロンプレイヤーのメインウィンドウではない場合は次のウィンドウの判定に進む
        return true;
    }

    // ウィンドウのクラス名がUnityのスタンドアロンプレイヤーのメインウィンドウのものであるか判定する
    private static bool IsWindowClassUnityStandalonePlayer(nint windowHandle)
    {
        // バッファをクリアする
        windowClassNameBuffer.Clear();

        // ウィンドウのクラス名を取得する
        int charCount = User32Wrapper.GetClassName(
            windowHandle, windowClassNameBuffer, WindowClassNameBufferCapacity - 1);

        // ウィンドウのクラス名を取得できなかった場合は失敗
        if (charCount == 0) return false;

        // バッファからウィンドウのクラス名の文字列を取得する
        var windowClassName = windowClassNameBuffer.ToString();

        // バッファをクリアする
        windowClassNameBuffer.Clear();

        // ウィンドウのクラス名がスタンドアロンプレイヤーのメインウィンドウのクラス名と一致するか判定する
        return windowClassName == UnityWindowClassName;
    }
#endif
}

概要

ウィンドウのクラス名

// スタンドアロンプレイヤーのウィンドウに割り当てられているクラス名
private const string UnityWindowClassName = "UnityWndClass";

以下の画像は、SubsystemRegistration時点でのメインウィンドウの情報の一部です。
メインウィンドウの情報
Handleの部分1EnumWindowsで列挙されたウィンドウハンドル、
Textの部分2がウィンドウのタイトル文字列、
Classの部分がウィンドウのクラス名3
Styleの部分がウィンドウスタイル4
Parentの部分が親ウィンドウのウィンドウハンドル5を示します。
このウィンドウのクラス名を見るに、なんともUnityと関連がありそうです。

また、方針パートでも述べた通り、スタンドアロンプレイヤーのプロセスIDと一致するウィンドウが他にも2個あるためそちらにも目を向けると、
IME
MSCTFIME UI
残りの2個はこのようなウィンドウたちです。クラス名を見るからにIMEに関連していそうです。

スタンドアロンプレイヤーのプロセスIDと一致するウィンドウのそれぞれの関係性は、

クラス名「UnityWndClass」のウィンドウ
 ┗━ ウィンドウ名が「Default IME」で、クラス名が「IME」のウィンドウ
   ┗━ ウィンドウ名とクラス名が「MSCTFIME UI」のウィンドウ

のようになっています。

これらのことから、「UnityWndClass」クラスのウィンドウがスタンドアロンプレイヤーのメインウィンドウである可能性がとても高いと考えられます(結果論にはなりますが、実際にこのウィンドウがスタンドアロンプレイヤーのメインウィンドウです)。

ウィンドウの列挙を行う

// 自身のプロセスIDを取得する
uint selfProcessId = Kernel32Wrapper.GetCurrentProcessId();

// バッファを準備する
windowClassNameBuffer = new(WindowClassNameBufferCapacity);

// ウィンドウを列挙してメインウィンドウを探す
User32Wrapper.EnumWindows(GetWindowHandleCallback, ref selfProcessId);

// バッファの参照をクリアする
windowClassNameBuffer = null;
  • 列挙の前に、自身のプロセスIDを取得します。プロセスIDはプロセスが終了するまでは一意であり続ける値であるため、何度も実行せず、最初に1回だけGetCurrentProcessIdを実行してプロセスIDを取得し、それをパラメーターとしてEnumWindowsに渡します
  • バッファの大きさは、今回は16文字とします
    今回の場合は、"UnityWndClass"という文字列と等しいかを判定できればよいため、13文字以上で切りがいい16を選択しました

列挙されたウィンドウを判定する

// ウィンドウハンドル取得処理の列挙用のコールバック
[MonoPInvokeCallback(typeof(User32Wrapper.UInt32EnumWindowsProc))]
[return: MarshalAs(UnmanagedType.Bool)]
private static bool GetWindowHandleCallback(nint windowHandle, ref uint selfProcessId)
{
    if (User32Wrapper.GetWindowThreadProcessId(windowHandle, out var processId) == 0 ||
        selfProcessId != processId)
    {
        // ウィンドウのプロセスIDが自身のプロセスIDと異なる場合は次のウィンドウの判定に進む
        return true;
    }

    if (IsWindowClassUnityStandalonePlayer(windowHandle))
    {
        // ウィンドウがスタンドアロンプレイヤーのメインウィンドウである場合はウィンドウハンドルを保持する
        mainWindowHandle = windowHandle;

        // ウィンドウハンドルの値が有効であるフラグを立てる
        isHandleValid = true;

        // 列挙を終了する
        return false;
    }

    // ウィンドウがスタンドアロンプレイヤーのメインウィンドウではない場合は次のウィンドウの判定に進む
    return true;
}
  • アンマネージドなコードであるWINAPIにコールバックを渡す際に、IL2CPPでビルドした時は[MonoPInvokeCallback]を指定する必要があります
    IL2CPPでアンマネージドなコードに渡されるコールバックにこの属性を指定しない場合は、コールバックを渡すタイミングで例外が出て動きません
  • GetWindowThreadProcessIdを使用してウィンドウを生成したプロセスのIDを取得します
    なお、戻り値はウィンドウを生成したスレッドのIDを示します
    そのため、処理の成功判定として利用します
  • 先述の通り、自身(スタンドアロンプレイヤー)のプロセスIDとウィンドウを生成したプロセスのIDが一致すれば、それは「スタンドアロンプレイヤーがそのウィンドウを生成した」ということができます
  • もし、クラス名がスタンドアロンプレイヤーのメインウィンドウのものであれば、ウィンドウハンドルを保持しておいて、そこで列挙を終了します

クラス名の判定

// ウィンドウのクラス名がUnityのスタンドアロンプレイヤーのメインウィンドウのものであるか判定する
private static bool IsWindowClassUnityStandalonePlayer(nint windowHandle)
{
    // バッファをクリアする
    windowClassNameBuffer.Clear();

    // ウィンドウのクラス名を取得する
    int charCount = User32Wrapper.GetClassName(
        windowHandle, windowClassNameBuffer, WindowClassNameBufferCapacity - 1);

    // ウィンドウのクラス名を取得できなかった場合は失敗
    if (charCount == 0) return false;

    // バッファからウィンドウのクラス名の文字列を取得する
    var windowClassName = windowClassNameBuffer.ToString();

    // バッファをクリアする
    windowClassNameBuffer.Clear();

    // ウィンドウのクラス名がスタンドアロンプレイヤーのメインウィンドウのクラス名と一致するか判定する
    return windowClassName == UnityWindowClassName;
}
  • GetClassName6にウィンドウハンドルを渡すと、そのウィンドウのクラス名を取得できます
    この関数は、成功すると戻り値として取得できた文字数を返し、失敗すると戻り値として0を返します
  • 今回は、クラス名の先頭15文字(WindowClassNameBufferCapacity(16) - 1)を取得しています
    また、今回の判定には少なくとも14文字は必要7です

おわりに

この結構手間のかかる実装をお手軽に導入、使用できるようにしたパッケージ化しました。MITライセンスなので取り回しはいいかと思います。
実装は概ね今回紹介したものと一致しているため、最速タイミング(SubsystemRegistration)でのウィンドウハンドルの取得にも対応しています。

  1. 実行ごとに変化します

  2. プロジェクト設定によって変化します

  3. ウィンドウの生存中は変化しません

  4. 実行タイミングやウィンドウの状態によって変化します

  5. 単独実行であれば必ず親ウィンドウなし(ハンドル値:0000000000000000)ですが、外部への埋め込みなどで変化する可能性があります

  6. 正確にはウィンドウのクラス名をUTF-16で取得するGetClassNameW関数

  7. "UnityWndClass"は13文字で、ちょうど13文字だけ取得する処理だと"UnityWndClass"で始まるクラス名のウィンドウも対象になってしまうため

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?