WWDC20 10017 - Core Data 杂项与准则

本文是对 WWDC 2020 Core Data: Sundries and maxims Session 的翻译。

本 Session 将从三个方面介绍如何优化 Core Data 性能:

  • 使用批量操作
  • 定制查询操作
  • 响应变化通知

下面会以这个地震信息列表 Demo 为例,说明上面的内容:

示例中,从 USGS(美国地质调查局) 获取到 JSON Feed 通过 JSON Parser 解析后,通过 Background Context 存入到持久化存储中,View Context 合并过数据后交给显示层。

在上面的过程中,大量的 Managed Object 在被创建保存之后立即就被废弃了。这正是批量操作的使用场景。

批量操作

批量操作在保持轻量的同时支持插入、更新和删除,但它没有提供结果的通知或回调。一个解决方案是开启持久化历史(Persistent History),这样我们可以得到批量操作的通知。至于回调,我们可以通过解析持久化历史来找出对应的变化。

storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

接下来会详细解说各个批量操作。

批量插入

iOS 14 在 NSBatchInsertRequest 上新增了一套基于 Block 的批量插入 API:

// NSBatchInsertRequest.h

@available(iOS 13.0, *)
open class NSBatchInsertRequest : NSPersistentStoreRequest {
    open var resultType: NSBatchInsertRequestResultType

    // iOS 13 旧接口,通过数组批量插入
    public convenience init(entityName: String, objects dictionaries: [[String : Any]])
    public convenience init(entity: NSEntityDescription, objects dictionaries: [[String : Any]])

    // iOS 14 新增,通过 Block 批量插入
    @available(iOS 14.0, *)
    open var dictionaryHandler: ((inout Dictionary<String, Any>) -> Void)?
    open var managedObjectHandler: ((inout NSManagedObject) -> Void)?

    public convenience init(entity: NSEntityDescription, dictionaryHandler handler: @escaping (inout Dictionary<String, Any>) -> Void)
    public convenience init(entity: NSEntityDescription, managedObjectHandler handler: @escaping (inout NSManagedObject) -> Void)
}

举个🌰,不使用 Batch 逐个插入新对象:

// Earthquakes Sample - Regular Save

   for quakeData in quakesBatch {
        // 逐个创建 Entity
        guard let quake = NSEntityDescription.insertNewObject(forEntityName: "Quake", into: taskContext) as? Quake else { ... }
        do {
            // 逐个填充数据
            try quake.update(with: quakeData)
        } catch QuakeError.missingData {
            ...
            taskContext.delete(quake)
        }
        ...
    }
    do {
        try taskContext.save()
    } catch { ... }

使用老的数组式 Batch Request 插入对象:

// Earthquakes Sample - Batch Insert

// 构建数组
var quakePropertiesArray = [[String:Any]]()
for quake in quakesBatch {
    quakePropertiesArray.append(quake.dictionary)
}

let batchInsert = NSBatchInsertRequest(entityName: "Quake", objects: quakePropertiesArray)

var insertResult : NSBatchInsertResult
do {
    insertResult = try taskContext.execute(batchInsert) as! NSBatchInsertResult
    ... 
}

使用 iOS 14 新加的 Block 式 Batch:

//Earthquakes Sample - Batch Insert with a block

var batchInsert = NSBatchInsertRequest(entityName: "Quake", dictionaryHandler: { 
    (dictionary) in
        if (blockCount == batchSize) {
            // 返回 true 表示结束
            return true
        } else {
            dictionary = quakesBatch[blockCount]
            blockCount += 1
        }
    })
    var insertResult : NSBatchInsertResult
    do {
        insertResult = try taskContext.execute(batchInsert) as! NSBatchInsertResult
        ...
    }

让我们来看看这三种方式在插入大量数据时性能上的差别:

方式 操作耗时 内存
无 Batch 62s 31M
iOS 13 数组式 Batch 30s 25.2 M
iOS 14 Block 式 Batch 11s 24.3 M

