Swift 脚本方案简析

Swift 脚本方案简析

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

本文基于 macOS & Swift 5.1 编写。

先驱:Marathon

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

作为示例,让我们来编写一个脚本,在终端中用浏览器打开当前目录 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 reslove 解析并获取依赖。通过 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