UiAutomator 深度探索 Uiautomator2(ATX)原理(附含源码解析)

ice_cream · 2023年07月23日 · 4884 次阅读

前言

​最近在使用 Uiautomator2 进行 UI 的自动化测试,想了解下他的原理和 appium 有什么不一样,发现一篇浅谈 Uiautomator2 的原理(ATX 浅谈自动化测试工具 python-uiautomator2)写的不错,就想着要不从代码层面深入的研究下他的原理

背景

UiAutomator 是 Google 提供的用来做安卓自动化测试的一个 Java 库,基于 Accessibility 服务。功能很强,可以对第三方 App 进行测试,获取屏幕上任意一个 APP 的任意一个控件属性,并对其进行任意操作,但有两个缺点:1. 测试脚本只能使用 Java 语言 2. 测试脚本要打包成 jar 或者 apk 包上传到设备上才能运行。

我们希望测试逻辑能够用 Python 编写,能够在电脑上运行的时候就控制手机。这里要非常感谢 Xiaocong He (@xiaocong),他将这个想法实现了出来(见 xiaocong/uiautomator),原理是在手机上运行了一个 http rpc 服务,将 uiautomator 中的功能开放出来,然后再将这些 http 接口封装成 Python 库。 因为 xiaocong/uiautomator 这个库,已经很久不见更新。所以我们直接 fork 了一个版本,为了方便做区分我们就在后面加了个 2 openatx/uiautomator2

相关链接:https://gitcode.net/mirrors/openatx/uiautomator2

前置条件

  • 安装 Python3
  • 安装 pycharm
  • pip install -U uiautomator2 >运行 python -m uiautomator2 init(执行作用具体可以参见《 python uiautomator2 init 作用》这篇文章)

注意:在过去的版本中,这一步是必须执行的,但是从 1.3.0 之后的版本,当运行 python 代码 u2.connect() 时就会自动推送这些文件了

浅谈工作原理

如图所示,python-uiautomator2 主要分为两个部分,python 客户端,移动设备

python 端: 运行脚本,并向移动设备发送 HTTP 请求
移动设备:移动设备上运行了封装了 uiautomator2 的 HTTP 服务,解析收到的请求,并转化成 uiautomator2 的代码。

整个过程

1、在移动设备上安装 atx-agent(守护进程), 随后 atx-agent 启动 uiautomator2 服务 (默认 7912 端口) 进行监听
2、在 PC 上编写测试脚本并执行(相当于发送 HTTP 请求到移动设备的 server 端)
3、移动设备通过 WIFI 或 USB 接收到 PC 上发来的 HTTP 请求,执行制定的操作

以上部分引用:ATX 浅谈自动化测试工具 python-uiautomator2

深度探索工作原理

​HTTP 的请求和返回结构

如上图所示,以查找 text 为 “蓝牙” 是否存在为例,通过对 uiautomator2 源码的断点调试(如何对源码进行调试详见Pycharm Debug(断点调试) 超详细攻略),发现请求使用 JSON-RPC 轻量级的远程过程调用协议,进行命令和参数的传递,知道传输的 HTTP 请求体之后,我们要了解下 HTTP 请求是如何在手机端进行传递的

HTTP 命令手机端如何进行传递


如上图所示:手机通过 7912 端口发送 HTTP 请求后给 atx-agent,atx-agent 收到请求后通过 9008 端口发送给 app-uiautomator-test.apk 中的 AutomatorHttpServer,AutomatorHttpServer 根据传递的 method 参数调用 Android 自带的 Uiautomator,并将结果进行返回

简单的总结:

7912 端口用于 Python 端和手机端的数据交互

9008 端口用于 atx-agent 和 Android 自带的 Uiautomato 的数据交互

知道了 HTTP 命令是如何在手机端进行传递后,我们逐个的分析下 atx-agent 和 app-uiautomator-test.apk 是如何被拉起并进行工作的

atx-agent 如何启动

在浅谈工作原理中有说 atx-agent 是一个守护进程,那这个守护进程是如何被拉起进行守护的呢?

答案:客户端发送 adb 命令拉起 atx-agent:
self.shell(self.atx_agent_path, 'server', '--nouia', '-d', "--addr", self.__atx_listen_addr)

