C#で他アプリケーションを操作するための基礎知識

こんにちは、大昔はVC++Windowsプログラミングをしていたwakです。先日業務でC#のコードから他のWindowsアプリを強制的にコントロール(メニューをクリックしたり、キー操作を行ったりといった手動操作をエミュレートしてアプリを制御する)して処理を自動化する必要に迫られ、頑張ってそんな感じのコードを書きました。今日はそのための基礎知識とサンプルコードをご紹介します。

もくじ

  • Windowsの仕組み
  • ウィンドウハンドルとメッセージ
  • メッセージの内容
  • 電卓を例にしたサンプル
f:id:nurenezumi:20151125110735j:plain

猫は操作できません

Windowsの仕組み……Windowsはウィンドウでできている

まずWindowsでは、

  • ウィンドウ(の外枠)
  • ボタン
  • テキストボックス
  • ラベル
  • デスクトップ
  • タスクバー

といった要素はすべて「ウィンドウ」です。要するに「ウィンドウ」という基底クラスを継承したオブジェクトが様々な外見や機能を持っているだけだと考えましょう。たとえばこれはWindows標準の電卓ですが、赤枠で囲ったものはすべて「ウィンドウ」です。

f:id:nurenezumi:20151120125142p:plain

パネル、ボタンやラベルまでが皆ウィンドウであることが分かるかと思います。

もちろん全部が全部「ウィンドウ」というわけではありません。タイトルバーやメニューは親のウィンドウの一部なので独立した「ウィンドウ」ではありません。Webページに表示されているボタンやテキストボックスはブラウザが描いている絵でしかありませんから「ウィンドウ」ではありません。シューティングゲームの弾や敵キャラが、(ゲーム内部ではオブジェクトかもしれませんが)「ウィンドウ」ではないのと同じです。

ウィンドウハンドルとメッセージ

さて、すべての「ウィンドウ」にはWindowsから一意のIDが振られます。これは32ビットの符号なし整数で、「ウィンドウハンドル」と呼ばれます。一部の値(さっき試したときの実際の値です)を書いてみました。もちろんこの値は電卓を複数起動したり再起動したりすると毎回変わります。

f:id:nurenezumi:20151120125202p:plain

このウィンドウハンドルは、Windowsアプリケーションが動作するにあたってとても重要な値です。たとえばユーザーが「5」ボタンをクリックしたとしましょう。すると、calc.exe(電卓)はWindowsから以下のような内容のメッセージを受け取ります。*1

  • メッセージのタイプは「マウスの左ボタンが押された(WM_LBUTTONDOWN)」だよ
  • 対象のボタンのウィンドウハンドルは 0x00022012 だよ
  • 一緒に押されていたキー(マウスの右ボタン、キーボードのSHIFTキーなど)はなかったよ
  • クリックされた場所の座標は(8, 11)だよ

メッセージを受け取ったプログラムはこれを解釈し、「Windowsから伝えられたメッセージはWM_LBUTTONDOWN、ウィンドウハンドルは0x00022012だったな。つまり"5"のボタン*2がクリックされたわけだから、それに応じた処理をしよう……」と考えるわけです。普段C#でプログラムを書いている人はこんなことを意識しませんが、ここら辺は .NET Framework がよろしくやって隠蔽してくれています。

メッセージは、作れる!

通常、「マウスがクリックされた」「キーが押された」といったメッセージはユーザーの操作に応じてWindowsがプログラムに対して発行されるものです。しかし、メッセージは自分で他のプログラムへ送信することもできるのです。これを使えば、自分のプログラムから他のアプリケーションの画面を「クリックしたことにする」「キー操作したことにする」といったこともできるようになります。これが冒頭で触れた「他アプリのコントロール」「自動操作」だということになります。

メッセージの内容

メッセージには4つのパラメータが含まれています。他のウィンドウにメッセージを送る関数の一つ、SendMessage関数の宣言をMSDNから引用してみましょう。

LRESULT SendMessage(
  HWND hWnd,      // 送信先ウィンドウのハンドル
  UINT Msg,       // メッセージ
  WPARAM wParam,  // メッセージの最初のパラメータ
  LPARAM lParam   // メッセージの 2 番目のパラメータ
);

これはC++のアンマネージドコードなので、見慣れない型がたくさん並んでいて逃げ出したくなるかもしれません。型については

を参考にして、さらに必要な宣言を加えてC#に書き換えるとこうなります。 *3

[DllImport("user32.dll")]
extern long SendMessage(
  IntPtr hWnd,    // 送信先ウィンドウのハンドル
  uint Msg,       // メッセージ
  uint wParam,    // メッセージの最初のパラメータ
  uint lParam     // メッセージの 2 番目のパラメータ
);

だいぶ親しみが持てる形になりました。4つの引数を順に説明してゆきます。

