云真机平台的设计与实现--架构分析

世面上云真机平台有很多,但开源的很少,且收费不菲,于是深挖了下实现原理,着手设计开发一个符合自身定制需求的平台。

背景

疫情期间,同事们在家远程办公,为保证移动端版本的测试进度,和移动设备的最大化利用率,基于开源框架搭建了一套云真机系统。
并根据应用兼容性标准,接入了常用的Android 4.4 ~ 9.0测试机,及IOS 10设备。
实际运行过程中,存在Android部分设备易掉线、IOS高版本不兼容、操作卡顿等现象。


TM20200311124328.png

现状

在平台搭建过程中,对比调研了现有一些比较知名的云真机服务平台,如下:

体验了下,基本上都是基于开源框架STF的Android远程真机,支持iOS端的很少,操作体验不是很好,并且收费也相对较高。
深入了解了为数不多的几个开源方案(STF 集成 iOSatxserver2 手机设备管理平台)的实现原理后,着手开始了平台核心组件的开发。

架构设计

系统前端采用reactjs开发,监听用户在设备显示区域的鼠标操作,通过http && websocket来与python flask服务端通信。后端将用户的操作转发给provider,由provider与对应的设备进行交互:

  • IOS调用WDA根据XCUITest封装的http接口
  • Android调用minitouch的websocket接口

云真机系统架构大部分都差不多,由于ios真机需要调用xcodebuild执行Test Scheme,所以需要部署在mac系统上,且要保持usb连接。Android对系统没有要求,只要有个provider去管理设备即可。


ui.png

核心轮子介绍

云真机系统核心是设备界面同步和用户的操作同步,了解到的方案对比如下:

界面同步框架

名称 安卓 苹果 备注
minicap
ios-minicap 一台mac只支持一台设备
scrcpy 需二次开发
adb 需二次开发自定义封装,且图片过大
idevicescreenshot 需二次开发自定义封装
MJPEG Server

操作同步框架

名称 安卓 苹果 备注
minitouch
scrcpy 需二次开发
webDriverAgent
adb

iOS解决方案

通过上述的框架对比,我最终选择了使用 appium-webDriverAgent 作为iOS设备的远程控制驱动,他们自定义封装的MJPEG Server,输出的 multipart/x-mixed-replace 格式的数据流,可以直接用在 <img />上。

实现效果

截的gif图像有些失真了,实际很清晰。


phone1.gif

关于appium-webDriverAgent的安装和开发者证书配置这里不再赘述,可以参见Readme

启动WDA

要集成到服务中,可以用xcodebuild命令行来启动,我们的持续集成平台也用的这个。

xcodebuild -project WebDriverAgent.xcodeproj \
           -scheme WebDriverAgentRunner \
           -destination 'platform=iOS Simulator,name=iPhone 6' \
           test

启动的是个模拟器,如果是真机把destination里的配置换成设备ID即可:-destination 'id=xxxxxxxid', 设备id可以通过Xcode或者idevice_id -l获取。

端口转发

如上面的启动wda服务后,你还需要把手机的MJPEG服务端口暴露出来,默认是9100,我们可以通过iproxy来转发9100端口,要做多设备管理,在上面xcodebuild的命令里加上MJPEG_PORT=xxxx参数来实现。
iproxy转发命令:

iproxy 9100 9100

