隨著移動互聯網時代的發展,移動終端的自動化測試日益活躍,總體來看在Android平臺上的自動化工具和實踐比較多,IOS的UI自動化測試由于系統封閉的原因,一直不太成熟。本著不侵入工程和擁抱原生的原則實現一套自動化測試方案。自動化測試節省時間節省真機的成本,而且更高效的覆蓋所有的iOS機型測試,避免每次上線前重復的人工回歸測試,保證每次上線的版本穩定運行。
在Xcode 8之前,基于UI Automation的自動化測試方案是比較好用且非常流行的。但在Xcode 8之后,蘋果在instruments工具集中直接廢除了Automation組件,轉而支持使用UI Testing。
UI Testing
從Xcode 7開始,蘋果提供了UI Testing框架,也就是我們在APP test工程中使用的XCTest的那一套東西。UI Testing包含幾個重要的類,分別是XCUIApplication、XCUIElement、XCUIElementQuery。
XCUIApplication
代表正在測試的應用程序的實例,可以對APP進行啟動、終止、傳入參數等操作。
-
XCUIApplication
- 代表正在測試的應用程序的實例,可以對APP進行啟動、終止、傳入參數等操作。
- (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。這個技術點是iOS APP能夠自動化測試的關鍵所在。
XCUIElement
表示界面上顯示的UI元素。XCUIElementQuery
用于定位UI元素的查詢對象。
上述幾個模塊就是一個UI測試框架的核心能力,后面在寫Appium的自動化腳本時也是一樣的套路:啟動APP->定位UI元素->觸發操作。
WebDriverAgent
WebDriverAgent是用于iOS的WebDriver服務器實現,可用于遠程控制iOS設備。它允許您啟動和終止應用程序,點擊并滾動視圖或確認屏幕上是否存在視圖。這使其成為用于應用程序端到端測試或通用設備自動化的理想工具。它通過鏈接XCTest.framework和調用Apple的API來直接在設備上執行命令來工作。WebDriverAgent是Facebook開發和用于端到端測試的,并已被Appium成功采用。
在2019年5月,Facebook開源了IDB,即“ iOS Development Bridge”,這是一個用于使iOS模擬器和設備自動化的命令行界面。我們目前正在將自己的內部項目從WDA遷移到IDB,并建議將其檢查出來作為替代方案。
有關IDB的更多信息:
雖然git上不再得到Facebook的積極的維護,移動端主流測試框架依然要借助WDA來實現與iOS交互測試,你可以在appium中下載可運行WebDriverAgent
準備工作
安裝 homebrew
homebrew 是 Mac OS 下最優秀的包管理工具,沒有之一。
xcode-select --install
ruby -e "$(curl -fsSLhttps://raw.githubusercontent.com/Homebrew/install/master/install)"
安裝 python
腳本語言 python 用來編寫模擬的用戶操作。
brew install python3
安裝 libimobiledevice
libimobiledevice 是一個使用原生協議與蘋果iOS設備進行通信的庫。通過這個庫我們的 Mac OS 能夠輕松獲得 iOS 設備的信息。
brew install --HEAD libimobiledevice
使用方法:
查看 iOS 設備日志
idevicesyslog
查看鏈接設備的UDID
idevice_id --list
查看設備信息
ideviceinfo
獲取設備時間
idevicedate
獲取設備名稱
idevicename
端口轉發
iproxy XXXX YYYY
屏幕截圖
idevicescreenshot
安裝 Carthage
Carthage 是一款iOS項目依賴管理工具,與 Cocoapods 有著相似的功能,可以幫助你方便的管理三方依賴。它會把三方依賴編譯成 framework,以 framework 的形式將三方依賴加入到項目中進行使用和管理。
WebDriverAgent 本身使用了 Carthage 管理項目依賴,因此需要提前安裝 Carthage。
brew install carthage
源碼分析
1.WebDriverAgent如何建立連接的?
webdriver協議是一套基于HTTP協議的JSON格式規范,協議規定了不同操作對應的格式。之所以需要這層協議,是因為iOS、Android、瀏覽器等都有自己的UI交互方式,通過這層”驅動層“屏蔽各平臺的差異,就可以通過相同的方式進行自動化的UI操作,做網絡爬蟲常用的selenium是瀏覽器上實現webdriver的驅動,而WebDriverAgent則是iOS上實現webdriver的驅動。
使用Xcode打開WebDriverAgent項目,連接上iPhone設備之后,選中WebDriverAgentRunner->Product->Test,則會在iPhone上安裝一個名為WebDriverAgentRunner的APP,這個APP實際上是一個后臺應用,直接點擊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為了防止程序退出,寫了一個死循環,自己手動維護主線程,監聽或實現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會在手機上8100端口啟動一個HTTP server,startServing方法內部就是一個死循環,監聽網絡傳輸過來的webdriver協議的數據,解析并處理路由事件。
- 在startHTTPServer里創建server并建立連接,調用registerRouteHandlers方法注冊所有路由
路由注冊
下面來看下注冊路由 [self registerRouteHandlers:[self.class collectCommandHandlerClasses]]方法的源碼
首先來看[self.class collectCommandHandlerClasses]方法的實現
//獲取所有遵循FBCommandHandler協議的類
+ (NSArray<Class<FBCommandHandler>> *)collectCommandHandlerClasses
{
//利用runtime 動態獲取所有注冊過FBCommandHandler協議的類
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決定是否應將類自動添加到路由處理程序,默認(如果未實現)是
*/
if ([aClass respondsToSelector:@selector(shouldRegisterAutomatically)]) {
if (![aClass shouldRegisterAutomatically]) {
continue;
}
}
[handlers addObject:aClass];
}
return handlers.copy;
}
#import "FBRuntimeUtils.h"
#import <objc/runtime.h>
//利用runtime 動態獲取注冊過FBCommandHandler協議的
NSArray<Class> *FBClassesThatConformsToProtocol(Protocol *protocol)
{
Class *classes = NULL;
NSMutableArray *collection = [NSMutableArray array];
/*獲取到當前注冊的所有類的總個數,它需要傳入兩個參數,
第一個參數 buffer :已分配好內存空間的數組,傳NULL會自動計算內存空間
第二個參數 bufferCount :數組中可存放元素的個數,
返回值是注冊的類的總數。*/
int numClasses = objc_getClassList(NULL, 0);
//如果沒有注冊類,直接返回空數組
if (numClasses == 0 ) {
return @[];
}
//遍歷所有注冊的類,如果遵循FBCommandHandler協議,就添加到數組里
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方法其實是利用runtime動態獲取到所有注冊過FBCommandHandler協議的類
下面來看下registerRouteHandlers方法的實現
- (void)registerRouteHandlers:(NSArray *)commandHandlerClasses
{
// 遍歷所有遵循FBCommandHandler協議的類
for (Class<FBCommandHandler> commandHandler in commandHandlerClasses) {
// 獲取類實現的routes方法返回的路由數組
NSArray *routes = [commandHandler routes];
for (FBRoute *route in routes) {
[self.server handleMethod:route.verb withPath:route.path block:^(RouteRequest *request, RouteResponse *response) {
//#warning 接收事件的回調
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 {
//創建路由,并解析path
Route *route = [self routeWithPath:path];
//每一個路由都持有一個對用的block,
route.handler = block;
[self addRoute:route forMethod:method];
}
//創建路由,并解析path
- (Route *)routeWithPath:(NSString *)path {
Route *route = [[Route alloc] init];//創建路由
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;
}
//添加路由到對應的方法
- (void)addRoute:(Route *)route forMethod:(NSString *)method {
//方法method排序
method = [method uppercaseString];
//以方法method為key,獲取routes里的對應的數組,如果沒有就創建一個數組作為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,存儲對應的route路由對象,每一個route對象都會有一個path和block,當接收到對應的path指令時去執行block。那么path指令是在何時接收的呢?
建立連接接受指令
在RoutingHTTPServer中,搜索routes objectForKey:,我們發現了這個方法
- (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;
}
順藤摸瓜,查找的這個方法的調用,在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
//這里調用的解析方法
httpResponse = [self httpResponseForMethod:method URI:uri];
if (httpResponse == nil)
{
[self handleResourceNotFound];
return;
}
[self sendResponseHeadersAndBody];
}
最終我們在GCDAsyncSocket Delegate 里找到了該方法的調用
- (void)socket:(GCDAsyncSocket *)sock didReadData:(NSData*)data withTag:(long)tag
GCDAsyncSocket是Server長鏈接,只要有消息過來就會調用GCDAsyncSocket 的Delegate方法
2.WebDriverAgent如何查找元素的?
剛剛有講過WDA中路由的注冊,從WebDriverAgent的源碼可以清晰的看到,在Commands目錄,是支持的操作類集合。每一個操作都通過routes類方法注冊對應的路由和處理該路由的函數。查找元素路由注冊放在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:依據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];
//根據類名獲取元素類型
XCUIElementType type = [FBElementTypeTransformer elementTypeWithTypeName:className];
if (self.elementType == type || type == XCUIElementTypeAny) {
[result addObject:self];
if (shouldReturnAfterFirstMatch) {
return result.copy;
}
}
//獲取當前元素的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;
}
最終會根據你的查找方式(usingText)去查找XCUIElement元素。
最終的查找會通過XCUIElement私有屬性allElementsBoundByAccessibilityElement,allElementsBoundByIndex去拿到到需要的Element。
allElementsBoundByAccessibilityElement
query中根據accessibility element得到的元素數組。得到XCUIElement數組
allElementsBoundByIndex
query中根據索引值得到的元素數組。得到XCUIElement數組
3.WebDriverAgent如何處理點擊事件的?
同樣是在Commands目錄下,Touch事件路由注冊放在FBTouchActionCommands.m中,
可以看到/wda/touch/perform、/wda/touch/multi/perform、/actions路由負責處理不同的點擊事件。那么當一個點擊的url請求過來時,如何轉化為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;
}
發現核心代碼是:
XCUIDevice的eventSynthesizer是私有方法,通過synthesizeEvent發送XCSynthesizedEventRecord(也是私有類)事件。到這里WebDriverAgent的流程就很清楚了。實際上由于使用了很多私有方法,WebDriverAgent并非僅能自動化當前APP,也是可以操作手機屏幕以及任意APP的。
總結
1、WDA為了防止程序退出,寫了一個死循環,利用RunLoop手動維護主線程,監聽或實現UI操作
2、RoutingHTTPServer繼承自HTTPServer,HTTPServer內部對GCDAsyncSocket進行封裝。HTTPConnection里實現了GCDAsyncSocket的代理方法。所以WDA內部是利用GCDAsyncSocket長連接,與appium進行通信。
3、對于元素的查找,WDA是利用了XCUIElementQuery進行element查找
利用XCUIApplication的launch方法來開啟指定app。
4、對于實現UI事件,XCUIDevice的eventSynthesizer是私有方法,通過synthesizeEvent發送XCSynthesizedEventRecord(也是私有類)事件。