Block 式 Batch 无论在耗时还是在内存峰值上都有最好的表现。非 Batch 的耗时主要花费在合并数据更变 Notification 上了。

自动合并对象

在 Core Data 文件的 Entities 的 右侧 Core Data Inspector 编辑栏 ,将属性加入到 Constraints 后,就在这个属性上建立了一个 Unique 的约束。设置 mergePolicy 可以让 Core Data 自动支持 Model 更新。

managedObjectContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy

批量更新

NSBatchUpdateRequest 支持快速批量的更新数据,我们无需再经历查询、更新、保存的老流程。下面的代码将所有 magitute 大于 2.5 的数据的 validate 属性更新为 true

// Earthquakes Sample - Batch Update
let updateRequest = NSBatchUpdateRequest(entityName: "Quake")
updateRequest.propertiesToUpdate = ["validated" : true]
updateRequest.predicate = NSPredicate("%K > 2.5", "magnitude")

var updateResult : NSBatchUpdateResult
do {
    updateResult = try taskContext.execute(updateRequest) as! NSBatchUpdateResult
    ... 
}

批量删除

NSBatchDeleteRequest 支持:

  • 批量删除对象图中的大部分内容
  • 遵守对象关系。删除是级联的,关系也会被置空
  • 适用于清理过期对象或控制对象的 TTL(存活时间)

下面的代码在后台线程将删除所有 creationDate 在 30 天之前的数据:

// Batch Delete without and with a Fetch Limit
DispatchQueue.global(qos: .background).async {
    moc.performAndWait { () -> Void in
       do {
           let expirationDate = Date.init().addingTimeInterval(-30*24*3600)

           let request = NSFetchRequest<Quake>(entityName: "Quake")
           request.predicate = NSPredicate(format:"creationDate < %@", expirationDate)

           let batchDelete = NSBatchDeleteRequest(fetchRequest: request)
           // batchDelete.fetchLimit = 1000
           moc.execute(batchDelete)
        }
    }
}

代码十分简单但也存在一个问题,如果需要处理的数据足够多,那么会耗费相当长的不受控的时间去执行。为了解决这一问题,可以设置 fetchLimit ,让耗时回到可控的范围内。

定制查询请求

在编写查询请求时,我们可以问问自己“真的需要这么多数据吗”。通常通过裁剪查询请求,我们可以获得更好的性能。NSFetchRequestresultType 属性允许我们控制查询结果的类型。

查询对象

managedObjectResultType 支持以对象作为查询结果,支持对象图的完整遍历,特别适用与 FetchResultController 配合使用。

控制 Batch 数量

在界面上显示出的 Cell 个数只有 15 个,而我们实际拉取的数量远远高于这个数字,这里就存在优化空间。通过控制 fetchBatchSize ,我们可以仅让前排需要展示的对象填充数据(Hydrate)。

批量获取的 Array 与普通的 Array 行为不同。

普通 Array 中所有的数据都是已经填充过的: 普通 Array

Batch Array 中只有当前 Batch 对应的数据会被填充成 Managed Object,其他都是以 ObjectID 存在:

Batch

通过开启 fetchBacthSize ,在我们的 Demo 中,内存使用从 17M 降低到了 12M!🎉

按需读取属性

Core Data 允许我们精细的控制需要获取的属性:

@available(iOS 3.0, *)
open var propertiesToFetch: [Any]?

设置了 propertiesToFetch 后,Demo 的应用内存下降了 1.2 M!

CoreData 为了节省内存占用,默认会用一个占位对象表示尚未加载到内存中的关联对象,当第一次访问到这个对象时才触发加载,这个过程被称为 faluting。当我们明确知道某些对象一定会被遍历到,那将这些对象预拉取 可以获得更好的性能。

@available(iOS 3.0, *)
open var relationshipKeyPathsForPrefetching: [String]?

Object IDs

不同于 Object, ObjectID 是线程安全的,因此可以在不同的线程之间传递,适用于只需要进行识别筛选对象的场景。

字典数据

