基于HWND的Win32 UI自动化

做了好一阵子的UI自动化,今天来总结总结。先说比较简单一点的以HWND为基础的Win32 UI自动化。

很多人的UI自动化应该是从自动化HWND开始,总体来说入门比较简单,至少接触的都是Win32 API,而且可以不用接触COM相关的东西。说了那么久,进入正题。

UI自动化,至少要处理下面几样东西:

  • 找到想要的窗口句柄HWND
  • 能够模拟键盘鼠标操作
  • 获取指定窗口的各种属性

这里我们以自动化同花顺客户端为例子,来看看一个简单的UI自动化该怎么写。同花顺客户端可以在这里下载。

要进行自动化,首先要找到你想进行自动化的那个窗口句柄。熟悉Win32的同学,这个应该不是什么难事。FindWindow/FindWindowEx/EnumWindows/EnumChildWindows几个函数基本上可以满足绝大多数情况下要求。

拿同花顺的登录对话框来说,我们要先找到用户名和密码2个框子的窗口句柄。

loginDlg

用户名和密码两个框子,我们用Spy++可以知道,都是标准的Win32 common control。一个是ComboBox,一个是Edit控件。对于ComboBox来说,它稍微有点特殊,是因为它里面嵌套了一个Edit控件。因此,用户名最终是填写在Edit控件里的。

要找到两个框子,先要找到登录对话框。

HWND hLoginDlg = FindWindow(_T("#32770"), _T("登录到全部行情主站"));

接下来,我们才可以继续枚举对话框里的子控件。先来找用户名ComboBox。

1:  HWND hChild = FindWindowEx(hLoginDlg, NULL, _T("ComboBox"), NULL);
2:  hChild = FindWindowEx(hChild, NULL, _T("Edit"), NULL);

好,至此,我们已经完成找用户名的框框了。其实,到了这里,也引出了一个问题,如果窗口的性质发生了变化,那么对应的窗口就可能找不到了?事实确实是这样的。因此对于UI的自动化,具体自动化的逻辑是要跟随UI的树形结构调整而调整的。

既然找到了用户名的框子,接下来就是输入用户名了。

在UI自动化里,模拟键盘鼠标的输入是相对来说更复杂一点的事情。因为这些事情都最终都是通过Win32的消息来实现的。既然是Win32消息,那么我们可以通过PostMessage/SendMessage的方式来做啦。但是,如果你尝试去做的话,你会发现这个方法的成功率不是很高,特别是PostMessage。那这是为什么呢(其实,我也没法清楚解释Sad smile,抱歉啦~~)?更合理的做法应该是调用SendInput。比方说,我们要模拟键盘的回车,可以这么来写:

INPUT inputs[2];
SecureZeroMemory(inputs, sizeof(inputs));
 
inputs[0].type = INPUT_KEYBOARD;
inputs[0].ki.wVk = VK_RETURN;
inputs[1] = inputs[0];
inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;
 
SendInput(_countof(inputs), inputs, sizeof(INPUT));

这样,你就能模拟发送一个回车事件了。

看到这里,我想有些人应该还会有新的问题,那就是到底这个回车消息发送给了哪个窗口?SendInput并没有指定窗口句柄,谁会接收到这个键盘消息呢?简单地说,当前的焦点窗口会接收到这个键盘消息。那么问题又来了,既然SendInput没有指定窗口句柄,并且模拟的消息是发给焦点窗口的,那么有必要在发送消息前先验证下当前的焦点窗口是否是我期望的那个。这个想法很自然吧?要获得当前的焦点窗口,可以使用GetFocus。一般来说,我们的UI自动化代码肯定是在另一个进程空间的(因为我们写了一个UI自动化的工具,并且很多时候这个工具本身并不是一个GUI程序)。这是,你会发现如果你调用GetFocus,它一直返回为空。回去检查下,明明想要的窗口是有焦点的,可是GetFocus就是不工作。不光GetFocus不工作,类似的SetFocus,GetActiveWindow等也不工作。咋了这是?

上MSDN再看看GetFocus,我们看到这里还提到了一个特别的API:AttachThreadInput。这是个神马函数呢?我们来看MSDN:

Windows created in different threads typically process input independently of each other. That is, they have their own input states (focus, active, capture windows, key state, queue status, and so on), and their input processing is not synchronized with the input processing of other threads. By using the AttachThreadInput function, a thread can attach its input processing mechanism to another thread.

看懂没?意思就是说键盘鼠标等的输入是和线程挂钩的。不同的线程有它自己的输入状态,和别的线程独立。如果你希望一个线程能够知道另外一个线程的输入状态,你就需要先调用AttachThreadInput函数。之后你的线程就能查询并修改另外一个线程的输入状态了。原来要这样之后你才可以GetFocus/SetFocus,是不是有点坑爹?另外,看到这里你也许才会对Focus这个概念有更深刻的认识:

