configure首先会去调用Appium类的getDeviceType这个方法,而这个方法最终又会去调用getDeviceTypeFromPlatform这个方法:
在获得deviceType后,configure方法下一个重要的步骤就是去根据这个deviceType字串去调用getNewDevice这个方法获得或者叫做创建一个对应的设备对象了:
DeviceClass这个变量是通过匿名函数返回的一个别的地方export出来的一个对象,比如以DT_ANDROID这个deviceType为例子,它返回的是Android,而Android的定义是:
...
最终getNewDevice这个方法通过new DeviceClass()对设备类进行实例化,事实上就是相当于new Android(),在我们这个例子中。那么在实例化Android这个设备类的时候其构造函数调用init方法又做了什么事情呢?
...
...
...
...
...
当中还调用了一个initQueue方法来把Appium的Work Queue给初始化了,这个Work Queue其实就是nodejs的async这个库的queue这个流程控制对象。首先,我们要搞清楚我们为什么需要用到这个queue呢?我们知道nodejs是异步执行框架的,如果不做特别的处理的话,我们一下子来了几个命令如“1.点击按钮打开新页面;2.读取新页面读取目标控件内容和预期结果比较”,那么nodejs就会两个命令同时执行,但不保证谁先占用了cpu完成操作,那么问题就来了,如果在准备执行1之前,cpu调度切换到2,那么我们的脚本就会失败,因为我们1还没有执行完,新页面还没有打开!
而async这个库的不同对象就是专门针对这些问题提供的解决办法,比如waterfals,auto,serials和queue等,其他的我暂时没有碰到,所以不清楚,至于queue是怎么运作的,我们摘录下网上的一个解析:
queue: 是一个串行的消息队列,通过限制了worker数量,不再一次性全部执行。当worker数量不够用时,新加入的任务将会排队等候,直到有新的worker可用。
从倒数第2行我们可以看到worker是1,也就是一次过appium只会处理一个task,其他push进去的task只能等待第一个task处理完。那么这样就清楚了,我们刚才提到的两个命令,只要保证1先于2入队列,那么在异步执行的nodejs框架中就能保证1会先于2而执行。
说到执行,其实就是初始化queue的第一个匿名函数的参数,而第二个参数就是上面提到的worker的数量了,那我们继续看下这个执行函数是怎么执行的。
首先它会从push进来的task中取出action和params两个参数(其实这两个就是要一个命令的主要组成部分),我们在第4小节会描述一个task是怎么push进来的
然后到最重要的一行代码就是调用了uiautomator的sendAction方法,当然这里我们还在初始化阶段,所以并没有任务可以执行。我们在第4小节会描述action是怎么发送出去的
那么到现在为止Appium在调用start方法启动时的第一步configure算是完成了,往下就要看第二步,
3. 建立Appium Server和Bootstrap的连接
我们先进入Appium类的invoke这个方法,这个方法是在第2节初始化Appium Work Queue等configuration成功的基础上才会执行的。
Appium.prototype.invoke = function (cb) { this.sessionId = UUID.create().hex; logger.debug('Creating new appium session ' + this.sessionId); if (this.device.args.autoLaunch === false) { ... } else { // the normal case, where we launch the device for folks var onStart = function (err, sessionIdOverride) { if (sessionIdOverride) { this.sessionId = sessionIdOverride; logger.debug("Overriding session id with " + JSON.stringify(sessionIdOverride)); } if (err) return this.cleanupSession(err, cb); logger.debug("Device launched! Ready for commands"); this.setCommandTimeout(this.desiredCapabilities.newCommandTimeout); cb(null, this.device); }.bind(this); this.device.start(onStart, _.once(this.cleanupSession.bind(this))); } }; |
onStart是启动连接上设备后的回调,重要的是最后面的一行,从上一节我们知道appium现在保存的设备类其实已经是Android类了,它调用device的start其实就是调用了Android实例的start,我们跳到/devices/android/android.js看下这个start做了什么:
Android.prototype.start = function (cb, onDie) { this.launchCb = cb; this.uiautomatorExitCb = onDie; logger.info("Starting android appium"); if (this.adb === null) { this.adb = new ADB(this.args); } if (this.uiautomator === null) { this.uiautomator = new UiAutomator(this.adb, this.args); this.uiautomator.setExitHandler(this.onUiautomatorExit.bind(this)); } logger.debug("Using fast reset? " + this.args.fastReset); async.series([ this.prepareDevice.bind(this), this.packageAndLaunchActivityFromManifest.bind(this), this.checkApiLevel.bind(this), this.pushStrings.bind(this), this.processFromManifest.bind(this), this.uninstallApp.bind(this), this.installAppForTest.bind(this), this.forwardPort.bind(this), this.pushAppium.bind(this), this.initUnicode.bind(this), this.pushSettingsApp.bind(this), this.pushUnlock.bind(this), this.uiautomator.start.bind(this.uiautomator), this.wakeUp.bind(this), this.unlock.bind(this), this.getDataDir.bind(this), this.setupCompressedLayoutHierarchy.bind(this), this.startAppUnderTest.bind(this), this.initAutoWebview.bind(this) ], function (err) { if (err) { this.shutdown(function () { this.launchCb(err); }.bind(this)); } else { this.didLaunch = true; this.launchCb(null, this.proxySessionId); } }.bind(this)); }; |
这个方法很长,但做的事情也很重要:
建立adb,代码跟踪进去可以见到建立adb不是在appium server本身的源码里面实现的,调用的是另外一个叫"appium-adb"的库,我手头没有源码,所以就不去看它了,但是不用看我都猜到是怎么回事,无非就是像本人以前分析《MonkeyRunner源码分析之启动》时分析chimpchat一样,把adb给封装一下,然后提供一些额外的方便使用的方法出来而已
创建uiautomator这个底层与bootstrap和目标机器交互的类的实例,既然需要和目标机器交互,那么刚才的adb时必须作为参数传进去的了。大家还记得上面提到的在初始化Android这个设备类的时候uiautomator这个变量时设置成null的吧,其实它是在这个时候进行实例化的。这里有一点需要注意的是systemPort这个参数,appium server最终与bootstrap建立的socket连接的端口就是它,现在传进来的就是
var UiAutomator = function (adb, opts) {
this.adb = adb;
this.proc = null;
this.cmdCb = null;
this.socketClient = null;
this.restartBootstrap = false;
this.onSocketReady = noop;
this.alreadyExited = false;
this.onExit = noop;
this.shuttingDown = false;
this.webSocket = opts.webSocket;
this.systemPort = opts.systemPort;
this.resendLastCommand = function () {};
};
往下我们会看到nodejs流程控制类库async的另外一个对象series,这个有别于上面用到的queue,因为queue时按照worker的数量来看同时执行多少个task的,而series时完全按顺序执行的,所以叫做series
这个series 要做的事情就多了,主要就是真正运行时的环境准备,比如检查目标及其api的level是否大于17,安装测试包,bootstrap端口转发,开始测试目标app等,如果每个都进行分析的话大可以另外开一个系列了。这个不是不可能,今后看我时间吧,这里我就分析跟我们这个小节密切相关的uiautomator.start这个方法,其实其他的大家大可以之后自行分析。
往下分析之前大家要注意bind的参数
this.uiautomator.start.bind(this.uiautomator),
大家可以看到bind的参数是当前这个Android实例的uiautomator这个对象,所以最终start这个方法里面用到的所有的this指得都是Android这个实例的uiautomator对象。
UiAutomator.prototype.start = function (readyCb) { logger.info("Starting App"); this.adb.killProcessesByName('uiautomator', function (err) { if (err) return readyCb(err); logger.debug("Running bootstrap"); var args = ["shell", "uiautomator", "runtest", "AppiumBootstrap.jar", "-c", "io.appium.android.bootstrap.Bootstrap"]; this.alreadyExited = false; this.onSocketReady = readyCb; this.proc = this.adb.spawn(args); this.proc.on("error", function (err) { logger.error("Unable to spawn adb: " + err.message); if (!this.alreadyExited) { this.alreadyExited = true; readyCb(new Error("Unable to start Android Debug Bridge: " + err.message)); } }.bind(this)); this.proc.stdout.on('data', this.outputStreamHandler.bind(this)); this.proc.stderr.on('data', this.errorStreamHandler.bind(this)); this.proc.on('exit', this.exitHandler.bind(this)); }.bind(this)); }; |
UiAutomator的实例在启动的时候会先通过传进来的adb实例spawn一个新进程来把bootstrap给启动起来,启动的详细流程这里就不谈了,大家可以看本人之前的文章《Appium Android Bootstrap源码分析之启动运行》
启动好bootstrap之后,下面就会设置相应的事件处理函数来处理adb启动的bootstrap在命令行由标准输出,错误,以及退出的情况,注意这些输出跟bootstrap执行命令后返回给appium server的输出是没有半毛钱关系的,那些输出是通过socket以json的格式返回的。
这里我们看下outputStreamHandler这个收到标准输出时的事件处理函数,这个函数肯定是会触发的,你可以去adb shell到安卓机器上面通过uiatuomator命令启动bootstrap看下,你会看到必然会有相应的标准输出打印到命令行中的,只是在这里标准输出被这个函数进行处理了,而不是像我们手动启动那样只是打印到命令行给你看看而已。
UiAutomator.prototype.outputStreamHandler = function (output) {
this.checkForSocketReady(output);
this.handleBootstrapOutput(output);
};
很简短,仅仅两行,第二行就是刚才说的去处理bootstrap本应打印到命令行的其他标准输出。而第一行比较特殊,注意它的输入参数也是ouput这个标准输出数据,我们进去看看它要干嘛:
UiAutomator.prototype.checkForSocketReady = function (output) {
if (/Appium Socket Server Ready/.test(output)) {
this.socketClient = net.connect(this.systemPort, function () {
this.debug("Connected!");
this.onSocketReady(null);
}.bind(this));
this.socketClient.setEncoding('utf8');
...
}
};
它做的第一件事情就是去检查启动bootstrap 的时候标准输出有没有"Appium Socket Server Ready"这个字符串,有就代表bootstrap已经启动起来了,没有的话这个函数就直接finish了。
为了印证,我们可以在adb shell中直接输入下面命令然后看该字串是否真的会出现:
uiautomator runtest AppiumBootstrap.jar -c io.appium.android.bootstrap.Bootstrap
输入如下,果不其然:
在确保bootstrap已经启动起来后,下一个动作就是我们这一小节的目的,通过调用nodejs标准库的方法net.connect去启动与bootstrap的socket连接了:
this.socketClient = net.connect(this.systemPort, function () {
UiAutomator实例,也就是Android类实例的uiautomator对象,所拥有的socketClient就是appium server专门用来与bootstrap通信的实例。这里的this.systemPort就是4724,至于为什么,我就不回答了,大家去再跟踪细点就知道了
4. 往Bootstrap发送命令
既然任务队列已经初始化,与boostrap通信的socket也建立妥当了,那么现在就是时候看一个实例来看下appium server是如何在接受到appium client的rest命令后,往bootstrap那边去灌命令的了。
开始之前我们先看下debug log,看下appium client端发送过来的命令及相关的输出:
我们可以看到client端发送过来的命令是:
info: --> POST /wd/hub/session/ae82c5ae-76f8-4f67-9312-39e4a52f5643/element/2/click {"id":"2"}
那么我们参照路由routing表查找到对应的处理controller:
rest.post('/wd/hub/session/:sessionId?/element/:elementId?/click', controller.doClick);
对应的处理方法是controller.doClick。注意这里前面一部分是http request的body,后面一部分是params。
这里我们可以回顾下上面说过的打问好的是变量,会用真实的值进行替代的,如在我们的例子中:
sessionId:ae82c5ae-76f8-4f67-9312-39e4a52f5643
elementId: 2
从这些参数我们可以知道appium client端需要appium server帮忙处理的事情是:请在当前这个session中点击当前界面的bootstrap那边的控件哈稀表键值为2的控件(至于控件哈稀表这个概念如果不清楚的劳烦你先去看下本人的这篇文章《Appium Android Bootstrap源码分析之控件AndroidElement》)
ok,往下我们进入这个doClick方法进行分析:
exports.doClick = function (req, res) {
var elementId = req.params.elementId || req.body.element;
req.device.click(elementId, getResponseHandler(req, res));
};
第一行是把client传送过来的控件在bootstrap中的控件哈稀表key解析出来,至于为什么需要传两个一样的值然后进行或,我还没有看appium client端的代码,所以这里解析不了,也许今后有时间分析client代码的话会给大家说明白。但在这里你只需要知道这个elementId是怎么回事做什么用的就够了,这个不影响我们去理解appium server的运行原理。
第二行去调用nodejs提供的http request对象的device的click方法,这个device是什么呢?这个大家不记得的话请翻看第1节我们在初始化路由表的时候调用的getGlobalBeforeFilter方法中是把Appium对西那个的device对象赋予给了了这个request.device对象的:
req.device = appium.device;
而appium.device对象又是在第2节在start Appium实例时通过其configuration等一系列调用中初始化的,最终在我们安卓环境中就是初始化成Android这个设备类的实例,而Android这个类又extend了android-controller.js里面的所有方法:
...
, androidController = require('./android-controller.js')
...
_.extend(Android.prototype, androidController);
所以最终的click落实到了android-controller.js里面也就是androidController对象的click方法:
androidController.click = function (elementId, cb) {
this.proxy(["element:click", {elementId: elementId}], cb);
};
只有一行,调用的是proxy这个方法,跳进去:
exports.proxy = function (command, cb) {
logger.debug('Pushing command to appium work queue: ' + JSON.stringify(command));
this.push([command, cb]);
};
所做的事情就是直接把刚才那传命令作为一个task来push到上面提到的async.queue这个Apium Work Queue里面。
其实到了这里,click的处理已经完成任务了,因为这个queue不是由click的相关处理controller来控制的,它只是负责把这个任务加入到队列,而真正去队列取出任务进行执行的是我们上面第2节最后面提到的初始化async queue的时候的第一个参数,而那个参数是个匿名函数,没当有一个task进入队列需要执行之前都会去调用这个方法,我们回顾下:
Android.prototype.initQueue = function () { this.queue = async.queue(function (task, cb) { var action = task.action, params = task.params; this.cbForCurrentCmd = cb; if (this.adb && !this.shuttingDown) { this.uiautomator.sendAction(action, params, function (response) { this.cbForCurrentCmd = null; if (typeof cb === 'function') { this.respond(response, cb); } }.bind(this)); } else { this.cbForCurrentCmd = null; var msg = "Tried to send command to non-existent Android device, " + "maybe it shut down?"; if (this.shuttingDown) { msg = "We're in the middle of shutting down the Android device, " + "so your request won't be executed. Sorry!"; } this.respond({ status: status.codes.UnknownError.code , value: msg }, cb); } }.bind(this), 1); }; |
取得传进来的task相关键值,在我们这个例子中就是:
action:"element:click"
params:"{elementId:2"}
然后调用uiatutomator的sendAction方法,并把以上两个参数给传进去:
UiAutomator.prototype.sendAction = function (action, params, cb) {
if (typeof params === "function") {
cb = params;
params = {};
}
var extra = {action: action, params: params};
this.sendCommand('action', extra, cb);
};
将参数组合成以下并传给sendCommand:
参数1:‘action’
参数2:'{action:action,parames:{elementId:2}'
进入sendCommand:
UiAutomator.prototype.sendCommand = function (type, extra, cb) { ... else if (this.socketClient) { ... var cmd = {cmd: type}; cmd = _.extend(cmd, extra); var cmdJson = JSON.stringify(cmd) + "\n"; this.cmdCb = cb; var logCmd = cmdJson.trim(); if (logCmd.length > 1000) { logCmd = logCmd.substr(0, 1000) + "..."; } this.debug("Sending command to android: " + logCmd); this.socketClient.write(cmdJson); } ... } |
根据传进来的参数,最终组合成以下参数通过第3节初始化好的与bootstrap进行socket通信的socketClient来往bootstrap灌入命令:
{"cmd":"action","action":element:click","params":{"elementId":"2"}}
大家对比下图看下高亮圈住的最后一行由bootstrap打印出来的接收到的json命令字串,它们是绝对吻合的:
往下的事情就由bootstrap进行处理了,至于不清楚bootstrap怎么处理这些命令的,请查看我上一个bootstrap源码分析系列。
5. 小结
通过本文我们了解了appium server作为bootstrap的客户端,同时又作为appium client的服务器端,是如何处理从client来的命令然后组建成相应的json命令字串发送到bootstrap来进行处理的:
初始化REST路由表来接收appium client过来的REST命令调用并分发给相应的controller方法来进行处理
appium client发送命令之前会先触发一个创建session的请求,在路由表表现为“rest.post('/wd/hub/session', controller.createSession);”。对应的controller处理方法会开始初始化与bootstrap的连接
创建的session的过程会调用Appium类的start方法然后机型一系列的动作:
初始化设备类Android
用Async库的queue对象初始化Appium的Work Queue
创建adb实例并启动adb
安装测试apk
通过adb启动bootstrap
建立Appium Server和bootstrap的socket连接
...,等等等等
创建好连接后Appium Server就会接收client过来的命令,比如click,然后对应的controller处理方法就会建立命令对应的task并把它push到async queue里面
一旦有新task进入到async queue,对应的回调函数就会触发,开始往已经建立的与bootstrap的socket连接发送json字串命令