通过将 resultType 设置为 dictionaryResultType,查询结果会以字典的形式展示。这些轻量的可以安全的传递到其他线程,同时也支持一些复杂的数据统计操作。下面的代码以地区 (place) 为单位计算了震幅的平均值:

// Fetch average magnitude of each place

let magnitudeExp = NSExpression(forKeyPath: "magnitude")
let avgExp = NSExpression(forFunction: "avg:", arguments: [magnitudeExp])

let avgDesc = NSExpressionDescription()
avgDesc.expression = avgExp
avgDesc.name = "average magnitude"
avgDesc.expressionResultType = .floatAttributeType

let fetch = NSFetchRequest<NSFetchRequestResult>(entityName: "Quake")
fetch.propertiesToFetch = [avgDesc, "place"]
fetch.propertiesToGroupBy = ["place"]
fetch.resultType = .dictionaryResultType

数量统计

通过将 resultType 设置为 countResultType ,可以获得查询结果的数量统计。简单实用,不多说了。

响应通知

Core Data 提供了丰富的通知,让应用可以实时得知数据的变化。本文将着重介绍 ObjectID 通知和远端改变通知。

objectID 通知

iOS 14 中新增了 ObjectID 相关的通知,与 Managed Object 的通知相对应,是由持久性历史事务生成。这些通知也终于 Swift 化。

//NSManagedObjectContext.h
@available(iOS 14.0, *)
extension NSManagedObjectContext {
    public static let willSaveObjectsNotification: Notification.Name
    public static let didSaveObjectsNotification: Notification.Name
    public static let didChangeObjectsNotification: Notification.Name
  
    // ObjectID
    public static let didSaveObjectIDsNotification: Notification.Name
    public static let didMergeChangesObjectIDsNotification: Notification.Name
}
//NSManagedObjectContext.h
@available(iOS 14.0, *)
extension NSManagedObjectContext {
    public enum NotificationKey : String {  
        case sourceContext
        case queryGeneration
        case invalidatedAllObjects
        case insertedObjects
        case updatedObjects
        case deletedObjects
        case refreshedObjects
        case invalidatedObjects
        // Object ID
        case insertedObjectIDs
        case updatedObjectIDs
        case deletedObjectIDs
        case refreshedObjectIDs
        case invalidatedObjectIDs
    }
}

远端更变通知

远程更变通知提供的信息量非常大,在我们进程内外的所有操作都会使 CoreData 客户端发送一个通知 。这允许我们避免轮询查询变化,而是由通知驱动。

开启远端更变通知也非常简单:

storeDesc.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
storeDesc.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)

开启了远程改变通知后,应用可以接收到进程外的数据改变(如 Share Extension),并且可以知道是哪个进程,在什么时刻,在哪里改变了什么数据。

值得一提的是,对于持久化历史的查询也同样支持通过条件裁剪:

let changeDesc = NSPersistentHistoryChange.entityDescription(with: moc)
let request = NSFetchRequest<NSFetchRequestResult>()

// Set fetch request entity and predicate
request.entity = changeDesc
// 仅查询指定 ID 的数据
request.predicate = 
    NSPredicate(format: "%K = %@",changeDesc?.attributesByName["changedObjectID"], objectID)
   
// Set up history request with distantPast and set fetch request              
let historyReq = NSPersistentHistoryChangeRequest.fetchHistory(after: Date.distantPast)
historyReq.fetchRequest = request
                    
let results = try moc.execute(historyReq)

总结

  1. 尽可能使用批量操作
  2. 按需查询
  3. 充分利用通知

Tags: WWDC, Core Data

Swift 脚本方案简析

几年前,”人生苦短,我用 Python” 这种说法还颇为流行,受此影响,我也试着 Python 写了几个脚本。Python 作为动态语言,写起来确实是行云流水,酣畅淋漓。可惜好景不长,几个月后我再次打开这些 Python 脚本,看着这些代码的时候不禁发出了哲学三问:“你是什么类型?你在哪里被定义?你在哪里被使用”?(当然这和我薄弱的 Python 熟练度有关)。这时候我想,我能否使用强类型的 Swift 编写脚本呢?答案是肯定的,而且已经有不少开源大佬都在这条路上作出了贡献。

