STF [STF] 二次开发之批量安装 APK

卡农Lucas · 2018年01月20日 · 最后由 listen 回复于 2019年11月22日 · 5035 次阅读
本帖已被设为精华帖!

1. 前言

STF 四年前就有了吧?很遗憾,没能赶上时代的浪潮,现在只能作为债务进行偿还。我写不出这么牛的框架,只能在其基础上平凡创新,批量安装就是一个例子。当然这个也是来自部门的需要,我作为基层实施者,通过我的实践谈谈我的理解。如果有人从中受益,也算我为社区做了点贡献。

2. 安装

不做介绍了,这方面的内容实在太多了,自己找找多试试就行了。

3. 基础知识

这个也不打算介绍,鄙人毕竟非资深前端。除了官网介绍的几个安装组件,以下内容还是要知道的,否则改造起来就会一头雾水。(磨刀不误砍材工嘛)

  1. NodeJS,基石。
  2. express,http 服务器。
  3. socket.io,websocket 通信。
  4. protobuf,通信。
  5. ZMQ,消息队列,各种模式需要知道:pub/sub,push/pull,router/dealer,request/reply。
  6. angluar,前端 mvc 框架,这些概念要清楚:module,controller,router,service,directive。
  7. bower,包管理工具,angular 的组件多是基于 brower。
  8. wepback,打包工具,将 nodejs 打包成 js,这里是打包前端 Angular。
  9. bluebird,ES6 之前的支持 Promise 编码形式的框架,对该组件的使用主要是明白 STF 用它做了什么。
  10. Event,清楚如何注册事件,出发 websocket 以及 zmq 的。很多路由多是基于 event。
  11. rethinkdb,这个列出来是因为前端拉取设备信息是从 db 里读取出来的。(原来想修改设备表结构了)
  12. minitouchminicapminirevSTFService.apk等高级 linux 知识略。

4. STF 的拓扑结构

这个拓扑一开始肯定懵,尤其 ZMQ 不熟悉,还有 triproxy 干什么的根本不知道,所以不下一番功夫理解是有一定难度的。
如何理解这个拓扑,我是先看拓扑,再看代码,然后看部署文档 + 代码一起看,反复看,之后自己加 log 打日志,再仔细阅读文档。一定要自己阅读 deployment.md 这个文档,而且要结合 units 里面的代码看,适当看看 cli 里的参数帮助理解个 unit 之间的绑定关系。在强调一遍,deployment.md 这个文档仔细看。
拓扑图如下。(有人已经做过介绍了,可以自行搜索,我这里再重复唠叨唠叨方便记忆,算是查缺补漏吧)

5. 拓扑结构解释

我就按照从上到下的顺序说说我的理解吧,有问题欢迎指正。
(当然,这些内容也出自 deployment.md 加上我个人的一些理解)

5.1 websocket

websocket 本机实际启动了一个 websocket,这个 unit 是一个中间层,它用来将客户端的 javscript 与服务端的 zmq 以及 wire proto 进行结合。客户端实际上就是前端那个界面,前端代码对应的是 app,前端的载体及服务器对应的是 unit 的 app。zmq 和 protobuf 这个对应的比较多,多个 unit 都用到了。

5.2 triproxy

这个组件没有任何逻辑,他就是个分发器,这个 triproxy 连接的是 websocket 和 processor,5.1 说了 websocket 是客户端与服务端的结合者,客户端是 app,app 就是 stf 启动的那个前端网站,所以它用来接收和发送 app 端(网站)发来的请求,然后分发给 processor 进行处理。所以这个 triproxy 是 app 端的 proxy,文档有介绍。triproxy 应该翻译成三端代理,就想文档中介绍的,它实际连接了三个端,app 端 pull,app 端 pub,processor 端 dealer。(triple 是三个一组的意思,triple-proxy 简称 triproxy,三端一个组)

5.3 processor