同时因为前端的同源限制,我需要把服务通过nginx再次给转发下。实际项目中“8100、9100”端口是动态生成入库跟踪的,同时会动态生成nginx的配置文件,通过nginx -s reload去更新服务
nginx中的转发配置:

    location deviceControllPort/ { proxy_pass http://127.0.0.1:deviceControllPort/; } //设备操作控制服务
    location deviceScreenPort/ { proxy_pass http://127.0.0.1:deviceScreenPort/; } //设备界面显示服务

界面同步

这里我用css给ios加了设备边框,目前代码不全,只写了比较通用的iphone和刘海屏的X系列。可以根据设备的大小自动调节边框。

      <div className={styles.phone} style={{height: windowSize.height + 24,width: windowSize.width + 24}}>
          <div className={styles["phone_bg1"]}>
            <div className={styles["phone_bg2"]}>
              <div  className={styles["phone_bg3"]}>
                <div  className={styles["phone_lh"]}>
                  <div  className={styles["phone_lh_con"]}>
                    <div className={styles["lh_tiao"]}></div>
                    <div className={styles["lh_yuan"]}></div>
                  </div>
                </div>
                <div className={styles["phone_screen"]}>
                  <img
                    style={{ height: windowSize.height, width: windowSize.width }}
                    src={screenUrl}
                    alt=""
                    onMouseDown={e => onMouseDown(e)}
                    onMouseUp={e => onMouseUp(e)}
                    // onMouseMove={e => this.handleMouseMove(e)}
                    onDragStart={e => onDragStart(e)}
                    onDragEnd={e => onDragEnd(e)}
                  />
                </div>
                <div className={styles["phone_home"]}></div>
              </div>
            </div>
          </div>
          <div className={styles["jingyin"]}></div>
          <div className={styles["yl_jia"]}></div>
          <div className={styles["yl_jian"]}></div>
          <div className={styles["suoping"]}></div>
        </div>

操作同步

因为操作也是调WDA接口,所以上面设置好了后,设备无再做其他的设置。

关于操作设备,我们可以直接让前端与设备通信,也可以让前端把请求发送server再由去调WDA。
各有利弊,前者直接通信会快点,但安全性不好控制。可以根据实际使用场景来设计。

现阶段WDA的操作还是http请求的,有精力有能力时可以转成websocket提高效率。同样原版的webdriveragent里点击api判断的逻辑较多,参照mrx1203的修改方案,做了些优化,也加了些自定义的api,如控制设备旋转屏幕等常用操作。关于使用XCEventGenerator私有api,优化点击速度的方案需慎用,不兼容Xcode10.1以上。

主要的修改如下:


//用于远程控制,通过旋转角度设置横竖屏
[[FBRoute POST:@"/orientation_Control"].withoutSession respondWithTarget:self action:@selector(handleSetOrientation_Control:)],
[[FBRoute GET:@"/orientation_Control"].withoutSession respondWithTarget:self action:@selector(handleGetOrientation_Control:)],

+ (id<FBResponsePayload>)handleSetOrientation_Control:(FBRouteRequest *)request
{
  [XCUIDevice sharedDevice].orientation = [request.arguments[@"orientation"] integerValue];
  return FBResponseWithOK();
}

+ (id<FBResponsePayload>)handleGetOrientation_Control:(FBRouteRequest *)request
{
  UIDeviceOrientation orientation = [XCUIDevice sharedDevice].orientation ;
  return FBResponseWithObject( @{
                                 @"func":@"orientation_Control",
                                 @"orientation":[NSString stringWithFormat:@"%ld",(long)orientation]
                                 });
}

Android解决方案

对比了现有框架,我采用的是minicap做界面同步,minitouch做操作同步,服务端封装adb命令执行辅助操作。推荐个将两者结合了工具 atx-agent 这是非必须的,根据自己需要添加。minitouch与minicap本身也可以通过websocket与外部通信,关于详细的实现原理参见其Readme。

界面同步

因为使用了atx-agent,界面同步和操作同步,我只需监听设备的一个端口即可,默认的设备上是7912。要做多设备集成,可以通过adb forward把设备端口与服务端任意个端口进行绑定,与之前的iproxy功能类似。
通过atx-agent启动minicap与minitouch命令:

$ adb shell /data/local/tmp/atx-agent server -d   # 启动 | 停止需加上--stop

转发设备端口

$ adb forward tcp:serverPort  tcp:7912

此时可以通过http://server:port/screenshot来看到设备的一张静态图片了,要让它动起来,我们需要借助前端代码实现。

创建显示组件

AndroidPhoneFrame为自定义封装的外框组件,主要的界面同步显示代码为其中img标签,给它绑定了ref(例子为reactjs语法),后面根据这个ref对其src属性进行编辑。

        <AndroidPhoneFrame>
            <div className={styles.deviceScreen}>
              <img
                ref={node => {
                  this.androidScreen = node;
                }}
                src={`http://server:port/screenshot?t=${new Date().getTime()}`}
                alt=""
              />
            </div>
          </AndroidPhoneFrame>

建立连接并实时刷新显示

代码如下,最好放在Dom加载后触发。可以看到这里是建立了一个socket(如果没有用atx-agent可以将地址换成minicap的服务地址),监听服务端发来blob图片,并将其更新到前面定义的显示标签上。

minicap的图片已经被压缩处理过了了,比原生adb截图小近百倍,而且atx-agent还进行了二次处理,因此android这种方案流畅度更好。

syncDisplay = () => {
    let ws = new WebSocket('ws://server:port/minicap/broadcast')
    ws.onclose = () => {
      console.log('onclose ')
    }
    ws.onerror = function () {
      console.log('onerror')
    }
    ws.onmessage = (message) => {
      if (!this.androidScreen){
        console.log('error')
        return
      }
      if (message.data instanceof Blob) {
        let blob = new Blob([message.data], {
          type: 'image/jpeg'
        })
        let URL = window.URL || window.webkitURL
        let u = URL.createObjectURL(blob)
        this.androidScreen.src = u //更新 ref Dom
      } else {
        console.log("receive message:", message.data)
      }
    }
    ws.onopen = function () {
      console.log('onopen')
    }
  }

操作同步

用户在网页端的操作主要是鼠标事件,iOS部分没有细化介绍,这里简单说下,因为minitouch本身的语法格式要求,可以看到这里把u, d, c, w这几个事件与鼠标mouseDown, mouseMove, mouseUp结合了起来,也正是由于其特殊的实现方式,安卓可以实现按住滑动,而iOS是滑动完才会触发事件。

syncTouchpad() {
    const element = this.androidScreen;
    let touchSync = (operation, event) => {
      var e = event;
      if (e.originalEvent) {
        e = e.originalEvent
      }
      e.preventDefault()
      let x = e.offsetX, y = e.offsetY
      let w = e.target.clientWidth, h = e.target.clientHeight
      let scaled = this.coords(w, h, x, y, this.rotation);
      ws.send(JSON.stringify({
        operation: operation, // u, d, c, w
        index: 0,
        pressure: 0.5,
        xP: scaled.xP,
        yP: scaled.yP,
      }))
      ws.send(JSON.stringify({ operation: 'c' }))
    }

    function mouseMoveListener(event) {
      touchSync('m', event)
    }

    function mouseUpListener(event) {
      touchSync('u', event)
      element.removeEventListener('mousemove', mouseMoveListener);
      document.removeEventListener('mouseup', mouseUpListener);
    }

    function mouseDownListener(event) {
      touchSync('d', event)
      element.addEventListener('mousemove', mouseMoveListener);
      document.addEventListener("mouseup", mouseUpListener)
    }

    let ws = new WebSocket("ws://server:port/minitouch")

    ws.onopen = (ret) => {
      console.log("minitouch connected")
      ws.send(JSON.stringify({ // touch reset, fix when device is outof control
        operation: "r",
      }))
      element.addEventListener("mousedown", mouseDownListener)
    }
    ws.onmessage = (message) => {
      console.log("minitouch recv", message)
    }

    ws.onclose = () => {
      console.log("minitouch closed")
      element.removeEventListener("mousedown", mouseDownListener)
    }
  }

辅助操作

由于minitouch本身只是UI的操作,所以对于旋转、Home、Back等快捷操作,还需要外部的辅助。我使用的是adb命令。
以旋转屏幕为例:

$ adb shell settings put system user_rotation 1 # 0,1,2,3,4对应着0~360°,先确保自动旋转已关闭。

这些adb命令可以通过服务端封装,针对被控设备 -s deviceId 调用。

实现效果

由于安卓机型众多,暂时就不加边框显示了。

phone2.gif

结语

稳定可以扩展云真机的系统,不仅仅是一个移动设备的管理平台,还可以结合移动UI自动化、移动应用持续集成、远程调试等产生更多的价值。

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

推荐阅读更多精彩内容