前言

承接上一篇 自动化工具 Nico,从零开始干掉 Appium,移动端自动化测试框架实现(一) ,这部分将给大家带来 iOS 部分的讲解。

老规矩先在开始之前先介绍一下背景 (原谅我废话有点多,实在是对 iOS 的怨念颇深,想看干货的可以直接跳过),之前在完成 Nico 的安卓部分自动化之后,公司的 iOS 的自动化需求也开始要介入了。如果不考虑自己实现的话,就得用两套,一套 Nico,一套用 Airtest。但这样的作法实在有点愚蠢,当然我也可以选择不要脸的拿 Airtest 套个壳或者找一个开源框架套个壳,上层封装一层假装成 Nico 的一部分。只是这着实不是我的行事风格,况且我对的 wda(WebDriverAgent) 启动会弹窗这事实在不能容忍,于是又开始了漫漫探索之路。

至今为止,谈到 iOS 自动化,都绕不开一个东西,那就是传说中的 wda(WebDriverAgent),一个由 Facebook 开源的 “中间商”,它可以帮助我们与 iOS 设备进行交互,执行各种操作,从而实现自动化测试。打这东西问世起至今也快 10 年了,后来 Facebook 放弃维护,转而由 Appium 接手进行继续维护。期间我见过无数人吐槽过部分接口执行耗时较长,执行效率不高的问题,也有很多人说它不够稳定,即使 appium 接收过来进行二次更新仍存在很多问题。但这不妨碍它始终是在做 iOS 的自动化时候的唯一解法,市面上我不敢说 100%,但 90% 的 iOS 自动化都是基于它开发的,剩下大部分都使用 sdk 插桩的方式进行入侵式测试(过于定制化,没有通用性)。

那么这玩意就是无解的吗,并不。我首先是阅读了 WebDriverAgent 的源码,但它是由 Objective-C 写的,且内部使用了一些苹果未曾公开的私有方法,在简单了解了运行机制之后就放弃了深入了解(然而这更加坚定了我要使用更加简单易懂的代码编写属于自己的 “wda”,于是后来我选择了用 swift 来开发,对比 OC,它的语法真是简单多了)然后,我开始在网上疯狂搜寻各种资料。

众所周知,由于苹果系统的特性原因。我们是没有办法像安卓一样直接将.ipa 包直接安装在真机上的,于是我最初的想法是能否找到一种,不借助第三方 app,通过类似 adb 的命令直接调用苹果的 api 进行一些操作。然后我找到了idb,它可以实现很多类似 dump 元素,点击拖动的操作。乍一看这也是也是由 facebook 开发的。心想好家伙,你不继续搞 wda 搞这玩意去了。但很可惜的是,它只能作用于模拟器上,对真机只能简单做一些读取和启动杀死 app 的操作。于是我又开始新一轮探索,寻找过程中我发现字节也对 wda 有同样困扰,并且对此进行了深入研究iOS 自动化测试驱动工具探索。里面非常详细的阐述了整个 iOS 自动化的运行原理以及一些 pc 与 iOS 之间的通讯原理,并且最终它们实现了自己的 “wda”。

一切看似都很好,但最终没有开源代码可供借鉴(抄)😇 ,导致这一切又是如此的不美好。

俗话说借鉴是不能借鉴的,只能靠自己写写代码维持 Nico 这个框架这个样了。最终我开始了编写属于自己的 “wda” 之路。

PC 端与移动端的通讯

其实有了之前写安卓那边的经验,iOS 这边只需要照猫画虎就行了。首先我们需要考虑的就是 iOS 有没有类似 adb 一样的工具,可以拿到一些 iOS 的一些基础信息。前文提到 facebook 开源了一个叫 idb 的东西,但它仅能运行在 mac 上。好在后来我在论坛中找到了tideivce(在此也非常感谢这位大佬的开源方案),它可以同时运行于 win 和 mac,并且提供端口转发,和脱离 xcode 也能够执行 XCUITest 能力。有了这项利器,我只需要把重心放在类 “wda” 的实现上。

关于编程语言的选择,我没有使用 OC,因为相较于 OC,Swift 的语法规则实在简单太多了。同样,为了能够服务端 app 在运行过程中不弹前台。我依然把运行逻辑放在了 XCUTest 套件中,而主程序是一个空壳应用。

然后,依旧在开辟一个线程启动一个服务来监听 PC 端发来的请求,iOS 上我使用了 CocoaAsyncSocket,CocoaAsyncSocket 是一个非常强大可以异步执行的 socket 通讯库,极大程度上简化了我们收发请求的问题。