processor 是 stf 的主要工作组件,它连接了 devices 和 app 之间的几乎所有的通信。devices 指代设备,app 指代前端 app,这说明它连接了前端和设备。前端其实不只 app,还有 storage,storage 负责 apk、图片的上传下载等逻辑。

5.4 triproxy

又回到了 triproxy,这个是 dev 端 proxy,即设备端的 triproxy。它是用来发送和接受来自 provider 端的请求(provider 指的是你的宿主机),然后分发给 processor 组件去处理。

5.5 dev

这个是设备 unit,文档上没有说明。看一下 units/device 的代码就知道了,主要封装了对设备操作的逻辑。譬如实际的 apk 安装,touch 操作,实时显示图像,调用 adbkit 安装 app 等逻辑。

5.6 provider

provider 用来连接 adb,然后为每个设备启动一个 worker process。之后接受并发送从这些 processors 传过了的命令。

6. 批量安装实践

前面讲了大部分概念,也没有涉及具体实践。这是因为,不搞懂前面的诸多内容和原理,改造起来实在是个一团糟。没有一个清晰的思路和逻辑,只能越做越懵。

6.1 改动的文件

6.2 device-list/index.js

  1. 引入 ng-file-upload。
  2. angluar 引入文件上传服务。
require('./device-list.css')
require('ng-file-upload')    // 这里引用了angluar上传文件功能

module.exports = angular.module('device-list', [
  require('angular-xeditable').name,
  require('stf/device').name,
  require('stf/user/group').name,
  require('stf/control').name,
  require('stf/common-ui').name,
  require('stf/settings').name,
  require('./column').name,
  require('./details').name,
  require('./empty').name,
  require('./icons').name,
  require('./stats').name,
  require('./customize').name,
  require('./search').name,
  'angularFileUpload'    // 这里添加上传文件服务
])
  .config(['$routeProvider', function($routeProvider) {
    $routeProvider
      .when('/devices', {
        template: require('./device-list.pug'),
        controller: 'DeviceListCtrl'
      })
  }])
  .run(function(editableOptions) {
    // bootstrap3 theme for xeditables
    editableOptions.theme = 'bs3'
  })
  .controller('DeviceListCtrl', require('./device-list-controller'))

6.3 device-list/device-controller.js

  1. 在 controller 最后添加 installApk 方法。
  2. 使用 $http 服务获取设备信息。
  3. 使用 upload 服务将文件上传到服务器并获取响应里面的上传路径地址以及 manifest 信息。
  4. 调用 angular 的 controlService,在 app/components/stf/control。
  5. 调用 install 方法。
$scope.installApk = function($files) {
  var installDevices = []
  var cbList = document.getElementsByClassName('installCheckbox')

  if($files.length) {
    $http.get('/api/v1/devices')
      .success(function(res) {
        var devices = res.devices
        var len = cbList.length

        ;devices.forEach(function(device) {
          for (var i = 0; i < len; i++) {
            var cb = cbList[i]
            var serial = cb.getAttribute('serial')

            if (cb.checked && serial === device.serial) {
              installDevices.push(device)
            }
          }
        })
      })

    $upload.upload({
      url: '/s/upload/apk'
      , method: 'POST'
      , file: $files
    })
      .then(function(value) {
        var href = value.data.resources.file.href
        $http.get(href + '/manifest')
          .then(function(res) {
            if (res.data.success) {
              installDevices.forEach(function(installDevice) {
                var control = ControlService.create(installDevice, installDevice.channel)
                control.install({
                  href: href
                  , manifest: res.data.manifest
                  , launch: true
                })
                  .progressed(function(result) {

                  })
              })
            } else {
              throw new Error('Install apk to all selected devices failed')
            }
          })
          .then(function() {
            console.log('Install apk to all devices task succeed')
          })
          .catch(function(err) {
            throw new Error('Install apk meet error.')
          })
      })
  }
}

6.4 device-list/device-list.pug

这个是前端,我随便加了一个安装 apk 的 ng 方法