本文基于 macOS & Swift 5.1 编写。

先驱:Marathon

相信每一位学习过 Swift 的同学都或多或少从 Swift by Sundell 博客中获益过。 Marathon 是高产的 Sundell 基于 Swift Package Manager 开发的帮助开发者开发命令行的工具,提供了依赖管理、编译、安装等功能。

作为示例,让我们来编写一个脚本,在终端中用浏览器打开当前目录 Git 仓库的地址。举个🌰,在 Alamofire 的目录下执行这个脚本,就会在浏览器中打开 https://github.com/Alamofire/Alamofire.

首先使用 marathon 新建一个脚本:

marathon create ogu # ogu is short for 'open git url'

Marathon 内部在 ~/.marathon/Script/Cache 目录下创建了一个新的 Swift Package,后续的依赖管理、编译都基于这个 Package 展开。

接着开始编辑脚本:

marathon edit ogu

键入实际的代码:

import Foundation
import ShellOut // marathon:https://github.com/JohnSundell/ShellOut.git
import Rainbow // marathon:https://github.com/onevcat/Rainbow.git
import Files // marathon:https://github.com/JohnSundell/Files.git

func main() {
    let notAGitRepoDescription = "fatal: not a git repository (or any of the parent directories): .git"
    
    guard let origins = try? shellOut(to: "git remote") else {
        print(">>> Failed to parse git remote address")
        return
    }

    guard origins != notAGitRepoDescription else {
        print(">>> Not a git repo")
        return
    }

    let originList = origins.components(separatedBy: "\n")
    if originList.isEmpty {
        print(">>> Don't have remote yet")
    } else if originList.count == 1, let onlyOrigin = originList.first {
        openURL(from: onlyOrigin)
    } else {
        for (i, origin) in originList.enumerated() {
            print("\(i): \(origin)")
        }
        print(">>> Please select one of the remote to open: [0..<\(originList.count)]")
        
        guard let input = readLine(),
            let selectedIndex = Int(input),
            0..<originList.count ~= selectedIndex else {
                print(">>> Not a validate input")
                return
        }
        openURL(from: originList[selectedIndex])
    }
}

func openURL(from origin: String) {
    guard let gitAddress = try? shellOut(to: "git remote get-url \(origin)"),
        let address = convertGitAddressToWebURL(from: gitAddress) else {
            print(">>> Failed to parse git remote address")
            return
    }
    
    print(">>> Opening remote [\(origin.red)]: " + address.underline)
    do {
        try shellOut(to: "open \(address)")
    } catch {
        print("Failed to open \(address), error: \(error.localizedDescription)")
    }
}

func convertGitAddressToWebURL(from urlString: String) -> String? {
    if urlString.hasPrefix("https:") && urlString.hasSuffix(".git") {
        return String(urlString.prefix(urlString.count - 4))
    } else if urlString.hasPrefix("git@") && urlString.hasSuffix(".git") {
        let webAddress = urlString.replacingOccurrences(of: ":", with: "/")
            .replacingOccurrences(of: "git@", with: "https://")
        return String(webAddress.prefix(webAddress.count - 4))
    }
    return nil
}

main()

可以看到,和普通的 Swift 代码唯一的不同支出就是 import 之后的注释,它让 marathon 知道这个依赖的地址。

编写完成后,通过 marathon run ogu --verbose 执行这个脚本。确认实现符合预期后,执行 marathon install ogu 将编译好的执行文件安装到 /usr/local/bin,这样在命令行输入 ogu 就可以直接执行我们的脚本了。

Marathon 对 SPM 的封装大幅的简化了编写脚本的难度。但目前并不推荐使用它来编写脚本,为什么呢?接着往下看。

swift-sh

homebrew 的作者 mxcl 在 Apple 短暂参与过 Swift Package Manager 的开发后,开源了 swift-sh,同样是一款帮助开发者简化脚本编写工作的工具。 在使用上,swift-sh 和 Marathon 除了命令不同外并没有很大区别。

