UI自动化解决方案:opendx学习记录——设备控制

opendx是一套完整的UI自动化解决方案,项目地址:opendx,目前已停止维护,但是新的项目还没有发布,所以先学习一下opendx来入门UI自动化框架和解决方案设计。
opendx包含了server服务、agent代理服务和一个web后台项目,关系如图所示:

opendx架构图

查看opendx的详细介绍请移步官方文档:介绍 | opendx
本文章主要写的是agent服务实现设备连接和运行自动化脚本相关的功能模块(以Android设备模块为例),目前是草稿状态,后续会继续补充

检测设备

当agent服务启动时,在启动加载类AgentStartRunner会重启本地ADB服务,并向ADB添加一个监听器androidDeviceChangeListener,通过监听器的回调,agent服务可以监听设备的状态变更。
androidDeviceChangeListener实现了AndroidDebugBridge.IDeviceChangeListener接口,该接口提供了3个回调方法,分别对应设备连接设备断开连接设备状态变更 3种场景的监听回调:

void deviceConnected(IDeivce var1);
void deviceDisconnected(IDevice var1);
void deviceChanged(IDevice var1, int var2);

当有Android设备接入时,androidDeviceChangeListener的设备连接回调方法会进行以下处理;
1、启动一个子线程,后续步骤都在子线程中处理
2、等待设备(IDevice)上线,等待期间每秒判断一次设备是否上线,超时时间5分钟
3、如果设备在agent有记录,就更新agent的设备对象属性,然后通知server设备已上线
3、如果是首次连接agent,启动本地appium服务,向server查询设备是否已入库
4、如果设备已入库,server返回一个Mobile对象,通过设备、Mobile和appiumServer实例化一个AndroidDevice对象
4、如果未入库,则:
(1)实例化Mobile对象,将设备信息赋值给对象对应的属性字段
(2)通过设备实例、Mobile实例和appiumServer实例化一个AndroidDevice
(3)通过androidDevice给设备安装minicap、minitouch和一个测试安装包
(4)初始化appium,设备截图并上传server
(5)给AndroidDevice实例设置Minicap、Minitouch、Scrcpy、AdbKit属性
5、将Android实例存入agent的DeviceHolder,然后通知server设备已上线,同时请求server新增/更新设备信息
6、一些其余的agent初始化操作

IDevice、Mobile和Android的关系如图所示:
设备对象之间的关系

IDevice对象来自于ADB的监听回调,因此IDevice代表真实的设备对象状态和属性,AndroidDevice通过这个IDevice实例来构造对象。AndroidDevice获取到IDevice中的设备数据后,通过Mobile对象来维护具体的设备参数信息,而AndroidDevice对象本身则维护一些系统定义的设备状态和设备驱动信息等。

设备连接回调的部分代码:

protected void mobileConnected(IDevice iDevice) {
    String mobileId = iDevice.getSerialNumber();

    Device device = DeviceHolder.get(mobileId);
    if (device == null) {
        log.info("[{}]首次接入agent", mobileId);

        Mobile mobile = ServerClient.getInstance().getMobileById(mobileId);

        log.info("[{}]启动appium server...", mobileId);
        AppiumServer appiumServer = new AppiumServer();
        appiumServer.start();
        log.info("[{}]启动appium server完成, url: {}", mobileId, appiumServer.getUrl());

        if (mobile == null) {
            try {
                log.info("[{}]首次接入server,开始初始化...", mobileId);
                device = initMobile(iDevice, appiumServer);
            } catch (Exception e) {
                log.info("[{}]停止appium server", mobileId);
                appiumServer.stop();
                throw new RuntimeException(String.format("[%s]初始化失败", mobileId), e);
            }
        } else {
            log.info("[{}]已接入过server", mobileId);
            device = newMobile(iDevice, mobile, appiumServer);
        }

        beforePutDeviceToHolder(device);
        DeviceHolder.put(mobileId, device);
    } else {
        log.info("[{}]重新接入agent", mobileId);
        reconnectToAgent(device, iDevice);
    }

    device.onlineToServer();
    log.info("[{}]MobileConnected处理完成", mobileId);
}