server --nouia:表示启动 atx-agent,不启动 uiautomator。

-d:表示将 atx-agent 作为后台进程运行。

--addr 127.0.0.1:7912:设定 atx-agent 的监听 IP 地址和端口。

注意:这里只是启动了 atx-agent 并没有启动 uiautomator,uiautomator 的启动我们文章的后面会说到详见“atx-agent 如何拉起 app-uiautomator-test.apk 并进行数据交互” 和 “app-uiautomator-test.apk 中 Stub.java 文件解析”这两个章节

具体调用过程

Python 端在发送请求之前会检查 atx-agent 是否启动,如果没有就会重新拉起 atx-agent,详细的调用路径如下,感兴趣的同学可以自己扒拉代码看下,代码太多我就不全贴了:

uiautomator2._AgentRequestSession.request 发送 HTTP 请求时调用
uiautomator2._BaseClient._prepare_atx_agent() 方法,当方法抛出异常时调用
uiautomator2._BaseClient._setup_atx_agent() 方法最终调用
uiautomator2.init.Initer.setup_atx_agent() 的方法启动 atx-agent

从上面的调用是不是就可以看出来只要我们运行 Python 代码,这个 atx-agent 就会一直在,不在就把他拉起

如何停止 atx-agent

专门说这个主要是方便大家验证 atx-agent 被停止后怎么被拉起的

方法一:在 ATX.apk 中点击 “停止 ATXAGENT”

方法二:直接使用 adb 命令停止

adb shell /data/local/tmp/atx-agent server --stop

启动 atx-agent 成功后,我们来看看他主要是做什么的,他的任务是什么

atx-agent main.go 文件的主要任务

涉及代码:https://github.com/openatx/atx-agent

  • 添加一个反向代理对象,将客户端接收的 http 请求,转发给 127.0.0.1:9008 的服务器进行处理
uiautomatorProxy = &httputil.ReverseProxy{
        Director: func(req *http.Request) {
            req.URL.RawQuery = "" // ignore http query
            req.URL.Scheme = "http"
            req.URL.Host = "127.0.0.1:9008"

            if req.URL.Path == "/jsonrpc/0" {
                uiautomatorTimer.Reset()
            }
        },
        Transport: &http.Transport{
            // Ref: https://golang.org/pkg/net/http/#RoundTripper
            Dial: func(network, addr string) (net.Conn, error) {
                conn, err := (&net.Dialer{
                    Timeout:   5 * time.Second,
                    KeepAlive: 30 * time.Second,
                    DualStack: true,
                }).Dial(network, addr)
                return conn, err
            },
            MaxIdleConns:          100,
            IdleConnTimeout:       180 * time.Second,
            TLSHandshakeTimeout:   10 * time.Second,
            ExpectContinueTimeout: 1 * time.Second,
        },
    }
  • 初始化命令控制功能(cmdctrl.go )并使用关键词添加映射关系: 当远程用户发送命令时,将接收到命令并按照指定的格式进行解析。经过解析后,该文件将会在后台启动一个线程来处理命令
service  = cmdctrl.New()
service.Add("uiautomator", cmdctrl.CommandInfo{
        Args: []string{"am", "instrument", "-w", "-r",
            "-e", "debug", "false",
            "-e", "class", "com.github.uiautomator.stub.Stub",
            "com.github.uiautomator.test/androidx.test.runner.AndroidJUnitRunner"}, // update for android-uiautomator-server.apk>=2.3.2
        //"com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner"},
        Stdout:          os.Stdout,
        Stderr:          os.Stderr,
        MaxRetries:      1, // only once
        RecoverDuration: 30 * time.Second,
        StopSignal:      os.Interrupt,
        OnStart: func() error {
            uiautomatorTimer.Reset()
            // log.Println("service uiautomator: startservice com.github.uiautomator/.Service")
            // runShell("am", "startservice", "-n", "com.github.uiautomator/.Service")
            return nil
        },
        OnStop: func() {
            uiautomatorTimer.Stop()
            // log.Println("service uiautomator: stopservice com.github.uiautomator/.Service")
            // runShell("am", "stopservice", "-n", "com.github.uiautomator/.Service")
            // runShell("am", "force-stop", "com.github.uiautomator")
        },
    })
  • 创建 TCP 端口为 7912 的监听,用于获取客户端传递的 HTTP 请求