1. ハンドル

これは最初に説明しました。ハンドルが0x00022012であれば、new IntPtr(0x00022012)のように書けます。既存のウィンドウからウィンドウハンドルを取得する方法については後述します。

2. メッセージ

Windowsには様々なメッセージが定義されています。ここにはそのメッセージを示す整数を渡します。よく使いそうなものを挙げるとこんな感じです。

名前 意味
WM_KEYDOWNキーボードのキーが押された0x0100
WM_KEYUPキーボードのキーが離された0x0101
WM_LBUTTONDOWNマウスのボタンが押された0x0201
WM_LBUTTONUPマウスのボタンが離された0x0202

完全な一覧はAbout Messages and Message Queues (Windows)にあります。マウス・キー操作はWM系を参照すれば良いでしょう。C++であればwinuser.hというヘッダファイルをインクルードすれば一挙に定数がインポートできるのですが、C#にはそのようなものはないので自前で定数を定義して使うことになります(別にハードコードしても構いませんが)。

なお、受け取ったメッセージをどのように処理するかはアプリケーションの自由です。また、メッセージとはいえども実体はただの整数値ですから、アプリケーション単位で好きな値を決めて独自のメッセージを定義することもできます。同時起動した同じアプリ同士、あるいは異なるアプリ同士*4で連携を行う場合などに使われています。詳しくはWM_USERで検索してみてください。

3~4. パラメータ

メッセージの内容をさらに詳細に示すためのパラメータです。「最初の」とか「2番目の」とか曖昧な表現になっているのは、メッセージの種別によって担うべき情報が異なるからです。たとえばWM_LBUTTONDOWNでは、

  • 最初のパラメータ……同時に押された他のキーやボタンを示す
  • 2番目のパラーメータ……クリックされた場所の座標を示す(下位2バイトがX座標、上位2バイトがY座標)

となっています。またWM_KEYDOWNでは、

  • 最初のパラメータ……押されたキーの仮想キーコードを示す
  • 2番目のパラーメータ……オートリピート中であるかどうかなどの補助情報

が格納されます。一部のメッセージでは常に固定値となっているものもあります。こういった情報はメッセージ名で検索すると容易に見つけることができます。

電卓を例にしたサンプル

ここまでの話を具体的に試すためのサンプルとして、起動中の電卓の「5」ボタンを押すコードを書いてみます。こんな手順になります。

  1. 電卓のウィンドウハンドルを検索する(これで親となるウィンドウのウィンドウハンドルが分かる)
  2. ウィンドウの親子構造をたどり(あるいは再帰的に子ウィンドウを探し)目的のボタンを見つける
  3. 見つけたボタンのウィンドウハンドルにWM_LBUTTONDOWNを送る(これでマウスのボタンを押したことになる)
  4. 見つけたボタンのウィンドウハンドルにWM_LBUTTONUPを送る(これでマウスのボタンを離した=クリックが完了したことになる)

目当てのウィンドウを見つけるのはWebでスクレイピングを行うのと似ています。ラベルとクラスだけで特定できれば楽なのですが、そうはいかないケースも多々あります。たとえば電卓の場合、ボタンのラベルは「文字」ではなく「絵」(グラフィック)で描画されていますので、ラベルは全てのボタンでブランクになっています。今回は強引に「全てのボタンを列挙し、その10番目を選ぶ」という方法で行ってみます。

ウィンドウの探し方

WindowsAPIFindWindowEx関数などを使って探すのですが、冗長になるためこの記事の最後に完全なコードを示します。今はあらかじめ分かっていることにします。

サンプルコード

class Program
{
    public const int WM_LBUTTONDOWN = 0x201;
    public const int WM_LBUTTONUP = 0x202;
    public const int MK_LBUTTON = 0x0001;

    [DllImport("user32.dll")]
    public static extern int SendMessage(IntPtr hWnd, uint Msg, uint wParam, uint lParam);

    static void Main(string[] args)
    {
        // 電卓のトップウィンドウのウィンドウハンドル(※見つかることを前提としている)
        var mainWindowHandle = Process.GetProcessesByName("calc")[0].MainWindowHandle;

        // 対象のボタンを探す(これでボタンのハンドルが取得できる)
        var hWnd = FindTargetButton(mainWindowHandle);

        // マウスを押してから放す
        SendMessage(hWnd, WM_LBUTTONDOWN, MK_LBUTTON, 0x000A000A);
        SendMessage(hWnd, WM_LBUTTONUP, 0x00000000, 0x000A000A);
    }

    public static IntPtr FindTargetButton(IntPtr hTopWindow) { /* ... */ }
}

まとめ

というわけで、全体の流れを復習するとこんな感じです。