.stf-device-list
  .row.stf-stats-container.unselectable
    .col-md-12
      device-list-stats(tracker='tracker')

  .row.unselectable
    .col-md-12
      .widget-container.fluid-height.stf-device-list-tabs
        .widget-content.padded
          .filtering-buttons
            datalist(id='searchFields')
              select(name='searchFields')
                option(ng-repeat='column in columns', ng-value='column.name + ": "',
                ng-bind='columnDefinitions[column.name].title | translate')
            input(type='search', autosave='deviceFilter'
              name='deviceFilter', ng-model='search.deviceFilter', ng-change='applyFilter(search.deviceFilter)',
              ng-model-options='{debounce: 150}'
              autocorrect='off', autocapitalize='off', spellcheck='false',
              list='searchFields', multiple, focus-element='search.focusElement',
              text-focus-select, accesskey='4').form-control.input-sm.device-search.pull-right

            span.pull-right(ng-if='activeTabs.details && !$root.basicMode')
              // ====== 加在这个地方了 ====
              .btn-group(uib-dropdown).pull-right
                button.btn.btn-sm.btn-primary-outline(type='button', ng-file-select='installApk($files)')
                  i.fa.fa-columns
                  span install apk
              // ========================
                button.btn.btn-sm.btn-primary-outline(type='button', uib-dropdown-toggle)
                  i.fa.fa-columns
                  span(ng-bind='"Customize"|translate')

6.5 device-list/column/device-column-service.js

  1. 为 DeviceColuknService 添加一列。
  2. 添加 DeviceInsallCell 方法用来显示该列。
install: DeviceInstallCell({
    title: 'Install'
})
function DeviceInstallCell(options) {
  return _.defaults(options, {
    title: options.title
  , defaultOrder: 'asc'
  , build: function() {
      var td = document.createElement('td')
      var input = document.createElement('input')
      input.type = 'checkbox'
      input.className = 'installCheckbox'
      td.appendChild(input)

      return td
    }
  , update: function(td, device) {
      var cb = td.firstChild

      if(device.state === 'using' || device.state === 'absent') {
        cb.disabled = true
      } else {
        cb.setAttribute('serial', device.serial)
      }

      return td
    }
  , compare: function(a, b) {

    }
  , filter: function(item, filter) {

    }
  })
}

7. 实际界面

