WebDriverAgent(WDA)的配置使用及源码分析

随着移动互联网时代的发展,移动终端的自动化测试日益活跃,总体来看在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.png

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;
}

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)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(也是私有类)事件。

你可能感兴趣的:(WebDriverAgent(WDA)的配置使用及源码分析)