将上面的代码替换掉头部,我们便得到了一个 swift-sh 脚本:

#!/usr/bin/swift sh

import Foundation
import ShellOut // @JohnSundell ~> 2.0.0
import Rainbow // @onevcat ~> 3.1.5
import Files // @JohnSundell ~> 4.0.0

// ...

执行 swift sh edit ogu 可以执行这个脚本。swift-sh 巧妙的利用了 swift 命令提供的子命令(subcommand)拓展特性,swift sh abc 实际执行的是 swift-sh abc

由于一些原因,目前 swift-sh 的自动补全失效 了。

执行 swift sh ogu.swift 可以直接执行这个脚本。swift-sh 内部也使用了 SPM 对依赖进行管理。

此外,swift sh 非常贴心的支持将脚本转换成一个 Swift Package:

swift sh eject ogu.swift

遗憾的是,swift-sh 并没有提供方法将编译后的可执行文件直接安装到 usr/bin/local 中,可以通过 ~/Library/Developer/swift-sh.cache/ogu/.build/release 里找到编译结果放到 usr/bin/local

Swift Package Manager

上述的两款工具都是基于 SPM 开发,那能否用 SPM 直接写脚本呢?当然可以,经过数年的开发,SPM 也日渐成熟,原先繁琐的操作得以简化,也正是这个原因,Marathon 宣布放弃维护,转而推荐开发者直接使用 SPM。

新建一个可执行文件的 Swift Package:

swift package init --type executable

核心脚本代码部分并不需要改动。类似的,我们需要以某种方式指定依赖,这一次是 Package.swift:

// swift-tools-version:5.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "spm",
    products: [.library(name: "MarathonDependencies", type: .dynamic, targets: ["spm"])],
    dependencies: [
        .package(url: "https://github.com/JohnSundell/Files.git", from: "4.0.0"),
        .package(url: "https://github.com/onevcat/Rainbow.git", from: "3.0.0"),
        .package(url: "https://github.com/JohnSundell/ShellOut.git", from: "2.0.0")
    ],
    targets: [
        .target(
            name: "spm",
            dependencies: ["Files", "Rainbow", "ShellOut"])
    ],
    swiftLanguageVersions: [.version("5")]
)

执行 swift package resolve 解析并获取依赖。通过 swift package generate-xcodeproj 生成一个 Xcode 工程后,可以直接用 Xcode 编辑。

记得将运行环境修改为 Mac!

可以直接使用 swift build 将 Package 编译为一个二进制文件,这个二进制文件位于当前目录下 .build/release 目录下。

swift build -c release

最后,手动将编译好的文件放入 /usr/local/bin 中就大功告成了。

install .build/release/ogu /usr/local/bin

总结

对于单文件的脚本,swift-sh 无疑是最佳的选择。而当脚本日益增大,将它转换成一个 Swift Package 维护则是一个更好的选择。

参考链接

Tags: Shell, productivity

在命令行中舞蹈

在我们的日常开发工作中,通常有一部分重复性的工作。这些工作一般复杂度很低,对认知要求不高,属于浮浅工作,存在很大的自动化空间,因此最适合在命令行中完成。将这类工作尽量简化,我们可以更专注的投入到更有价值的深度工作中,提升工作效率。

本文总结了帮助我节省了无数时间的一些经验,希望对你也有帮助。

目录定位

在终端中,定位到指定目录通常需要数次的 cdls,繁琐的操作让每一个想打开终端的人却步。

我们需要一个工具在目录之间快速跳转,而 fasd 正是这样的工具。fasd 帮助我们直接跳转切换过的目录。

$ j some_directory

对于一些已经在 Finder 中打开了目录的场景,可以使用 go2Shell 在命令行直接切换到当前目录。

在使用 fasd 的初期,它通常能百分百命中我们预期的目录。然而经过一段使用之后,这段时间内切换过的目录可能会污染 fasd 的预测结果。

简单查阅 fasd 的文档,可以知道它的数据存储在 ~/.fasd 文件中,每一行存储了目录的相关信息:

