STF [假如我来实现 STF 系统不用 node] 整体思路及实现,长文慎入

kyowang · 2018年03月05日 · 最后由 sunrise 回复于 2018年06月25日 · 2975 次阅读
本帖已被设为精华帖!

第一次写文章,希望为社区做点贡献。
STF框架大家已经不陌生,论坛也有很多大牛们写的关于STF细节的优秀文章,这里我主要想给大家分享的是:

假如你的老板对你说:“小A啊,我们公司业务越来越好,测试人员越来越多,android测试手机也多。几百台啦。
老是借来借去,一借借一天,测试也就几十分钟,效率好低啊。你有没有什么解决办法呢”,
这时候,你想起在论坛看过的STF框架,你终于抑制不住激动的情绪对老板说:“老板我就等你这句话呢,我都研究好久啦,STF”
老板:“STF什么玩意儿?”
你:“STF可以管理手机,远程使用,测试,不用借来借去的啦。基于node js的一个开源项目。我们可以拿来试试。”
老板:“我这人行走江湖几十年😄,最讨厌的就是node js了,你能不能换java呢。咱们得搞点儿自主知识产权的东西嘛。”
你:“...”
于是你陷入了无尽的沉思中。骚年,别怕,这里我就带你一起理一理,如何弄一个让你老板满意的系统。
首先,我们要思考一下整体系统应该如何搭建。看下图(用了一张老图,懒的改了)

图一(系统架构)
此图中我们能看出系统中有两个核心服务

一 HostAgent(暂时先这么叫,你完全可以取一个更加洋气的名字,比如awsomeAgent)

Agent主要部署在手机通过usb连接的电脑上,负责维护手机的状态,比如手机上线,下线,管理apk安装,卸载,以及终结websocket,并转换成tcp socket(面向手机上的服务)。
之所以把agent独立出来,主要还是考虑到手机众多,按照stf官方的兼容机+PCI-E+USB hub的配置,一台电脑最多带28台手机(4*7)。那么系统要维护上百台的话,就需要多台电脑来维护手机。因此独立出来方便扩展。
另外,为什么websocket是从浏览器到agent,而不是浏览器到webserver,后者看起来貌似要更合理一些,对的,看起来是要合理一些。我一开始也是采用的后者,之所以改为前者是因为我的webserver要部署在公司集中机房,而agent只用部署在办公室实验室里面,而大部分测试人员会通过办公电脑访问设备租用服务,这么一来,如果websocket是从浏览器到webserver终结,那么意味着所有minicap帧需要从实验室的手机,通过tcp到达webserver,然后到达浏览器,比起第一种方案,数据到集中机房绕了一圈又回到了办公室网络,所以果断换成了第一种方案,事实上实测下来,多兜一圈的话的确会增加大概500ms-1s的延迟。为了高效选了前者,而你可以根据你的实际情况作出选择。

二 WebServer

WebServer的主要职责是提供管理页面,设备租用页面。维护管理数据库,是websock的发起端(js),webserver根据负荷可以扩展,但不同于agent,server的所有扩展机是对等的。agent由于单个手机只能挂在一台电脑上,所以每台agent的资源是有区别的。

三 系统理解

