STF STF 系列之二---minitouch 流程源码分析

shuta · June 04, 2017 · Last by ElanCao replied at July 29, 2019 · 4727 hits

上一篇文章分析STF如何同步设备截图的,这一篇分析用户在前端页面touch设备A时,STF如何将touch动作同步在设备A?本文将从前端,服务端,手机 三个方向说明。

touch流程

touch的整个流程如下所示:

其中triproxy/index.js 从pull到pub用虚线标识出来原因是,中间省略了一系列过程。这些过程会在服务端部分详细说明。

前端

当用户touch设备时,前端如何捕捉用户动作?捕捉到用户动作之后,是如何将数据传输至服务端?

捕捉用户动作-- screen-directive.js

这里说明一下,下文中所有设备详情页指的是http://localhost:7100/#!/control/${serial}页面,

设备详情页中看到设备截图的那个部分,如下图所示

是由screen-directive.js 文件管理的。screen-directive.js中除了之前说的渲染图片二进制文件功能,还可以捕捉用户点击,滑动“设备屏幕”等事件,并调用ControlService.js 文件中对应的函数。代码如下所示

element.on('mousedown', mouseDownListener)  //当用户触发mousedown事件后,调用mouseDownListener函数
function mouseDownListener(){
...
...
control.touchDown(nextSeq(), 0, scaled.xP, scaled.yP, pressure)
...
$document.bind('mouseup', mouseUpListener)
}

当用户在前端点击设备上某个位置时,screen-directive.js捕捉了touchDown动作,调用的是mouseDownListener函数, mouseDownListener会通过$scope.control(即代码中的control)调用ControlService.js的中touchDown函数,向服务端发送消息--点击该台设备的(x,y)位置。

至于为什么在control-panes-controller.js中定义的$scope.control, 能在screen-directive.js使用?这就属于angular 1.x知识,感兴趣的同学可以自己去了解,这里不赘述。

传输数据至服务端 -- control-panes-controller.js

该js文件完成$scope.device,和$scope.control的初始化

...
function getDevice(serial) {
DeviceService.get(serial, $scope)
.then(function(device) {
return GroupService.invite(device)
})
.then(function(device) {
$scope.device = device
$scope.control = ControlService.create(device, device.channel)

// TODO: Change title, flickers too much on Chrome
// $rootScope.pageTitle = device.name

SettingsService.set('lastUsedDevice', serial)

return device
})
.catch(function() {
$timeout(function() {
$location.path('/')
})
})
}
...

其中:

$scope.control = ControlService.create(device, device.channel)

我们来分析ControlService.js文件

function sendOneWay(action, data) {
socket.emit(action, channel, data)
}

...
...
this.touchDown = function(seq, contact, x, y, pressure) {
sendOneWay('input.touchDown', {
seq: seq
, contact: contact
, x: x
, y: y
, pressure: pressure
})
}
...

ControlService.js中封装了一系列操作设备的函数:如: this.touchDown。但这些操作设备函数都调用了sendOneWay方法。sendOneWay方法调用了socket.emit(),而这个socket来自socket-service.js文件。这个文件主要内容如下所示

var io = require('socket.io')
var websocketUrl = AppState.config.websocketUrl || ''

var socket = io(websocketUrl, {
reconnection: false, transports: ['websocket']
})
...

这段代码创建了websocket client(websocket 在之前的文章已经描述过了,这里不再赘述)。ControlService.js文件使用这个socket向服务端发送消息。

总结写一下,this.touchDown函数,通过socket.emit向服务端发送'input.touchDown'消息,并传输该设备的channel和按压点的x,y坐标。

需要注意的是,设备A详情页和设备B详情页,var websocketUrl = AppState.config.websocketUrl || ''不同(端口不同)。

服务端

准备工作

请先仔细阅读zeromq官方文档zeromq其他参考资料。着重看push/pull,pub/sub部分。

服务端流程

服务端是如何接收前端发送的数据,并将数据传输至对应手机呢?这里以单个设备举例。

服务端处理信息流转有三个文件,websocket/index.js, triproxy/index.js, processor/index.js 。最后接收数据和minitouch通信是来自device的sub.js,touch/index.js

在执行stf local命令时,发现STF启动了两个triproxy的实例app001 和 dev001

