随着移动互联网时代的发展,移动终端的自动化测试日益活跃,总体来看在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
image.pngWebDriverAgent是用于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;
}
image.png
最终会根据你的查找方式(usingText)去查找XCUIElement元素。
image.png最终的查找会通过XCUIElement私有属性allElementsBoundByAccessibilityElement,allElementsBoundByIndex去拿到到需要的Element。
allElementsBoundByAccessibilityElement
query中根据accessibility element得到的元素数组。得到XCUIElement数组
allElementsBoundByIndex
query中根据索引值得到的元素数组。得到XCUIElement数组
3.WebDriverAgent如何处理点击事件的?
同样是在Commands目录下,Touch事件路由注册放在FBTouchActionCommands.m中,
image.png
可以看到/wda/touch/perform、/wda/touch/multi/perform、/actions路由负责处理不同的点击事件。那么当一个点击的url请求过来时,如何转化为iOS的UIEvent事件呢?跟踪代码
image.png+ (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;
}
发现核心代码是:
image.png
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(也是私有类)事件。
网友评论