8 安装流程分析(自顶向下)

  1. STF 的安装是在 dashboard 里面的安装逻辑的。
  2. 顶层的 apk 安装逻辑在 app/control-panes/dashboard/install/install-controller.js

    $scope.installFile = function($files) {
    if ($files.length) {
      return InstallService.installFile($scope.control, $files)
    }
    }
    
  3. 这里面调用了 InstallService 的 installFile 方法。

  4. InstallService 在 app/component/stf/install/install-service.js。

    installService.installFile = function(control, $files) {
    var installation = new Installation('uploading')
    $rootScope.$broadcast('installation', installation)
    return StorageService.storeFile('apk', $files, {
        filter: function(file) {
          return /\.apk$/i.test(file.name)
        }
      })
      .progressed(function(e) {
        if (e.lengthComputable) {
          installation.update(e.loaded / e.total * 100 / 2, 'uploading')
        }
      })
      .then(function(res) {
        installation.update(100 / 2, 'processing')
        installation.href = res.data.resources.file.href
    
        return $http.get(installation.href + '/manifest')
          .then(function(res) {
            if (res.data.success) {
              installation.manifest = res.data.manifest
              return control.install({
                  href: installation.href
                , manifest: installation.manifest
                , launch: installation.launch
                })
                .progressed(function(result) {
                  installation.update(50 + result.progress / 2, result.lastData)
                })
            }
            else {
              throw new Error('Unable to retrieve manifest')
            }
          })
      })
      .then(function() {
        installation.okay('installed')
      })
      .catch(function(err) {
        installation.fail(err.code || err.message)
      })
    }
    
  5. installFile 里面广播了一个 instalation 事件,这个主要是通知前端也即更新前端进度条状态信息。

  6. 之后调用 StorageService 的 storeFile,同时过滤掉非 apk 结尾的文件。这个调用跟我上面写的上传逻辑是一样的。stf 在安装 apk 的时候,需要先将 apk 上传到/var/1 的目录里面,然后获取 apk 的安装路径,以及 manifest 信息,这些信息需要传给 controll.install 方法,下面是 StorageService 的上传文件的方法。这个方法的 upload 调用的 unit/storage/temp.js 里面的 api,temp 标识将文件上传的临时区域或临时内存等等。

    service.storeFile = function(type, files, options) {
    var resolver = Promise.defer()
    var input = options.filter ? files.filter(options.filter) : files
    
    if (input.length) {
      $upload.upload({
          url: '/s/upload/' + type
        , method: 'POST'
        , file: input
        })
        .then(
          function(value) {
            resolver.resolve(value)
          }
        , function(err) {
            resolver.reject(err)
          }
        , function(progressEvent) {
            resolver.progress(progressEvent)
          }
        )
    }
    else {
      var err = new Error('No input files')
      err.code = 'no_input_files'
      resolver.reject(err)
    }
    
    return resolver.promise
    }
    
  7. 上传成功后即调用 controll.install 进行安装。需要注意的是,controll 是在 device-list-controller 里面已经初始化了的。通过 ControllService.create 初始化。该方法接受两个参数,一个是 target 是设备实体信息即 device,对应数据库的 device 的一条记录。channel 是通道信息,其实就是 sub 订阅的一个 message。

  8. install 发送 device.isntall 的 websocket 命令,scoket 出发 device.install 事件。这个 socket 在 socket 及 transaction 服务里面进行定义。

    this.install = function(options) {
      return sendTwoWay('device.install', options)
    }
    
    function sendTwoWay(action, data) {
      var tx = TransactionService.create(target)
      socket.emit(action, channel, tx.channel, data)
      return tx.promise
    }
    
  9. websocket 的 uni 接收到前端的时间后,并将其装成 protobuf,然后推送到 zmq。

    .on('device.install', function(channel, responseChannel, data) {
      joinChannel(responseChannel)
      push.send([
        channel
      , wireutil.transaction(
          responseChannel
        , new wire.InstallMessage(
            data.href
          , data.launch === true
          , JSON.stringify(data.manifest)
          )
        )
      ])
    })
    
  10. 设备端收到消息后,正是开始安装 apk。

    router.on(wire.InstallMessage, function(channel, message) {
      function pushApp() {
        var req = request({
          url: url.resolve(options.storageUrl, message.href)
        })
    
        // We need to catch the Content-Length on the fly or we risk
        // losing some of the initial chunks.
        var contentLength = null
        req.on('response', function(res) {
          contentLength = parseInt(res.headers['content-length'], 10)
        })
    
        var source = new stream.Readable().wrap(req)
        var target = '/data/local/tmp/_app.apk'
    
        return adb.push(options.serial, source, target)
          .timeout(10000)
          .then(function(transfer) {
            var resolver = Promise.defer()
    
            function progressListener(stats) {
              if (contentLength) {
                // Progress 0% to 70%
                sendProgress(
                  'pushing_app'
                , 50 * Math.max(0, Math.min(
                    50
                  , stats.bytesTransferred / contentLength
                  ))
                )
              }
            }
    
            function errorListener(err) {
              resolver.reject(err)
            }
    
            function endListener() {
              resolver.resolve(target)
            }
    
            transfer.on('progress', progressListener)
            transfer.on('error', errorListener)
            transfer.on('end', endListener)
    
            return resolver.promise.finally(function() {
              transfer.removeListener('progress', progressListener)
              transfer.removeListener('error', errorListener)
              transfer.removeListener('end', endListener)
            })
          })
      }
    }
    
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 32 条回复 时间 点赞

一开始可能觉得 STF 比较高深莫测,随着技术的熟悉,以及思路的理清,并没有想象的那么难。

被你一讲就像回过头来看大学课程

恒温 将本帖设为了精华贴 01月20日 22:41
恒温 回复

恒温过奖了。回头再看,发现还是有很多不足的地方。以后弥补。

不考虑不同机型安装时需要点击确定按钮么?而且这种写法一定会导致手机断链的情况。

@lucasluo 感谢分享,请问安装过程中的系统弹框有什么好的处理机制不?

0x88 回复

恩,暂时先不考虑,先上一版,满足基础需求,以后优化。如有好建议,也希望多指教。

Kun 回复

不 root 进行静默安装的话,有计划修改 STFService.apk 通过 Accessibility 安装,规划中。有些手机可以更改系统设置,多数还是不太支持。

这样写,安装进度,安装错误信息什么的都没有

mling 回复

对,是的。正在优化中。高手,一眼看穿问题本质。赞。

赞一个!只是对 STF 上传 apk 装包是覆盖装包。
😂 默默的搞了个页面批量 可卸载可覆盖 安装 apk 的功能。

感觉大家都还是挺多的时间专门去深入研究

—— 来自 TesterHome 官方 安卓客户端

代码和你的一样,为什么页面没有显示 install apk 按钮

fs123wb 回复

所有的改动都一致么?

卡农Lucas 回复

对啊,服务也重启了,node.js 一点不会,直接复制粘贴你的代码

17楼 已删除
23楼 已删除
22楼 已删除
20楼 已删除
21楼 已删除

device-list-controller 的 defaultColumns 的 json 对象字面量添加一下:

var defaultColumns = [
   {
     name: 'install'          // <-------------
   , selected: true
   }
 , {
     name: 'state'
   , selected: true
   }
卡农Lucas 回复


改完了,还是没有出现

fs123wb 回复

npm link 一下。webpack 得重编译。

卡农Lucas 回复

有了,大神请收下我的膝盖,谢谢

fs123wb 回复

客气了,我不是大神。

卡农Lucas 回复

选择文件上传后,怎么一点反应都没有,没有安装 apk

fs123wb 回复

这个方案比较简单,你后台看一下日志,或者打开浏览器的控制台,输出一些东西。

@lucasluo 我照着你的提供的代码修改了之后,执行 npm link 后,把服务起来,依然没有什么效果,是还需要什么步骤吗?

不是很清楚你具体怎么改的。你看看 fs123wb 同学的问题,是否你也遇到了。


点击重置按钮,install 就出来了

楼主的环境是什么那,各种依赖都安装了 为什么我在源码目录下执行 npm install,总是报错,然后装了个 phantomjs,再执行 npm install ,又报 fsevents 版本不支持的错,我这儿用的 centos7,node9.0.0,npm 是 6.4.1

zhanglimin 回复

centos 7 我部署过好几套。基本什么坑都遇见过。zmq,lib-jpeg,node 等源码安装。确认 gcc 版本,centos7 应该不需要确认。如果报 gcc 的错,升级即可。

卡农Lucas 回复

嗯 谢谢 已解决。是因为 npm 没把包装全,换成 cnpm 好了。而且出现的 bower 安装包问题,单独安装也可以了,bower 安装需要使用 root 权限。

感谢提供思路,大神少说了一个要改的地方 device-list-controller.js 的 DeviceListCtrl 需要引用 $http 和 $upload,要不然点了 install 后没反应~
看了下好像 stf 底层也是通过 adb 命令来安装的,不过这样很多手机会安装不成功,因为会弹出确认框不点的话就安装失败了~
感觉这个静默安装有点麻烦的

zlp 回复

嗯。是的。

simple 专栏文章:[精华帖] 社区历年精华帖分类归总 中提及了此贴 12月13日 14:44
simple [精彩盘点] TesterHome 社区 2018 年 度精华帖 中提及了此贴 01月07日 12:08
仅楼主可见
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册