这里是 StartServer 的逻辑,如果命令行没有指定运行端口,则默认使用 8200 端口。

接着我们用命令行启动 xcuitest,看到以下场景就表示测试服务已启动成功,第一个 bundle-id 是测试套件 app 的包名,第二个 bundle-id 是空壳应用的包名

最后让我们启动端口转发,然后尝试发起 socket 请求试试。值得一提的是,与安卓不同,iOS 的每个应用都是运行在沙盒环境里,互相之间是没有权限访问的。因此执行任何操作都必须指定包名,否则拿不到任何数据。

这里可以看到我们的请求发送成功,正确获取到桌面的 UI 树结构。

到此为止,就算是实现了整个通讯过程。

元素查找与操作

查找

接下来说到元素查找的问题,在 iOS 部分,我没有延续安卓那边先 dump 整个 UI 树保存为 xml 的逻辑,而是改为优先调用原生 API 查找,UI 树作为辅助 (用于查询父母兄弟节点)。为什么没有这么做?因为跟安卓的 ui dump 想比,iOS 要慢上不少。

造成这个差异的原因是安卓的 dump UI 树是只拉取当前页面可见的元素,而 iOS dump UI 是以 app 为目标,拉取当前指定 app(且必须是当前前台运行的 app) 的所有元素包含可见和不可见的部分。

举个例子,假设现在我打开我的手机通讯录,我的通讯录里有 100 个联系人。我是用安卓 dump UI 树只能够获取到前 6 个联系人的元素信息,而如果是 iOS 则会获取 6+ 上剩下所有可以被枚举出来的联系人的元素信息。一旦页面存在已渲染但不可见的元素数量越多,dump 的速度也就越慢。

在这种差异化的情况下,我还是不能偷懒,只能选择调用 XCUITest 原生的 API 在 WDA 端实现元素查找的逻辑。接下来让我们来看一下 XCUITest 原生支持的定位方式长啥样。
一种是如下这样,先指定元素类型,然后使用元素包含的文本 (不需要指定包含的文本的类型,比如 identifier,label,value)

//swift code
let app = XCUIApplication() 
app.launch() 
// 指定元素类型并使用label查询元素
let myButton = app.buttons["My Button Text"]

还有一种叫做谓词定位

//swift code
let predicate = NSPredicate(format: "label CONTAINS[c] %@", "Button Text")
let myButton = app.buttons.containing(predicate).element

经过我的测试,谓词定位相对前一种速度要快一点点,且可定制化比较高,最终我的策略方案就是将上层的 query 全部转成谓词,然后在底层使用谓词定位进行元素搜索。

所以当我们使用 nico(value= "你好", identifier_contains="hello") 时,会被先转成 label == "你好" OR identifier CONTAINS "hello" ,然后再传给服务端进行谓词查找。

值得一提的时,当时在编写这块逻辑的时候遇到了一个小坑,在做自动化的时候我们通常都会使用到一个查找条件叫class name,在安卓和 iOS 中它对应的都是元素的类型。但在谓词定位表达式中时不能直接使用 class_name = xxx,与此相关的只有elemnt_type这个选项,且它为纯数字。无奈我只能在本地写个映射表来转换其类型

说完了谓词定位,为了补充定位方式的多样性。我还加入了 xpath,对就是那个曾经被无数人诟病速度超慢的 xpath。

在此我们先分析一波,为什么 xpath 很慢。之前 appium 官方给出的解释是,iOS 原生并不支持直接生成 xml 树的 api 接口,而 xpath 的查询又是一种基于 xml 结构的查找方式。当时 wda 为了实现生成 xml 结构的元素树,选择在移动端从根节点开始遍历整个 UI 树来构建整个层级结构,直到现在我们在使用 appium inspector 工具查看 UI 树时,使用的还是那一套逻辑。

开始我非常怀疑,只是生成一个 UI 树真的有这么慢吗?于是我在移动端里写了一个方法,用于遍历整个 UI 树,为了加快速度,我还排除了所有不可见的元素。

//swift code
    private func getVisibleElementsDescription(element: XCUIElement, indent: String = "") -> String {
        var description = ""
        if element.isHittable {
            description += "\(indent)\(element.debugDescription)\n"
        }

        for i in 0..<element.children(matching: .any).count {
            let child = element.children(matching: .any).element(boundBy: i)
            description += getVisibleElementsDescription(element: child, indent: indent + "  ")
        }

        return description
  }

