随着移动互联网时代的发展,移动终端的自动化测试日益活跃,总体来看在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的更多信息:
- 在GitHub上的项目
- 来自2019 F8的谈话
虽然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)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 *)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 *)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 *)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 *)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 *)fb_descendantsMatchingPredicate:(NSPredicate *)predicate shouldReturnAfterFirstMatch:(BOOL)shouldReturnAfterFirstMatch
{
NSPredicate *formattedPredicate = [NSPredicate fb_formatSearchPredicate:predicate];
NSMutableArray *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 *)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 *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 *)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 *)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 *)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 *)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)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(也是私有类)事件。