【事前準備】

  • 対象のアプリを事前に手動操作してどんなメッセージを送るべきかを確認しておく
  • 対象のアプリのウィンドウ構造を確認しておく
  • 対象のアプリの操作対象となるウィンドウをどのように探せば良いかを確認しておく

【プログラム側】

  • 対象のアプリのウィンドウを検索してウィンドウハンドルを取得する
  • 決まった順にメッセージを飛ばしてマウスやキー操作をエミュレートする(必要があれば適宜ウェイトを入れる)

Windowsのベースとなる仕組みを使っているため、非常に煩雑ではありますが、何か特殊な対策を行っていない限り原理的にはどのようなアプリケーションも制御できることになります。使えるものは全部使って良い自動化ライフを送りましょう。それでは。

完全なコード

class Program
{
    public const int WM_LBUTTONDOWN = 0x201;
    public const int WM_LBUTTONUP = 0x202;
    public const int MK_LBUTTON = 0x0001;
    public static int GWL_STYLE = -16;

    [DllImport("user32.dll")]
    public static extern int SendMessage(IntPtr hWnd, uint Msg, uint wParam, uint lParam);
    
    [DllImport("user32.dll")]
    public static extern IntPtr FindWindowEx(IntPtr hWnd, IntPtr hwndChildAfter, string lpszClass, string lpszWindow);
    
    [DllImport("user32")]
    public static extern int GetWindowLong(IntPtr hWnd, int nIndex);
    
    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern int GetWindowTextLength(IntPtr hWnd);

    [DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);


    static void Main(string[] args)
    {
        // 電卓のトップウィンドウのウィンドウハンドル(※見つかることを前提としている)
        var mainWindowHandle = Process.GetProcessesByName("calc")[0].MainWindowHandle;

        // 対象のボタンを探す
        var hWnd = FindTargetButton(GetWindow(mainWindowHandle));

        // マウスを押して放す
        SendMessage(hWnd, WM_LBUTTONDOWN, MK_LBUTTON, 0x000A000A);
        SendMessage(hWnd, WM_LBUTTONUP, 0x00000000, 0x000A000A);
    }

    // 全てのボタンを列挙し、その10番目のボタンのウィンドウハンドルを返す
    public static IntPtr FindTargetButton(Window top)
    {
        var all = GetAllChildWindows(top, new List<Window>());
        return all.Where(x => x.ClassName == "Button").Skip(9).First().hWnd;
    }


    // 指定したウィンドウの全ての子孫ウィンドウを取得し、リストに追加する
    public static List<Window> GetAllChildWindows(Window parent, List<Window> dest)
    {
        dest.Add(parent);
        EnumChildWindows(parent.hWnd).ToList().ForEach(x => GetAllChildWindows(x, dest));
        return dest;
    }

    // 与えた親ウィンドウの直下にある子ウィンドウを列挙する(孫ウィンドウは見つけてくれない)
    public static IEnumerable<Window> EnumChildWindows(IntPtr hParentWindow)
    {
        IntPtr hWnd = IntPtr.Zero;
        while ((hWnd = FindWindowEx(hParentWindow, hWnd, null, null)) != IntPtr.Zero) { yield return GetWindow(hWnd); }
    }

    // ウィンドウハンドルを渡すと、ウィンドウテキスト(ラベルなど)、クラス、スタイルを取得してWindowsクラスに格納して返す
    public static Window GetWindow(IntPtr hWnd)
    {
        int textLen = GetWindowTextLength(hWnd);
        string windowText = null;
        if (0 < textLen)
        {
            //ウィンドウのタイトルを取得する
            StringBuilder windowTextBuffer = new StringBuilder(textLen + 1);
            GetWindowText(hWnd, windowTextBuffer, windowTextBuffer.Capacity);
            windowText = windowTextBuffer.ToString();
        }

        //ウィンドウのクラス名を取得する
        StringBuilder classNameBuffer = new StringBuilder(256);
        GetClassName(hWnd, classNameBuffer, classNameBuffer.Capacity);

        // スタイルを取得する
        int style = GetWindowLong(hWnd, GWL_STYLE);
        return new Window() { hWnd = hWnd, Title = windowText, ClassName = classNameBuffer.ToString(), Style = style };
    }
}

class Window
{
    public string ClassName;
    public string Title;
    public IntPtr hWnd;
    public int Style;
}

*1:この一瞬後、プログラムはさらに「マウスの左ボタンが離されたよ」というメッセージも受け取ることになりますが、話が面倒になるので省略します

*2:どのウィンドウハンドルがどのボタンを示すかの対応表はプログラムが自分で覚えておく必要があります

*3:最後のlParamは本当はint型になるはずですが、パラメーターを作るのが面倒なのでuint型にしています。どのみちビット列の解釈の違いでしかないので問題にはなりません

*4:つまりプロセスが異なるのでメモリも共有できない状態