世面上云真机平台有很多,但开源的很少,且收费不菲,于是深挖了下实现原理,着手设计开发一个符合自身定制需求的平台。
背景
疫情期间,同事们在家远程办公,为保证移动端版本的测试进度,和移动设备的最大化利用率,基于开源框架搭建了一套云真机系统。
并根据应用兼容性标准,接入了常用的Android 4.4 ~ 9.0测试机,及IOS 10设备。
实际运行过程中,存在Android部分设备易掉线、IOS高版本不兼容、操作卡顿等现象。
现状
在平台搭建过程中,对比调研了现有一些比较知名的云真机服务平台,如下:
体验了下,基本上都是基于开源框架STF的Android远程真机,支持iOS端的很少,操作体验不是很好,并且收费也相对较高。
深入了解了为数不多的几个开源方案(STF 集成 iOS、atxserver2 手机设备管理平台)的实现原理后,着手开始了平台核心组件的开发。
架构设计
系统前端采用reactjs开发,监听用户在设备显示区域的鼠标操作,通过http && websocket来与python flask服务端通信。后端将用户的操作转发给provider,由provider与对应的设备进行交互:
- IOS调用WDA根据XCUITest封装的http接口
- Android调用minitouch的websocket接口
云真机系统架构大部分都差不多,由于ios真机需要调用xcodebuild执行Test Scheme,所以需要部署在mac系统上,且要保持usb连接。Android对系统没有要求,只要有个provider去管理设备即可。
核心轮子介绍
云真机系统核心是设备界面同步和用户的操作同步,了解到的方案对比如下:
界面同步框架
名称 | 安卓 | 苹果 | 备注 |
---|---|---|---|
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图像有些失真了,实际很清晰。
关于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
调用。
实现效果
由于安卓机型众多,暂时就不加边框显示了。
结语
稳定可以扩展云真机的系统,不仅仅是一个移动设备的管理平台,还可以结合移动UI自动化、移动应用持续集成、远程调试等产生更多的价值。