listener, err := net.Listen("tcp", listenAddr)
  • 使用 mux.NewRouter() 添加路由器对象,用于处理 HTTP 请求和相应(调用 httpserver.go 文件中的 NewServer() 方法)
m := mux.NewRouter()
m.Handle("/jsonrpc/0", uiautomatorProxy)
  • 创建一个 JSON-RPC 客户端用于接收 9008 端口响应 (httpserver.go 文件中的 NewServer() 方法)
rpcc := jsonrpc.NewClient("http://127.0.0.1:9008/jsonrpc/0")
rpcc.ErrorCallback = func() error {
    service.Restart("uiautomator")
    // if !service.Running("uiautomator") {
    //  service.Start("uiautomator")
    // }
    return nil
}
rpcc.ErrorFixTimeout = 40 * time.Second
rpcc.ServerOK = func() bool {
    return service.Running("uiautomator")
}

atx-agent 如何拉起 app-uiautomator-test.apk 并进行数据交互

还记得我们之前留下的疑问吗?atx-agent 是如何启动 Uiautomator 的?看章节名我们可能会有点蒙圈,不是启动 Uiautomator 吗怎么启动一个 apk 了?让我们带着问题继续往下看

首先我们先看下怎么启动这个 app-uiautomator-test.apk 的

答案:Python 客户端发送命令:'http://127.0.0.1:51392/services/uiautomator'

具体调用过程

Python 端

Python 端在发送请求时,返回 502 异常(GatewayError(, 'gateway error, time used 0.0s'))捕获异常后调用
uiautomator2._BaseClient.reset_uiautomator() 方法进行重试在调用
uiautomator2._BaseClient._force_reset_uiautomator_v2() 方法在调用
uiautomator2._BaseClient.uiautomator() 方法通过 uiautomator2._Service 来发送请求

atx-agen 端

atx-agent 的 7912 端口监听到命令后 cmdctrl.go 文件进行解析,使用 adb 命令拉起 app-uiautomator-test 包下 Stub.java 文件:

adb shell am instrument -w -r -e debug false -e class com.github.uiautomator.stub.Stub com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner

    m.HandleFunc("/uiautomator", func(w http.ResponseWriter, r *http.Request) {
    err := service.Start("uiautomator")
    if err == nil {
        io.WriteString(w, "Successfully started")
    } else if err == cmdctrl.ErrAlreadyRunning {
        io.WriteString(w, "Already started")
    } else {
        http.Error(w, err.Error(), 500)
    }
}).Methods("POST")
    service.Add("uiautomator", cmdctrl.CommandInfo{
    Args: []string{"am", "instrument", "-w", "-r",
        "-e", "debug", "false",
        "-e", "class", "com.github.uiautomator.stub.Stub",
        "com.github.uiautomator.test/androidx.test.runner.AndroidJUnitRunner"}, // update for android-uiautomator-server.apk>=2.3.2
    //"com.github.uiautomator.test/android.support.test.runner.AndroidJUnitRunner"},
    Stdout:          os.Stdout,
    Stderr:          os.Stderr,
    MaxRetries:      1, // only once
    RecoverDuration: 30 * time.Second,
    StopSignal:      os.Interrupt,
    OnStart: func() error {
        uiautomatorTimer.Reset()
        // log.Println("service uiautomator: startservice com.github.uiautomator/.Service")
        // runShell("am", "startservice", "-n", "com.github.uiautomator/.Service")
        return nil
    },
    OnStop: func() {
        uiautomatorTimer.Stop()
        // log.Println("service uiautomator: stopservice com.github.uiautomator/.Service")
        // runShell("am", "stopservice", "-n", "com.github.uiautomator/.Service")
        // runShell("am", "force-stop", "com.github.uiautomator")
    },
})

如何停止 uiautomator

打开 atx.apk 点击 “停止 UIAUTOMATOR”