然后使用桌面 app 做实验 (我手机里的主页有两个界面,最开始使用 appium inspector 启动桌面进行元素查看,结果直到超时,页面都没加载出来),结果我足足跑了 1 分多分钟,仍没有结束。我仔细一看运行日志,遍历单一一个节点也还好,只花了半秒钟不到。但架不住节点多,总共上百个节点,0.5s * 100 就差不多 50 多秒了。且由于 iOS 的查询机制,并不会缓存走过的节点,所以每一次节点的查询时间都是一致的不会变得更快。

那难道就没有更快获取整个 UI 树结构的方法吗?其实是有的,使用如下方法即可快速获取整个 UI 树的结构

let app = XCUIApplication(bundleIdentifier: String(bundle_id))
    //                     app.launch()
let response = app.debugDescription

只是与安卓不同,它返回的是一个展示树状结构的字符串,并不是一个实际的 xml

Attributes: Application, 0x106049fc0, pid: 61, label: ' '
Element subtree:
 →Application, 0x106049fc0, pid: 61, label: ' '
    Window, 0x104d551d0, {{0.0, 0.0}, {414.0, 736.0}}
      Other, 0x104d56910, {{0.0, 0.0}, {414.0, 736.0}}
        Other, 0x104d56cd0, {{0.0, 0.0}, {414.0, 736.0}}
          Other, 0x104d56a30, {{0.0, 0.0}, {414.0, 736.0}}
      Other, 0x104d56b50, {{0.0, 0.0}, {414.0, 736.0}}
    Window, 0x104d56df0, {{0.0, 0.0}, {414.0, 736.0}}
      Other, 0x104d56f10, {{0.0, 0.0}, {414.0, 736.0}}
        Other, 0x104d57030, {{0.0, 0.0}, {414.0, 736.0}}
          Other, 0x104d57150, {{0.0, 0.0}, {414.0, 100.0}}
        Other, 0x104d57270, {{0.0, 0.0}, {414.0, 736.0}}, identifier: 'Home screen icons'
...

获取整个桌面的 UI 树结构只花了 0.6 秒左右!

那么既然拿到了整个树状结构,我们只需要再写个方法基于每一行文本前面的空行数量就可以构筑起其层级结构,无需通过遍历整个 UI 树的形式来做,也不需要在移动端里处理了再传回来,直接拉下来放本地处理更快。(关于 string 如何转成 xml 的代码,可以参见 auto_nico/ios/tools/format_converter.py 这个 py 文件里 converter 方法。这里就不做赘述啦。)

这里是转换前后对比,圈起来的部分方便大家观察对应关系,上面是转换后的 xml,下面是原始数据。

有了 xml 文件,我们执行 xpath 再也不是龟速啦!但这样仅仅这样还是不够,当面对一些页面元素多,结构复杂的场景app.debugDescription 也要花个将近 7-8 秒。即使使用页面缓存,业务量大起来之后,xpath 还是不够看。

为了进一步加速,我想到了 appium 之前有个大神开发过一个叫 class chain 的东西。深入了解后,发现原理非常简单。因为在 iOS 中是可以直接指定你要查什么类型的元素,像这样app.buttons.containing

所以 class chain 选择不遍历创建整个 UI 目录树,直接从根节点开始精准查找下一级里指定 class 类型且满足指定条件的元素,最后形成一条直线链路。

根据这个思路,我在 iOS 中写了这样一个方法。首先将 xpath 以"/"分割成一个数组,假设 xpath="Window[1]/Other[0]/Other[1]/Other[0]/Other[0]/Icon[0]/Icon[3]",那么 xpath 的数组就是[ "Window[1]", "Other[0]", "Other[1]", "Other[0]", "Other[0]", "Icon[0]", "Icon[3]"]。最终,我们通过遍历这个数组,挨个从当前元素的子元素中获取指定类型的元素,直接拿到我们想要的那个元素。

实测起来速度跟谓词定位差不多,即使元素结构复杂,查询速度依旧能够差不多持平。

操作

操作这块其实没什么特别好讲的,就是简单调系统 API。我这里写了一个类,通过传入的坐标,然后进行点击长按等操作。

然后还添加了一些物理按键的操作,后续如果有更多的操作需求再陆续添加吧。

录制

