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

yca · 2018年11月25日 · 最后由 codeskyblue 回复于 2019年03月14日 · 817 次阅读

一 简单说两句

数月之前,在我刚完成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
    {#    连接minitouchwebsocket,由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。

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

不要孤独,看好你

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

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

现在推广的怎么样了?

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