/Users/kukushi/Documents/Project|10.7195|1547211464

| 划分了目录,访问频率,上一次访问时间。利用这些信息,我们可以对 .fasd 进行一些清理。于是乎,我编写了一个脚本执行一些简单的清理,让跳转更加的精确。

将以下代码 .zshrc / .bashrc 下,终端每次启动时都会执行此脚本。

# 需要将这个路径改为你存放这个脚本的路径
cleanFASDScript=~/Documents/Scripts/script/clean_fasd.py
if [ -f "$cleanFASDScript" ]; then
  python3 "$cleanFASDScript" -s true
fi

打开工程

进入到工作目录后,我们需要打开工程文件。 对于 Xcode 工程,它提供了 xed 命令让开发者从命令行打开工程文件。

xed

Opens files for editing in XCode.

- Open file in XCode:
    xed file1

- Open file(s) in XCode, create if it doesn't exist:
    xed -c filename1

- Open a file in XCode and jump to line number 75:
    xed -l 75 filename

但是,在一些 Edge Cases,如目录下没有工程文件,xed 会尝试 Markdown 或其他文件,这通常不是我们需要的。

既然 Xcode 提供的不好用,不如让我们自己来做一个吧。编写简单的 bash 代码,利用简单的正则匹配,我们可以找到需要打开的工程文件。

