WindowsのGUIで出来たプログラムを評価していて、頻度の低い問題にぶち当たったとします。例えば下記の処理を100回繰り返すと1回ぐらい例外で落ちるんですーみたいなの。
- EXECUTEボタンをクリック
- 処理
- 終了するとENDボタンをクリック
これをさすがに手でやる訳にもいかないので自動的にWindows様にやって頂けると助かります。UI Automationを使うとそれが出来るらしいので、調べて実装してみました。
これ系はざっとググると今(2020年2月)と事情が違う情報もあったりします。なので、今どうなのという参考にちょっとでもなってくれれば幸いです。
UI Automationって
以下、公式から引用です。
>UI オートメーション は、デスクトップ上のほとんどの ユーザー インターフェイス (UI) 要素へのプログラムによるアクセスを提供し、スクリーン リーダーなどの補助技術製品が UI に関する情報をエンド ユーザーに提供したり、標準入力方式以外の方法で UI を操作したりできるようにします。 また、UI オートメーション は、自動テスト スクリプトが UIと対話できるようにします。
例えばボタンをクリックしたりとか、メニューの位置が取得出来ます。これらの機能を使うことで、諸々の操作を自動的にやれるという事なようです。
今回の自動実行シナリオ
実際に作って試した方が早かろうという事で、以下のシナリオでの自動実行を行ってみました。
- 電卓を起動します
- 123456 ÷ 5 と計算させます(プログラムでキーを叩いて計算させます)
- その結果をコピーします(該当パーツへのフォーカスとキー入力)
- メモ帳を起動します
- 結果をペーストします(該当パーツへのフォーカスとキー入力)
- 更にメニューからバージョンを表示させます(マウスクリックによる動作とキー入力)
- 「notepadの内容を確認して、enterして下さい」と表示
- enterが入力されたら、電卓アプリを終了させます
開発・実行環境について
以下の環境でしか確認はしていません。。
- Windows10 Professional(64bit)
- VisualStdio2019
- .NET Framework 4.7.2
電卓アプリについて
あとこないだ知ったのですが、Windows10をクリーンインストールすると電卓が標準で入って来ない事があるみたいです。その場合はストアから入手出来ます。以下
今回はこれを使っています。calcで起動出来る所は従来と同じですが、内部仕様は結構変わっているみたいです。なので、古いUI Automationのサンプルだとそのままでは動かなかったりします。
自動実行に関するコードはこんな感じ
上記のシナリオで必要とされる機能を関数化し。それを集めてクラス化したコードを以下に。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Automation;
using System.Windows.Forms;
namespace ConsoleApp1
{
// UI Automation関連の処理で使う関数を集めてみました
// 個別に抜き出して使うことも鑑み、各関数の独立を意図的に高める記述をしています。
public class UIAutomationLib
{
readonly string ModuleName = "UIAutomationLib";
// UI automation系以外に、Win32APIも使いますのでその宣言。
[DllImport("USER32.dll", CallingConvention = CallingConvention.StdCall)]
static extern void SetCursorPos(int X, int Y);
[DllImport("USER32.dll", CallingConvention = CallingConvention.StdCall)]
static extern void mouse_event(int dwFlags, int dx, int dy, int cButtons, int dwExtraInfo);
// マウスイベント
// 定義は以下に
// https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-mouse_event
//
private const int MOUSEEVENTF_LEFTDOWN = 0x2;
private const int MOUSEEVENTF_LEFTUP = 0x4;
//指定したタイトルの文字列が含まれているプロセスを取得
//一個目を戻すだけなので、複数対応はしていません。
public Process UpdateTargetProcess(string title)
{
Process process = null;
foreach (Process p in Process.GetProcesses())
{
if (p.MainWindowTitle.Contains(title))
{
process = p;
break;
}
}
if (process == null)
{
MessageBox.Show(title + "のプロセスが見つかりません。", ModuleName);
}
return process;
}
//指定されたプロセスのMainFramに関するAutomationElementを取得
public AutomationElement GetMainFrameElement(Process p)
{
return AutomationElement.FromHandle(p.MainWindowHandle);
}
//指定された名前のButtonをクリックします
//(例外対策はしていませんので注意)
public void PushButtonByName(AutomationElement element, string name)
{
InvokePattern button = FindElementsByName(element, name).First()
.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;
button.Invoke();
}
//指定されたAutomationIdのButtonをクリックします
//(例外対策はしていませんので注意)
public void PushButtonById(AutomationElement element, string AutomationId)
{
InvokePattern button = FindElementById(element, AutomationId)
.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern;
button.Invoke();
}
//指定されたAutomationIdのパーツをクリックします
//(例外対策はしていませんので注意。clockableじゃないパーツ叩くと多分落ちるw)
public void ClickElement(AutomationElement element, string AutomationId)
{
AutomationElement target = FindElementById(element, AutomationId);
System.Windows.Point p = target.GetClickablePoint();
SetCursorPos((int)p.X, (int)p.Y);
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
}
//指定されたAutomationElementにキーボード叩いた体で文字列を送り込みます
//(対象はTextBoxなどを想定)
//
//focusはキー叩く前に該当パーツにマウスを移動するかどうか
// 制御コードなどは以下を参考に
//
// https://docs.microsoft.com/ja-jp/dotnet/api/system.windows.forms.sendkeys?view=netframework-4.8
//
public void Keyin(bool focus, AutomationElement element, string text)
{
if (focus)
{
element.SetFocus();
}
Thread.Sleep(200);
SendKeys.SendWait(text);
Thread.Sleep(200);
}
// 指定されたautomationIdに一致するAutomationElementを取得
public AutomationElement FindElementById(AutomationElement rootElement, string automationId)
{
return rootElement.FindFirst(
TreeScope.Element | TreeScope.Descendants,
new PropertyCondition(AutomationElement.AutomationIdProperty, automationId));
}
// 指定された名前に一致するAutomationElement達をIEnumerableで戻します、
public IEnumerable<AutomationElement> FindElementsByName(AutomationElement rootElement, string name)
{
return rootElement.FindAll(
TreeScope.Element | TreeScope.Descendants,
new PropertyCondition(AutomationElement.NameProperty, name))
.Cast<AutomationElement>();
}
}
}
コードの簡単な説明
- 関数一覧
関数名 | 説明 |
---|---|
UpdateTargetProcess | 指定したタイトルの文字列が含まれているプロセスクラスを取得 |
GetMainFrameElement | 指定されたプロセスクラスのMainFramに関するAutomationElementを取得 |
PushButtonByName | 指定された名前のButtonをクリックします |
PushButtonById | 指定されたAutomationIdのButtonをクリックします |
ClickElement | 指定されたAutomationIdのパーツを左クリックします |
Keyin | 指定されたAutomationElementにキー入力で文字列を送り込みます |
FindElementById | 指定されたautomationIdに一致するAutomationElementを取得 |
FindElementsByName | 指定された名前に一致するAutomationElement達をIEnumerableで取得 |
私がC#に関しては素人から毛を抜いたような人なので、そんなに難しいコードが理解できるハズもないため、MSDNのAPI仕様を見つつ上記のコードを見れば、まぁ何となくは分かるのではないかと思いますが、いくつか補足します。
- AutomationElementというのは各リソースに対応するUI Automationに関する要素です(Element直訳なのかな)。例えばフレームとかボタンとかメニューとかです。それぞれにAutomationElementが割り振られていて、制御する際にそれを使うという理解で多分大丈夫な気がします…
- PushButtonByXXX系ですが、ボタンに関するAutomationElementは関数内で取得しますので、親フレームのAutomationElementを与えれば良いです。電卓みたいに簡単な構造であればMainFramに関するAutomationElementで良いです。
- ボタンをクリックする場合はボタン上のテキスト(電卓なら1とか2とか…)で指定する方法と、そのボタンのAutomationId(AutomationElement毎に割り振られるユニークな文字列)とを用意しました。AutomationIdの取得方法は後述します、
- ClickElementは指定されたAutomationIdの場所を割り出してフォーカスを当ててクリックします。Clickableなリソースでないとエラーになります(すみません対策してません)ので注意です。
- Keyinは指定された文字列をキー入力します。CTRL系とかも入力可能です。キーインの前にそのリソースにフォーカスを当てるかどうかは選択出来ます(既にフォーカスが当たっている場合はfalseにする運用イメージです)
- Find系はAutomationElementからAutomationElementを取る時などに使う事を想定しています。
このコードを使う場合に必要な設定
上記のコードを使う場合にはVisualStdioの「参照」→「参照の追加」→「アセンブリ」で以下を追加します。
- System.Windows.Forms
- UIAutomationClient
- UIAutomationTypes
- WindowsBase(CUIの場合にこれの追加が必要)
AutomationIdの取得方法
UI Automationの機構を利用して、何かを制御する場合にAutomationIdを指定する方が楽なケースもあります。例えば電卓の「÷」は名前を ÷ にしてもダメです。名前で行く場合には 除算 としないとダメでした。こうなるともうAutomationIdを指定した方が楽だと思います。
で、これらの情報をどう収集するかというと、 Automation Spyというツールを使って調べるのがどうも定番みたいです、下記にURLを起きます。私のChromeだとここ、危険サイト扱いになってますね…
とはいえこのツール、今は開発が終わっていようです。なのでAutomationId周辺情報を取得するツールを作ってみました。GitHubにて公開しています。使い方などはGitHubの説明を参照下さい、このツールで制御対象のAutomationId等は取得できます、電卓の÷が除算だというのも私はこのツールで調べました。
- 電卓の「÷」を調べた例。Nameが表示と違うんですよね…
自動実行のコードを記述
それでは上記のクラスを用いて、前述したシナリオに沿って動作するDOSアプリのソースコードを示します。以下です。
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Automation;
namespace ConsoleApp1
{
class Program
{
static void Main(string[] args)
{
//
// 自動実行のコードはこんな風にも書けます。
//
UIAutomationLib ui = new UIAutomationLib();
// 電卓を起動します
Process calc = Process.Start(@"calc");
// 起動待ち
Thread.Sleep(2000);
// 電卓のMainFRameのAutomationElementを取得
calc = ui.UpdateTargetProcess("電卓"); // 更新
AutomationElement calcElement = ui.GetMainFrameElement(calc);
// 電卓操作
ui.PushButtonById(calcElement, "clearButton");
ui.PushButtonByName(calcElement, "1");
ui.PushButtonByName(calcElement, "2");
ui.PushButtonByName(calcElement, "3");
ui.PushButtonByName(calcElement, "4");
ui.PushButtonByName(calcElement, "5");
ui.PushButtonByName(calcElement, "6");
ui.PushButtonById(calcElement, "divideButton");
ui.PushButtonByName(calcElement, "5");
ui.PushButtonById(calcElement, "equalButton");
// 結果のテキストを取り出し、CTRL-Cでクリップボードにコピーします。
AutomationElement ResultElement = ui.FindElementById(calcElement, "CalculatorResults");
ui.Keyin(true, ResultElement, "^c"); // ^ = CTRL
// notepadを起動させます。
Process notepad = Process.Start(@"notepad");
// 起動待ち
Thread.Sleep(2000);
// 電卓のMainFRameのAutomationElementを取得
notepad = ui.UpdateTargetProcess("メモ帳"); // 更新
AutomationElement notepadElement = ui.GetMainFrameElement(notepad);
// で、ペーストします。
ui.Keyin(true, notepadElement, "^v"); // ^ = CTRL
// さらにメニューをクリック操作してバージョンを出します。
string notepadHelpMenuId = "Item 5"; // 「メニュー」のAutomationId
ui.ClickElement(notepadElement, notepadHelpMenuId);
ui.Keyin(false, notepadElement, "a");
// 確認のメッセージです。
Console.WriteLine("notepadの内容を確認して、<enter>して下さい(電卓は消しますがnotepadhaは残します)");
Console.ReadKey();
// 電卓プロセスを終了させます
calc.CloseMainWindow();
}
}
}
一直線なシナリオになりますので、コメントとその下の手続きを見てもらえればおおよそは理解できるのではないかと思います。
VisualStdioを使って、上記2つのソースコードを入れ、前述した参照の設定を行うことで実際に確認も出来るかと思います。その際、電卓はプログラムで落とすので放置しておいて下さい(<enter>する前に電卓手動で落とすとプログラムが正常に終わりません)
1点補足しますと、今の電卓だとUpdateTargetProcess関数の処理、つまり制御を行う前に現状のプロセスを再度取得する必要があるです。私の環境でここをコメントアウトすると死にます。少し古いWeb情報ですとこれが不要なのですが、今の電卓アプリには必要みたいです。
参考
これらのコードを組んだり動かしたりするのに、以下のサイトを参考にさせて頂きました。各サイトの皆様ありがとうございました。
- https://qiita.com/nob0303/items/f92bbc5d9be77b9f703f
- https://qiita.com/harmegiddo/items/afb8ffb65156d3e9fd84
- https://tercel-tech.hatenablog.com/entry/2015/04/29/181723
- https://gist.github.com/hein946/0d0cc6abc85f16a50b5c7aa19a2d107a
- http://fragrammer.hatenablog.com/entry/2016/05/14/221354
- https://social.msdn.microsoft.com/Forums/vstudio/en-US/c3172080-9b78-4857-b223-c2fa3cf74bcf/console-app-needs-systemwindowspoint-class?forum=csharpgeneral
最後に
Windowsで自動実行を行う場合の定番は電卓になるのですが(笑)、ストアアプリになった関係か結構電卓の実装仕様が変わってしまっています。その為ちょっと前の情報がそのままでは使えなくなっているので、スキルの無い私は動かすまで苦労しました。2020年2月の時点ではこれで恐らく動くと思います。同じような感じで調べている方の参考になれば幸いです。
あと、当然ですがこの情報を使用した際の損害は誰も請け負ってくれません。そこはお願いします。