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字串命令