stf源码解读 stf(smartphone test farm)是一个开源的远程调试工具,这个项目使用到了很多组件如屏幕信息的minicap,屏幕触控的minitouch,消息传递的zeromq等等,非常复杂。简要记录下自己二次开发的过程,值得注意的是整个项目主要就是两个开发者,而且开放了一批周边插件,很厉害。 这个项目是B/S模式,浏览器端使用的是angular1+jade编写,服务器端采用的node,构建工具使用的是webpack+gulp。安装环境要求是linux或者mac,我第一次配置感觉还挺复杂,可以看下这篇文章 。我们的需求是将#/controls页面中的Devices tab的内容换成屏幕数据的直接展示,这其中遇到了机器bios连接数和多个canvas绘制的性能问题。可以看下我在github上面提的两个issue:
Not enough host controller resources for new device #489
A performance issue after i change some code #497 解决方式我后面会阐述,先看下最终的效果图 功能繁多,我以我如何实现需求进行说明。
前端代码 所有的前端代码在stf/res目录下,后端代码都在stf/lib目录下。我们能快速找到入口js文件res/app/app.js。但是却没有找到入口页面,为什么呢。因为首页是服务器端输出的。在lib/units/app/index.js中可以看到一个典型的express node服务器的代码1
2
3
4
5
6
7
8
9
app.set('view engine' , 'pug' )
app.set('views' , pathutil.resource('app/views' ))
app.set('strict routing' , true )
app.set('case sensitive routing' , true )
app.set('trust proxy' , true )
app.get('/' , function (req, res ) {
res.render('index' )
})
设置了模板引擎是pug(jade改名而来),设置模板存放位置在app/views,设置默认情况下render index(也就是app/views/index.pug)。入口找到了,接下来就看路由设置,熟悉过angular1的人应该都知道路由的定义是要用到$routeProvider的。我们要修改的页面路由是http://localhost:7100/#!/devices。 然而在app.js中我们看到了这段路由定义1
2
3
4
5
6
7
.config(function ($routeProvider, $locationProvider ) {
$locationProvider.hashPrefix('!' )
$routeProvider
.otherwise({
redirectTo : '/devices'
})
})
这里并没有如我所想的$routeProvider.when(‘devices’)。其实这个配置被分到了每个模块之中,比如定义devices路由的在app/device-list/index.js中。相应的也看到了这个路由对象的template和controller1
2
3
4
5
6
7
.config(['$routeProvider' , function ($routeProvider ) {
$routeProvider
.when('/devices' , {
template : require ('./device-list.pug' ),
controller : 'DeviceListCtrl'
})
}])
前端代码的架构大概就是这样,分出了很多模块,每个模块中包含了路由配置,模板文件,controller文件,service文件等等。
后端代码 前面说了,后端代码都在stf/lib目录下,一般启动stf服务器用的是node bin/stf local。顺着bin/stf文件很轻易找到cli.js。这个文件很重要,使用command.js定义了大量的node命令,这些命令又调用util/procutil.js创建大量进程。我们先看local参数,它的代码太长就不列出来了,可以看出它新建了如下进程,其中app值得app一侧消息处理的进程;dev指是device一侧消息的处理进程: 1.’triproxy’, ‘app001’ 2.’triproxy’, ‘dev001’ 3.’processor’, ‘proc001’ 4.’processor’, ‘proc002’ 5.’reaper’, ‘reaper001’ 6.’provider’ 7.auth 8.’app’ 9.’api’ 10.’websocket’ 11.’storage’ 12.’storage-plugin-apk’ 13.’poorxy’ 看到这里我就蒙了,还要在github上找了这张结构图,顺着结构图看才能看明白
以这个场景为例:browser中路由#/control页面中显示手机屏幕画面,滑动屏幕画面,真实设备的画面也随之改变。 1.找到该页面对应的模板是app/control-panes/device-control/device-control.pug。 2.找到屏幕显示部分是一个device-screen(device=’device’, control=’control’)的指令 3.在app/components/screen/screen-directive.js定义了device-screen.可以看到它就是一个canvas。另外在这个指令中使用了websocket连接到服务器端的socket1
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
var ws = new WebSocket(device.display.url)
ws.binaryType = 'blob'
ws.onmessage = (function ( ) {
return function messageListener (message ) {
screen.rotation = device.display.rotation
if (message.data instanceof Blob) {
if (shouldUpdateScreen()) {
if (scope.displayError) {
scope.$apply(function ( ) {
scope.displayError = false
})
}
var blob = new Blob([message.data], {
type : 'image/jpeg'
})
var img = imagePool.next()
img.onload = function ( ) {
updateImageArea(this )
g.drawImage(img, 0 , 0 , img.width, img.height)
img.onload = img.onerror = null
img.src = BLANK_IMG
img = null
blob = null
URL.revokeObjectURL(url)
url = null
}
img.onerror = function ( ) {
img.onload = img.onerror = null
img.src = BLANK_IMG
img = null
blob = null
URL.revokeObjectURL(url)
url = null
}
var url = URL.createObjectURL(blob)
img.src = url
}
}
else if (/^start / .test(message.data)) {
applyQuirks(JSON .parse(message.data.substr('start ' .length)))
}
else if (message.data === 'secure_on' ) {
scope.$apply(function ( ) {
scope.displayError = 'secure'
})
}
}
}
实际连接多台设备的时候显示每个device.display.url是不同的,其实是每台设备对应对应一个线程,这样是有道理的,多个不同的port有利于突破浏览器的同域下请求数量的限制,提高数据传输量。在onmessage也能看到大量的内存优化,提高垃圾回收效率,传输的blob数据就是屏幕数据,使用windowURL类创建bloburl,然后使用canvas drawImage绘制在画布。
那服务器端怎么建立起websocket服务器端呢? 直接看到cli.js启动的provider进程,这个代码在lib/units/provider/index.js1
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
var client = adb.createClient({
host : options.adbHost
, port : options.adbPort
})
client.trackDevices().then(function (tracker ) {
tracker.on('add' , filterDevice(function (device ) {
function spawn ( ) {
var allocatedPorts = ports.splice(0 , 4 )
var proc = options.fork(device, allocatedPorts.slice())
var resolver = Promise .defer()
}
}
tracker.on('change' , filterDevice(function (device ) {
flippedTracker.emit(device.id, 'change' , device)
}))
tracker.on('remove' , filterDevice(function (device ) {
flippedTracker.emit(device.id, 'remove' , device)
}))
}
adbkit 是openstf开源的一个adb的nodejs client。client.trackDevices这个api就可以监听设备的plug和unplug消息。所以这里新设备插入之后会fork出一个新的进程,而这个命令的定义就在cli.js中的’device ‘。这个进程代码就在lib/units/device/index.js。这个文件夹下包含了大量的代码,与屏幕数据相关的代码就在/deivce/screen/stream.js。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
56
57
58
59
60
61
function createServer ( ) {
var wss = new WebSocket.Server({
port : screenOptions.publicPort
, perMessageDeflate : false
})
}
return createServer()
.then(function (wss ) {
wss.on('connection' , function (ws ) {
function wsFrameNotifier (frame ) {
return new Promise (function (resolve, reject ) {
switch (ws.readyState) {
case WebSocket.OPENING:
return reject(new Error (util.format(
'Unable to send frame to OPENING client "%s"' , id)))
case WebSocket.OPEN:
ws.send(frame, {
binary : true
}, function (err ) {
return err ? reject(err) : resolve()
})
return
case WebSocket.CLOSING:
return
case WebSocket.CLOSED:
broadcastSet.remove(id)
return reject(new Error (util.format(
'Unable to send frame to CLOSED client "%s"' , id)))
}
})
}
ws.on('message' , function (data ) {
var match = /^(on|off|(size) ([0-9]+)x([0-9]+))$/ .exec(data)
if (match) {
switch (match[2 ] || match[1 ]) {
case 'on' :
broadcastSet.insert(id, {
onStart : wsStartNotifier
, onFrame : wsFrameNotifier
})
break
case 'off' :
broadcastSet.remove(id)
break
case 'size' :
frameProducer.updateProjection(
Number (match[3 ]), Number (match[4 ]))
break
}
}
})
}
});
代码太多我就不全列举出来了,这里就是建立了WebSocketServer与前面device-screen中的连接一一对应。发送的on,size,off等消息与这里onmessage正好也对应。broadcastSet用于距离多个连接的数据。wsFrameNotifier中ws.send(frame)就是将数据推送到device-screen中,画面也就在canvas中显示出来了
上面解释了画面怎么显示出来,再来看看滑动画面是怎么传递消息的: 前端代码还是在screen-directive.js中,直接找到mouseMoveListener1
2
3
4
5
6
7
8
9
10
11
12
13
14
control.touchMove(nextSeq(), 0 , scaled.xP, scaled.yP, pressure)
if (addGhostFinger) {
control.touchDown(nextSeq(), 1 , 1 - scaled.xP, 1 - scaled.yP, pressure)
}
else if (deleteGhostFinger) {
control.touchUp(nextSeq(), 1 )
}
else if (fakePinch) {
control.touchMove(nextSeq(), 1 , 1 - scaled.xP, 1 - scaled.yP, pressure)
}
control.touchCommit(nextSeq())
这个地方的control是control-service:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function sendOneWay (action, data ) {
socket.emit(action, channel, data)
}
function sendTwoWay (action, data ) {
var tx = TransactionService.create(target)
socket.emit(action, channel, tx.channel, data)
return tx.promise
}
this .touchMove = function (seq, contact, x, y, pressure ) {
sendOneWay('input.touchMove' , {
seq : seq
, contact : contact
, x : x
, y : y
, pressure : pressure
})
}
这里的socket是res/app/components/stf/socket/index.js中的socket-service。socket-service中使用socket.io。不同于screen-directive中使用的ws,这两种都是websocket通信的实现,ws模块更加贴近原生js语法。而socket.io则有自己的一套api,socket.emit就是客户端发送消息的api,而node端创建这个socketserver的代码则在lib/units/websocket/index.js中,可以很顺利的在socket.io的监听函数中找到touchMove信息监听。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var push = zmqutil.socket('push' )
.on('input.touchMove' , function (channel, data ) {
push.send([
channel
, wireutil.envelope(new wire.TouchMoveMessage(
data.seq
, data.contact
, data.x
, data.y
, data.pressure
))
])
})
在这里可以看到push socket的消息发送,这已经使用到了zeromq了,有时间另外详细讲解,我们回头看上面贴出来的stf的架构图,现在顺着看下流程。websocket进程中的push将数据推送到triproxy进程的pull中。看下lib/units/triproxy/index.js中的相关代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function proxy (to ) {
return function ( ) {
to.send([].slice.call(arguments ))
}
}
var pub = zmqutil.socket('pub' )
pub.bindSync(options.endpoints.pub)
log.info('PUB socket bound on' , options.endpoints.pub)
var dealer = zmqutil.socket('dealer' )
dealer.bindSync(options.endpoints.dealer)
dealer.on('message' , proxy(pub))
log.info('DEALER socket bound on' , options.endpoints.dealer)
var pull = zmqutil.socket('pull' )
pull.bindSync(options.endpoints.pull)
pull.on('message' , proxy(dealer))
log.info('PULL socket bound on' , options.endpoints.pull)
pull socket收到消息之后又会调用proxy方法用dealer socket将消息传递出去,对应上图,这个消息被processor进程中的dealer处理了,看下lib/units/processor/index.js相关代码:1
2
3
4
5
6
7
8
9
10
var appDealer = zmqutil.socket('dealer' )
var devDealer = zmqutil.socket('dealer' )
appDealer.on('message' , function (channel, data ) {
devDealer.send([channel, data])
})
devDealer.on('message' , wirerouter()
.on('' )
processor中包含appDealer和devDealer两种socket。考虑到这不是设备端的消息,另外在devDealer的处理中也并没有看到对touchMove消息的处理,所以这里touchMove的消息是appDealer socket接收,devDealer socket发送出去;看图可以发现消息又被triproxy进程处理,这次是其中的dealer socket处理消息,它又将消息从pub socket中转发出来。这时候又被provider进程和provider创建的一批device子进程处理这批消息。 可以在lib/units/device/touch/index.js中最终找到,这个消息的处理1
2
3
4
5
6
7
router
.on(wire.TouchMoveMessage, function (channel, message ) {
queue.push(message.seq, function ( ) {
touchConsumer.touchMove(message)
})
})
简要的说了下我的理解,至于开头需求的实现,下一篇再讲