GetFocus() : Retrieves the handle to the window that has the keyboard focus.

那么,针对某个特定焦点窗口发送一个按键消息的函数可是这个样子的(我定义了一个Keyboard类和相关的成员函数,合理的说,这个类中的成员函数都应该是静态的):

void Keyboard::SendKey(HWND window, DWORD dwVk) const
{
    if (GetFocus() == window)
    {
        SendKey(dwVk);
    }
    else
    {
        // print warning message here.
    }
}
 
void Keyboard::SendKey(DWORD dwVk) const
{
    INPUT inputs[2];
    SecureZeroMemory(inputs, sizeof(inputs));
 
    inputs[0].type = INPUT_KEYBOARD;
    inputs[0].ki.wVk = static_cast<WORD>(dwVk);
    inputs[1] = inputs[0];
    inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;
 
    SendInput(_countof(inputs), inputs, sizeof(INPUT));
}

这只是发送单个按键,如果我要发送一串字符串,比方说,在用户名的输入框键入一连串的字符,怎么办?当然可以反复调用Keyboard::SendKey。不过这个相对来说还是太麻烦了。其实输入一连串字符直接通过一个SendInput也是可以的。

void Keyboard::SendString(const std::basic_string<TCHAR>& str) const
{
    std::vector<INPUT> vInputs;
    std::for_each(str.begin(), str.end(), [&vInputs](TCHAR ch) {
        INPUT inputs[4];    // 最多可能有2键组合的形式来输入ch
        SecureZeroMemory(inputs, sizeof(inputs));
 
        for (int i = 0; i < _countof(inputs); ++i)
        {
            inputs[i].type = INPUT_KEYBOARD;
            inputs[i].ki.dwFlags = i & 1 ? 0 : KEYEVENTF_KEYUP;
        }
 
        switch (ch)
        {
            // case for input character and fill the corresponding inputs and vInputs.
        }
    });
 
    SendInput(vInputs.size(), vInputs.data(), sizeof(INPUT));
    Sleep(500);
}

填充完上面的switch后,事实上,你会发现,这个函数不能解决所有情况的输入,至少你无法输入'_’(下划线)。因为我们平时一直用的Virtual Key Codes里好像没有这玩意。这个时候,你就要求助于你听说过但未必使用过的扫描码(Scan Code)了。我们假定我们的同花顺账号是fcg_dev。基本上这个字串都得用scan code来解决。怎么找对应的scan code?文档在此。修改下上面的代码后,这个函数的样子大概是:

void Keyboard::SendString(const std::basic_string<TCHAR>& str) const
{
    std::vector<INPUT> vInputs;
    std::for_each(str.begin(), str.end(), [&vInputs](TCHAR ch) {
        INPUT inputs[4];
        SecureZeroMemory(inputs, sizeof(inputs));
 
        for (int i = 0; i < _countof(inputs); ++i)
        {
            inputs[i].type = INPUT_KEYBOARD;
            inputs[i].ki.dwFlags = KEYEVENTF_SCANCODE;
        }
 
        switch (ch)
        {
        case 'f':
            inputs[0].ki.wScan = 0x21;
            inputs[1].ki.wScan = 0xa1;
            inputs[1].ki.dwFlags |= KEYEVENTF_KEYUP;
 
            vInputs.push_back(inputs[0]);
            vInputs.push_back(inputs[1]);
            break;
 
        case 'c':
            inputs[0].ki.wScan = 0x2e;
            inputs[1].ki.wScan = 0xae;
            inputs[1].ki.dwFlags |= KEYEVENTF_KEYUP;
 
            vInputs.push_back(inputs[0]);
            vInputs.push_back(inputs[1]);
            break;
 
        case 'g':
            inputs[0].ki.wScan = 0x22;
            inputs[1].ki.wScan = 0xa2;
            inputs[1].ki.dwFlags |= KEYEVENTF_KEYUP;
 
            vInputs.push_back(inputs[0]);
            vInputs.push_back(inputs[1]);
            break;
 
        case '_':
            inputs[0].ki.wScan = 0x2a;
            inputs[1].ki.wScan = 0x0c;
            inputs[2].ki.wScan = 0x8c;
            inputs[3].ki.wScan = 0xaa;
 
            inputs[2].ki.dwFlags |= KEYEVENTF_KEYUP;
            inputs[3].ki.dwFlags |= KEYEVENTF_KEYUP;
 
            vInputs.push_back(inputs[0]);
            vInputs.push_back(inputs[1]);
            vInputs.push_back(inputs[2]);
            vInputs.push_back(inputs[3]);
            break;
 
        case 'd':
            inputs[0].ki.wScan = 0x20;
            inputs[1].ki.wScan = 0xa0;
            inputs[1].ki.dwFlags |= KEYEVENTF_KEYUP;
 
            vInputs.push_back(inputs[0]);
            vInputs.push_back(inputs[1]);
            break;
 
        case 'e':
            inputs[0].ki.wScan = 0x12;
            inputs[1].ki.wScan = 0x92;
            inputs[1].ki.dwFlags |= KEYEVENTF_KEYUP;
 
            vInputs.push_back(inputs[0]);
            vInputs.push_back(inputs[1]);
            break;
 
        case 'v':
            inputs[0].ki.wScan = 0x2f;
            inputs[1].ki.wScan = 0xaf;
            inputs[1].ki.dwFlags |= KEYEVENTF_KEYUP;
 
            vInputs.push_back(inputs[0]);
            vInputs.push_back(inputs[1]);
            break;
        }
    });
 
    SendInput(vInputs.size(), vInputs.data(), sizeof(INPUT));
}