除了查找和操作,还有一个很重要的是录制。之前看过其他框架的录制实现貌似都是调用了 ios 库中的 AVFoundation 或者 AVCaptureSession,要么就是需要借助第三方工具。我这里说一下我的实现原理,我是利用了截屏的方案。

首先通过单独启动一个线程在后台不停截图,

//swift code
 if message.contains("start_recording") {
                    isRecording = true
                    images = []
                    var screenshotCount = 0
                    let maxScreenshots = 100 // 设置最大截图数量

                    DispatchQueue.global(qos: .background).async { [weak self] in
                        guard let self = self else { return }
                        while self.isRecording && screenshotCount < maxScreenshots {
                            DispatchQueue.main.async {
                                let screenshot = XCUIScreen.main.screenshot()
                                let image = screenshot.image
                                self.images.append(image)
                            }
                            screenshotCount += 1
                            usleep(100_000)
                            // 等待1秒钟后再进行下一次截图
                        }
                    }
                }

然后将所有图像转成二进制代码,传输到 PC 端。

 //swift code
if message.contains("stop_recording") {
                    isRecording = false
                    // 将所有图像数据与结束标记组合在一起进行一次写入
                    var dataToSend = Data()

                    for image in images {
                        if let jpegData = image.jpegData(compressionQuality: 0.1) {
                            print(jpegData)
                            sock.write(jpegData, withTimeout: -1, tag: 0)
                            sock.write("end_with".data(using: .utf8), withTimeout: -1, tag: 0)
                        }
                    }
                    // 清空图像数组
                    images.removeAll()
}

再解析成对应的图像格式并组装生成.mp4
```python
# pyhton code
def stop_recording(self, path='output.mp4'):
        logger.debug("stop recording, start to save video")
        time.sleep(1)
        exists_port = self.runtime_cache.get_current_running_port()
        respo = send_tcp_request(exists_port, "stop_recording")
        images = []
        # print(respo)
        images_byte = respo.split(b'end_with')
        for image_data in images_byte:
            if not image_data:
                continue
            image = bytes_to_image(image_data)
            images.append(image)
        images_to_video(images, path)
        logger.debug("save video successfully")

但这种方案有个缺陷就是受限于截图速度,FPS 最高只能到 10 左右。后续有时间再研究其他方案把:)
以上就是 iOS 部分全部的实现。

尝试完全脱离 XCode 编译

之前在还在用 Appium 的时候,就曾听过可以将已编译好的 wda.ipa 文件用自己的苹果账号重签名,可以实现完全脱离 xcode 进行编译安装,于是我自己也试了一下。

首先要 build 一个可重签名的包,先点 xcode 的 product,然后点 build。

之后我们就可以得到两个未加壳的.app 后缀的文件,一个是主程序,一个是 xcuitest 测试套件。它们通常位于/Users/你的用户名/Library/Developer/Xcode/DerivedData/dump_hierarchy-fdsjnabirrlbkzeshmvdtfaywupu/Build/Products/Debug-iphoneos/。.app 类型的文件通常是可以直接被安装在模拟器上的,这也是为什么模拟器不需要签名的原因。

接着我们将.app 文件里的内容解压出来,这里为了方便展示,我将其拖到了 window 上,里面包含了旧的签名,源码以及导入的库等。注意图上的时间,等下会考。

然后直接创建一个新的名为 Payload 的文件夹将刚才的.app 文件夹整个拖进去,压缩,修改后缀为 ipa。打开爱思助手的重签名工具(有的教程说,需要先把已有的签名删掉。但我这里直接使用的是爱思助手的重签名工具,删不删都无所谓,它会帮我把签名替换掉)

签名成功后,重新导入签名后的包,可以发现开发者信息已经变了。

同时我们会发现这三个文件夹的时间也发生变化与其他的几个同,这是因为签名文件发生变化,且 framework 和 PlugIns 里的文件的签名也发生了改变。

最后,我们来尝试安装重签名后的 ipa 包,结果发现安装失败,提示 verify code signature of XCTestSupport.framework,也就是 XCTest 库的签名验证失败 了

所以是 XCTest 库没有重签名吗,实际上并不是,我重新解压后检索了该目录,签名文件的时间同样发生了变化,且时间与签名时间一致。info_list 的信息也换成了新签名的开发者信息。但现实情况就是这个 XCTest 库无法被重签名安装,也许跟苹果的安全机制有关?这个问题我始终没有找到答案,希望有识之士可以为我解答。


↙↙↙阅读原文可查看相关链接,并与作者交流