【测试人生】GAutomator安卓UE4版本的实现机理与优化实战

2年以前的一篇文章中,讲述了游戏UI自动化方案GAutomator的基础机理、使用方式和一些工具扩展的想法。今天,趁着Game Of AutoTest系列的连载,结合游戏自动化技术选型一文,笔者将深入剖析GAutomator作为UE4安卓游戏UI自动化方案的实现机理,以及自己在实际工作中对GAutomator的优化实践。

工作原理

GAutomator是这样的调用链路:

  • PC和手机的连通
    • GAutomator插件被启用编译,启动时在手机内启动一个tcp-server
    • PC端GAClient通过adb forward转发端口,然后连到手机内的tcp-server
  • 获取控件
    • 通过给GAutomator-Server发送DUMP_TREE命令,获取控件树的XML字符串
    • PC端GAClient接收到的控件树数据,可以被我们自己的业务逻辑取到,因此我们可以通过自定义的筛选条件找到对应控件的Element
  • 点击控件
    • PC端GAClient通过筛选控件得到的,或是自定义的Element,给到click接口
    • click接口发送GET_ELEMENTS_BOUND命令,根据Element信息,查询到对应控件在视口中的坐标
    • 获取坐标后,用adb input tap点击屏幕

UE-SDK

GAutomatorUE-SDK实质是一个UE4插件,按需启用。

插件启动

插件启动时,会启动一个TCP-Server监听设备的某个端口,接取命令请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 插件启动
void FGAutomatorModule::StartupModule()
{
#if defined PLATFORM_IOS || defined __ANDROID__
CommandDispatcherPtr = new WeTestU3DAutomation::FCommandDispatcher();
CommandDispatcherPtr->Initialize();
#endif
}

// CommandDispatcher初始化
bool FCommandDispatcher::Initialize()
{
SocketListenerThreadInstance = FRunnableThread::Create(this, TEXT("GAutomatorListenerThread"));
return SocketListenerThreadInstance != nullptr;
}

// 服务主循环
uint32 FConnectionHandler::Run()
{
bool result = true;
do
{
result = HandleOneCommand();
} while (result);
return 0;
}

// handle命令
bool FConnectionHandler::HandleOneCommand()
{
// 获取头部长度信息
int32 length = RecvIntLength();
if (length <= 0) {
return false;
}

// 获取命令请求body
TArray<uint8> BodyBinrary;
bool RecvContentResult = RecvContent(length, BodyBinrary);
if (!RecvContentResult) {
return false;
}
FString ContentStr = StringFromBinaryArray(BodyBinrary);
UE_LOG(GALog, Log, TEXT("Recv command:%s"), *ContentStr);
TSharedPtr<FJsonValue> JsonParsed;
TSharedRef< TJsonReader<TCHAR> > JsonReader = TJsonReaderFactory<TCHAR>::Create(ContentStr);
bool BFlag = FJsonSerializer::Deserialize(JsonReader, JsonParsed);
if (!BFlag) {
UE_LOG(GALog, Error, TEXT("Deserialize request to json failed.\n %s"));
return false;
}

// handle-command并返回
FString Response;
bool res= HandleCommandInGameThread(JsonParsed, Response);
length= this->SendData(Response);
return res;
}

根据不同的命令码,内部会dispatch到不同handler去运行得到对应命令的结果。

控件信息获取

GAutomator最重要的一个功能是控件树导出,其实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 获取控件树xml字符串
FString GetCurrentWidgetTree() {
TSharedPtr<FXmlFile> xml = CreateFXmlFile();
FString XmlStr;
FXmlNode* RootNode = xml->GetRootNode();
// 遍历每层可见的根UUserWidget实例
for (TObjectIterator<UUserWidget> Itr; Itr; ++Itr)
{
UUserWidget* UserWidget = *Itr;
if (UserWidget == nullptr || !UserWidget->GetIsVisible() || UserWidget->WidgetTree == nullptr) {
UE_LOG(GALog, Log, TEXT("UUserWidget Iterator get a null(unvisible) UUserWidget"));
continue;
}
// 迭代向下遍历
ForWidgetAndChildren(UserWidget->WidgetTree->RootWidget, RootNode);
}
WriteNodeHierarchy(*RootNode, FString(), XmlStr);
return MoveTemp(XmlStr);
}