至此,通过上面的一些代码,你基本上已经可以自己搞定登陆框的自动化了。需要注意一点的是,你也许要在一些切换动作上做一些简单的Sleep,保证焦点窗口能够有时间消化这些自动的输入。

因为同花顺是炒股软件,所以进去后一般都喜欢看涨幅排名以及相关股票代码的分时和K线,需要的操作是按 60 后再按F5或者回车,然后再按翻页(PgDn)键。依照前面的内容这几步都是重复的体力劳动,难度不大。

但是,实际的执行过程中,你会发现一些问题,那就是VK_NEXT操作一直没有效果。这是神马情况?难道VK_NEXT和一般的不一样?非也,其实是因为同花顺程序对VK_NEXT进行了一个GetAsyncKeyState(VK_NEXT) & 0x8000的判断。GetAsyncKeyState是GetKeyState的异步版本,它用来判断该函数被调用时,键盘上指定键处与按下还是已经弹起的状态。如果我们调用前面的SendKey(VK_NEXT),那么GetAsyncKeyState(VK_NEXT)一直返回0。通过查看GetAsyncKeyState的MSDN,你会发现另外一个特别有用并且很重要的函数:GetKeyboardStateSetKeyboardState。那我们赶紧用起来,在SendKey之前,加入下面的代码:

BYTE byteVks[256];
 
GetKeyboardState(byteVks);
byteVks[VK_NEXT] |= 0x80;
SetKeyboardState(byteVks);
 
Keyboard()->SendKey(VK_NEXT);

坑爹的是,还是没有用。直接在SetKeyboardState后面加GetAsyncKeyState(VK_NEXT),也是返回0。这两个函数根本没有用。从这里,也可以知道,要想翻页成功,必须是前面加的GetAsyncKeyState不返回0。在继续往下读之前,你可以尝试其他的方法,接下来我说一说我的解决方案。

其实,关键就在SendInput。我们的例子里,鼠标键盘的模拟都是一个连续快速的动作。一个按键过程通过调用一次SendInput就搞定了。如果遇到了这种情况,其实应该把这个过程分两次SendInput。一次处理按下,一次处理弹起。

void Keyboard::SendKey(DWORD dwVk, UINT uMiliSeconds) const
{
    INPUT inputs[2];
    SecureZeroMemory(inputs, sizeof(inputs));
 
    inputs[0].type = INPUT_KEYBOARD;
    inputs[0].ki.wVk = static_cast<WORD>(dwVk);
    inputs[1] = inputs[0];
    inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;
 
    SendInput(1, &inputs[0], sizeof(INPUT));
    Sleep(uMiliSeconds);
    SendInput(1, &inputs[1], sizeof(INPUT));
}

提供上面这个函数,对VK_NEXT调用这个版本的SendKey你就会发现GetAsyncKeyState不会返回0了(放在第一个SendInput后面)。

那么到这里,整个键盘相关的自动化处理就说完了。而鼠标的自动化和键盘是一样的,也是通过SendInput。就不多说了。

最后,说到获取指定窗口的各种属性,就不多介绍了。这个不是难点。

总结一下,整个基于HWND的UI自动化涉及到的几个重要函数是:

  • 找窗口相关的FindWindow等函数
  • 和键盘焦点相关的函数:AttachThreadInput
  • 模拟键盘鼠标消息的:SendInput
  • 获取和修改键盘焦点的:GetFocus/SetFocus等
  • 一些基础知识:Virtual Key Code/Scan Code

总的说来,基于HWND的自动化简单但是比较有局限性,因为有些Win32 UI并不是一个窗口,而是直接自绘的。更现代的UI自动化应该基于最新的UI Automation。它既提供了COM接口也有.Net实现。方便并且更强大。有机会我再介绍吧。

原文地址:https://www.cnblogs.com/wpcockroach/p/2706509.html