0%

支持IOS&Android的云真机平台的设计与实现

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

图片没有单独传,可以在我的简书中看到:https://www.jianshu.com/p/2ff4c60243f5

背景

疫情期间,同事们在家远程办公,为保证移动端版本的测试进度,和移动设备的最大化利用率,基于开源框架搭建了一套云真机系统。
并根据应用兼容性标准,接入了常用的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命令行来启动,我们的持续集成平台也用的这个。

1
2
3
4
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转发命令:

1
iproxy 9100 9100

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

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

界面同步

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<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以上。

主要的修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

//用于远程控制,通过旋转角度设置横竖屏
[[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命令:

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

转发设备端口

1
$ adb forward tcp:serverPort  tcp:7912

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

创建显示组件

1
2
3
4
5
6
7
8
9
10
11
12
```html
<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这种方案流畅度更好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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是滑动完才会触发事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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命令。
以旋转屏幕为例:

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

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

实现效果

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

phone2.gif

结语

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