void ForWidgetAndChildren(UWidget* Widget, FXmlNode* Parent)
{
// 过滤无效widget
if (Widget == nullptr || Parent == nullptr || !Widget->IsVisible()) {
return;
}
// 提取UWidget实例信息
FXmlNode* WidgetXmlNode = TransformUmg2XmlElement(Widget, Parent);
// 遍历Named-Slot,参考:https://docs.unrealengine.com/5.0/en-US/using-named-slots-in-umg-for-unreal-engine/
if (INamedSlotInterface* NamedSlotHost = Cast<INamedSlotInterface>(Widget))
{
TArray<FName> SlotNames;
NamedSlotHost->GetSlotNames(SlotNames);
for (FName SlotName : SlotNames)
{
if (UWidget* SlotContent = NamedSlotHost->GetContentForSlot(SlotName))
{
ForWidgetAndChildren(SlotContent, WidgetXmlNode);
}
}
}
// 遍历Panel-Widget
if (UPanelWidget* PanelParent = Cast<UPanelWidget>(Widget))
{
for (int32 ChildIndex = 0; ChildIndex < PanelParent->GetChildrenCount(); ChildIndex++)
{
if (UWidget* ChildWidget = PanelParent->GetChildAt(ChildIndex))
{
ForWidgetAndChildren(ChildWidget, WidgetXmlNode);
}
}
}
}

机理上,会从所有的Root Widget开始向下遍历,拿到每个Widget的数据
而获取控件坐标方面,会涉及寻找控件的逻辑。在UE4插件内部,GAutomator默认支持通过控件名的方式查找:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const UWidget* FindUWidgetObject(const FString& name)
{
for (TObjectIterator<UUserWidget> Itr; Itr; ++Itr)
{
UUserWidget* UserWidget = *Itr;
if (UserWidget == nullptr || !UserWidget->GetIsVisible() || UserWidget->WidgetTree == nullptr) {
UE_LOG(GALog, Log, TEXT("UUserWidget Iterator get a null(unvisible) UUserWidget"));
continue;
}
// 通过控件名寻找控件
UWidget* Widget = UserWidget->GetWidgetFromName(FName(*name));
if (Widget != nullptr) {
return Widget;
}
}
return nullptr;
}

机理上,会遍历所有根控件,调用GetWidgetFromName方法,找到的第一个Widget即返回。而之后获取屏幕视口坐标,则会从CachedGeometry获取到:

1
2
3
4
5
6
7
8
9
10
11
12
13
bool FUWidgetHelper::GetElementBound(const FString& name, FBoundInfo& BoundInfo)
{
const UWidget* WidgetPtr = FindUWidgetObject(name);
// 由GetCachedGeometry获取渲染几何信息
const FGeometry geometry = WidgetPtr->GetCachedGeometry();
FVector2D Position = geometry.GetAbsolutePosition();
FVector2D Size = geometry.GetAbsoluteSize();
BoundInfo.x = Position.X / WidthScale;
BoundInfo.y = Position.Y / HeightScale;
BoundInfo.width = Size.X / WidthScale;
BoundInfo.height = Size.Y / HeightScale;
return true;
}

优化手段

GAutomatorUE-SDK在实现上,现在还存在许多不足,在笔者的实际应用中发现,很多地方没有考虑到。比如:

  • 不支持ListView子空间的信息拉取
  • 不支持富文本控件
  • 不支持图片控件
  • 不支持输入控件输入内容
  • 不能通过UniqueID查询控件
    • 如果出现控件名重复,或者动态生成控件的情况,会难以定位到,甚至每次都只能查到第一个
  • 不能一次性返回控件基础+坐标信息
    • 若业务侧一开始查询控件树,不会一次性返回控件坐标,执行控件操作还需要额外再查询一次
  • PC游戏无法实现点击按下等操作

因此在实际业务中,笔者做了如下的优化,可供参考:

  • 支持ListView子控件的信息提取逻辑
  • 支持富文本控件信息提取(这个看具体项目富文本控件实现而定)
  • 支持以资源路径为图片控件的文本信息,利于筛选特定图片
  • 支持对EditableText等控件输入内容
  • 支持通过UniqueID查询控件
  • 支持拉取控件树时,一次性返回控件基础信息+视口坐标信息
  • 支持Broadcast控件委托来实现点击按下等操作,从而支持PC端游戏的控件操作

GA-Client

GAutomator的PC端Client主要的内容集中在GAutomatorAndroid以及GAutomatorIos下,本文以GAutomatorAndroid的部分为例,讲述GA-Client的核心实现。

GAutomatorAndroid项目本身杂糅了很多wetest相关的内容,以及很多无比粗糙的代码,这部分内容其实和GA-Client核心逻辑没有太大的联系。如果自己写一个GA-Client的话,可能只需要五分之一的代码量就可以了。

核心逻辑

GA-Client的核心部分在于GameEngine,所有与游戏内SDK交互的逻辑,都集中在这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# engine.py
class GameEngine(object):
def __init__(self, address, port,uiauto_interface):
self.address = address
self.port = port
self.sdk_version = None
# 初始化SocketClient实例,用以和游戏SDK通信
for i in range(0, 3):
try :
self.socket = SocketClient(self.address, self.port)
break
except Exception as e:
logger.error(e)
time.sleep(20)
ret = forward(self.port, unity_sdk_port) # with retry...
# 初始化UIAutomator实例
self.ui_device = uiauto_interface