oop() {
    file=$(find -E . -regex ".*xcworkspace" -maxdepth 1)
    fileLength=${#file}
    if [ "$fileLength" = 0 ]; then
        file=$(find -E . -regex ".*xcodeproj" -maxdepth 1)
    fi
    fileLength=${#file}

    if [ "$fileLength" = 0 ]; then
        echo ">>> 🤔  No Xcode Project Found!"
    else
        echo ">>> 💪  Opening $file"
        xcode=$(xcode-select -p)
        xcode=$(echo $xcode | cut -d'/' -f-3)
        open -a $xcode $file
    fi

习惯使用 AppCode 的话,可以把 Bash 代码中的 Xcode 替换为 AppCode。

同样的这份代码需要放到 .zshrc / .bashrc 下,改动之后需要 source .zshrc/.bashrc 让改动生效。

对于非 Xcode 工程,我习惯用 VSCode 编辑,它是如此常用,以至于我也为它设了一个别名:

alias c='code .'

提交代码

在每一个使用类 git-flow 开发流程的团队中,同步 dev 代码几乎是每日的例行公事。我们可以简单的将一些命令封装成一个 function:

syncdev() {
    git stash

    echo ">>> Updating dev"
    git checkout dev
    git pull origin dev

    echo ">>> Applying diff"
    git co -
    git rebase dev

    git stash pop
}

更快的 “Gitlab”

使用 lab,我们可以在命令行直接完成一些常规的 Gitlab 操作。

使用 brew 可以安装 lab:

$ brew install zaquestion/tap/lab

第一次使用 lab,需要输入一些 gitlab 的信息。

创建 MR

使用 lab,我们可以直接创建 MR:

$ lab mr create origin $branch -a $assigne -m $message1 -m $mesasge2

对于大部分的提交,MR 的第一行 Message (即 Title)可以简化为最近一次的 Commit Message。而对于有 Code Review 合码机制的团队,message 的第二行通常是需要进行 Review 的同事,因此我们的命令可以简化成:

# mmr $branch $reviewer
mmr () {
    latestMessage=$(git log -1 --pretty=%B)
    lab mr create origin $1 -a $2 -m $latestMessage -m $2
}

经过这轮简化,创建一个 MR 只需:

mmr dev @someone

你会爱上这种过感觉!

dotfiles

当习惯在命令行进行各种操作之后,我们会沉淀下很多有用的函数/别名/配置,这通常是开始构建自己的 dotfiles 的时候了,具体可以参考 dotfiles。dotfiles 帮助我们管理这些文件,作为一个 bouns point,我们可以在多个设备上”同步” dotfiles 了。

总结

本文总结了我在日常开发中使用命令行的一些经验,希望对其他同学有所帮助,也欢迎同学们交流。

每种工具都有长短,命令行长在执行一些操作,而 GUI 长在界面查看(如对比 Diff),没有必要局限于某种工具。重要是的 Picking the right tool for the job。

Tags: Shell, productivity

iOS UI Testing 不完全踩坑指南

最近研究了一下 UI Testing 的使用,发现还是有蛮多坑的,本文会介绍笔者遇到的坑和解决方案。本文不会涉及到如何编写一个 Test Case,因此希望你已经大致了解 UI Testing (可以看 onevcat 大大的介绍) 和 UI Testing Cheat Sheet.

与宿主应用 (Host App) 交互

UI Testing 运行在另外一个进程中,因此直接无法访问宿主应用的信息。那当我们希望在特定的启动条件下测试应用,要如何操作呢? 答案是利用 XCUIApplicationlaunchArguments

测试进程还是可以通过 Inter-process communication 与宿主进程通信,XCUIElement 的查询就是这么实现的。

首先,需要在 Test 中设定参数:

let app = XCUIApplication()
app.launchArguments = ["ResetDefaults"]
app.launch()

其次,在宿主应用中添加处理这些参数的代码:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
    
    #if DEBUG
    var arguments = ProcessInfo.processInfo.arguments
    arguments.removeFirst()
    setupTestingEnvironment(with: arguments)
    #endif
    
    configureSideMenu()
    return true
}

当然,这种做法的坏处是测试代码侵入了应用代码。虽然可以通过使用宏让这些代码在 Release 状态下不生效,但还是无法保持代码的整洁。

更优雅的 XCUIElement 获取

在使用自动录制生成测试代码时,经常会产生令人非常费解的获取 XCUIElement 的代码。这种代码基本无法理解,更谈不上维护了。

let validationPopup = app.children(matching: .window).element(boundBy: 0)
.children(matching: .other).element(boundBy: 1).children(matching: .other).element(boundBy: 1)

如何改善这种情况呢?让我们回忆一下,UI Testing 是构建与 Accessibility 之上的,Accessibility 提供了一个 accessibilityIdentifier 属性,利用它,可以帮助我们减少自动生成的代码。

An identifier can be used to uniquely identify an element in the scripts you write using the UI Automation interfaces. Using an identifier allows you to avoid inappropriately setting or accessing an element’s accessibility label.

class CustomView: UIView {
    // ...
}

let view = CustomView()
view.accessibilityIdentifier = "CustomView"

// Testsing
let customView = app.otherElements["CustomView"]

获取调试信息

debugDescription 可以输出当前视图的层级结构,查看更多的信息。

let element = /* can be element or app */
print(element.debugDescription)

强制点击

有时候,系统会错误的认为按钮是不可点击的(如在 TableView 中的按钮),这时你尝试通过 tap() 点击按钮会触发 Unable to find hit point。这时可以使用 XCUICoordinate 签证强制点击:

/// force taps a view if it reports to be not hittable - useful for buttons in cells
func forceTap() {
    if isHittable {
        self.tap()
    } else {
        // You can also try (0, 0)
        let coordinate: XCUICoordinate = self.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
        coordinate.tap()
    }
}

切换输入框

这也是个很奇怪的问题。Testing 在多个输入框切换焦点是会莫名的失败,如:

let userNameTextField = app.textFields["username"]
userNameTextField.tap()
userNameTextField.typeText(userName)

let passwordField = app.textFields["password"]
passwordField.tap() // Error!!!
passwordField.typeText(userName)

passwordField.tap() 可以无法正确的执行。一个 workaround 在第一个输入框输入完成之后,将键盘弹下再弹出,然后尝试输入:

/// hides keyboard if present & obstructs hit space
func hideKeyboardIfNeeded() {
    if keyboardHideButton.coordinate(withNormalizedOffset: CGVector.zero).screenPoint.x < UIScreen.main.bounds.width {
        keyboardHideButton.tap()
    }
}

Tags: UITesting, iOS