オスロアドベント2日目。3日目も私です。(何個書くんだろ…?)
はじめに
PCでアプリを立ち上げた時の初期設定とかルーチンワークとかめんどくさくないですか?私の場合は特殊な環境にある恋声の初期設定がめんどくさかったです。そこでそれを自動化 + α したのでその際どうやったかを書いていきます。いわゆるRPAですね。(新しいアプリではこの方法を使えないものがあるので注意)
その結果がこれです
事前準備
私は、GUIも作りたかったのでC#を使いました。C++でも私の紹介する方法は使えるので参考にしてください。また、アプリケーションを操作するために、アプリがどういう構造をしているか調べるツールを導入する必要があります。以下が開発に使ったものです。
- Visual Studio
- WinSpector(ダウンロードページに飛びます)
自動化
実際の例として恋声を例としてやっていきます。
操作する要素を知る
まず、ボタンをクリックするなら押すボタンを特定しなければなりません。ここで、 WinSpector を使って特定します。
WinSpector を開き、Window をクリックすると現在開いているウィンドウ(とその要素)の一覧が表示されます。
その中から目的のものを見つけ出します。基本的に、階層、要素の名前、クラス名で判断します。日本語は文字化けするので文字数を手掛かりに見つけます。目的のものが見つかったら、クラス名とタイトルを記録しておきましょう。C#側から探すときの手掛かりとなります。名前が無かったらめんどくさいですが、名前がないことを覚えておきましょう。
もし、文字化けしてわからなかったら、以下のように枠で囲ったところから目的の要素までドラッグ&ドロップをしてやると、丸で囲んだところみたいに薄い灰色になるので使ってみるといいです。
C#で要素を取得
それでは、プログラム側から要素を取得していきます。いったん目的のトップウィンドウからすべての子孫要素を取得して、そこから使いまわすという方針でいきます。ここで使うWin32API関数は以下の5つです。、ここから要素のことをウィンドウとか書いたりしますが気にしないでください。
using System.Runtime.InteropServices;
/// <summary>
/// 指定したクラス名、タイトルを持つ要素のハンドラを取得
/// </summary>
/// <param name="lpClassName">指定するクラス名(Winspectorで表示されている)</param>
/// <param name="lpWindowName">指定するタイトル(Winspectorで表示されている)</param>
/// <returns>指定した要素のハンドル。指定したものが無ければ0が戻る</returns>
[DllImport("user32.dll")]
private static extern IntPtr FindWindow(string lpClassName, string lpWindowName);
/// <summary>
/// 指定した子要素を取得。ひとつづつしか取れないので、第二引数でどの子要素を取るか指定
/// </summary>
/// <param name="hWnd">親要素のウィンドウハンドラ</param>
/// <param name="hwndChildAfter">このハンドラの次の子要素を取得</param>
/// <param name="lpszClass">クラス名を指定。nullで全て可。</param>
/// <param name="lpszWindow">タイトルを指定。nullで全て可。</param>
/// <returns>指定した子要素のハンドル</returns>
[DllImport("user32.dll")]
private static extern IntPtr FindWindowEx(IntPtr hWnd, IntPtr hwndChildAfter, string lpszClass, string lpszWindow);
/// <summary>
/// 指定したハンドルのクラス名を取得
/// </summary>
/// <param name="hWnd">指定する要素のハンドラ</param>
/// <param name="lpClassName">ここにクラス名が返ってくる</param>
/// <param name="nMaxCount">文字数の制限</param>
/// <returns>返った文字数</returns>
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
/// <summary>
/// 指定したハンドルのタイトル名の長さを取得
/// </summary>
/// <param name="hWnd">指定する要素のハンドラ</param>
/// <returns>タイトルの文字数</returns>
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern int GetWindowTextLength(IntPtr hWnd);
/// <summary>
/// 指定したハンドルのタイトルを取得
/// </summary>
/// <param name="hWnd">指定する要素のハンドラ</param>
/// <param name="lpString">ここにタイトルが返ってくる</param>
/// <param name="nMaxCount">文字数制限</param>
/// <returns>返った文字数</returns>
[DllImport("user32.dll", CharSet = CharSet.Auto, SetLastError = true)]
private static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
ここで出てくる[DllImport("user32.dll")]
というのは Win32API の関数を使いますよといった合図みたいなものです。
操作するのに使いそうな情報を集めた以下のような構造体かクラスがあると便利です。
class Window
{
public string ClassName;
public string Title;
public IntPtr hWnd;
}
それでは以上のことを組み合わせて、指定の要素と、その子要素すべてを列挙したリストを返す関数を作ります。
using System.Linq;
/// <summary>
/// 指定の要素と、その子要素すべてを列挙したリストを返す
/// </summary>
/// <param name="parent">指定する要素</param>
/// <param name="dest">元々あるリスト</param>
/// <returns>parentとその子要素をdestに追加したリスト</returns>
public static List<Window> GetAllChildWindows(Window parent, List<Window> dest)
{
dest.Add(parent);
EnumChildWindows(parent.hWnd).ToList().ForEach(x => GetAllChildWindows(x, dest));
return dest;
}
private static IEnumerable<Window> EnumChildWindows(IntPtr hParentWindow)
{
IntPtr hWnd = IntPtr.Zero;
while ((hWnd = FindWindowEx(hParentWindow, hWnd, null, null)) != IntPtr.Zero) { yield return GetWindow(hWnd); }
}
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);
return new Window() { hWnd = hWnd, Title = windowText, ClassName = classNameBuffer.ToString() };
}
こうしてやると、以下のようにして先ほどのスクショにあったボタンの要素は取得できます。
using System.Threading;
using System.Linq;
IntPtr koigoe = Process.GetProcessesByName("koigoe")[0].MainWindowHandle;
List<Window> all = GetAllChildWindow(koigoe, new List<Window>());
Window button = all.Where(x.ClassName == "Button" && x.Title == "OPEN");
取得した要素に対して操作する
操作する要素を取得できたので、今度はそれを操作していきます。やばそうに聞こえますがそこまでやばくありません。まあ、私の場合これで恋声をバグらせてしまって何回か設定をぶっ飛ばしてしまいましたが。
要素への操作は以下の Win32API 関数で全て行えます。
using System.Runtime.InteropServices;
/// <summary>
/// プロセス間通信。メッセージを送る。
/// </summary>
/// <param name="hWnd">送り先の要素のハンドラ</param>
/// <param name="Msg">メッセージの種類</param>
/// <param name="wParam">メッセージの中身1</param>
/// <param name="lParam">メッセージの中身2</param>
/// <returns>結果</returns>
[DllImport("user32.dll")]
private static extern int SendMessage(IntPtr hWnd, uint Msg, uint wParam, uint lParam);
この関数に適切な引数を与えて使えば、全て操作を行うことができます。以下の表が私が実際に使った組み合わせです。
内容 | Msg | wParam | lParam |
---|---|---|---|
マウスの左を押す | 0x0201 | 0x00000001 | 0x000A000A |
マウスの左を離す | 0x0202 | 0x00000000 | 0x000A000A |
コンボボックスを指定の インデックスに設定 |
0x014E | 指定の インデックス |
0x00000000 |
参考記事のところに一覧がのったページがあります。
高負荷の罠
高負荷時に、ウィンドウを開く・閉じる等やや重い動作があることをすると、その後のSendMessage()
がうまく動作しないことがあります。このせいで成果物がかなり不安定になりました。(今は解消しています)その対処法を書きます。
安定化のすすめ
問題は、ウィンドウを開閉するときにラグが生じるというとこです。なので、ウィンドウが安定するまで待てば安定するはず!→安定しました。
恋声は、2種類でウィンドウを制御していたので Win32API を2つ使います。
using System.Runtime.InteropServices;
/// <summary>
/// ハンドラがさすウィンドウが存在するか
/// </summary>
/// <param name="hWnd">確かめるハンドラ</param>
/// <returns>存在するならtrue</returns>
[DllImport("user32.dll")]
public static extern bool IsWindow(IntPtr hWnd);
/// <summary>
/// 指定したウィンドウの指定した属性を調べる
/// </summary>
/// <param name="hWnd">ウィンドウのハンドラ</param>
/// <param name="nIndex">属性指定</param>
/// <returns>結果</returns>
[DllImport("user32.dll")]
public static extern uint GetWindowLong(IntPtr hWnd, int nIndex);
恋声では、ウィンドウを生成・破棄するタイプと、 style の visible を制御するタイプの二種類がありました。前者は大丈夫だと思いますが、後者のやり方だけ書きます。
using System.Runtime.InteropServices;
using System;
if(GetWindowLong(some_hWnd, -16) % 0x20000000 / 0x10000000 == 1){
Console.WriteLine("visibleだよ");
}
else{
Console.WriteLine("visibleじゃないよ"); //ifの計算の結果は0となっている。
}
おわりに
今回紹介したものは古いし、簡単にできるRPAツールが最近出てきているので枯れた技術かなぁと思いつつも複雑なことをやったり、他のことに組み合わせたりするのに使えるのかなぁと思ったりしてます。ではよい自動化ライフを。