GameEngine实例初始化的时候,会生成一个连接游戏内SDKsocket实例,以及一个uiautomator实例ui_device。当游戏需要和native-ui交互的时候(比如QQ登录),就需要uiautomator的支持(然而在GameEngine的基础方法里,ui_device实例没有发挥作用)。

当我们向游戏SDK发送命令的时候,会调用到socketsend_command方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# engine.py
class GameEngine(object):
def _get_dump_tree(self):
"""获取控件树"""
ret = self.socket.send_command(Commands.DUMP_TREE)
return ret


# socket_client.py
class SocketClient(object):
def send_command(self, cmd, params=None, timeout=20):
"""发送命令,带重试机制"""
if not params:
params = ""
command = {}
command["cmd"] = cmd
command["value"] = params
for retry in range(0, 2):
try:
self.socket.settimeout(timeout)
self._send_data(command)
ret = self._recv_data()
return ret
except:
# 这里忽略异常处理/重连逻辑
pass
raise Exception('Socket Error')

def _send_data(self, data):
"""发送数据"""
try:
serialized = json.dumps(data)
except (TypeError, ValueError) as e:
raise WeTestInvaildArg('You can only send JSON-serializable data')
length = len(serialized)
buff = struct.pack("i", length)
self.socket.send(buff)
if six.PY3:
self.socket.sendall(bytes(serialized, encoding='utf-8'))
else:
self.socket.sendall(serialized)

从代码内容易知,发送命令的方式是:

  • json.dumps序列化命令cmd和参数params
  • 在序列化数据前pack一个int长度信息,把它和数据连起来发送给游戏内SDK
  • 游戏内SDKrecv长度信息,再根据长度信息recv对应长度的数据,用json.loads反序列化,就得到原始命令和参数

当接收到数据时,也是跟游戏内SDK接收数据相同的方式。具体的实现在recv_package里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# socket_client.py
class SocketClient(object):
def recv_package(self):
# 拉取长度信息
length_buffer = self.socket.recv(4)
if length_buffer:
total = struct.unpack_from("i", length_buffer)[0]
else:
raise WeTestSDKError('recv length is None?')
# 拉取数据,开total长度的memoryview作为buffer
view = memoryview(bytearray(total))
next_offset = 0
while total - next_offset > 0:
recv_size = self.socket.recv_into(view[next_offset:], total - next_offset)
next_offset += recv_size
# 反序列化数据
try:
if six.PY3:
deserialized = json.loads(str(view.tobytes(), encoding='utf-8'))
else:
deserialized = json.loads(view.tobytes())
return deserialized
except (TypeError, ValueError) as e:
raise WeTestInvaildArg('Data received was not in JSON format')

类似dump_tree这种命令,返回的是xml-string,相当于是没有二次封装过的控件树。而类似click这种操作命令,实际用到的就是adb shell input这一系列的命令了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# engine.py
class GameEngine(object):
def click(self, locator):
if locator is None:
return
if isinstance(locator, Element): # 考虑入参Element的情况
try:
bound = self.get_element_bound(locator)
if bound:
return self.click_position(bound.x + bound.width / 2, bound.y + bound.height / 2)
except WeTestRuntimeError as e:
logger.error("Get element({0}) bound faild {1}".format(locator, e.message))
return False
else: # 忽略只给ElementBound以及其他情况
pass

def click_position(self, x, y):
x = int(x)
y = int(y)
cmd = "shell input tap " + str(x) + " " + str(y)
excute_adb_process(cmd) # adb shell input tap
return True


# adb_process.py
def excute_adb_process(cmd, serial=None):
if serial:
command = "adb -s {0} {1}".format(serial, cmd)
else:
command = "adb {0}".format(cmd)
# popen一个adb命令进程,执行命令
ret = ""
for i in range(0,3):
p = subprocess.Popen(command, shell=True, stderr=subprocess.STDOUT, stdout=subprocess.PIPE)
lines = p.stdout.readlines()
ret = ""
for line in lines:
ret += str(line) + "\n"
if "no devices/emulators found" in ret or "device offline" in ret:
logger.error("rety in excute_adb_process")
time.sleep(20)
else:
return ret
return ret

优化手段

GA-Client核心逻辑的实现可以看到,有很多地方是值得精简的。以笔者的经验为例,是按照自己自动化框架约定,重写了一版GA-Client。具体是做了以下优化:

  • 单独分离出设备接口模块,用以统一管理设备信息和操作
    • 设备序列号、adb命令、shell命令,都在这个模块执行
  • GA-Clientuiautomator分离,做成插件的形式
    • GAutomatoruiautomator的操作,比如点击按下这些,就可以由设备接口模块执行
  • 控件树的XML字符串做二次封装,对每个控件抽象成Widget
    • 单独做一个GA操作接口模块,传入Widget类实例就可以对控件做操作
    • Widget类做一些更复杂的控件筛选功能,这块就不需要游戏内SDK来深入做了
版权声明
本文为博客HiKariのTechLab原创文章,转载请标明出处,谢谢~~~