测试工具GAutomator的研究(三)——GAutomator原理

文章目录

      • GAutomator源码结构
      • GAutomator测试脚本命令的发送方式
      • 简单的测试用例
      • 安卓端的SDK
        • 问:C#层从哪里接收这些命令?
        • 问:C#层是如何处理这些命令的?
        • jar包的作用?
        • C#是如何调用jar包的API的?
        • 触摸事件是如何处理的?
      • Unity工程在安卓

GAutomator源码结构

这里只讨论 Unity + UGUI + Android 的情况。实际上,测试脚本只区分是Android还是IOS平台。至于游戏开发平台和UI种类,一开始嵌入游戏的sdk会告诉我们。

那么我们讨论的源码根目录就是GAutomator/GAutomatorAndroid/。结构参考官方文档的这张图:
测试工具GAutomator的研究(三)——GAutomator原理_第1张图片

在这个目录中,最重要的目录就是wpyscripts目录。在编写测试用例的时候,我们需要导入这个包底下的各种模块或API。testcase是官方给的各种测试用例,sample是关于wpyscripts底下各种API的使用示例。

GAutomator测试脚本命令的发送方式

前一篇文章讨论了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做了什么事情。

安卓端总共导入两个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,//返回点击的节点


    }

问:C#层从哪里接收这些命令?

前面已经解释了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类)被存放到一个队列中,准备做下一步的处理。

问:C#层是如何处理这些命令的?

所有的命令处理都在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命令。

jar包的作用?

现在我们着重看一条命令:HANDLE_TOUCH_EVENTS。当PC端测试脚本发来这条命令的时候,意味着测试脚本需要模拟一次屏幕触摸事件,这个事件可以是UP,DOWN或MOVE。

触摸事件是Android系统处理的,所以如果要实现这个事件,就需要用java跟Android交互,去生成一次MotionEvent(如果对Android的触摸事件不太了解,建议先学习)。

现在jar包的作用已经知道了,就是代替C#完成跟Android系统的交互。

C#层开了有两个线程参与有关触摸事件命令的处理。一个接收新的模拟触摸事件,解析命令,生成一个触摸事件动作实例,然后把它放到一个队列中。这些动作可以从触摸事件命令的处理函数handleTorchActions()开始看起。另一个线程每帧从队列中取一个触摸事件,调用java的接口处理。这个具体过程后面讲。

C#是如何调用jar包的API的?

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工程在安卓

新建一个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文件中的内容:
测试工具GAutomator的研究(三)——GAutomator原理_第2张图片
其中android,java,javax,org,bitter都是android或java自带的包。而com.unity3d.player是Unity打包到apk后都会生成的一个包(sorry,我拿不到源码)。

里面的内容大致是:
测试工具GAutomator的研究(三)——GAutomator原理_第3张图片
在第九行可以看到UnityPlayerActivity。

不过在jar包中实际用到的是第一行——UnityPlayer类。我到现在它引用这些东西都是什么意思。

你可能感兴趣的:(Unity游戏开发,#,Unity,Shader,测试开发)