好我们现在来理解一下,上图系统的设计思路。并且理清楚主要技术方案和可行性。

  • 手机动态管理

    首先对于agent来说,最重要的功能就是手机动态管理,因为,我不想我的系统每次插上一个新手机还需要手动敲一堆命令,或者在系统里面手动添加,这样太low对吧,我们想要的是手机通过USB插上agent电脑的一瞬间,我们的agent自动感知到手机接入,并且自动更新server的数据(online/offline状态),如果是第一次插入的手机我们可能还想给手机自动截个屏上传,作为展示图标,因为这样显得更得体。而不是用一张通图,然后还要显示的信息是手机的品牌,型号,内存,屏幕等等信息这些我们只在新手机第一次接入时主动获取,并更新给server保存。
    好,那怎么感知呢?还好有开源库,那就是google的ddmlib,android studio就是用这个库来管理手机调试的。所以直接拿来用就好了。加入下面的依赖到你的项目里。

    <dependency>
        <groupId>com.android.tools.ddms</groupId>
        <artifactId>ddmlib</artifactId>
        <version>25.2.0</version>
    </dependency>
    

    然后你需要实现一个自己的listener来告知在相关事件里面你需要做的处理。例如:

    @Service
    public class DeviceChangeListener implements AndroidDebugBridge.IDeviceChangeListener {
    Logger  log = Logger.getLogger(DeviceChangeListener.class);
    
    @Override
    public void deviceConnected(IDevice iDevice) {
        log.info("Device: "+iDevice.getSerialNumber()+" connected");
        if(iDevice.getState() != null && iDevice.getState().equals(IDevice.DeviceState.ONLINE)){
            onOnline(iDevice);
        }
    }
    
    @Override
    public void deviceDisconnected(IDevice iDevice) {
        log.info("Device: "+iDevice.getSerialNumber()+" disconnected");
        onOffline(iDevice);
    }
    
    @Override
    public void deviceChanged(IDevice iDevice, int i) {
        log.info("Device: "+iDevice.getSerialNumber()+" changed");
        if(iDevice.getState().equals(IDevice.DeviceState.ONLINE)){
            onOnline(iDevice);
        }
    }
    
    public void onOnline(IDevice iDevice){
        log.info("Device: "+iDevice.getSerialNumber()+" online");
        onlineTask.onDeviceOnline(iDevice);
    }
    
    public void onOffline(IDevice iDevice){
        log.info("Device: "+iDevice.getSerialNumber()+" offline");
        offlineTask.onDeviceOffline(iDevice);
    }
    }
    

    这里放点核心代码去掉冗余,你需要实现AndroidDebugBridge.IDeviceChangeListener这个接口。注意在deviceConnected里面要判断是否是ONLINE状态,因为手机连接后有可能是offline或者未授权状态,然后在online的时候你需要一个异步任务去做你online要做的事情,不要阻塞事件感知的线程。
    现在你已经拥有了一个Listener处理函类了。你需要把它的实例注册给ddmlib的AndroidDebugBridge。
    当然要先初始化一下,然后创建好adb对象后可能你要需要调用isConnected函数检查一下是否adb连接成功了。

    AndroidDebugBridge.init(false);
    AndroidDebugBridge.addDeviceChangeListener(listener);
    adb = AndroidDebugBridge.createBridge(adbpath,false);
    //adb.isConnected()
    

    嗯,恭喜你,到这里为止,你的agent已经可以动态感知手机的插入和拔出了。至于在online时你需要做什么具体的事情,在上面已经有罗列,你需要一张管理device的数据库表,里面有手机序列号,手机状态(online/offline),系统版本,品牌,型号等等的很多参数,你需要在online的时候动态更新这张表。

  • 手机服务管理及forward管理

好了,现在假设你感知到了手机的上线事件。为了完成设备远程控制(租用),是不是上线的时候还需要做点什么?
对的,你需要把stf的几个手机端的服务(minicap,minitouch,stf agent, stf service),从stf项目剥离出来,然后在手机上线的时候安装进手机。下面分享一些实用的命令给大家,都是代码片段,非完整代码,请自行修改使用。
然后我们需要把stf的服务编译好(具体参考stf官方文档),然后将其放入agent的项目的某个目录里面,这样才能用adb install命令或者adb push命令将其安装到手机上面。安装过程比较简单,可以参考下面的命令完成。

private static final String SHELL_OPEN_BROWSER = "am start -a android.intent.action.VIEW -d %s";
private static final String SHELL_ADB_INSTALL = "pm install -r %s";
private static final String SHELL_ADB_UNLOCK = "am start -anw io.appium.unlock/.Unlock";
private static final String SHELL_ADB_RECENT_TASK = "am start -anw com.android.systemui/.recent.RecentsActivity";
private static final String SHELL_ADB_LAUNCH = "am start -anw %s";
private static final String SHELL_ADB_HOME = "input keyevent 3";
private static final String SHELL_ADB_BACK = "input keyevent 4";
private static final String SHELL_ADB_POWER = "input keyevent 26";
private static final String SHELL_ADB_CHECK_SCREEN_ON = " dumpsys power | grep mScreenOn=";
private static final String SHELL_ADB_CHECK_SCREEN_ON_2 = " dumpsys power | grep \"Display Power\"";
private static final String SHELL_ADB_GET_IP = "ifconfig |grep \"inet addr\" |grep -v \"127.0.0.1\"";
private static final String SHELL_ADB_GET_IP2 = "ip addr show |grep inet |grep -v inet6 |grep -v \"127.0.0.1\"";
// Below is just part of some commands.
switch (info){
        case INFO_CPU:
            return "cat /proc/cpuinfo |grep Hardware";
        case INFO_RAM:
            return "cat /proc/meminfo |grep MemTotal";
        case INFO_BATTERY:
            return "dumpsys batterystats 2>/dev/null |grep Capacity";
        case INFO_RESOLUTION:
            return "wm size 2>/dev/null ";
        case INFO_RESOLUTION2:
            return "dumpsys window 2>/dev/null | grep  'init='";
        case INFO_PACKAGE_LIST:
            return "pm list packages 2>/dev/null |grep " + (para == null ? "" : para);
        case INFO_WEBVIEW_VERSION:
            return "pm dump com.google.android.webview 2>/dev/null | grep versionName";
        default:
            return "";
    }

