隨著移動互聯(lián)網(wǎng)時代的發(fā)展漠酿,移動終端的自動化測試日益活躍,總體來看在Android平臺上的自動化工具和實(shí)踐比較多躁倒,IOS的UI自動化測試由于系統(tǒng)封閉的原因幔亥,一直不太成熟只锻。本著不侵入工程和擁抱原生的原則實(shí)現(xiàn)一套自動化測試方案。自動化測試節(jié)省時間節(jié)省真機(jī)的成本紫谷,而且更高效的覆蓋所有的iOS機(jī)型測試齐饮,避免每次上線前重復(fù)的人工回歸測試,保證每次上線的版本穩(wěn)定運(yùn)行。
在Xcode 8之前笤昨,基于UI Automation的自動化測試方案是比較好用且非常流行的祖驱。但在Xcode 8之后,蘋果在instruments工具集中直接廢除了Automation組件瞒窒,轉(zhuǎn)而支持使用UI Testing捺僻。
UI Testing
從Xcode 7開始,蘋果提供了UI Testing框架,也就是我們在APP test工程中使用的XCTest的那一套東西匕坯。UI Testing包含幾個重要的類束昵,分別是XCUIApplication、XCUIElement葛峻、XCUIElementQuery锹雏。
XCUIApplication
代表正在測試的應(yīng)用程序的實(shí)例,可以對APP進(jìn)行啟動术奖、終止礁遵、傳入?yún)?shù)等操作。
-
XCUIApplication
- 代表正在測試的應(yīng)用程序的實(shí)例采记,可以對APP進(jìn)行啟動佣耐、終止、傳入?yún)?shù)等操作唧龄。
- (void)launch; - (void)activate; - (void)terminate; @property (nonatomic, copy) NSArray <NSString *> *launchArguments; @property (nonatomic, copy) NSDictionary <NSString *, NSString *> *launchEnvironment;
- XCUIApplication在iOS上提供了兩個初始化接口
//Returns a proxy for the application specified by the "Target Application" target setting. - (instancetype)init NS_DESIGNATED_INITIALIZER; //Returns a proxy for an application associated with the specified bundle identifier. - (instancetype)initWithBundleIdentifier:(NSString *)bundleIdentifier NS_DESIGNATED_INITIALIZER;
其中initWithBundleIdentifier接口允許傳入一個bundle id來操作指定APP兼砖。這個技術(shù)點(diǎn)是iOS APP能夠自動化測試的關(guān)鍵所在。
XCUIElement
表示界面上顯示的UI元素既棺。XCUIElementQuery
用于定位UI元素的查詢對象讽挟。
上述幾個模塊就是一個UI測試框架的核心能力,后面在寫Appium的自動化腳本時也是一樣的套路:啟動APP->定位UI元素->觸發(fā)操作援制。
WebDriverAgent
WebDriverAgent是用于iOS的WebDriver服務(wù)器實(shí)現(xiàn)戏挡,可用于遠(yuǎn)程控制iOS設(shè)備芍瑞。它允許您啟動和終止應(yīng)用程序晨仑,點(diǎn)擊并滾動視圖或確認(rèn)屏幕上是否存在視圖。這使其成為用于應(yīng)用程序端到端測試或通用設(shè)備自動化的理想工具拆檬。它通過鏈接XCTest.framework和調(diào)用Apple的API來直接在設(shè)備上執(zhí)行命令來工作洪己。WebDriverAgent是Facebook開發(fā)和用于端到端測試的,并已被Appium成功采用竟贯。
在2019年5月答捕,F(xiàn)acebook開源了IDB,即“ iOS Development Bridge”屑那,這是一個用于使iOS模擬器和設(shè)備自動化的命令行界面拱镐。我們目前正在將自己的內(nèi)部項目從WDA遷移到IDB,并建議將其檢查出來作為替代方案持际。
有關(guān)IDB的更多信息:
雖然git上不再得到Facebook的積極的維護(hù)沃琅,移動端主流測試框架依然要借助WDA來實(shí)現(xiàn)與iOS交互測試,你可以在appium中下載可運(yùn)行WebDriverAgent
準(zhǔn)備工作
安裝 homebrew
homebrew 是 Mac OS 下最優(yōu)秀的包管理工具蜘欲,沒有之一益眉。
xcode-select --install
ruby -e "$(curl -fsSLhttps://raw.githubusercontent.com/Homebrew/install/master/install)"
安裝 python
腳本語言 python 用來編寫模擬的用戶操作。
brew install python3
安裝 libimobiledevice
libimobiledevice 是一個使用原生協(xié)議與蘋果iOS設(shè)備進(jìn)行通信的庫。通過這個庫我們的 Mac OS 能夠輕松獲得 iOS 設(shè)備的信息郭脂。
brew install --HEAD libimobiledevice
使用方法:
查看 iOS 設(shè)備日志
idevicesyslog
查看鏈接設(shè)備的UDID
idevice_id --list
查看設(shè)備信息
ideviceinfo
獲取設(shè)備時間
idevicedate
獲取設(shè)備名稱
idevicename
端口轉(zhuǎn)發(fā)
iproxy XXXX YYYY
屏幕截圖
idevicescreenshot
安裝 Carthage
Carthage 是一款iOS項目依賴管理工具年碘,與 Cocoapods 有著相似的功能,可以幫助你方便的管理三方依賴展鸡。它會把三方依賴編譯成 framework屿衅,以 framework 的形式將三方依賴加入到項目中進(jìn)行使用和管理。
WebDriverAgent 本身使用了 Carthage 管理項目依賴娱颊,因此需要提前安裝 Carthage傲诵。
brew install carthage
源碼分析
1.WebDriverAgent如何建立連接的?
webdriver協(xié)議是一套基于HTTP協(xié)議的JSON格式規(guī)范箱硕,協(xié)議規(guī)定了不同操作對應(yīng)的格式拴竹。之所以需要這層協(xié)議,是因?yàn)閕OS剧罩、Android栓拜、瀏覽器等都有自己的UI交互方式,通過這層”驅(qū)動層“屏蔽各平臺的差異惠昔,就可以通過相同的方式進(jìn)行自動化的UI操作幕与,做網(wǎng)絡(luò)爬蟲常用的selenium是瀏覽器上實(shí)現(xiàn)webdriver的驅(qū)動,而WebDriverAgent則是iOS上實(shí)現(xiàn)webdriver的驅(qū)動镇防。
使用Xcode打開WebDriverAgent項目啦鸣,連接上iPhone設(shè)備之后,選中WebDriverAgentRunner->Product->Test来氧,則會在iPhone上安裝一個名為WebDriverAgentRunner的APP诫给,這個APP實(shí)際上是一個后臺應(yīng)用,直接點(diǎn)擊ICON打開的話會退出啦扬。
具體到代碼層面中狂,WebDriverAgentRunner的入口在UITestingUITests.m文件
- (void)testRunner
{
FBWebServer *webServer = [[FBWebServer alloc] init];
webServer.delegate = self;
[webServer startServing];
}
- (void)startServing
{
[FBLogger logFmt:@"Built at %s %s", __DATE__, __TIME__];
self.exceptionHandler = [FBExceptionHandler new];
[self startHTTPServer]; // 初始化Server 并注冊路由
[self initScreenshotsBroadcaster]; //
self.keepAlive = YES;
NSRunLoop *runLoop = [NSRunLoop mainRunLoop];
//這里是WDA為了防止程序退出,寫了一個死循環(huán)扑毡,自己手動維護(hù)主線程胃榕,監(jiān)聽或?qū)崿F(xiàn)UI操作
while (self.keepAlive &&
[runLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);
}
- (void)startHTTPServer
{
//初始化Server
self.server = [[RoutingHTTPServer alloc] init];
[self.server setRouteQueue:dispatch_get_main_queue()];
[self.server setDefaultHeader:@"Server" value:@"WebDriverAgent/1.0"];
[self.server setDefaultHeader:@"Access-Control-Allow-Origin" value:@"*"];
[self.server setDefaultHeader:@"Access-Control-Allow-Headers" value:@"Content-Type, X-Requested-With"];
[self.server setConnectionClass:[FBHTTPConnection self]];
//注冊所有路由
[self registerRouteHandlers:[self.class collectCommandHandlerClasses]];
[self registerServerKeyRouteHandlers];
NSRange serverPortRange = FBConfiguration.bindingPortRange;
NSError *error;
BOOL serverStarted = NO;
for (NSUInteger index = 0; index < serverPortRange.length; index++) {
NSInteger port = serverPortRange.location + index;
[self.server setPort:(UInt16)port];
serverStarted = [self attemptToStartServer:self.server onPort:port withError:&error];
if (serverStarted) {
break;
}
[FBLogger logFmt:@"Failed to start web server on port %ld with error %@", (long)port, [error description]];
}
if (!serverStarted) {
[FBLogger logFmt:@"Last attempt to start web server failed with error %@", [error description]];
abort();
}
[FBLogger logFmt:@"%@http://%@:%d%@", FBServerURLBeginMarker, [XCUIDevice sharedDevice].fb_wifiIPAddress ?: @"localhost", [self.server port], FBServerURLEndMarker];
}
- WebDriverAgentRunner會在手機(jī)上8100端口啟動一個HTTP server,startServing方法內(nèi)部就是一個死循環(huán)瞄摊,監(jiān)聽網(wǎng)絡(luò)傳輸過來的webdriver協(xié)議的數(shù)據(jù)勋又,解析并處理路由事件。
- 在startHTTPServer里創(chuàng)建server并建立連接换帜,調(diào)用registerRouteHandlers方法注冊所有路由
路由注冊
下面來看下注冊路由 [self registerRouteHandlers:[self.class collectCommandHandlerClasses]]方法的源碼
首先來看[self.class collectCommandHandlerClasses]方法的實(shí)現(xiàn)
//獲取所有遵循FBCommandHandler協(xié)議的類
+ (NSArray<Class<FBCommandHandler>> *)collectCommandHandlerClasses
{
//利用runtime 動態(tài)獲取所有注冊過FBCommandHandler協(xié)議的類
NSArray *handlersClasses = FBClassesThatConformsToProtocol(@protocol(FBCommandHandler));
NSMutableArray *handlers = [NSMutableArray array];
//篩選shouldRegisterAutomatically返回YES的類
for (Class aClass in handlersClasses) {
/*
shouldRegisterAutomatically
BOOL deciding if class should be added to route handlers automatically, default (if not implemented) is YES
BOOL決定是否應(yīng)將類自動添加到路由處理程序楔壤,默認(rèn)(如果未實(shí)現(xiàn))是
*/
if ([aClass respondsToSelector:@selector(shouldRegisterAutomatically)]) {
if (![aClass shouldRegisterAutomatically]) {
continue;
}
}
[handlers addObject:aClass];
}
return handlers.copy;
}
#import "FBRuntimeUtils.h"
#import <objc/runtime.h>
//利用runtime 動態(tài)獲取注冊過FBCommandHandler協(xié)議的
NSArray<Class> *FBClassesThatConformsToProtocol(Protocol *protocol)
{
Class *classes = NULL;
NSMutableArray *collection = [NSMutableArray array];
/*獲取到當(dāng)前注冊的所有類的總個數(shù),它需要傳入兩個參數(shù)膜赃,
第一個參數(shù) buffer :已分配好內(nèi)存空間的數(shù)組挺邀,傳NULL會自動計算內(nèi)存空間
第二個參數(shù) bufferCount :數(shù)組中可存放元素的個數(shù),
返回值是注冊的類的總數(shù)。*/
int numClasses = objc_getClassList(NULL, 0);
//如果沒有注冊類端铛,直接返回空數(shù)組
if (numClasses == 0 ) {
return @[];
}
//遍歷所有注冊的類泣矛,如果遵循FBCommandHandler協(xié)議,就添加到數(shù)組里
classes = (__unsafe_unretained Class*)malloc(sizeof(Class) * numClasses);
numClasses = objc_getClassList(classes, numClasses);
for (int index = 0; index < numClasses; index++) {
Class aClass = classes[index];
if (class_conformsToProtocol(aClass, protocol)) {
[collection addObject:aClass];
}
}
free(classes);
return collection.copy;
}
collectCommandHandlerClasses方法其實(shí)是利用runtime動態(tài)獲取到所有注冊過FBCommandHandler協(xié)議的類
下面來看下registerRouteHandlers方法的實(shí)現(xiàn)
- (void)registerRouteHandlers:(NSArray *)commandHandlerClasses
{
// 遍歷所有遵循FBCommandHandler協(xié)議的類
for (Class<FBCommandHandler> commandHandler in commandHandlerClasses) {
// 獲取類實(shí)現(xiàn)的routes方法返回的路由數(shù)組
NSArray *routes = [commandHandler routes];
for (FBRoute *route in routes) {
[self.server handleMethod:route.verb withPath:route.path block:^(RouteRequest *request, RouteResponse *response) {
//#warning 接收事件的回調(diào)
NSDictionary *arguments = [NSJSONSerialization JSONObjectWithData:request.body options:NSJSONReadingMutableContainers error:NULL];
FBRouteRequest *routeParams = [FBRouteRequest
routeRequestWithURL:request.url
parameters:request.params
arguments:arguments ?: @{}
];
[FBLogger verboseLog:routeParams.description];
@try {
[route mountRequest:routeParams intoResponse:response];
}
@catch (NSException *exception) {
[self handleException:exception forResponse:response];
}
}];
}
}
}
- (void)handleMethod:(NSString *)method withPath:(NSString *)path block:(RequestHandler)block {
//創(chuàng)建路由禾蚕,并解析path
Route *route = [self routeWithPath:path];
//每一個路由都持有一個對用的block您朽,
route.handler = block;
[self addRoute:route forMethod:method];
}
//創(chuàng)建路由,并解析path
- (Route *)routeWithPath:(NSString *)path {
Route *route = [[Route alloc] init];//創(chuàng)建路由
NSMutableArray *keys = [NSMutableArray array];
if ([path length] > 2 && [path characterAtIndex:0] == '{') {
// This is a custom regular expression, just remove the {}
path = [path substringWithRange:NSMakeRange(1, [path length] - 2)];
} else {
NSRegularExpression *regex = nil;
// Escape regex characters
regex = [NSRegularExpression regularExpressionWithPattern:@"[.+()]" options:0 error:nil];
path = [regex stringByReplacingMatchesInString:path options:0 range:NSMakeRange(0, path.length) withTemplate:@"\\\\$0"];
// Parse any :parameters and * in the path
regex = [NSRegularExpression regularExpressionWithPattern:@"(:(\\w+)|\\*)"
options:0
error:nil];
NSMutableString *regexPath = [NSMutableString stringWithString:path];
__block NSInteger diff = 0;
[regex enumerateMatchesInString:path options:0 range:NSMakeRange(0, path.length)
usingBlock:^(NSTextCheckingResult *result, NSMatchingFlags flags, BOOL *stop) {
NSRange replacementRange = NSMakeRange(diff + result.range.location, result.range.length);
NSString *replacementString;
NSString *capturedString = [path substringWithRange:result.range];
if ([capturedString isEqualToString:@"*"]) {
[keys addObject:@"wildcards"];
replacementString = @"(.*?)";
} else {
NSString *keyString = [path substringWithRange:[result rangeAtIndex:2]];
[keys addObject:keyString];
replacementString = @"([^/]+)";
}
[regexPath replaceCharactersInRange:replacementRange withString:replacementString];
diff += replacementString.length - result.range.length;
}];
path = [NSString stringWithFormat:@"^%@$", regexPath];
}
route.regex = [NSRegularExpression regularExpressionWithPattern:path options:NSRegularExpressionCaseInsensitive error:nil];
//讓route持有path
if ([keys count] > 0) {
route.keys = keys;
}
return route;
}
//添加路由到對應(yīng)的方法
- (void)addRoute:(Route *)route forMethod:(NSString *)method {
//方法method排序
method = [method uppercaseString];
//以方法method為key换淆,獲取routes里的對應(yīng)的數(shù)組哗总,如果沒有就創(chuàng)建一個數(shù)組作為value,存入routes
NSMutableArray *methodRoutes = [routes objectForKey:method];
if (methodRoutes == nil) {
methodRoutes = [NSMutableArray array];
[routes setObject:methodRoutes forKey:method];
}
//將route對象緩存在routes中
[methodRoutes addObject:route];
// Define a HEAD route for all GET routes
if ([method isEqualToString:@"GET"]) {
[self addRoute:route forMethod:@"HEAD"];
}
}
以上是WDA注冊路由的源碼倍试,原理是通過一個全局的字典routes讯屈,以方法method為key,存儲對應(yīng)的route路由對象县习,每一個route對象都會有一個path和block涮母,當(dāng)接收到對應(yīng)的path指令時去執(zhí)行block。那么path指令是在何時接收的呢躁愿?
建立連接接受指令
在RoutingHTTPServer中叛本,搜索routes objectForKey:,我們發(fā)現(xiàn)了這個方法
- (RouteResponse *)routeMethod:(NSString *)method withPath:(NSString *)path parameters:(NSDictionary *)params request:(HTTPMessage *)httpMessage connection:(HTTPConnection *)connection {
//routes中找出路由對象
NSMutableArray *methodRoutes = [routes objectForKey:method];
if (methodRoutes == nil)
return nil;
for (Route *route in methodRoutes) {
NSTextCheckingResult *result = [route.regex firstMatchInString:path options:0 range:NSMakeRange(0, path.length)];
if (!result)
continue;
// The first range is all of the text matched by the regex.
NSUInteger captureCount = [result numberOfRanges];
if (route.keys) {
// Add the route's parameters to the parameter dictionary, accounting for
// the first range containing the matched text.
if (captureCount == [route.keys count] + 1) {
NSMutableDictionary *newParams = [params mutableCopy];
NSUInteger index = 1;
BOOL firstWildcard = YES;
for (NSString *key in route.keys) {
NSString *capture = [path substringWithRange:[result rangeAtIndex:index]];
if ([key isEqualToString:@"wildcards"]) {
NSMutableArray *wildcards = [newParams objectForKey:key];
if (firstWildcard) {
// Create a new array and replace any existing object with the same key
wildcards = [NSMutableArray array];
[newParams setObject:wildcards forKey:key];
firstWildcard = NO;
}
[wildcards addObject:capture];
} else {
[newParams setObject:capture forKey:key];
}
index++;
}
params = newParams;
}
} else if (captureCount > 1) {
// For custom regular expressions place the anonymous captures in the captures parameter
NSMutableDictionary *newParams = [params mutableCopy];
NSMutableArray *captures = [NSMutableArray array];
for (NSUInteger i = 1; i < captureCount; i++) {
[captures addObject:[path substringWithRange:[result rangeAtIndex:i]]];
}
[newParams setObject:captures forKey:@"captures"];
params = newParams;
}
RouteRequest *request = [[RouteRequest alloc] initWithHTTPMessage:httpMessage parameters:params];
RouteResponse *response = [[RouteResponse alloc] initWithConnection:connection];
if (!routeQueue) {
[self handleRoute:route withRequest:request response:response];
} else {
// Process the route on the specified queue
dispatch_sync(routeQueue, ^{
@autoreleasepool {
[self handleRoute:route withRequest:request response:response];
}
});
}
return response;
}
return nil;
}
順藤摸瓜彤钟,查找的這個方法的調(diào)用来候,在HTTPConnection類中replyToHTTPRequest方法里
- (void)replyToHTTPRequest
{
HTTPLogTrace();
if (HTTP_LOG_VERBOSE)
{
NSData *tempData = [request messageData];
NSString *tempStr = [[NSString alloc] initWithData:tempData encoding:NSUTF8StringEncoding];
HTTPLogVerbose(@"%@[%p]: Received HTTP request:\n%@", THIS_FILE, self, tempStr);
}
// Check the HTTP version
// We only support version 1.0 and 1.1
NSString *version = [request version];
if (![version isEqualToString:HTTPVersion1_1] && ![version isEqualToString:HTTPVersion1_0])
{
[self handleVersionNotSupported:version];
return;
}
// Extract requested URI
NSString *uri = [self requestURI];
// Extract the method
NSString *method = [request method];
// Note: We already checked to ensure the method was supported in onSocket:didReadData:withTag:
// Respond properly to HTTP 'GET' and 'HEAD' commands
//這里調(diào)用的解析方法
httpResponse = [self httpResponseForMethod:method URI:uri];
if (httpResponse == nil)
{
[self handleResourceNotFound];
return;
}
[self sendResponseHeadersAndBody];
}
最終我們在GCDAsyncSocket Delegate 里找到了該方法的調(diào)用
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData*)data withTag:(long)tag
GCDAsyncSocket是Server長鏈接,只要有消息過來就會調(diào)用GCDAsyncSocket 的Delegate方法
2.WebDriverAgent如何查找元素的逸雹?
剛剛有講過WDA中路由的注冊营搅,從WebDriverAgent的源碼可以清晰的看到,在Commands目錄峡眶,是支持的操作類集合剧防。每一個操作都通過routes類方法注冊對應(yīng)的路由和處理該路由的函數(shù)植锉。查找元素路由注冊放在FBFindElementCommands.m
@implementation FBFindElementCommands
#pragma mark - <FBCommandHandler>
+ (NSArray *)routes
{
return
@[
[[FBRoute POST:@"/element"] respondWithTarget:self action:@selector(handleFindElement:)],
[[FBRoute POST:@"/elements"] respondWithTarget:self action:@selector(handleFindElements:)],
[[FBRoute POST:@"/element/:uuid/element"] respondWithTarget:self action:@selector(handleFindSubElement:)],
[[FBRoute POST:@"/element/:uuid/elements"] respondWithTarget:self action:@selector(handleFindSubElements:)],
[[FBRoute GET:@"/wda/element/:uuid/getVisibleCells"] respondWithTarget:self action:@selector(handleFindVisibleCells:)],
#if TARGET_OS_TV
[[FBRoute GET:@"/element/active"] respondWithTarget:self action:@selector(handleGetFocusedElement:)],
#else
[[FBRoute GET:@"/element/active"] respondWithTarget:self action:@selector(handleGetActiveElement:)],
#endif
];
}
以handleFindElement為例辫樱,代碼追蹤
+ (id<FBResponsePayload>)handleFindElement:(FBRouteRequest *)request
{
FBSession *session = request.session;
/*
Using:查找的方式
Value:依據(jù)Value去查找元素
under:從under開始查找元素 session.activeApplication繼承自XCUIApplication
*/
XCUIElement *element = [self.class elementUsing:request.arguments[@"using"]
withValue:request.arguments[@"value"]
under:session.activeApplication];
if (!element) {
return FBNoSuchElementErrorResponseForRequest(request);
}
return FBResponseWithCachedElement(element, request.session.elementCache, FBConfiguration.shouldUseCompactResponses);
}
+ (XCUIElement *)elementUsing:(NSString *)usingText withValue:(NSString *)value under:(XCUIElement *)element
{
return [[self elementsUsing:usingText
withValue:value
under:element
shouldReturnAfterFirstMatch:YES] firstObject];
}
+ (NSArray *)elementsUsing:(NSString *)usingText withValue:(NSString *)value under:(XCUIElement *)element shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
NSArray *elements;
const BOOL partialSearch = [usingText isEqualToString:@"partial link text"];
const BOOL isSearchByIdentifier = ([usingText isEqualToString:@"name"] || [usingText isEqualToString:@"id"] || [usingText isEqualToString:@"accessibility id"]);
if (partialSearch || [usingText isEqualToString:@"link text"]) {
NSArray *components = [value componentsSeparatedByString:@"="];
NSString *propertyValue = components.lastObject;
NSString *propertyName = (components.count < 2 ? @"name" : components.firstObject);
elements = [element fb_descendantsMatchingProperty:propertyName value:propertyValue partialSearch:partialSearch];
} else if ([usingText isEqualToString:@"class name"]) {
elements = [element fb_descendantsMatchingClassName:value shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch];
} else if ([usingText isEqualToString:@"class chain"]) {
elements = [element fb_descendantsMatchingClassChain:value shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch];
} else if ([usingText isEqualToString:@"xpath"]) {
elements = [element fb_descendantsMatchingXPathQuery:value shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch];
} else if ([usingText isEqualToString:@"predicate string"]) {
NSPredicate *predicate = [FBPredicate predicateWithFormat:value];
elements = [element fb_descendantsMatchingPredicate:predicate shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch];
} else if (isSearchByIdentifier) {
elements = [element fb_descendantsMatchingIdentifier:value shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch];
} else {
[[NSException exceptionWithName:FBElementAttributeUnknownException reason:[NSString stringWithFormat:@"Invalid locator requested: %@", usingText] userInfo:nil] raise];
}
return elements;
}
以上查找的方法放在了XCUIElement +FBFind 分類里
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import "XCUIElement+FBFind.h"
#import "FBMacros.h"
#import "FBElementTypeTransformer.h"
#import "FBPredicate.h"
#import "NSPredicate+FBFormat.h"
#import "XCElementSnapshot.h"
#import "XCElementSnapshot+FBHelpers.h"
#import "FBXCodeCompatibility.h"
#import "XCUIElement+FBUtilities.h"
#import "XCUIElement+FBWebDriverAttributes.h"
#import "XCUIElementQuery.h"
#import "FBElementUtils.h"
#import "FBXCodeCompatibility.h"
#import "FBXPath.h"
@implementation XCUIElement (FBFind)
+ (NSArray<XCUIElement *> *)fb_extractMatchingElementsFromQuery:(XCUIElementQuery *)query shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
if (!shouldReturnAfterFirstMatch) {
return query.fb_allMatches;
}
XCUIElement *matchedElement = query.fb_firstMatch;
return matchedElement ? @[matchedElement] : @[];
}
#pragma mark - Search by ClassName
- (NSArray<XCUIElement *> *)fb_descendantsMatchingClassName:(NSString *)className shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
NSMutableArray *result = [NSMutableArray array];
XCUIElementType type = [FBElementTypeTransformer elementTypeWithTypeName:className];
if (self.elementType == type || type == XCUIElementTypeAny) {
[result addObject:self];
if (shouldReturnAfterFirstMatch) {
return result.copy;
}
}
XCUIElementQuery *query = [self.fb_query descendantsMatchingType:type];
[result addObjectsFromArray:[self.class fb_extractMatchingElementsFromQuery:query shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch]];
return result.copy;
}
#pragma mark - Search by property value
- (NSArray<XCUIElement *> *)fb_descendantsMatchingProperty:(NSString *)property value:(NSString *)value partialSearch:(BOOL)partialSearch
{
NSMutableArray *elements = [NSMutableArray array];
[self descendantsWithProperty:property value:value partial:partialSearch results:elements];
return elements;
}
- (void)descendantsWithProperty:(NSString *)property value:(NSString *)value partial:(BOOL)partialSearch results:(NSMutableArray<XCUIElement *> *)results
{
if (partialSearch) {
NSString *text = [self fb_valueForWDAttributeName:property];
BOOL isString = [text isKindOfClass:[NSString class]];
if (isString && [text rangeOfString:value].location != NSNotFound) {
[results addObject:self];
}
} else {
if ([[self fb_valueForWDAttributeName:property] isEqual:value]) {
[results addObject:self];
}
}
property = [FBElementUtils wdAttributeNameForAttributeName:property];
value = [value stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"];
NSString *operation = partialSearch ?
[NSString stringWithFormat:@"%@ like '*%@*'", property, value] :
[NSString stringWithFormat:@"%@ == '%@'", property, value];
NSPredicate *predicate = [FBPredicate predicateWithFormat:operation];
XCUIElementQuery *query = [[self.fb_query descendantsMatchingType:XCUIElementTypeAny] matchingPredicate:predicate];
NSArray *childElements = query.fb_allMatches;
[results addObjectsFromArray:childElements];
}
#pragma mark - Search by Predicate String
- (NSArray<XCUIElement *> *)fb_descendantsMatchingPredicate:(NSPredicate *)predicate shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
NSPredicate *formattedPredicate = [NSPredicate fb_formatSearchPredicate:predicate];
NSMutableArray<XCUIElement *> *result = [NSMutableArray array];
// Include self element into predicate search
if ([formattedPredicate evaluateWithObject:self.fb_cachedSnapshot ?: self.fb_lastSnapshot]) {
if (shouldReturnAfterFirstMatch) {
return @[self];
}
[result addObject:self];
}
XCUIElementQuery *query = [[self.fb_query descendantsMatchingType:XCUIElementTypeAny] matchingPredicate:formattedPredicate];
[result addObjectsFromArray:[self.class fb_extractMatchingElementsFromQuery:query shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch]];
return result.copy;
}
#pragma mark - Search by xpath
- (NSArray<XCUIElement *> *)fb_descendantsMatchingXPathQuery:(NSString *)xpathQuery shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
// XPath will try to match elements only class name, so requesting elements by XCUIElementTypeAny will not work. We should use '*' instead.
xpathQuery = [xpathQuery stringByReplacingOccurrencesOfString:@"XCUIElementTypeAny" withString:@"*"];
NSArray<XCElementSnapshot *> *matchingSnapshots = [FBXPath matchesWithRootElement:self forQuery:xpathQuery];
if (0 == [matchingSnapshots count]) {
return @[];
}
if (shouldReturnAfterFirstMatch) {
XCElementSnapshot *snapshot = matchingSnapshots.firstObject;
matchingSnapshots = @[snapshot];
}
return [self fb_filterDescendantsWithSnapshots:matchingSnapshots selfUID:nil onlyChildren:NO];
}
#pragma mark - Search by Accessibility Id
- (NSArray<XCUIElement *> *)fb_descendantsMatchingIdentifier:(NSString *)accessibilityId shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
NSMutableArray *result = [NSMutableArray array];
if (self.identifier == accessibilityId) {
[result addObject:self];
if (shouldReturnAfterFirstMatch) {
return result.copy;
}
}
XCUIElementQuery *query = [[self.fb_query descendantsMatchingType:XCUIElementTypeAny] matchingIdentifier:accessibilityId];
[result addObjectsFromArray:[self.class fb_extractMatchingElementsFromQuery:query shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch]];
return result.copy;
}
@end
以ClassName為例
- (NSArray<XCUIElement *> *)fb_descendantsMatchingClassName:(NSString *)className shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
NSMutableArray *result = [NSMutableArray array];
//根據(jù)類名獲取元素類型
XCUIElementType type = [FBElementTypeTransformer elementTypeWithTypeName:className];
if (self.elementType == type || type == XCUIElementTypeAny) {
[result addObject:self];
if (shouldReturnAfterFirstMatch) {
return result.copy;
}
}
//獲取當(dāng)前元素的XCUIElementQuery
//self.fb_query見下圖
XCUIElementQuery *query = [self.fb_query descendantsMatchingType:type];
[result addObjectsFromArray:[self.class fb_extractMatchingElementsFromQuery:query shouldReturnAfterFirstMatch:shouldReturnAfterFirstMatch]];
return result.copy;
}
+ (NSArray<XCUIElement *> *)fb_extractMatchingElementsFromQuery:(XCUIElementQuery *)query shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
if (!shouldReturnAfterFirstMatch) {
return query.fb_allMatches;
}
XCUIElement *matchedElement = query.fb_firstMatch;
return matchedElement ? @[matchedElement] : @[];
}
- (XCUIElement *)fb_firstMatch
{
XCUIElement* match = FBConfiguration.useFirstMatch
? self.firstMatch
: self.fb_allMatches.firstObject;
return [match exists] ? match : nil;
}
- (NSArray<XCUIElement *> *)fb_allMatches
{
return FBConfiguration.boundElementsByIndex
? self.allElementsBoundByIndex
: self.allElementsBoundByAccessibilityElement;
}
最終會根據(jù)你的查找方式(usingText)去查找XCUIElement元素。
最終的查找會通過XCUIElement私有屬性allElementsBoundByAccessibilityElement俊庇,allElementsBoundByIndex去拿到到需要的Element狮暑。
allElementsBoundByAccessibilityElement
query中根據(jù)accessibility element得到的元素數(shù)組。得到XCUIElement數(shù)組
allElementsBoundByIndex
query中根據(jù)索引值得到的元素數(shù)組辉饱。得到XCUIElement數(shù)組
3.WebDriverAgent如何處理點(diǎn)擊事件的搬男?
同樣是在Commands目錄下,Touch事件路由注冊放在FBTouchActionCommands.m中彭沼,
可以看到/wda/touch/perform、/wda/touch/multi/perform、/actions路由負(fù)責(zé)處理不同的點(diǎn)擊事件节槐。那么當(dāng)一個點(diǎn)擊的url請求過來時,如何轉(zhuǎn)化為iOS的UIEvent事件呢按脚?跟蹤代碼
+ (id<FBResponsePayload>)handlePerformAppiumTouchActions:(FBRouteRequest *)request
{
XCUIApplication *application = request.session.activeApplication;
NSArray *actions = (NSArray *)request.arguments[@"actions"];
NSError *error;
if (![application fb_performAppiumTouchActions:actions elementCache:request.session.elementCache error:&error]) {
return FBResponseWithUnknownError(error);
}
return FBResponseWithOK();
}
- (BOOL)fb_performAppiumTouchActions:(NSArray *)actions elementCache:(FBElementCache *)elementCache error:(NSError **)error
{
return [self fb_performActionsWithSynthesizerType:FBAppiumActionsSynthesizer.class actions:actions elementCache:elementCache error:error];
}
- (BOOL)fb_performActionsWithSynthesizerType:(Class)synthesizerType actions:(NSArray *)actions elementCache:(FBElementCache *)elementCache error:(NSError **)error
{
//將actions事件生成synthesizer對象
FBBaseActionsSynthesizer *synthesizer = [[synthesizerType alloc] initWithActions:actions forApplication:self elementCache:elementCache error:error];
if (nil == synthesizer) {
return NO;
}
//synthesizer生成eventRecord
XCSynthesizedEventRecord *eventRecord = [synthesizer synthesizeWithError:error];
if (nil == eventRecord) {
return [self.class handleEventSynthesWithError:*error];
}
return [self fb_synthesizeEvent:eventRecord error:error];
}
- (BOOL)fb_synthesizeEvent:(XCSynthesizedEventRecord *)event error:(NSError *__autoreleasing*)error
{
return [FBXCTestDaemonsProxy synthesizeEventWithRecord:event error:error];
}
+ (BOOL)synthesizeEventWithRecord:(XCSynthesizedEventRecord *)record error:(NSError *__autoreleasing*)error
{
__block BOOL didSucceed = NO;
[FBRunLoopSpinner spinUntilCompletion:^(void(^completion)(void)){
void (^errorHandler)(NSError *) = ^(NSError *invokeError) {
if (error) {
*error = invokeError;
}
didSucceed = (invokeError == nil);
completion();
};
if (nil == FBXCTRunnerDaemonSessionClass) {
[[self testRunnerProxy] _XCT_synthesizeEvent:record completion:errorHandler];
} else {
XCEventGeneratorHandler handlerBlock = ^(XCSynthesizedEventRecord *innerRecord, NSError *invokeError) {
errorHandler(invokeError);
};
if ([XCUIDevice.sharedDevice respondsToSelector:@selector(eventSynthesizer)]) {
//核心代碼
[[XCUIDevice.sharedDevice eventSynthesizer] synthesizeEvent:record completion:(id)^(BOOL result, NSError *invokeError) {
handlerBlock(record, invokeError);
}];
} else {
[[FBXCTRunnerDaemonSessionClass sharedSession] synthesizeEvent:record completion:^(NSError *invokeError){
handlerBlock(record, invokeError);
}];
}
}
}];
return didSucceed;
}
發(fā)現(xiàn)核心代碼是:
XCUIDevice的eventSynthesizer是私有方法,通過synthesizeEvent發(fā)送XCSynthesizedEventRecord(也是私有類)事件敦冬。到這里WebDriverAgent的流程就很清楚了辅搬。實(shí)際上由于使用了很多私有方法,WebDriverAgent并非僅能自動化當(dāng)前APP脖旱,也是可以操作手機(jī)屏幕以及任意APP的堪遂。
總結(jié)
1、WDA為了防止程序退出萌庆,寫了一個死循環(huán)溶褪,利用RunLoop手動維護(hù)主線程,監(jiān)聽或?qū)崿F(xiàn)UI操作
2践险、RoutingHTTPServer繼承自HTTPServer竿滨,HTTPServer內(nèi)部對GCDAsyncSocket進(jìn)行封裝。HTTPConnection里實(shí)現(xiàn)了GCDAsyncSocket的代理方法捏境。所以WDA內(nèi)部是利用GCDAsyncSocket長連接于游,與appium進(jìn)行通信。
3垫言、對于元素的查找贰剥,WDA是利用了XCUIElementQuery進(jìn)行element查找
利用XCUIApplication的launch方法來開啟指定app。
4筷频、對于實(shí)現(xiàn)UI事件蚌成,XCUIDevice的eventSynthesizer是私有方法,通過synthesizeEvent發(fā)送XCSynthesizedEventRecord(也是私有類)事件凛捏。