INF/util:procutil 40934 [*] Forking "/Users/sheranjun/Code/stf/lib/cli.js triproxy app001 --bind-pub tcp://127.0.0.1:7111 --bind-dealer tcp://127.0.0.1:7112 --bind-pull tcp://127.0.0.1:7113"
INF/util:procutil 40934 [*] Forking "/Users/sheranjun/Code/stf/lib/cli.js triproxy dev001 --bind-pub tcp://127.0.0.1:7114 --bind-dealer tcp://127.0.0.1:7115 --bind-pull tcp://127.0.0.1:7116"

其中app001是处理从前端UI push的消息;dev001 是处理从手机push的消息。这两个实例一起完成了前端UI和手机的双向流通。当然本文只分析从前端UI发送消息到手机接收消息单方向的流程

websocket/index.js

index.js 做了两件事,启动websocket服务端,等待接收websocket 客户端消息;启动 app001 push,发送消息至 app001 pull

  • websocket server 初始化
var socketio = require('socket.io')

var io = socketio.listen(server, {
serveClient: false
, transports: ['websocket']
})

....
server.listen(options.port)
log.info('Listening on port %d', options.port)

设备A详情页和设备B详情页启动的是不同的websocket server.

  • app001 push 初始化
var push = zmqutil.socket('push')
log.info("endopoints is " + options.endpoints)
log.info("endopoints is push " + options.endpoints.push)
Promise.map(options.endpoints.push, function(endpoint) {
return srv.resolve(endpoint).then(function(records) {
return srv.attempt(records, function(record) {
log.info('Sending output to "%s"', record.url)
push.connect(record.url)
return Promise.resolve(true)
})
})
})
.catch(function(err) {
log.fatal('Unable to connect to push endpoint', err)
lifecycle.fatal()
})

其中push.connect(record.url), record.url是app001 pull 服务的URL。是唯一的,所有的设备详情页使用的是同一个app001 pull 服务的URL。

  • 什么时候push呢?