stf服务在安装好之后,我不建议你立即启动这些服务,一方面是启动之后会一直占用系统资源,另一方面是部分低版本手机,在minicap/minitouch启动之后,真机上的触摸屏就没有反应了。只能通过minitouch控制。还有就是如果是上线就启动,如果中途服务出了问题,你也无法预知。所以最好的办法是有远程控制session建立的时候动态启动服务,结束之后停止服务。
好,我认为你的服务也能正常启动了。不幸的是,很快你会发现新的问题又来了。
1 你的server要使用websocket来传输所有数据(minicap frame/minitouch command),而你发现stf服务都是提供的tcp服务,这个怎么办?中间肯定需要转换的。
2 另外一个问题是,stf服务启动在手机上,agent要连接这些tcp服务需要感知手机的ip地址,并且手机的ip和agent必须要3层路由可达。

对于第二个问题,adb有提供一个forward功能可以把本机的某个端口和手机的某个端口映射起来,让你不用考虑手机的ip,通过agent访问本机的ip+端口就可以间接访问到手机上的对应服务。这个在stf文档里面也可以看到。具体命令如下:

//minicap forward
iDevice.createForward(item.getPortMinicap(),"minicap", IDevice.DeviceUnixSocketNamespace.ABSTRACT);
//minitouch forward
iDevice.createForward(item.getPortMinitouch(),"minitouch", IDevice.DeviceUnixSocketNamespace.ABSTRACT);
iDevice.createForward(item.getPortStfService(),"stfservice",IDevice.DeviceUnixSocketNamespace.ABSTRACT);
iDevice.createForward(item.getPortStfAgent(),"stfagent",IDevice.DeviceUnixSocketNamespace.ABSTRACT);

在你使用了forward功能之后,你可以方便的用tcp连接本机的端口就可以连接到对应的手机端stf服务了。嗯,你看起来很兴奋。
但是,你发现还有一个问题需要进入你的考虑范围,那就是,一个agent连接多台手机,对于本机端口也不能重复,所以,需要映射到本机的端口你需要根据手机的数量管理起来。当然这个小问题是难不倒你的。不过我仍然提供一个参考的解决方案给你。
本机为每个服务类型预留一个段,每个段有100个端口,基本够的,因为你每台机器带不了100台手机那么多。

adb forward tcp:1313 localabstract:minicap #(host预留1300)
adb forward tcp:1111 localabstract:minitouch #(host预留1400)
adb forward tcp:1100 tcp:1100 #(host预留1500)
adb forward tcp:1090 tcp:1090 #(host预留1600)

对于第一个问题,tcp的连接你得自己来写代码了。你通过刚才的端口,建立tcp连接到对应的手机上(服务得先起来才行:)),然后正确读写即可。
对于连上minicap/minitouch之后,如果读写并解码,后面我尽量单独开一个帖子讲一下。如果有时间的话。

  • 设备租用会话管理

现在有了上面的铺垫,可能你已经在agent里面实现了几个线程,这几个线程分别负责agent到手机stf服务的tcp连接的编解码。
对于一个设备租用的会话来说,会话资源会包括上面4个线程,还会包括一个websocket session,可能还有端口资源(forward相关)等等。而agent会在这两者之间不断交换数据。
这时候你发现把这些资源管理起来很有必要,要保证一个设备租用会话里面的所有资源,在启动的时候,合理申请,并且在正常结束,或者异常结束时能够全部被回收。否则手机一多,你的整个状态就是混乱的。并且可能带来各种意想不到的问题。