连接设备

前端先通过server查询设备是否可用,然后请求agent的/scrcpy/android/{mobileId}/user/{username}/project/{projectId}接口[1]来建立前端agent之间的websocket连接。

当连接建立时,会进行以下处理:

  1. 从DeviceHolder查找指定deviceId的空闲设备,如果查找为空则发送错误信息并抛出异常
  2. 从ws连接池(WebSocketSessionPool)池查找指定deviceId的会话,如有会话则发送错误信息并抛出异常
  3. 设备空闲且未被连接,将当前会话加入ws连接池,并告知server设备已占用
  4. 通过Scrcpy将设备屏幕内容持续发送给前端(子线程)
  5. 刷新设备的appium驱动(RemoteWebDriver,来自selenium)
  6. 将设备remoteWebDriver的sessionId发送给前端
@OnOpen
public void onOpen(@PathParam("mobileId") String mobileId, @PathParam("username") String username,
                   @PathParam("projectId") Integer projectId, Session session) throws Exception {
    onWebsocketOpenStart(mobileId, username, session);
    scrcpy = ((AndroidDevice) device).getScrcpy();

    sender.sendText("启动scrcpy...");
    scrcpy.start(imgData -> {
        try {
            sender.sendBinary(imgData);
        } catch (IOException e) {
            log.error("[{}]发送scrcpy数据异常", mobileId, e);
        }
    });

    freshDriver(projectId);
    onWebsocketOpenFinish();
}

在Scrcpy的start方法中,先将scrcpy的jar包推到设备缓存目录,然后异步启动设备的scrcpy服务:

if (isRunning) {
    return;
}

pushScrcpyToDevice();

CountDownLatch countDownLatch = new CountDownLatch(1);

new Thread(() -> {
    try {
        String startCmd = String.format(拼接启动scrcpy的cmd字符串...);
        log.info("[{}]start scrcpy: {}", mobileId, startCmd);
        iDevice.executeShellCommand(startCmd, new MultiLineReceiver() {...}, 0, TimeUnit.SECONDS);
        log.info("[{}]scrcpy已停止运行", mobileId);
        isRunning = false;
    } catch (Exception e) {
        throw new RuntimeException(String.format("[%s]启动scrcpy失败", mobileId), e);
    }
}).start();

int scrcpyStartTimeoutInSeconds = 30;
boolean scrcpyStartSuccess = countDownLatch.await(scrcpyStartTimeoutInSeconds, TimeUnit.SECONDS);
if (!scrcpyStartSuccess) {
    throw new RuntimeException(String.format("[%s]启动scrcpy失败,超时时间:%d秒", mobileId, scrcpyStartTimeoutInSeconds));
}

log.info("[{}]scrcpy启动完成", mobileId);
isRunning = true;

然后和设备的scrcpy服务建立socket连接,将设备消息转发到agent本地端口,获取本地端口的输入输出流,输入流用于获取屏幕内容,输入流用于操控设备:

// 获取可用的本地端口
int localPort = PortProvider.getScrcpyAvailablePort();

log.info("[{}]adb forward: {} -> remote scrcpy", mobileId, localPort);
iDevice.createForward(localPort, "scrcpy", IDevice.DeviceUnixSocketNamespace.ABSTRACT);

