自动化工具 MAT: 深入探讨远程真机的 python 实现以及开源

yca · 2018年11月25日 · 最后由 yca 回复于 2022年04月29日 · 3350 次阅读

一 简单说两句

数月之前,在我刚完成 MAT 平台并信心满满的放出推介文章之后,就着手在公司落地,可惜的是因为种种原因落地失败了,一方面是我的失误,另一方面是在一个技术环境差的状态下一个小喽啰想改变些什么的无力,此项目就当作学习以及帮助学习的东西吧,你们可以尽情的嘲笑我,能给大家带来欢乐我特么的也满足了,至少有那么点用了😓 😭 没看过的同学可以先去熟悉一下 MAT 平台,当然经过一系列优化,目前 MAT 的操作体验要优于之前写推介帖子的时候。
【MAT 简介文章】-->MAT:远程测试机&自动化调试执行机之 web 平台
【MAT 安装操作指南】-->MAT:安装及操作指南
【开源地址】-->open-MAT

二 总体设计概述

因为 MAT 平台从思路到设计再到实现完全由我一人独立实现,所以整体设计充分体现了拍脑袋的设计方法,欢迎拍砖欢迎来喷。
1.MAT 平台由Django搭建;
2.投屏&远程操控的插件来自于 open-STF 的minicap&minitouch (社区有充分的文章,不懂的同学可以自行社区搜索)
3.投屏&远程控制的异步任务由 python 异步分布式框架 Celery 支持,异步队列应用 redis 实现 (理论上 MAT 由于 Cerely 和 redis 的加持是支持分布式的设备连接)
4.集成 Appium 及 UI Automator Viewer 集成到 web 上支持远程调试 Appium 及运行 Appium 脚本 (当然你也可以把 Appium 换成其他 UI 框架,只需更改极少的代码)

三 关键代码分析

要承认 MAT 平台的 python 代码并不多,大量的 JavaScript 代码,甚至让 github 都认为我这是 JavaScript 项目。。

