这里只讨论 Unity + UGUI + Android 的情况。实际上,测试脚本只区分是Android还是IOS平台。至于游戏开发平台和UI种类,一开始嵌入游戏的sdk会告诉我们。
那么我们讨论的源码根目录就是GAutomator/GAutomatorAndroid/。结构参考官方文档的这张图:
在这个目录中,最重要的目录就是wpyscripts目录。在编写测试用例的时候,我们需要导入这个包底下的各种模块或API。testcase是官方给的各种测试用例,sample是关于wpyscripts底下各种API的使用示例。
前一篇文章讨论了GAutomator控制Android端游戏的主要方式是两个:一个是直接在终端执行相关的adb命令,一个是通过adb forward
转发一些自定义的命令(协议)或收发来自安卓端游戏的回应。
执行adb命令API在wpyscripts/common/adb_process.py里。主要有以下API:
def excute_adb(cmd, serial=None):
def excute_adb_process(cmd, serial=None):
def excute_adb_process_daemon(cmd, shell=False, serial=None, sleep=3 , needStdout=True):
我们在这三个方法里加入一些print或logger打印,方便知道整个测试过程中都执行了哪些adb命令。
自定义的命令的定义在wpyscripts/common/protocol.py里。具体内容如下:
class Commands(object):
GET_VERSION = 100 # 获取版本号
FIND_ELEMENTS = 101 # 查找节点
FIND_ELEMENT_PATH = 102 # 模糊查找
GET_ELEMENTS_BOUND = 103 # 获取节点的位置信息
GET_ELEMENT_WORLD_BOUND = 104 # 获取节点的世界坐标
GET_UI_INTERACT_STATUS = 105 # 获取游戏的可点击信息,包括scene、可点击节点,及位置信息
GET_CURRENT_SCENE = 106 # 获取Unity的Scene名称
GET_ELEMENT_TEXT = 107 # 获取节点的文字内容
GET_ELEMENT_IMAGE = 108 # 获取节点的图片名称
GET_REGISTERED_HANDLERS = 109 # 获取注册的函数的名称
CALL_REGISTER_HANDLER = 110 # 调用注册的函数
SET_INPUT_TEXT = 111 # input控件更换文字信息
GET_OBJECT_FIELD=112 # 通过反射获取gameobject中component的属性值
FIND_ELEMENTS_COMPONENT=113 #获取所有包含改组件的gameobject
SET_CAMERA_NAME=114 #设置渲染的最佳的Camera
GET_COMPONENT_METHODS = 115 # 反射获取组件上的方法
CALL_COMPONENT_MOTHOD = 116 # 通过反射调用组件的函数
LOAD_TEST_LIB=117 #初始化testlib服务
PRC_SET_METHOD=118#注册python端的方法
RPC_METHOD = 119#游戏内的接口可调用,python端的方法
#######################/
HANDLE_TOUCH_EVENTS = 200 # 发送down move up
DUMP_TREE = 300
前面讨论过,adb forward
实际上会在测试脚本和adb之间建立一条TCP链接。建立连接的事情在调用get_engine()的时候完成。具体在wpyscripts/wetest/engine.py的UnityEngine类的构造函数中。当然,UnityEngine实际上是继承了GameEngine,查看GameEngine的构造函数,有这么一段话:
def __init__(self, address, port,uiauto_interface):
self.address = address
self.port = port
self.sdk_version = None
for i in range(0, 3):
try :
self.socket = SocketClient(self.address, self.port) # 在这里建立的TCP链接
break
except Exception as e:
logger.error(e)
time.sleep(20)
ret = forward(self.port, unity_sdk_port) # with retry...
self.ui_device = uiauto_interface
在这个地方,GameEngine建立了三条TCP链接,分别用于探索测试精灵,录制和命令执行。我们可以在这个地方打印它的ip和port,看是否跟adb forward
命令里写的对应。
建立TCP链接之后,就要执行自定义命令(或者说是转发自定义协议)。具体API也在engine.py中:
def send_command_with_retry(self,command, param=None , timeout=20):
for i in range(0,2):
try:
ret = self.socket.send_command(command, param,timeout)
return ret
except Exception as e:
ret = excute_adb_process("forward --list")
logger.info("adb forward list : " + str(ret))
ret = forward(self.port, unity_sdk_port)# with retry...
logger.info("after reforward list : " + str(ret))
try:
self.socket = SocketClient(self.address, self.port)
except Exception as e :
logger.exception(e)
time.sleep(5)
我们可以打印命令的值,以及它可能带有的参数param。
在try语句块里,它尝试调用自己封装的socket类的send_command()
去发送命令,如果失败了,在异常处理里它首先会执行adb forward --list
。这是一个查看当前adb forward
开启的TCP链接的命令。send_command
的执行失败,说明TCP链接没有成功建立。它会尝试再建立一个TCP链接,即ret = forward(self.port, unity_sdk_port)
和self.socket = SocketClient(self.address, self.port)
。
现在我们将执行以下简单的测试脚本,这个脚本将获取sdk的版本,获取游戏当前场景信息,查找一个名为**/Canvas/Button**的按钮并点击该按钮。我们来看看脚本都执行了哪些命令:
from wpyscripts.tools.basic_operator import *
def test():
engine=manager.get_engine()
logger=manager.get_logger()
version=engine.get_sdk_version()
logger.debug("Version Information : {0}".format(version))
scene=engine.get_scene()
logger.debug("Scene : {0}".format(scene))
sample_button=engine.find_element("/Canvas/Button")
logger.debug("Button : {0}".format(sample_button))
#engine.click(sample_button)
screen_shot_click(sample_button)
if __name__=="__main__":
test()
结果如下(只显示adb命令和自定义命令,以及socket链接信息)
D:\software\python3\python.exe D:/program/GAutomator/GAutomatorAndroid/my_test.py
Excute_adb_process: adb forward tcp:53001 tcp:27019 # 建立forward通道
Excute_adb_process: adb forward --list
Excute_adb: adb shell ps
Excute_adb: adb shell ps -ef
Excute_adb_process: adb push
Excute_adb_process: adb forward --remove tcp:19008
Excute_adb_process: adb forward tcp:19008 tcp:9008
Excute_adb_process: adb forward --list
Excute_adb_process_daemon: adb shell uiautomator runtest uiautomator-stub.jar -c com.github.uiautomatorstub.Stub
In GameEngine, address is :127.0.0.1 and port is :53001 # 与adb建立链接。端口号与第一次forward的端口一致
Excute_adb: adb shell getprop ro.product.cpu.abi
Excute_adb_process: adb push
Excute_adb_process: adb shell chmod 777 /data/local/tmp/minitouch
Excute_adb_process_daemon: adb shell /data/local/tmp/minitouch
Excute_adb_process: adb forward tcp:40000 localabstract:minitouch
Connection established # 链接至此建立完毕,下面才可以发送自定义的命令
send_command_with_retry, command :100param :1 # 获取版本号命令
send_command_with_retry, command :101param :['/Canvas/Button'] # 查找节点命令
send_command_with_retry, command :103param :[1042] # 获取节点的位置信息命令
Excute_adb_process: adb shell /system/bin/screencap -p
Excute_adb_process: adb pull /data/local/tmp/Canvas_Button_1570846742.081164.png
Excute_adb_process: adb shell rm /data/local/tmp/Canvas_Button_1570846742.081164.png
Excute_adb_process: adb shell input tap 720 405 # adb模拟点击命令
Excute_adb: adb shell ps
Excute_adb: adb shell kill 1387
Excute_adb_process: adb forward --remove tcp:40000
Excute_adb: adb shell ps
Excute_adb: adb shell kill 1364
Process finished with exit code 0
现在我们来查看安卓端的SDK做了什么事情。
安卓端总共导入两个SDK,一个是在Assets目录下的U3DAutomation.dll,一个是在Assets/Plugins/Android下的u3dautomation.jar。
在前面演示导入的SDK的时候,我们还建立了一个空物体,让dll中的一个类挂载在这个空物体上,使得它能够执行。所以,SDK最先执行的dll。jar存放的目录实际上是Unity的一个特殊的目录。有关安卓平台的插件都会放在这个目录中并被Untiy特殊打包。如果是java插件,Unity会使用JNI(Java Native Interface)去访问这些内容。当然我们Unity已经封装了一些C# API给我们来访问这些java类。具体的是UnityEngine.AndroidJavaClass
,这是一个类。稍后我们会看到jar包的作用,以及dll是如何通过这个类访问jar里的java类的。
对应于UGUI的Unity dll的源码位于D:\program\GAutomator\GAutomatorSdk\UnitySDK\UGUI\4.x\U3DAutomation下。用 vs 打开里面的sln解决方案,可以方便地调试源码。
adb命令在安卓端是由adbd执行的,跟dll无关。dll负责接收来自adb forward
传来的自定义的命令并处理,这些命令的定义在dll中也有一份,在protocl/ProtoclCommon.cs中:
[Serializable]
enum Cmd
{
EXIT=0,//退出游戏
////////////////////////
GET_VERSION=100,//获取版本号
FIND_ELEMENTS=101,//查找节点
FIND_ELEMENT_PATH=102,//模糊查找
GET_ELEMENTS_BOUND=103,//获取节点的位置信息
GET_ELEMENT_WORLD_BOUND=104,//获取节点的世界坐标
GET_UI_INTERACT_STATUS=105,//获取游戏的可点击信息,包括scene、可点击节点,及位置信息
GET_CURRENT_SCENE=106,//获取Unity的Scene名称
GET_ELEMENT_TEXT=107,//获取节点的文字内容
GET_ELEMENT_IMAGE=108,//获取节点的图片名称
GET_REGISTERED_HANDLERS=109,//获取注册的函数的名称
CALL_REGISTER_HANDLER=110,//调用注册的函数
SET_INPUT_TEXT=111,//input控件更换文字信息
GET_OBJECT_FIELD=112,//反射获取对象属性值
FIND_ELEMENTS_COMPONENT = 113,//获取所有包含改组件的gameobject
SET_CAMERA_NAME=114,//设置渲染的最佳的Camera
GET_COMPONENT_METHODS = 115,//反射获取组件上的方法
CALL_COMPONENT_MOTHOD = 116,//通过反射调用组件的函数
LOAD_TEST_LIB = 117,//拉起test相关的库
PRC_SET_METHOD=118,//注册python端的方法
RPC_METHOD = 119,//游戏内的接口可调用,python端的方法
///////////////////////////////////////////////
HANDLE_TOUCH_EVENTS=200,//发送down,move,up
//////////////////////////////////////////////
DUMP_TREE=300,//获取节点树xml
FIND_ELEMENT_BY_POS=301,//根据位置信息获取节点内容
//////////////////////////////////////////////
GET_FPS=400,//获取FPS
GET_TRAFFIC_DATA=401,//获取流量
//////////////////////////////////////////////
ENTER_RECORD=500,//开始录制
LEAVE_RECORD=501,//结束录制
TOUCH_NOTIFY=502,//返回点击的节点
}
前面已经解释了adb forward
的原理,那么为了能够从在安卓端的sdk能够接收到adb forward
传来的命令,那它必须和adbd建立一个TCP链接。这部分代码在CommandDispatcher.Create(port, blocklog)
。这个方法实现建立一条TCP链接。调用者是CommandDispatcher.RecvThread()
。这个方法里总共开启了四条链接。
private static void RecvThread()
{
Logger.d("Start RecvThread");
for (int i = 0; i < MAX_RETRY; ++i)
{
try
{
if (!Create(UIAUTOMATION_SERVER_PORT, MAX_CONNECT))
{
Logger.d("Try to close pre game");
ClosePreGame();
Thread.Sleep(500);
}
else
{
break;
}
}
catch (System.Exception ex)
{
Logger.e(ex.Message + " " + ex.StackTrace);
}
}
}
Create()
方法的具体内容如下:
private static bool Create(int port, int blocklog)
{
IPEndPoint ipEndPoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), port);
serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
try
{
serverSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);;
serverSocket.Bind(ipEndPoint);
serverSocket.Listen(blocklog);
serverSocket.BeginAccept(new AsyncCallback(Accept), serverSocket); //接收命令
return true;
}
catch (System.Exception ex)
{
Logger.e("Exception" + ex);
}
return false;
}
新开的Socket调用Accept()
方法去异步接收命令。接收的命令是一段未解析的json数据,具体的格式在官方文档里有。接收的命令被存放到StateObject.m_Buffer
,然后用parse()
进行解析,解析出的命令(一个Command类)被存放到一个队列中,准备做下一步的处理。
所有的命令处理都在CommandHandler.cs
中进行。这个类里面定义着一个字典用来保存每个命令对应的处理函数。
private Dictionary<Cmd, CmdHandler> handlers = new Dictionary<Cmd, CmdHandler>();
这个类中,有一个方法HandleCommand()
。这是一个协程,在DLL开始运行时,这个协程就会被开启,每帧都会执行里面的逻辑。
public IEnumerator HandleCommand()
{
while (true)
{
Command command = CommandDispatcher.GetCommand();
if (command == null)
{
yield return null;
}
else
{
Logger.v("Find command : " + command.cmd + " value :" + command.recvObj);
try
{
CmdHandler handler = null;
if (handlers.TryGetValue(command.cmd,out handler))
{
long beg = DateTime.Now.Ticks / 10000;
handler(command);
StringBuilder sb = new StringBuilder();
sb.Append("[");
sb.Append(command.socket);
sb.Append("] Cmd: ");
sb.Append(command.cmd);
sb.Append(" costs: ");
sb.Append(DateTime.Now.Ticks / 10000 - beg);
sb.Append("ms");
Debug.Log(sb.ToString());
sdkUseTime += (DateTime.Now.Ticks / 10000 - beg) / 1000;
}
else
{
//没法找到
command.status = ResponseStatus.NO_SUCH_CMD;
CommandDispatcher.SendCommand(command);
}
}
catch (System.Exception ex)
{
Logger.d("Handle Command expection =>" + ex.Message + " \n" + ex.StackTrace);
command.status = ResponseStatus.UN_KNOW_ERROR;
CommandDispatcher.SendCommand(command);
}
}
yield return null;
}
}
前面提到CommandDispatcher()
从TCP链接中接收命令并解析后,存放到内部的一个命令队列中。这个HandleCommand()
每帧检查这个队列里面是否有新的命令,有则取一条命令并按照字典里的映射,找对应的处理函数处理该命令。否则返回一条NO_SUCH_CMD
命令。
现在我们着重看一条命令:HANDLE_TOUCH_EVENTS
。当PC端测试脚本发来这条命令的时候,意味着测试脚本需要模拟一次屏幕触摸事件,这个事件可以是UP,DOWN或MOVE。
触摸事件是Android系统处理的,所以如果要实现这个事件,就需要用java跟Android交互,去生成一次MotionEvent(如果对Android的触摸事件不太了解,建议先学习)。
现在jar包的作用已经知道了,就是代替C#完成跟Android系统的交互。
C#层开了有两个线程参与有关触摸事件命令的处理。一个接收新的模拟触摸事件,解析命令,生成一个触摸事件动作实例,然后把它放到一个队列中。这些动作可以从触摸事件命令的处理函数handleTorchActions()
开始看起。另一个线程每帧从队列中取一个触摸事件,调用java的接口处理。这个具体过程后面讲。
C语言调用java就是通过JNI实现的。当然我们不可能直接去写C代码,然后C#调C再调java。Unity提供了一个类来访问java中的类,即AndroidJavaClass
,它对应于java中的一个类。而DLL SDK又给我们封装了一个类去使用,即AndroidRobot.cs
,这是一个单例。它的构造函数很简单,就是用调用AndroidJavaClass
构造函数去访问一个java类,将这个类作为AndroidRobot.cs
的单例实体。
private AndroidRobot()
{
u3dautomation = new AndroidJavaClass("com.tencent.wetest.U3DAutomation");
}
AndroidJavaClass
提供了很多方法去访问这个Java类中的成员和方法。具体可以查看这个类的定义。
在CommandHandler.cs
中,有一个方法InjectTouchEvent()
,它每帧都会被调用。它从触摸事件队列中取一个触摸事件,然后调用AndroidRobot.InjectMotionEvent()
去处理这个事件。
protected void InjectTouchEvent()
{
try
{
// Logger.d("InjectTouchEvent");
List<TouchEvent> events = TouchEventHandler.INSTANCE.GetTouchEvents();
if (events != null)
{
float offsetx = 0, offsety = 0, scalex = 0, scaley = 0;
if (CoordinateTool.GetCurrenScreenParam(ref offsetx, ref offsety, ref scalex, ref scaley))
{
for (int i = 0; i < events.Count; ++i) {
events[i].x -= offsetx;
events[i].y -= offsety;
}
}
for (int i = 0; i < events.Count; ++i)
{
TouchEvent touchEvent=events[i];
AndroidRobot.INSTANCE.InjectMotionEvent(touchEvent.x, touchEvent.y, touchEvent.type);
}
}
}
catch (System.Exception ex)
{
Logger.w(ex.Message + "\n" + ex.StackTrace);
}
}
实际上,AndroidRobot.InjectMotionEvent()
就是调用Java中的静态方法InjectTouchEvent()
处理这个事件的,因为C#对与Android系统交互无能为力。
现在,终于把视角转化到Java源码中了。我们来看看InjectTouchEvent()
具体做了什么。
public static void InjectTouchEvent(int action, float x, float y) {
long now = SystemClock.uptimeMillis();
final float DEFAULT_SIZE = 1.0f; // 按压下去的触发区域大小
final int DEFAULT_META_STATE = 0; // The state of any meta / modifier keys that were in effect when the event was generated
final float DEFAULT_PRECISION_X = 1.0f; // X坐标的精度
final float DEFAULT_PRECISION_Y = 1.0f; // Y坐标的精度
final int DEFAULT_DEVICE_ID = 0; // 说明这个事件来自哪个设备。0表示不来自物理设备
final int DEFAULT_EDGE_FLAGS = 0; // 位字段,表示触摸了哪些边
float pressure = (action == MotionEvent.ACTION_UP) ? 0.0f : 1.0f; // 只有Press和Down事件才有压力参数,压力从0-1
// 生成一个MotionEvent实例
MotionEvent event = MotionEvent.obtain(now, now, action, x, y,
pressure, DEFAULT_SIZE, DEFAULT_META_STATE,
DEFAULT_PRECISION_X, DEFAULT_PRECISION_Y, DEFAULT_DEVICE_ID,
DEFAULT_EDGE_FLAGS);
// 获取系统的API级别
if (Build.VERSION.SDK_INT >= 12) {
event.setSource(InputDevice.SOURCE_TOUCHSCREEN); // 这个方法在API 12 以后才加入,也不知道干什么用的,官网也没讲,大概是设置事件的来源
}
View unityPlayer = (View)getUnityPlayerActivity();
if (unityPlayer == null) {
Log.e(TAG,
"Unable to get UnityPlayer object! please check the Unity3D version.");
} else {
if (unity_version == 0) {
unity_version = getUnityVersion();
Log.i(TAG, "Unity version = " + unity_version);
}
unityPlayer.post(new InjectAction(event, unityPlayer)); // View.post(Runnable)开启一个线程去执行InjectAction类,InjectAction继承了Runnable接口,这个类会将前面生成的点击事件注入到unityPlayer中。
}
}
这个方法生成一个触摸事件实例MotionEvent,然后通过最后一行代码unityPlayer.post(new InjectAction(event, unityPlayer));
将触摸事件注入到一个View(unityPlayer)中,unityPlayer是Unity游戏在安卓端运行时的入口Activity。这个知识点我也搞不太清楚,只是大致知道一点,这部分单独拎出来做一节讲解。
现在,整个测试脚本的运行原理都差不多弄清楚。但实际上,还有很多细节还没有了解,比如C#层用到了大量的反射,多线程,java访问的内容究竟是什么意思,python测试脚本的其他模块是什么作用,等等。
新建一个Unity工程,什么也不做,打包成apk。我们用Android Studio解压这个apk,查看一下里面具体都是什么内容:
我们重点关注两个对象:classes.dex和AndroidManifest.xml。前者是Android平台上(Dalvik虚拟机)的可执行文件,后者则是Android工程里最重要的清单文件,里面描述了这个Android工程的基础配置。
AndroidManifest.xml里,我们重点关注
<application
android:theme="@ref/0x7f040001"
android:label="@ref/0x7f030000"
android:icon="@ref/0x7f020000"
android:banner="@ref/0x7f010000"
android:isGame="true">
<activity
android:label="@ref/0x7f030000"
android:name="com.unity3d.player.UnityPlayerActivity"
android:launchMode="2"
android:screenOrientation="10"
android:configChanges="0x40003fff"
android:hardwareAccelerated="false">
<intent-filter>
<action
android:name="android.intent.action.MAIN" />
<category
android:name="android.intent.category.LAUNCHER" />
<category
android:name="android.intent.category.LEANBACK_LAUNCHER" />
intent-filter>
<meta-data
android:name="unityplayer.UnityActivity"
android:value="true" />
activity>
<meta-data
android:name="unity.build-id"
android:value="4273ad55-4dc8-42aa-99c0-f6e41a5b47a5" />
<meta-data
android:name="unity.splash-mode"
android:value="0" />
<meta-data
android:name="unity.splash-enable"
android:value="true" />
application>
注意android:name="com.unity3d.player.UnityPlayerActivity"
。它表明任何一个Unity-Android工程的入口Activity都是com.unity3d.player.UnityPlayerActivity
。这个Activity在dex里一定有一个对应的java类。我们再看看.dex文件中的内容:
其中android,java,javax,org,bitter都是android或java自带的包。而com.unity3d.player是Unity打包到apk后都会生成的一个包(sorry,我拿不到源码)。
里面的内容大致是:
在第九行可以看到UnityPlayerActivity。
不过在jar包中实际用到的是第一行——UnityPlayer类。我到现在它引用这些东西都是什么意思。