io.on('connection', function(socket) {
socket.on('input.touchDown', function(channel, data){
log.info("touch down from websocket")
push.send([
channel
, wireutil.envelope(new wire.TouchDownMessage(
data.seq
, data.contact
, data.x
, data.y
, data.pressure
))
])
})
}

这段语句,将websocket和push联系起来,先来看看前端发送的input.touchDown消息,传输的数据

function sendOneWay(action, data) {
socket.emit(action, channel, data)
}

...
...
this.touchDown = function(seq, contact, x, y, pressure) {
sendOneWay('input.touchDown', {
seq: seq
, contact: contact
, x: x
, y: y
, pressure: pressure
})
}
...

两端代码联系起来,前端发送input.touchDown消息,后端成功接收input.touchDown消息之后,调用 app001 的push端,将channel和封装了data(封装方式这里不重要,不讨论),发送至app 001 的pull端。
同时可以知道,尽管设备A和设备B详情页使用的不同的websocket,但它们使用的push端相同。

triproxy/index.js

triproxy中的index.js,主要是新建pull端,dealer端,以及pub端。代码如下所示

function proxy(to) {
return function() {
to.send([].slice.call(arguments))
}
}

// App/device output
var pub = zmqutil.socket('pub')
pub.bindSync(options.endpoints.pub)
log.info('PUB socket bound on', options.endpoints.pub)

// Coordinator input/output
var dealer = zmqutil.socket('dealer')
dealer.bindSync(options.endpoints.dealer)
dealer.on('message', proxy(pub))
log.info('DEALER socket bound on', options.endpoints.dealer)

// App/device input
var pull = zmqutil.socket('pull')
pull.bindSync(options.endpoints.pull)
pull.on('message', proxy(dealer))
log.info('PULL socket bound on', options.endpoints.pull)

在启动STF时,新建了两个triproxy:app001,dev001。这里拿app001 triproxy来举例,

  • pull.on('message', proxy(dealer)),意味着pull端接受来自websocket/index.js push端的channel/data后,将channel/data通过 dealer 服务端发送出去。
  • dealer.on('message', proxy(pub)),意思是 dealer服务端接收到channel/data后,将channel/data通过pub 端发送出去。

前面提到过,STF新建了两个triproxy:app001 和dev 001。所以,websocket/index.js 中的channel/data通过 app001 push到了app001的pull端。app001pull端,将channel/data从app001 dealer 服务端发送出去,那channel/data又在哪里被接收了呢?

processor/index.js

processor中的index.js,主要是为了app001 dealer client 和dev001 dealear client之间的消息转发。代码如下

// App side
var appDealer = zmqutil.socket('dealer')
Promise.map(options.endpoints.appDealer, function(endpoint) {
return srv.resolve(endpoint).then(function(records) {
return srv.attempt(records, function(record) {
log.info('App dealer connected to "%s"', record.url)
appDealer.connect(record.url)
return Promise.resolve(true)
})
})
})
.catch(function(err) {
log.fatal('Unable to connect to app dealer endpoint', err)
lifecycle.fatal()
})

// Device side
var devDealer = zmqutil.socket('dealer')

appDealer.on('message', function(channel, data) {
devDealer.send([channel, data])
})

Promise.map(options.endpoints.devDealer, function(endpoint) {
return srv.resolve(endpoint).then(function(records) {
return srv.attempt(records, function(record) {
log.info('Device dealer connected to "%s"', record.url)
devDealer.connect(record.url)
return Promise.resolve(true)
})
})
})
.catch(function(err) {
log.fatal('Unable to connect to dev dealer endpoint', err)
lifecycle.fatal()
})

processor中的index.js 中新建了两个dealer client:appDealer client 和 devDealer client。分别连接triproxy中,app001 dealer 服务端和dev001 dealer 服务端。其中

appDealer.on('message', function(channel, data) {
devDealer.send([channel, data])
})

appDealer client 接收到来自 app001 dealer 服务端的channel/data之后,将channel/data传递给devDealer client,devDealer client发送至 dev001 dealer服务端。再联系triproxy/index.js一小节的流程,dev001 的dealer服务端接收到channel/data后,将channel/data通过dev001的pub端发送出去。

至此服务端channel/data流转前半截就将完了。接下来将dev001的pub端的channel/data流向哪里了?

lib/units/device 下sub.js,solo.js,以及touch/index.js

每一个手机都会创造一个device对象。device对象中包含很多功能。和本篇文章相关的是两个功能,一是dev001 sub端并订阅固定channel。这个功能由sub.js和solo.js两个文件完成;二是创造一个touch对象,STF用它来向手机发送指令,这个功能由touch/index.js文件完成。如下图所示:

至于device对象何时创造?如何被创建?这个流程和lib/cli.js,/lib/provider/index.js文件有关。关键词是fork 以及.command('device <serial>'),有兴趣自己研究。

  • sub.js

    为每个手机创建连接到dev001 pub端的sub端。代码:

    var sub = zmqutil.socket('sub')

    return Promise.map(options.endpoints.sub, function(endpoint) {
    return srv.resolve(endpoint).then(function(records) {
    return srv.attempt(records, function(record) {
    log.info('Receiving input from "%s"', record.url)
    sub.connect(record.url)
    return Promise.resolve(true)
    })
    })
    })
    .then(function() {
    // Establish always-on channels
    [wireutil.global].forEach(function(channel) {
    log.info('Subscribing to permanent channel "%s"', channel)
    sub.subscribe(channel)
    })
    })
    .return(sub)

    这里的sub.connect(record.url),record.url是dev001 pub端,这一点可以通过stf启动日志验证。

    STF日志中dev001 pub端口地址如下所示:

    INF/util:procutil 47526 [*] Forking "/Users/****/Code/stf/lib/cli.js triproxy dev001 --bind-pub tcp://127.0.0.1:7114 --bind-dealer tcp://127.0.0.1:7115 --bind-pull tcp://127.0.0.1:7116"

    而STF日志中sub.js打印出的日志:

    INF/device:support:sub 48054 [63a5b447] Receiving input from "tcp://127.0.0.1:7114"
  • solo.js

    solo.js,为sub.js中创建的sub端,订阅该手机的固定频道。代码如下

    function makeChannelId() {
    var hash = crypto.createHash('sha1')
    hash.update(options.serial)
    return hash.digest('base64')
    }

    var channel = makeChannelId()

    log.info('Subscribing to permanent channel "%s"', channel)
    sub.subscribe(channel)

    solo.js通过手机的serial创建channel。可能会有人问,怎么确定dev001 pub端发送的channel(本质上来自前端手机A详情页传输的channel),和该手机在solo.js中订阅的channel是同一个呢?这里简单解释一下。前端传递过来的channel,也来自于后端。前端获取channel的代码在device-service.js文件中,代码如下所示:

    deviceService.load = function(serial) {
    return $http.get('/api/v1/devices/' + serial)
    .then(function(response) {
    return response.data.device
    })
    }

    请求的api,最终执行的后端函数如下所示:

      function getDeviceBySerial(req, res) {
    var serial = req.swagger.params.serial.value
    var fields = req.swagger.params.fields.value

    dbapi.loadDevice(serial)
    .then(function(device) {
    ...
    }

    其中dbapi.loadDevice是操作数据库函数,而对device对象的一系列数据库操作中,只有setDeviceReady函数是更新了device的channel,所以dbapi.loadDevice中channel数据来自于setDeviceReady函数。

      dbapi.setDeviceReady = function(serial, channel) {
    return db.run(r.table('devices').get(serial).update({
    channel: channel
    , ready: true
    , owner: null
    , reverseForwards: []
    }))
    }

    dbapi.setDeviceReady函数又在哪里被调用呢?是的,在processor/index.js。

    devDealer.on('message', wirerouter()
    .on(wire.DeviceReadyMessage, function(channel, message, data) {
    dbapi.setDeviceReady(message.serial, message.channel)
    .then(function() {
    devDealer.send([
    message.channel
    , wireutil.envelope(new wire.ProbeMessage())
    ])

    appDealer.send([channel, data])
    })
    })

    而processor/index.js接收的channel/message/data,来自solo.js。至于solo.js的push,如何将channel/message/data传输至processor/index.js的devDealer client中,按照上一小节的triproxy.js的流程图,对照STF日志,自己分析。这里不再赘述。

    return {
    channel: channel
    , poke: function() {
    push.send([
    wireutil.global
    , wireutil.envelope(new wire.DeviceReadyMessage(
    options.serial
    , channel
    ))
    ])
    }
    }

    所以,前端的channel,也是来自于solo.js,而且channel和serial有一一对应的关系。这就解决了来自前端手机A详情页的数据一定会发送到后端手机A的对应的channel上。

  • touch/index.js

    touch/index.js中做了两件事:一是启动手机的minitouch服务,而是订阅dev001 pub端的数据,并将固定频道channel数据传输至手机。每一个手机都会有一个touch/index.js

    • 启动手机minitouch服务

      • 启动手机上的minitouch服务
      TouchConsumer.prototype._startService = function() {
      log.info('Launching screen service')
      return minitouch.run()
      .timeout(10000)
      }
      • 将手机端启动的minitouch tcp 服务端端口映射到pc端。连接到该pc端口,返回socket。该功能由adb.openLocal函数完成,代码如下所示
      TouchConsumer.prototype._connectService = function() {
      function tryConnect(times, delay) {
      return adb.openLocal(options.serial, 'localabstract:minitouch')
      .timeout(10000)
      .then(function(out) {
      return out
      })
      .catch(function(err) {
      if (/closed/.test(err.message) && times > 1) {
      return Promise.delay(delay)
      .then(function() {
      return tryConnect(times - 1, delay * 2)
      })
      }
      return Promise.reject(err)
      })
      }
      log.info('Connecting to minitouch service')
      // SH-03G can be very slow to start sometimes. Make sure we try long
      // enough.
      return tryConnect(7, 100)
      }

      有兴趣的,可以研究一下adb.openLocal函数,这里不再详细讨论该函数流程

    • 订阅dev001 pub端固定channel

      touch/index.js通过引用sub.js和solo.js,使用订阅固定频道的dev001 sub,等待来自dev001 pub端数据,代码如下所示:

      router
      .on(wire.TouchDownMessage, function(channel, message) {
      log.info("touch down from toucb")
      queue.push(message.seq, function() {
      touchConsumer.touchDown(message)
      })
      })

      而router来自router.js中,router.js引用了sub.js

      module.exports = syrup.serial()
      .dependency(require('./sub'))
      .define(function(options, sub, channels) {
      var log = logger.createLogger('device:support:router')
      var router = wirerouter()

      sub.on('message', router.handler())

      return router
      })

      其中dev001 push端在channelA上发送数据后,订阅了channelA的dev001 sub端接收该数据。并调用touchConsumer.touchDown(message)。而touchDown函数的相关代码如下所示:

          TouchConsumer.prototype.touchDown = function(point) {
      log.info("touch down from touch index")
      this._queueWrite(function() {
      return this._write(util.format(
      'd %s %s %s %s\n'
      , point.contact
      , Math.floor(this.touchConfig.origin.x(point) * this.banner.maxX)
      , Math.floor(this.touchConfig.origin.y(point) * this.banner.maxY)
      , Math.floor((point.pressure || 0.5) * this.banner.maxPressure)
      ))
      })
      }

      TouchConsumer.prototype._write = function(chunk) {
      this.socket.stream.write(chunk)
      }

      touchDown函数中的this._write调用的this.socket来自this._connectService()返回值。this.socket代表着连接手机minitouch tcp服务端的tcp 客户端。touchDown函数接收touchDown消息后,向手机发送以'd'开头的字符串命令。

    到此,服务端消息的流程已经全部解析完成。再看看最开始画的流程图,是不是清晰很多呢?

手机

手机上主要是启动了minitouch的tcp服务,接收STF服务端操作手机指令。并根据指令,操作手机它的代码如下所示

  • start_server函数,启动tcp服务

    static int start_server(char* sockname)
    {
    int fd = socket(AF_UNIX, SOCK_STREAM, 0);

    if (fd < 0)
    {
    perror("creating socket");
    return fd;
    }

    struct sockaddr_un addr;
    memset(&addr, 0, sizeof(addr));
    addr.sun_family = AF_UNIX;
    strncpy(&addr.sun_path[1], sockname, strlen(sockname));

    if (bind(fd, (struct sockaddr*) &addr,
    sizeof(sa_family_t) + strlen(sockname) + 1) < 0)
    {
    perror("binding socket");
    close(fd);
    return -1;
    }

    listen(fd, 1);

    return fd;
    }

    fd既是创造TCP 服务端

  • 等到STF 服务端发送的指令,根据指令操作设备,这里以touchDown指令,举例说明

    int client_fd = accept(server_fd, (struct sockaddr *) &client_addr,
    &client_addr_length);

    .....
    while (io_length < sizeof(io_buffer) &&
    read(client_fd, &io_buffer[io_length], 1) == 1)
    {
    if (io_buffer[io_length++] == '\n')
    {
    break;
    }
    }

    ...
    switch (io_buffer[0])
    {
    case 'c': // COMMIT
    commit(&state);
    break;
    case 'r': // RESET
    touch_panic_reset_all(&state);
    break;
    case 'd': // TOUCH DOWN
    contact = strtol(cursor, &cursor, 10);
    x = strtol(cursor, &cursor, 10);
    y = strtol(cursor, &cursor, 10);
    pressure = strtol(cursor, &cursor, 10);
    touch_down(&state, contact, x, y, pressure);
    break;
    .....
    }

    其中client_fd接收来自STF服务端的指令,io_buffer存储STF服务端消息,并根据第一字符判断操作类型。若第一个字符为d,调用touch_down函数。

后记

touch动作源码分析,整理出来算是一个大的工程了。涉及的核心知识点是zeromq,这里只分析了前端操作是如何同步到设备,逆向流程并没有涉及,有兴趣的可以自己了解。

由于项目需要改造STF,阅读了STF的源码,但项目改造STF重点在STF的前端和STF认证。这两块没有涉及多少STF整体架构设计,更多的是angular 1.x框架的知识。对这两块有兴趣的,欢迎讨论。

共收到 10 条回复 时间 点赞
shuta 关闭了讨论 05 Jun 19:20
shuta 重新开启了讨论 05 Jun 19:20

厉害👍,将代码解析到这种程度,让人很容易理解touch及通讯的整个过程。受用了,多谢分享

shuta #4 · June 21, 2017 作者

我写完这篇帖子已经有17天了,你是第一个回复我的。感动。。。😂 😂 😂

没看先赞,LZ加油。。

虽然知道zmq在STF中的地位很重要,但还是看zmq不爽

受教了,写的非常清晰

大神 minitouch 对 windows 的兼容不好,win7、win10总报socket 错误。这个你遇到过么?不知道是我的问题,还是 minitouch的问题?

楼主好棒,这种原理解析的文章太好了,能读透源码的程序员真厉害

谢谢分享,想问下对设备中的日志和界面数据的传回直到展示这块有没有研究,界面数据的传回和解析时minicap做的吗 stf有做什么处理吗

感觉应该加精

Author only
需要 Sign In 后方可回复, 如果你还没有账号请点击这里 Sign Up