世面上云真機(jī)平臺(tái)有很多,但開(kāi)源的很少篡诽,且收費(fèi)不菲,于是深挖了下實(shí)現(xiàn)原理稳衬,著手設(shè)計(jì)開(kāi)發(fā)一個(gè)符合自身定制需求的平臺(tái)霞捡。
背景
疫情期間,同事們?cè)诩疫h(yuǎn)程辦公薄疚,為保證移動(dòng)端版本的測(cè)試進(jìn)度碧信,和移動(dòng)設(shè)備的最大化利用率,基于開(kāi)源框架搭建了一套云真機(jī)系統(tǒng)街夭。
并根據(jù)應(yīng)用兼容性標(biāo)準(zhǔn)砰碴,接入了常用的Android 4.4 ~ 9.0測(cè)試機(jī),及IOS 10設(shè)備板丽。
實(shí)際運(yùn)行過(guò)程中呈枉,存在Android部分設(shè)備易掉線、IOS高版本不兼容埃碱、操作卡頓等現(xiàn)象猖辫。
現(xiàn)狀
在平臺(tái)搭建過(guò)程中,對(duì)比調(diào)研了現(xiàn)有一些比較知名的云真機(jī)服務(wù)平臺(tái)砚殿,如下:
體驗(yàn)了下啃憎,基本上都是基于開(kāi)源框架STF的Android遠(yuǎn)程真機(jī),支持iOS端的很少似炎,操作體驗(yàn)不是很好辛萍,并且收費(fèi)也相對(duì)較高。
深入了解了為數(shù)不多的幾個(gè)開(kāi)源方案(STF 集成 iOS羡藐、atxserver2 手機(jī)設(shè)備管理平臺(tái))的實(shí)現(xiàn)原理后贩毕,著手開(kāi)始了平臺(tái)核心組件的開(kāi)發(fā)。
架構(gòu)設(shè)計(jì)
系統(tǒng)前端采用reactjs開(kāi)發(fā)仆嗦,監(jiān)聽(tīng)用戶(hù)在設(shè)備顯示區(qū)域的鼠標(biāo)操作辉阶,通過(guò)http && websocket來(lái)與python flask服務(wù)端通信。后端將用戶(hù)的操作轉(zhuǎn)發(fā)給provider,由provider與對(duì)應(yīng)的設(shè)備進(jìn)行交互:
- IOS調(diào)用WDA根據(jù)XCUITest封裝的http接口
- Android調(diào)用minitouch的websocket接口
云真機(jī)系統(tǒng)架構(gòu)大部分都差不多,由于ios真機(jī)需要調(diào)用xcodebuild執(zhí)行Test Scheme睛藻,所以需要部署在mac系統(tǒng)上启上,且要保持usb連接。Android對(duì)系統(tǒng)沒(méi)有要求店印,只要有個(gè)provider去管理設(shè)備即可冈在。
核心輪子介紹
云真機(jī)系統(tǒng)核心是設(shè)備界面同步和用戶(hù)的操作同步,了解到的方案對(duì)比如下:
界面同步框架
名稱(chēng) | 安卓 | 蘋(píng)果 | 備注 |
---|---|---|---|
minicap | ? | ? | |
ios-minicap | ? | ? | 一臺(tái)mac只支持一臺(tái)設(shè)備 |
scrcpy | ? | ? | 需二次開(kāi)發(fā) |
adb | ? | ? | 需二次開(kāi)發(fā)自定義封裝按摘,且圖片過(guò)大 |
idevicescreenshot | ? | ? | 需二次開(kāi)發(fā)自定義封裝 |
MJPEG Server | ? | ? |
操作同步框架
名稱(chēng) | 安卓 | 蘋(píng)果 | 備注 |
---|---|---|---|
minitouch | ? | ? | |
scrcpy | ? | ? | 需二次開(kāi)發(fā) |
webDriverAgent | ? | ? | |
adb | ? | ? |
iOS解決方案
通過(guò)上述的框架對(duì)比包券,我最終選擇了使用 appium-webDriverAgent 作為iOS設(shè)備的遠(yuǎn)程控制驅(qū)動(dòng),他們自定義封裝的MJPEG Server炫贤,輸出的 multipart/x-mixed-replace
格式的數(shù)據(jù)流溅固,可以直接用在 <img />
上。
實(shí)現(xiàn)效果
截的gif圖像有些失真了兰珍,實(shí)際很清晰侍郭。
關(guān)于appium-webDriverAgent的安裝和開(kāi)發(fā)者證書(shū)配置這里不再贅述,可以參見(jiàn)Readme掠河。
啟動(dòng)WDA
要集成到服務(wù)中亮元,可以用xcodebuild命令行來(lái)啟動(dòng),我們的持續(xù)集成平臺(tái)也用的這個(gè)唠摹。
xcodebuild -project WebDriverAgent.xcodeproj \
-scheme WebDriverAgentRunner \
-destination 'platform=iOS Simulator,name=iPhone 6' \
test
啟動(dòng)的是個(gè)模擬器爆捞,如果是真機(jī)把destination里的配置換成設(shè)備ID即可:-destination 'id=xxxxxxxid'
, 設(shè)備id可以通過(guò)Xcode或者idevice_id -l
獲取勾拉。
端口轉(zhuǎn)發(fā)
如上面的啟動(dòng)wda服務(wù)后煮甥,你還需要把手機(jī)的MJPEG服務(wù)端口暴露出來(lái),默認(rèn)是9100
藕赞,我們可以通過(guò)iproxy來(lái)轉(zhuǎn)發(fā)9100端口成肘,要做多設(shè)備管理,在上面xcodebuild的命令里加上MJPEG_PORT=xxxx
參數(shù)來(lái)實(shí)現(xiàn)斧蜕。
iproxy轉(zhuǎn)發(fā)命令:
iproxy 9100 9100
同時(shí)因?yàn)榍岸说耐聪拗仆Ы伲倚枰逊?wù)通過(guò)nginx再次給轉(zhuǎn)發(fā)下。實(shí)際項(xiàng)目中“8100惩激、9100”
端口是動(dòng)態(tài)生成入庫(kù)跟蹤的,同時(shí)會(huì)動(dòng)態(tài)生成nginx的配置文件蟹演,通過(guò)nginx -s reload
去更新服務(wù)
nginx中的轉(zhuǎn)發(fā)配置:
location deviceControllPort/ { proxy_pass http://127.0.0.1:deviceControllPort/; } //設(shè)備操作控制服務(wù)
location deviceScreenPort/ { proxy_pass http://127.0.0.1:deviceScreenPort/; } //設(shè)備界面顯示服務(wù)
界面同步
這里我用css給ios加了設(shè)備邊框风钻,目前代碼不全,只寫(xiě)了比較通用的iphone和劉海屏的X系列酒请÷饧迹可以根據(jù)設(shè)備的大小自動(dòng)調(diào)節(jié)邊框。
<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>
操作同步
因?yàn)椴僮饕彩钦{(diào)WDA接口,所以上面設(shè)置好了后布朦,設(shè)備無(wú)再做其他的設(shè)置囤萤。
關(guān)于操作設(shè)備,我們可以直接讓前端與設(shè)備通信是趴,也可以讓前端把請(qǐng)求發(fā)送server再由去調(diào)WDA涛舍。
各有利弊,前者直接通信會(huì)快點(diǎn)唆途,但安全性不好控制富雅。可以根據(jù)實(shí)際使用場(chǎng)景來(lái)設(shè)計(jì)肛搬。
現(xiàn)階段WDA的操作還是http請(qǐng)求的没佑,有精力有能力時(shí)可以轉(zhuǎn)成websocket提高效率。同樣原版的webdriveragent里點(diǎn)擊api判斷的邏輯較多温赔,參照mrx1203的修改方案蛤奢,做了些優(yōu)化,也加了些自定義的api陶贼,如控制設(shè)備旋轉(zhuǎn)屏幕等常用操作啤贩。關(guān)于使用XCEventGenerator
私有api,優(yōu)化點(diǎn)擊速度的方案需慎用骇窍,不兼容Xcode10.1以上瓜晤。
主要的修改如下:
//用于遠(yuǎn)程控制,通過(guò)旋轉(zhuǎn)角度設(shè)置橫豎屏
[[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解決方案
對(duì)比了現(xiàn)有框架腹纳,我采用的是minicap做界面同步痢掠,minitouch做操作同步,服務(wù)端封裝adb命令執(zhí)行輔助操作嘲恍。推薦個(gè)將兩者結(jié)合了工具 atx-agent 這是非必須的足画,根據(jù)自己需要添加。minitouch與minicap本身也可以通過(guò)websocket與外部通信佃牛,關(guān)于詳細(xì)的實(shí)現(xiàn)原理參見(jiàn)其Readme淹辞。
界面同步
因?yàn)槭褂昧薬tx-agent,界面同步和操作同步俘侠,我只需監(jiān)聽(tīng)設(shè)備的一個(gè)端口即可象缀,默認(rèn)的設(shè)備上是7912。要做多設(shè)備集成爷速,可以通過(guò)adb forward
把設(shè)備端口與服務(wù)端任意個(gè)端口進(jìn)行綁定央星,與之前的iproxy功能類(lèi)似。
通過(guò)atx-agent啟動(dòng)minicap與minitouch命令:
$ adb shell /data/local/tmp/atx-agent server -d # 啟動(dòng) | 停止需加上--stop
轉(zhuǎn)發(fā)設(shè)備端口
$ adb forward tcp:serverPort tcp:7912
此時(shí)可以通過(guò)http://server:port/screenshot
來(lái)看到設(shè)備的一張靜態(tài)圖片了惫东,要讓它動(dòng)起來(lái)莉给,我們需要借助前端代碼實(shí)現(xiàn)毙石。
創(chuàng)建顯示組件
AndroidPhoneFrame
為自定義封裝的外框組件,主要的界面同步顯示代碼為其中img標(biāo)簽颓遏,給它綁定了ref(例子為reactjs語(yǔ)法)徐矩,后面根據(jù)這個(gè)ref對(duì)其src屬性進(jìn)行編輯。
<AndroidPhoneFrame>
<div className={styles.deviceScreen}>
<img
ref={node => {
this.androidScreen = node;
}}
src={`http://server:port/screenshot?t=${new Date().getTime()}`}
alt=""
/>
</div>
</AndroidPhoneFrame>
建立連接并實(shí)時(shí)刷新顯示
代碼如下叁幢,最好放在Dom加載后觸發(fā)滤灯。可以看到這里是建立了一個(gè)socket(如果沒(méi)有用atx-agent可以將地址換成minicap的服務(wù)地址)遥皂,監(jiān)聽(tīng)服務(wù)端發(fā)來(lái)blob圖片力喷,并將其更新到前面定義的顯示標(biāo)簽上。
minicap的圖片已經(jīng)被壓縮處理過(guò)了了演训,比原生adb截圖小近百倍弟孟,而且atx-agent還進(jìn)行了二次處理,因此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')
}
}
操作同步
用戶(hù)在網(wǎng)頁(yè)端的操作主要是鼠標(biāo)事件拂募,iOS部分沒(méi)有細(xì)化介紹,這里簡(jiǎn)單說(shuō)下窟她,因?yàn)閙initouch本身的語(yǔ)法格式要求陈症,可以看到這里把u, d, c, w
這幾個(gè)事件與鼠標(biāo)mouseDown, mouseMove, mouseUp
結(jié)合了起來(lái),也正是由于其特殊的實(shí)現(xiàn)方式震糖,安卓可以實(shí)現(xiàn)按住滑動(dòng)录肯,而iOS是滑動(dòng)完才會(huì)觸發(fā)事件。
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的操作吊说,所以對(duì)于旋轉(zhuǎn)论咏、Home、Back等快捷操作颁井,還需要外部的輔助厅贪。我使用的是adb命令。
以旋轉(zhuǎn)屏幕為例:
$ adb shell settings put system user_rotation 1 # 0雅宾,1养涮,2,3眉抬,4對(duì)應(yīng)著0~360°贯吓,先確保自動(dòng)旋轉(zhuǎn)已關(guān)閉。
這些adb命令可以通過(guò)服務(wù)端封裝蜀变,針對(duì)被控設(shè)備 -s deviceId
調(diào)用宣决。
實(shí)現(xiàn)效果
由于安卓機(jī)型眾多,暫時(shí)就不加邊框顯示了昏苏。
結(jié)語(yǔ)
穩(wěn)定可以擴(kuò)展云真機(jī)的系統(tǒng),不僅僅是一個(gè)移動(dòng)設(shè)備的管理平臺(tái),還可以結(jié)合移動(dòng)UI自動(dòng)化贤惯、移動(dòng)應(yīng)用持續(xù)集成洼专、遠(yuǎn)程調(diào)試等產(chǎn)生更多的價(jià)值。