以上我们就完成了 app-uiautomator-test.apk 的启动,好像还有一个问题等着我们解答咋调用 Android 自带的 UIautomator 的,继续往下看

app-uiautomator-test.apk 中 Stub.java 文件解析

首先 Stub.java 是一个 Java 单元测试文件,主要作用是启动一个 JsonRpcServer 的服务器并监听 9008 端口,通过 AutomatorServiceImpl 类对客户端的请求进行响应

@SdkSuppress(minSdkVersion = 18)
@RunWith(AndroidJUnit4.class)
public class Stub {
    private static final int CUSTOM_ERROR_CODE = -32001;
    private static final int LAUNCH_TIMEOUT = 5000;
    int PORT = 9008;
    private final String TAG = "UIAUTOMATOR";
    AutomatorHttpServer server = new AutomatorHttpServer(this.PORT);

    @Before
    public void setUp() throws Exception {
        launchService();
        JsonRpcServer jrs = new JsonRpcServer(new ObjectMapper(), new AutomatorServiceImpl(), AutomatorService.class);
        jrs.setShouldLogInvocationErrors(true);
        jrs.setErrorResolver(new ErrorResolver() {
            /* class com.github.uiautomator.stub.Stub.AnonymousClass1 */

            @Override // com.googlecode.jsonrpc4j.ErrorResolver
            public ErrorResolver.JsonError resolveError(Throwable throwable, Method method, List<JsonNode> list) {
                String data = throwable.getMessage();
                if (!throwable.getClass().equals(UiObjectNotFoundException.class)) {
                    throwable.printStackTrace();
                    StringWriter sw = new StringWriter();
                    throwable.printStackTrace(new PrintWriter(sw));
                    data = sw.toString();
                }
                return new ErrorResolver.JsonError(Stub.CUSTOM_ERROR_CODE, throwable.getClass().getName(), data);
            }
        });
        this.server.route("/jsonrpc/0", jrs);
        this.server.start();
    }
}
  • 重要命令解析

JsonRpcServer jrs = new JsonRpcServer(new ObjectMapper(), new AutomatorServiceImpl(), AutomatorService.class);

  • new ObjectMapper() 创建了一个 Jackson 序列化/反序列化工具的实例,用于处理 JSON 数据和 Java 对象之间的转换。

Jackson 是一个 Java 序列化工具,其能够自动将 Java 对象序列化为 JSON 格式的数据,并支持将 JSON 数据反序列化为 Java 对象。

  • new AutomatorServiceImpl() 创建了一个 AutomatorServiceImpl 对象,AutomatorServiceImpl 是 JsonRpcServer 所会调用的具体服务实现类,它实现了 AutomatorService 接口,并调用 Android 自带的 UIautomator,提供了一些通过 JSON-RPC 调用的服务
@Override // com.github.uiautomator.stub.AutomatorService
public boolean exist(String obj) {
    try {
        return getUiObject(obj).exists();
    } catch (UiObjectNotFoundException e) {
        return false;
    }
}

看红色框框的部分是不是就是 Android 已经实现的 Uiautomator,到这里我们是不是就可以理解为什么之前说怎么调用 Uiautomator 的时候我们说的是如何拉起 app-uiautomator-test.apk 的,因为调用 Uiautomator 的方法是在 app-uiautomator-test.apk 这个 apk 里面的方法实现哒,大家是不是就明白了

  • AutomatorService.class 是 AutomatorService 接口的定义,它规定了 AutomatorServiceImpl 需要实现的服务方法列表
@JsonRpcErrors({@JsonRpcError(code = -32002, exception = UiObjectNotFoundException.class)})
boolean exist(String str);

简单总结:
通过以上代码,我们可以创建一台监听 JSON-RPC 请求的服务器,当客户端向该服务器发送 JSON-RPC 请求时,服务器会自动将请求反序列化成服务方法的输入参数,并调用 AutomatorServiceImpl 实现相应的服务。服务返回结果会被自动序列化成 JSON 数据,并发送给客户

结束

以上是个人对 uiautomator2 原理的理解,大家有什么不同的看法可以私信我或者评论区留言

共收到 0 条回复 时间 点赞
ice_cream python uiautomator2 init 作用 中提及了此贴 07月23日 17:31
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册