new Thread(() -> {
    Socket controlSocket = null;
    try (Socket screenSocket = new Socket("127.0.0.1", localPort);
         InputStream screenStream = screenSocket.getInputStream()) {
        检测screenStream是否可用...
        log.info("[{}]connect scrcpy success", mobileId);

        // 初始化controlSocket和controlOutputStream
        controlSocket = new Socket("127.0.0.1", localPort);
        controlOutputStream = controlSocket.getOutputStream();

        获取屏幕宽高...
        log.info("[{}]scrcpy width: {} height: {}", mobileId, width, height);

        byte[] packet = new byte[1024 * 1024];
        int packetSize;

        while (isRunning) {
            通过screenStream获取设备屏幕图像,存到packet中...
            consumer.accept(ByteBuffer.wrap(packet, 0, packetSize));
        }
    } catch (IndexOutOfBoundsException ign) {
    } catch (Exception e) {
        log.warn("[{}]处理scrcpy数据失败", mobileId, e);
    } finally {
        关闭controlsocket和controlOutputStream...
    }
    log.info("[{}]已停止消费scrcpy图片数据", mobileId);

    // 移除adb forward
    try {
        log.info("[{}]移除adb forward: {} -> remote scrcpy", mobileId, localPort);
        iDevice.removeForward(localPort, "scrcpy", IDevice.DeviceUnixSocketNamespace.ABSTRACT);
    } catch (Exception e) {
        log.error("[{}]移除adb forward出错", mobileId, e);
    }
}).start();

自动化操作

新建测试计划后,server将生成的测试任务插入数据库device_test_task表。

初始化任务执行器

在agent中,Device基类初始化时会同时实例化DeviceTestTaskExecutor,该实例初始化时启动一个子线程来持续获测试任务。

// 设备基类Devcie.java
public Device(DeviceServer deviceServer) {
    this.deviceServer = deviceServer;
    //在对象初始化的同时实例化一个DeviceTestTaskExecutor
    deviceTestTaskExecutor = new DeviceTestTaskExecutor(this);
    ...
}
// 设备测试任务执行器类DeviceTestTaskExecutor.java
public DeviceTestTaskExecutor(Device device) {
    this.device = device;
    // 用于持续获取测试任务的子线程
    executeTestTaskThread = new Thread(() -> {
        DeviceTestTask deviceTestTask;
        while (true) {
            try {
                deviceTestTask = testTaskQueue.take(); // 没有测试任务,线程阻塞在此
            } catch (InterruptedException e) { ... }

            try {
                // 执行测试任务
                executeTestTask(deviceTestTask);
            } catch (Throwable e) { ... }
        }
    });
    executeTestTaskThread.start();
}

获取测试任务

executeTestTaskThread线程从taskTestQueue中获取任务,taskTestQueue的任务数据来源于定时任务ScheduledTaskExecutor.commitDeviceTestTask(),该任务10秒检测一次,将未开始的任务添加到所有空闲设备的任务队列中。

执行测试任务

executeTestTask方法中,会进行以下操作:
1、将设备变为使用中
2、将测试任务数据转换为自动化测试脚本
3、编译脚本
4、重置设备驱动(继承自selenium的RemoteWebDriver)
5、运行编译后的自动化脚本,开始自动化测试
6、关闭设备驱动,将设备变为空闲
如果中途编译脚本或运行脚本出错,会中止自动化测试,并将错误信息更新到device_test_ask表

转换自动化脚本

在executeTestTask方法中,这两行代码完成了由DeviceTestTask对象数据到TestNG代码文本的转换:

String className = "Test_" + UUIDUtil.getUUID();
String code = TestNGCodeConverterFactory.create(deviceTestTask.getPlatform()).convert(deviceTestTask, className);

首先创建一个随机的类名,然后通过TestNGCodeConverterFactory工厂类创建一个对应设备系统的TestNGCodeConverter对象,最后调用TestNGCodeConverter.convert方法将DeviceTestTask对象转换为testng代码文本。

构建代码内容对象

convert方法中,首先创建了一个名为dataModel的键值对,然后:

  • 添加testcases
    testcases的值是一个JSONObject列表,其字段如下:
List<Testcase> testcases = deviceTestTask.getTestcases();
dataModel.put("testcases", testcases.stream().map(testcase -> {
    JSONObject tc = new JSONObject();
    tc.put("invoke", getInvokeMethodStringWithDefaultParamValue(testcase));
    tc.put("description", getTestcaseDesc(deviceTestTask, testcase));
    tc.put("dependsOnMethods", getTestcaseDependsOnMethods(testcase.getDepends()));
    tc.put("id", testcase.getId());
    return tc;
}).collect(Collectors.toList()));