1.启动 minicap&minitouch 的代码分析

  • 前台 Ajax 代码
    代码位置/MAT/apps/appcrawler/static/appcrawler/js/app_c.js

    $.ajax({
              url: '/appcrawler/startMinicap',
              type: 'GET',
              datatype: 'json',
              data: {'udid': udid, 'test': 'True'}
          }).done(function (data) {
              if (data.data == 'False') {
                  WarningAlert('该设备投屏已被其他人占用,请更换设备使用!');
              } else {
                  $.ajax({
                      url: '/appcrawler/startMinicap',
                      type: 'GET',
                      datatype: 'json',
                      data: {'udid': udid, 'test': 'False'},
                      beforeSend: function () {
                          $('#AlertTitle').html('<h3>提示</h3>');
                          $('#msg').html('稍候,不要刷新页面,马上就好');
                          $('#myModal').modal('show');
                      }
                  }).done(function (data) {
                      $('#myModal').modal('hide');
                      if (data.data == 'True') {
                          $("#AppiumMessage").empty();
                          $("#AppiumMessage").html("正在控制:" + data.name);
                          $("#frameHere").empty();
                          $("#frameHere").html('<iframe src="appcrawler/minicapView?PORT=' + data.port + '&touch=' + data.touchPort + '&mtsp=' + data.mtsp + '&udid=' + data.udid + '" name="' + data.udid + '" scrolling="no" frameborder="0" id="myframe" style="width: 900px; height: 900px;"></iframe>')
    
                      } else {
                          ErrorAlert('系统错误,请联系管理员')
                      }
                  })
              }
          })
    

    启动成功后页面异步的会插入一个 iframe-> 文件位置/MAT/apps/appcrawler/templates/myframe.html 插入 iframe 的同时会触发异步请求 src="appcrawler/minicapView" views 里对应代码如下:
    代码位置/MAT/apps/appcrawler/views.py

    def startMinicap(request):
        udid = request.GET.get('udid')
        test = request.GET.get('test')
        devicesList = startappium.getYaml()
        devicesName = devicesList[udid]['name']
        pcPort = devicesList[udid]['pcPort']
        socketPort = devicesList[udid]['socketPort']
        # 本地端口映射minitouch
        minitouchPort = devicesList[udid]['minitouchPort']
        mtsp = devicesList[udid]['mtsp']
        if test == 'True':
            minicap = startappium.minicap_is_runner(socketPort)
            if minicap == 'True':
                return JsonResponse({'data': 'False'})
            else:
                return JsonResponse({'data': 'True'})
        else:
            windowSize = startappium.getOutput(
                "adb -s %s shell dumpsys window | grep -Eo 'init=\d+x\d+' | head -1 | cut -d= -f 2" % udid)
            cpu = startappium.getOutput("adb -s %s shell getprop ro.product.cpu.abi | tr -d '\r'" % udid)
            level = startappium.getOutput("adb -s %s shell getprop ro.build.version.sdk | tr -d '\r'" % udid)
            cpuPath, levelPath, appPath, minitouchPath, touchJsPath = startappium.returnPath(cpu, level)
            try:
                # 先判断文件是否存在,不存在则push文件入手机
                if startappium.isExist(udid, 'minicap'):
                    startappium.Foo("adb -s %s push %s /data/local/tmp" % (udid, cpuPath))
                    startappium.Foo("adb -s %s push %s /data/local/tmp" % (udid, levelPath))
                if startappium.isExist(udid, 'minitouch'):
                    startappium.Foo("adb -s %s push %s /data/local/tmp" % (udid, minitouchPath))
                # 异步启动minicap
                build_job.delay("adb -s %s shell LD_LIBRARY_PATH=/data/local/tmp /data/local/tmp/minicap -P %s@%s/0" % (udid, windowSize, windowSize))
                startappium.Foo("adb -s %s forward tcp:%s localabstract:minicap" % (udid, pcPort))  # 端口映射
                build_job.delay("adb -s %s shell /data/local/tmp/minitouch" % (udid))
                startappium.Foo("adb -s %s forward tcp:%s localabstract:minitouch" % (udid, minitouchPort))  # 端口映射
                build_job.delay("node %s %s %s" % (appPath, socketPort, pcPort))
                build_job.delay("node %s %s %s" % (touchJsPath, minitouchPort, mtsp))
                time.sleep(4)
            except Exception as e:
                print str(e)
                return JsonResponse({'data': 'Error'})
            return JsonResponse(
                {'data': 'True', 'port': str(socketPort), 'udid': str(udid), 'name': str(devicesName), 'touchPort': str(minitouchPort),'mtsp': str(mtsp)})
    

    startMinicap 方法会将启动 minicap&minitouch 的任务加入 redis 队列中,worker 会从 redis 队列中取出任务并执行任务,相关进程启动完毕后会将启动的 socket 相关映射端口号返回给 iframe,下面代码是 iframe 中接收 socket 并连接 minicap&minitouch:
    代码位置/MAT/apps/appcrawler/templates/myframe.html

    a) 连接 minicap
    /*minicap websocket browser:true*/
    var BLANK_IMG =
        'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
    var canvas = document.getElementById('canvas')
        , g = canvas.getContext('2d');
    
    if (!canvas.getContext) {
        alert("Canvas not supported. Please install a HTML5 compatible browser.");
    }
    var ws = new WebSocket('ws://serviceIP:{{ port }}', 'minicap');
    ws.binaryType = 'blob';
    
    ws.onclose = function () {
        console.log('onclose', arguments)
    };
    
    ws.onerror = function () {
        console.log('onerror', arguments)
    };
    bit = 1;  //定义全局变量
    ws.onmessage = function (message) {
        var blob = new Blob([message.data], {type: 'image/jpeg'});
        var URL = window.URL || window.webkitURL;
        var img = new Image();
        img.onload = function () {
            console.log(img.width, img.height);
            //缩放比例
            bit = img.height / 640;
            //设置画布宽
            canvas.width = img.width / bit;
            //web控件大小跟随图片联动
            $("#buttons").css('width', img.width / bit);
            $("label").css('width', img.width / (bit * 4));
            //设置画布高度
            canvas.height = img.height / bit;
            //画图
            g.drawImage(img, 0, 0, img.width / bit, img.height / bit);
            img.onload = null;
            img.src = BLANK_IMG;
            img = null;
            u = null;
            blob = null;
        };
        var u = URL.createObjectURL(blob);
        img.src = u
    };
    
    ws.onopen = function () {
        console.log('onopen', arguments);
        ws.send('1920x1080/0')
    };
    
    function getPointOnCanvas(canvas, x, y) {
        var bbox = canvas.getBoundingClientRect();
        var cx = parseInt((x - bbox.left * (canvas.width / bbox.width)) * bit);
        var cy = parseInt((y - bbox.top * (canvas.height / bbox.height)) * bit);
        return {'cx': cx, 'cy': cy}
    }
    

    此部分代码来自于 open STF 开源 minicap 部分代码,并做兼容屏幕尺寸相关等方面的修改,社区资料很多,我就不做过多解释了。

    b) 连接 minitouch
    {#    连接minitouch的websocket由websocket转发给minitouch#}
    var wst = new WebSocket("ws://serviceIP:{{ mtsp }}");
    wst.onopen = function (e) {
        console.log('Connection to server opened');
    };
    function sendMessage(met, x, y) {
        wst.send(met + ' 0 ' + x + ' ' + y + ' 50 \n');
    }
    
    tap = 0;
    //发给minitouch, click
    function mousedown(event) {
        second = 0;  //重置空闲时间
        minute = 0;
        $("#freetime").html('<span style="color: red;user-select:none">提示:</span>' + '用完请及时关闭投屏~');
        tap = 1;
        var e = event || window.event;
        var cx = e.pageX;
        var cy = e.pageY;
        var canvas = e.target;
        var bbox = canvas.getBoundingClientRect();
        var sx = parseInt((cx - bbox.left * (canvas.width / bbox.width)) * bit);
        var sy = parseInt((cy - bbox.top * (canvas.height / bbox.height)) * bit);
        sendMessage('d', sx, sy)
    }
    function mousemove(event) {
        // 一点都不优雅
        if (tap == 1) {
            var e = event || window.event;
            var ux = e.pageX;
            var uy = e.pageY;
            var canvas = e.target;
            var location = getPointOnCanvas(canvas, ux, uy);
            sendMessage('m', location.cx, location.cy)
        }
    }
    function mouseout(event) {
        tap = 0;
        sendMessage('u 0\n');
    }
    function mouseup(event) {
        second = 0;  //重置操作时间
        minute = 0;
        $("#freetime").html('<span style="color: red;user-select:none">提示:</span>' + '用完请及时关闭投屏~');
        tap = 0;
        var e = event || window.event;
        var ux = e.pageX;
        var uy = e.pageY;
        var canvas = e.target;
        var bbox = canvas.getBoundingClientRect();
        var ex = parseInt((ux - bbox.left * (canvas.width / bbox.width)) * bit);
        var ey = parseInt((uy - bbox.top * (canvas.height / bbox.height)) * bit);
        sendMessage('m', ex, ey);
        sendMessage('u 0\n');
    }
    

    此部分应用 js websocket 及 mouser 监控部分知识,点击鼠标左键后实时的将鼠标位置用 minicap 的尺寸压缩比还原为真实真机的坐标位置发送给 minitouch,并执行,就可以达到堪比 STF 的使用手感 (实际上就是 STF 的东西),不做过多解释。
    到这里最重要的启动投屏已经完成了,如果顺利的话你将得到如下页面:

2.启动 admin 模式

因为 MAT 平台是开放访问的,并且后台系统没有启用,所以我就搞了个偷鸡的方式,按 shift + <--(方向键 - 右) 启动 admin 模式,在此模式下你可以随意关闭其他人的投屏普通模式下是不允许关闭非本页面打开的投屏的。js 实现,由于代码过于分散,详情请参考位置 /MAT/apps/appcrawler/templates/app_c.html 第 96 行

3.半小时未操作自动关闭投屏

前期试用时老是有人用完不关闭投屏,导致资源浪费,故我通过 js 监控投屏的画布的鼠标动作来监控用户操作计时,当达到 30 分钟用户无操作将自动发起 ajax 移步关闭投屏的请求,自动关闭投屏,释放资源,js 代码同样分散到整个代码里,所以就不展示来,详情请参考位置 /AT/apps/appcrawler/templates/myframe.html 第 189 行

4.web 化 UI Automator Viewer

实现原理:adb 获取当前页面 xml,应用 python-xml 解析 xml 文件,生成 ul li 标签返回给前台,并遍历所有元素生成元素详情清单 yml 文件
详情代码如下:代码位置 /MAT/apps/appcrawler/UIAutomationView.py

# -*- coding: UTF-8 -*-
import xml.etree.ElementTree as ET
import yaml
import os

# 全局唯一标识
unique_id = 0
def write_yaml(json, path):
    # 写
    if type(json) != dict:
        json = eval(json)
    with open(path, 'w+') as f:
        yaml.dump(json, f, default_flow_style=False, allow_unicode=True, encoding='utf-8')

def read_yaml(path):
    # 读取案例yaml数据
    with open(path, 'r') as f:
        return yaml.load(f)

# 遍历所有的节点
def walkData(root_node, level, result_list, file_name, myyaml):
    ul = '<ul id="%s"><li id="%s">%s:%s%s</li>'
    global unique_id
    try:
        cls = root_node.attrib['class'].split('.')[-1]
        text = root_node.attrib['text']
    except:
        cls = root_node.tag
        text = ''
    if root_node.attrib.has_key('bounds'):
        ul_temp = ul % (level, unique_id, cls, text, root_node.attrib['bounds'])
    else:
        ul_temp = ul % (level, unique_id, cls, text, '[][]')
    result_list.append(ul_temp)
    myyaml.__setitem__(unique_id, root_node.attrib)
    # 全局唯一标识,递增
    unique_id += 1
    # 遍历每个子节点
    children_node = root_node.getchildren()
    if len(children_node) == 0:
        result_list.append('</ul>')
        write_yaml(myyaml, file_name)
        return
    for child in children_node:
        walkData(child, level + 1, result_list, file_name, myyaml)
    result_list.append('</ul>')
    write_yaml(myyaml, file_name)
    return result_list

# 获得原始数据
# out:
# <ul><li><span>xx</span></li></ul>
def getXmlData(file_name, name):
    level = 0  # 节点的深度从0开始
    myyaml = {}
    result_list = []
    root = ET.parse(file_name).getroot()
    os.remove(file_name)  #清除xml文件
    # yml
    file_name = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
                             'appcrawler/UIxml/%s.yml' % str(name))
    walkData(root, level, result_list, file_name, myyaml)
    # 运行结束,归0
    unique_id = 0
    return ''.join(result_list)