你可以尝试实现一个manager类,它负责管理所有租用session的创建,然后实现一个租用session类,它负责维护并管理所有session相关资源的聚合,启动,和释放。
按照之前的建议,stf 服务的启动和结束,也可以完全和租用session的生命周期匹配起来,即租用session启动的时候,同时启动手机里面的stf服务,租用session结束的时候也同时结束stf服务在手机中的运行。

  • websocket

    嗯。能看到这里,说明你对自己开发一个设备管理租用系统真的很有兴趣,你即将在执着的道路上越走越远,越走越远:)
    好了,现在agent的部分大概已经讲的差不多了。我们是时候考虑一下websocket方案了。把最后一块拼图拼出来,你就可以拉着你的老板show一下你的新系统了。
    对于websocket,你可以用原始的websocket对象,也可以使用各种库,这里我选的是SockJS库。考虑到和java服务端spring所支持的websocket方案配合。spring支持的STOMP不太适合我们这个使用场景,所以我没有使用STOPM方式。
    更详细的请参考spring官方文档:https://docs.spring.io/spring/docs/5.0.0.BUILD-SNAPSHOT/spring-framework-reference/html/websocket.html
    这里我先展示点前端的js部分逻辑。
    js在load的时候构建SockJS对象,建立socket连接,然后注册几个回调函数,onmessage会在服务端调用sendmessage函数发送信息后调用,onopen会在socket建立好之后调用,做一些初始化工作,onclose则在socket断开时调用。下面的代码只是框架我去掉了冗余的内容,加了点注释,
    重点是在onmessage里面,里面 要区分并处理minicap帧信息,minitouch初始化信息,stf agent和stf service的反馈等信息。

function connect(){
    socket = new SockJS(hostUrlPrefix + '/socket');
    socket.onopen = function () {
        console.log("on open");
        sendStart(serial,reso);
    }
    socket.onmessage = function (msg) {
        var prefix = msg.data.substr(0,6);
        var len = msg.data.length;
        // console.log("Received msg length:"+ len);
        // Handle message from websocket server。
        // it could be minicap frame or stf agent or stf service command feedback, or some other information
    }
    socket.onclose = function () {
        console.log("onClose received! closing");
        // sendClose(safeCode);
        disconnect();
        showEndFrame();
    }
}
  • minicap帧处理

这个时候你应该会想到,在agent端从minicap socket里面读到的每一帧都是2进制的内容,我怎么把它传输到浏览器,并在浏览器显示出手机的画面来呢?
嗯,这的确是一个问题。首先你参考了stf框架,发现它是用js的blob的结构把每一帧发送到浏览器,并在浏览器解开,画到canvas上,完成一帧的显示。
于是你也在网上搜了一圈,如何在java后端构造blob格式并且通过websocket发送给浏览器,很遗憾,没有太多的信息可以给你帮助。
而且你发现spring提供的websocket框架发送二进制貌似也不好用。于是在千钧一发之际,你不得不寻找其他方法。
嗯。你最终会发现可以将2进制图片编码成base64的格式,
传输到浏览器,而img元素刚好可以直接展示base64编码过的图片文件,当然,唯一的缺点是每一帧数据会大1/3。

function showFrame(message) {
    var imgx = imgpool.next();
    imgx.onload=function(){
        if(canvas.width * imgx.height != canvas.height * imgx.width){
            canvas.width = imgx.width;
            canvas.height = imgx.height;
        }
        ctx.drawImage(imgx,0,0,canvas.width,canvas.height);
        imgx.onload = imgx.onerror = null;
        imgx.src = BLANK_IMG;
        imgx = null;
    }
    imgx.onerror = function() {
        // Happily ignore. I suppose this shouldn't happen, but
        // sometimes it does, presumably when we're loading images
        // too quickly.

        // Do the same cleanup here as in onload.
        imgx.onload = imgx.onerror = null;
        imgx.src = BLANK_IMG;
        imgx = null;
    }
    imgx.src="data:image/jpg;base64,"+message;
    frameCount++ ;
}

java编码部分,我在发送前有可能对图片做压缩,所以多调用了一个压缩函数。

Base64.Encoder encoder = Base64.getEncoder();
String result = encoder.encodeToString(compressImg(head.getJpg(),qSize));

这样你就得到了需要最终发送的string,和收到帧数据后的处理逻辑。需要提醒的是在浏览器端,因为websocket只有一个链接,后端对应了4个tcp链接的数据,这里面你需要做一个区分。具体如何区分,我相信这个问题是难不倒你的。

  • minitouch命令与按键转换

好的,现在画面已经可以正确显示在浏览器上了。你是不是已经有了很大的成就感,一个牛逼的系统就要搞定啦。