其中,getInvokeMethodStringWithDefaultParamValue方法将测试任务中的Testcase(继承自Action类)转换为action_xxx(p1, p2, ...);格式的字符串,p1p2等是根据参数类型(Action.params[n].type)生成的默认值;
getTestcaseDesc方法返回由设备id,测试任务id,测试用例id是否开启视频录制,失败重试次数5个字段组成的字符串;
getTestcaseDependsOnMethods方法返回被依赖Action组成的字符串,格式如:{"action_2","action_1"},action_x的x是对应action的id。

  • 添加前置/后置处理类和方法
    使用前一个步骤创建的testcase列表初始化一个Action列表:List<Action> actions = new ArrayList<>(testcases);
    如果测试任务配置了BeforeClass/AfterClass、BeforeMethod/AfterMethod,将其添加到actions中;随后调用getInvokeMethodStringWithDefaultParamValue方法为生成带默认参数的字符串,将其添加到dataModel中,如:
Action beforeClass = deviceTestTask.getBeforeClass();
if (beforeClass != null) {
    actions.add(beforeClass);
    String invokeBeforeClass = getInvokeMethodStringWithDefaultParamValue(beforeClass);
    dataModel.put("beforeClass", invokeBeforeClass);
}
  • 添加actions
    将不重复的Action(包含Action中的importAction)添加到列表cachedActions(TestNGCodeConverter类的属性)中;
    然后将cachedAction列表中执行Java代码的Action移除;
    随后解析cachedAction列表中每个Action的局部变量、setup参数、步骤参数、tearDown参数,将${val}形式的参数转换为实际参数值;
    最后将cachedAction的值添加到dataModel:
// 将上个步骤的actions内的Action和其importAction去重添加到列表cachedActions
parseActions(actions);
// 移除执行Java代码的Action
cachedActions.remove(BaseAction.EXECUTE_JAVA_CODE_ID); // ExecuteJavaCode无需调用
// 解析cachedActions中的局部变量和占位符参数
handleActionValue();  
dataModel.put("actions", cachedActions.values());
  • 添加其它属性字段
dataModel.put("className", className);  // Test_xxxxxxxxx
dataModel.put("actionPrefix", ACTION_PREFIX);  // action_
dataModel.put("testcasePrefix", TESTCASE_PREFIX);  // testcase_
dataModel.put("executeJavaCodeActionId", BaseAction.EXECUTE_JAVA_CODE_ID);  // 1

dataModel.put("driverClassSimpleName", getDriverClass().getSimpleName());
dataModel.put("actionClassSimpleName", getActionClass().getSimpleName());
dataModel.put("deviceClassSimpleName", getDeviceClass().getSimpleName());

// 引入自动化脚本所需的所有依赖,放入javaImports集合中
handleJavaImports();
dataModel.put("javaImports", javaImports);

// 将全局变量的`{val}`形参值转换为实际值
handleGlobalVarValue(deviceTestTask.getGlobalVars());
dataModel.put("deviceTestTask", deviceTestTask);
代码对象写入文件
  • 将dataModel转换为代码字符串
    String code = FreemarkerUtil.process(FTL_BASE_PACKAGE_PATH, FTL_FILE_NAME, dataModel);
    通过FreeMaker[2]将dataModel转换为代码字符串,FTL_BASE_PACKAGE_PATH是包路径,FTL_FILE_NAME是文件名
  • 创建Document对象并实例化
    通过org.eclipse.jdt.core.ToolFactory创建CodeFormatter并执行格式化操作
IDocument doc = new Document(code);
ToolFactory.createCodeFormatter(null)
    .format(CodeFormatter.K_COMPILATION_UNIT, code, 0, code.length(), 0, null)
    .apply(doc);
返回代码字符串

最终convert方法返回上个步骤格式化后的doc的字符串:return doc.get();


  1. Android 5.0以下设备请求/stf/android/{mobileId}/user/{username}/project/{projectId}接口

  2. 什么是FreeMaker

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343