世面上云真机平台有很多,但开源的很少,且收费不菲,于是深挖了下实现原理,着手设计开发一个符合自身定制需求的平台。
图片没有单独传,可以在我的简书中看到:https://www.jianshu.com/p/2ff4c60243f5
背景 疫情期间,同事们在家远程办公,为保证移动端版本的测试进度,和移动设备的最大化利用率,基于开源框架搭建了一套云真机系统。 并根据应用兼容性标准,接入了常用的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
✘
✔
操作同步框架
iOS解决方案 通过上述的框架对比,我最终选择了使用 appium-webDriverAgent 作为iOS设备的远程控制驱动,他们自定义封装的MJPEG Server,输出的 multipart/x-mixed-replace
格式的数据流,可以直接用在 <img />
上。
实现效果 截的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转发命令:
同时因为前端的同源限制,我需要把服务通过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
转发设备端口 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 } 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, 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 ({ 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
这些adb命令可以通过服务端封装,针对被控设备 -s deviceId
调用。
实现效果 由于安卓机型众多,暂时就不加边框显示了。
结语 稳定可以扩展云真机的系统,不仅仅是一个移动设备的管理平台,还可以结合移动UI自动化、移动应用持续集成、远程调试等产生更多的价值。