对于如何将cavas上的鼠标操作以及键盘事件转化为minitouch支持的格式。你还需要一些代码来打磨。
对于键盘,浏览器得到的键盘码和安卓相同字符的键盘码有比较大的出入,你最好要维护一个映射表做对应的转换。
下面的代码是对功能按键的映射,对于键盘按键,功能键 只有keydown/keyup貌似没有keypress事件。而下面的代码
先判断是否为 特殊按键特殊按键需要转换,正常字符直接把字符发送给stf agent服务即可。

更详细的测试和文档可以参见:
https://dvcs.w3.org/hg/d4e/raw-file/tip/key-event-test.html
http://unixpapa.com/js/testkey.html
https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/keyCode

function keydown(event) {
    e = event || window.event;
    var char = getChar(event || window.event)
    var msg;
    var keyc;
    if(!char) {
        switch (event.keyCode){
            case 8://BACKSPACE
                e.preventDefault ? e.preventDefault() : (e.returnValue = false);
                keyc=67;
                break;
            case 13://ENTER
                keyc=66;
                break;
            case 27: //ESC
                keyc=111;
                break;
            case 38://UP Arrow
                keyc=19;
                break;
            case 37://LEFT Arrow
                keyc=21;
                break;
            case 40://DOWN Arrow
                keyc=20;
                break;
            case 39://RIGHT Arrow
                keyc=22;
                break;
            case 46://DELETE
                keyc=112;
                break;
            default:
                return;
        }
        msg = {
            event : 2, //key press
            keyCode : keyc,
            shiftKey : e.shiftKey,
            ctrlKey : e.ctrlKey,
            altKey : e.altKey,
            metaKey : e.metaKey,
            symKey : false,
            functionKey : false,
            capsLockKey : false,
            scrollLockKey : false,
            numLockKey :false
        };
        var message = JSON.stringify(msg);
        //0 means keyevnet
        message = "0"+message;
        console.log(message);
        sendStfAgentCommand(message);
    }
}

而对于鼠标在canvas上滑动,对应到真实的手机屏幕上,我们可以参考如下代码,做对应转换:

function getXFromEvent(event){

    var offx = event.offsetX;
    var canvasWidth = canvas.offsetWidth;
    var actX = offx*deviceWidth/canvasWidth;
    return parseInt(actX);
}
function getYFromEvent(event){
    var offy = event.offsetY;
    var canvasHeight = canvas.offsetHeight;
    var actY = offy*deviceHeight/canvasHeight;
    return parseInt(actY);
}

到此为止,基本就可以把功能串通了。你很高兴的找来老板演示了一把,老板也很开心,马上准备全公司推广试用。

但是好景不长,你在实验室搭建的过程中,发现,你们公司主要都是台式机或者部分mac机,默认一个台式机后面带4-6个USB接口。
你要挂40台机器,就需要8-10台物理机。这样机器利用率太低。
如果你足够细心,在研究stf的问题列表时你能找到stf两个作者的一些回答。对你很有用。
他们也用台式机,但是他们不用主板自带的USB口,因为太不可靠,而且供电不足。他们采用PCI-E USB卡,的方式提供USB口。
一块PCI-E USB卡4个USB口。然后再加4个USB hub,而USB hub的挑选也是一件头疼的事。因为大部分usb hub要么供电不足,
要么都是3.0,不够稳定。相比较而言2.0要比3.0的hub更稳定一些,3.0只是速度快对我们价值不大。

于是你照着这个方案,你还得去买PCI-E卡,自己插到电脑上,然后USB hub国内买不到stf推荐的。只能买到orico的一款10口带供电的。
你发现你好像快变成一个实验室管理员了。

这时候,手机口有了。实验室手机摆的乱七八糟,你觉得找一个手机架,把手机搁起来可能更好,于是你又到网上搜到了一个满意的架子,
到货后,嗯,你傻眼了。因为得自己装起来,因为没有别人会帮你做这件事。好的,此事占用你1个下午时间。
很明显你是不会被这些琐事所打倒的对吧,因为你心里那牛逼的工程还没完工,同事们还没大面积用起来不是?

很快,实验室基本成型了。同事们也开始用了起来,并且发现挺好用的,速度也不错。正如下面的截图所示。