篇幅有限,代码方面就说这么多吧

四 功能填充

MAT 平台到此时已经具备了云真机的基础功能 (实时远程操控),当然我们的初衷是结合 MAT 平台远程真机操控给它扩充更多实用功能,目前已经完成 UIAutomateViewer 的 web 化以及远程调试 Appium 及运行 Appium 脚本 (当然你也可以把 Appium 换成其他 UI 框架,只需更改极少的代码),还有一些其他辅助性功能,就不赘述了,相信 python 高手很多,Django 版的 MAT 开源作为抛砖引玉可能会给我们带来很多惊喜,当然这也可能这只是我的一厢情愿。

五 已知问题

目前已知的最大问题就是 cerely&redis 构建的异步任务承受不了很大的压力 (同时远程使用 8 台以上云真机),服务器会发烫严重,甚至有宕机的风险,我对这方面了解不深,希望有了解的同学可以参与进来与我共同优化 MAT 云真机平台。还有就是之前所说的 MAT 重要组件,flutter 版 MAT app,作用是把远程真机投屏到移动端供远程操控,因为落地失败,还有技术问题搁置了,希望有 flutter 开发经验的小伙伴参与进来。当然除了上述已知问题,还有很多很多的问题,希望同道中人与我联系,共同学习,共同进步。

六 开源及说明

目前代码已提交github 点击跳转,第一次开源项目,欢迎 star,欢迎提 issues。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 8 条回复 时间 点赞
yca MAT:安装及操作指南 中提及了此贴 11月27日 22:33
yca MAT:远程测试机&自动化调试执行机之 web 平台 中提及了此贴 11月28日 20:47

不要孤独,看好你

老弟加油- 3- 哥看好你!

在 android7.0 以上,你没有权限还能 adb 启动 minicap 跟 minitouch?

现在推广的怎么样了?

楼主,请问这个为什么必须要搭建在 mac 下呢

yca #9 · 2022年04月29日 Author
咖啡咖 回复

好多年了,都忘了,现在用 Scrcpy

yca #10 · 2022年04月29日 Author
codeskyblue 回复

大佬,我搞出来这个没多久就转开发了😂 ---来自三年后的回复

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册