嗯。老板找到你,说大家用起来反应不错,效率确实提升,我们要继续优化系统,保证稳定好用。
然后你试了各种方法让界面和操作延迟更小,比如,按读写拆分minicap线程,比如按帧率动态调节图片画质,比如,提供了远程调试功能,让开发也能随便使用你平台的手机随时调试应用。还有比如加入脚本录制等等。
在长期的运营过程中,你发现手机 不能长期挂着,这样电池容易出问题,比如挂久了,有的手机的电池容易鼓包(有点吓人对吧,还好没有爆炸,这是万幸)。
于是你不得不在每周结束的时候把所有机器都下掉。周一来再挂。
并且发现很多手机现在直接挂上去默认usb调试关闭了,需要手动切换一下usb状态。为了保障我们的系统更稳定的运行,这点事情对你来说不算什么。
然后随着使用变多,有的同事会向你反应,有的手机用不了。这时你抱着怀疑的态度来到他说的手机旁,发现部分手机挂着挂着就重启,并且进入系统失败了,需要手动重启。
部分手机是进入了recover模式,也需要手动重启,还有部分手机会因为莫名其妙的usb连接原因,直接掉线.....

嗯。当你走到这一步时,你应该能意识到,你已经成功变成了一名资深运维人员😄

题外话,当你成为资深运维人员之后,公司会采购新的手机,进入实验室。你在挂新手机的时候,你会发现,
vivo的手机需要强制登录vivo账号,并且用adb install安装apk时,会弹出对话框让输入密码。
小米部分手机,minitouch的权限(模拟触屏操作)需要登录小米账号,并且,插入sim卡,才能在开发者选项里面打开。
我能感受到你的愤怒,开发者选项开个权限,你让我登录账号我忍了。插sim卡是什么鬼,手机都是我的,
嗯嗯,你尽管骂吧,因为反正他们也听不到的。而且就算听到也不会改的,因为我看到过论坛无数人在骂了。

好的,本文就到此结束了。

共收到 28 条回复 时间 点赞
Lihuazhang 将本帖设为了精华贴 03月06日 17:26

作者是个stf 资深用户呀

厉害,搞一下

貌似没有adbkit,这块比较有用 光屏幕操作共享 意义还不是很大,希望谁能重写adbkit

这些坑我都遇到过,但就是没有勇气把stf改成其它语言。赶紧把我招进去做吧。😁

dongdong 回复

adbkit 本身就能单独使用,我以前写过关于这个的帖子

楼主这个是每次更新整张图吗?印象中minicap好像是可以局部更新的

dongdong 回复

adbkit是js的,其实就是adb操作的包装,系统实现肯定夸不过这一步。没有单独说。基于ddmlib已经够简单了实际上。

carl 回复

minicap没有局部更新,不过minicap在界面没有任何变化的时候就不会产生帧。

codeskyblue 回复

单独使用是可以但是需要 和其它系统一起控制权限的话就麻烦了

不错 深度好文。最近还在跟别人聊要不要搞个社区的设备租用平台

期待楼主后续~

点个赞,相当棒。可以加入stf QQ群里一起交流:168170256。

seveniruby 回复

憋搞了

在做python版的默默膜拜下~💯

楼主,你们在公司是什么岗位?

你已经成功变成了一名资深运维人员😂
@Lihuazhang ,当年我们搭建DE的lab好多了

mark一下,准备4月份开始做一个python版本的出来,借鉴借鉴~

能否告知一下,所需要的PCI-E usb 扩展卡 和 usb hub的具体牌子和型号?

lidongdabie 回复

PCI-E 卡现在很少,在网上搜了,选了西霸的,用下来感觉一般。
usb hub选的Orico的10口带供电的2.0那款。160左右吧。

只能说你们老板资源多,能投人重复造轮子👍

#16楼 @yoegg 请问python版本的情况怎样了?

—— 来自TesterHome官方 安卓客户端

可以把完整代码发出来学习下吗?

太佩服楼主了,对STF这块研究的很深入啊。一直都想花时间研究,哎

usb hub,orico的是坑货,用了就知道经验谈。。。
实践下来,还是推荐使用西普莱的工业级USB HUB,或者金田的usb hub。。。

😂给楼主点赞...我们组之前撸的就是react.js + python版本的...

楼主太强了😱

我们管理200多台手机,用工业级USB HUB暂时没发现问题,而且连接wifi插座,每天晚上断电,避免电池鼓包。
那些重新挂上去默认usb调试关闭的手机我们是集中到放在一台agent上的,每星期手动断电一次。

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