Swift 脚本开发环境搭建

2024.01.06 更:欢迎查阅 Swift 开发的文本纠错命令行工具。

不推荐使用 Swift 写脚本,和 Python 比起来,该生态链相对匮乏,开发耗时会增加很多。
但对于偏 Swift 的同学来说,这也的确是更可控和方便维护的方式之一。尤其对于公司内部工具,有问题可以更快的找到原因并处理,不至于手忙脚乱。

相对于 Python 环境丰富的基建资源,使用 Swift 做脚本开发的优点在于生态的一致性。
苹果官方抹平了 脚本开发 和 应用开发 的界限,即 开发环境、三方库导入和使用 (苹果自身的开源库 / 三方库)、系统平台 api (存储、网络、协程、并发等,UI api 除外) 等,均表现一致。
可以像应用开发的流程一样,进行脚本命令行的开发。

0x01 Swift 生态

一言两语,先概要描述下 Swift 目前的生态,以明确脚本开发所处的位置。

Swift 做为苹果大力推出的 iOS/Mac 多平台应用开发语言,目前广泛用于开发 app (ipa/dmg)。做为古老的 Objective-C 替代者,基本上对接了 OC 里面所有的 Api,可以直接调用。这对新语言的铺广有很大好处,使用成本有非常大的降低。
但 Swift 也抛弃了 OC 的消息传递机制,使得 patch 等能力很难实现。这也是大公司不愿意迁移的原因,在苹果不允许热更新之后,它们都耗费大精力维护一套不公开的 patch 能力,以在紧急时刻做补救。

Swift 做为后浪,完全集百家之长,也躲过了这些年高级语言已知的不完美,这是相当优秀的点。
至目前 (23 年底),已经对范型、协程、并发等高级场景做了完备的支持。
尤其值得关注的是发源地 app 开发,Swift 提供了 SwiftUI 做为原始 UIkit/AppKit 的替代,这是目前非常跟随潮流的 UI 基础建设。

Swift 以跨平台为前提,也同样可以做服务端开发、输出命令行可执行文件等。但刚才提到的 UI 能力并不能服务于此,毕竟这是不需要 UI 能力的。

playground & repl

对于学习 Swift 来说,playground 是一个不错的学习乐园。
playground 有两种,一种是 iPad 和 Mac 上都适配的 app,名字叫 Swift Playground,通过 SwiftUI 开发项目,可以直接上 App Store。
还有一种是通过 xcode - new - playground 生成的后缀名为 .playground 的项目,主要用于测试一些代码。

第一种完全是鸡肋,我是这么认为。第二种,也倾向于鸡肋,用处不大(更多用于调试,可 xcode 里创建一个项目已经足够方便)。
这里建议,对于 Swift 研发同学来说,可以不用看 playground 了,这样可以避免理解不少概念,如 PlaygroundSupport、Sources、Resources、多target分隔等。
这些概念和实践,在研发历程上,基本是无用的。凭空增加了 Swift 的知识点复杂度。

和 playground 类似的,还有 repl,即在终端通过 swift repl 进入的交互式编程环境。很多语言都带有这个环境,快速调试无可厚非,但我还是给予鸡肋的标签。

当然,对于其他语言使用者,仅仅希望了解一下 Swift,playground 和 repl 都是不错的选择,尤其 repl。可以不用理解完整项目里面的很多概念,如 AppDelegate/AppScene/Navigation 等。

!!! Package

Apple 在 Swift 出来的时候,提出了 SPM (Swift Package Manager) 概念,和 Cocoapods 一致,做为三方库的管理工具。其中描述文件就是 Package.swift 文件。
后期,SPM 扩展了能力,和 .xcodeproj 处于同样的地位。通过对 Package.swift 文件进行配置,Package 所在的文件夹可以做为 动静态库 被 workspace 管理,也可以设置多个 Target 进行依赖控制,还可以引入三方库,甚至可以直接配置成可执行文件和插件 (如代码格式化等)。
这使得对 Package 的理解复杂度增加。

一开始,Package 对 target、exec 的支持,是通过 swift package generate-xcodeproj 命令,生成辅助 .xcodeproj 文件来完成的。后期 Swift 升级,删除了这个命令,完全通过 Package.swift 配置文件来完成了。
这一改变,应该是为 Swift Playground 这个 app 做的。如上面说到的,这个 app 可以独立制作项目并上传 App Store,通过该 app 生成的项目文件是 .swiftpm,不用理解 .xcodeproj - build setting 等众多概念,多 target、三方库 均通过 Package.swift 配置。
可能因为这个原因,提升了 Package.swift 的级别,和 .xoceproj 处于同样的地位。

所以,对 .xcodeproj 熟悉的同学,不用刻意理解 Package.swift 里面的诸多配置,这些配置仅仅是对 .xcodeproj 配置的迁移。用到哪里,查一下资料即可。
下面给示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "pcd", // 该 Package 的名字,如果是动静态库,可以理解为 pcd.framework。外部通过 `import pcd` 来引入
products: [ // Build 和 Archive 输出的产物
.executable(name: "pcd", targets: ["pcd"]), // 输出可执行文件(脚本工具等)
.library(name: "pcdCore", type: .dynamic, targets: ["pcdCore"]) // 输出动静态库
],
dependencies: [ // 三方依赖。若通过 path 进行本地依赖,则可在当前工程直接编辑其内容,便于开发联调。同 cocoapods。
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
.package(url: "../Progress.swift", branch: "master"),
.package(path: "/Users/gebiwanger/Downloads/swift_public_project/SwiftShell") // 本地依赖,可在当前工程直接修改库代码
],
targets: [
.target(
name: "pcdCore" // 同 `.xcodeproj` 里面的 target 设置,这里设置一个动静态库
),
.executableTarget( // 本身是 Target,同 `.xcodeproj` 主项目,可通过 Archive 进行打包(产物类似 a.out,非 app/ipa/dmg)
name: "pcd",
dependencies: [ // 多 target 依赖。可以依赖当前项目的 target,也可以依赖三方库(同 Cocoapods)。注意,三方库一定需要依赖,才能在当前项目中被使用(Pods 也是一样,只是Pods工具帮忙做了)。
.target(name: "pcdCore"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Progress", package: "Progress.swift"),
.product(name: "SwiftShell", package: "SwiftShell"),
]
),
]
)

上图是单独的 Package 项目示例,可以通过 Xcode 进行和 .xcodeproj 一致的调试。

从上图可以验证,Package 项目,具有和 .xcodeproj 一致的功能,概念层级是和 .xcodeproj 一致的。

至此,Package 的概念和使用,基本已经介绍完毕,都是旧瓶装新酒。

Package 插件

除此之外,还想介绍一个 Package 特有的能力,就是插件。插件是一个可执行程序,可以本地运行或做为三方库接入,但不允许上架包引用,仅允许做本地调试。(不排除后期会开放 App Store 的能力)
对于目前 Xcode 支持的扩展,有些能力是可以通过插件来完成的,比如代码格式化、代码转换等。
插件的整体开发过程,和 动静态库 / 可执行文件 的开发流程基本一致,通过下面的配置进行区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let package = Package(
products: [ // 新增了 plugin 产物
.plugin(name: "FormatSwift", targets: ["FormatSwift"]),
],
targets: [
.plugin( // 新增了 plugin Target
name: "FormatSwift",
capability: .command( // 权限控制,支持读取/写入项目文件。在代码格式化的时候需要该权限
intent: .custom(
verb: "format",
description: "Formats Swift source files according to the Airbnb Swift Style Guide"),
permissions: [
.writeToPackageDirectory(reason: "Format Swift source files"),
]
),
),
.executableTarget( // 可以有 exec target 或者动静态库 target,便于调试或者被 plugin 依赖。
name: "AirbnbSwiftFormatTool",
),
])

通过 Archive 打包出的产物,可以做为可执行文件单独执行。其他项目也可以引入插件模块,右键主工程就可以看到插件的运行选项。

插件这一块的需求并不大,这里也不做更多介绍了。参考项目有 airbnb-swift-format ,有需要可以查阅更多相关资料。

生态小结

至此,Swift 的开发生态介绍的差不多了,整体和 Xcode IDE 依旧有非常深的绑定。
当然也可以通过 swift xxx 等命令行进行开发和调试,但对于 Mac 平台的开发人员来说,这完全是多此一举。

除此之外,前面也说到 Swift 是一门非常新也非常优秀的高级语言,其中的 协程、并发、多线程 等能力,也都是需要单独学习和掌握的。这属于语言本身,在环境篇就不再过多介绍。

命令行开发

这里进入核心环节,即通过 Swift 进行命令行开发的环境。
其实上面的环境篇,已经介绍了大部分命令行开发所需要的前期准备,如工程的搭建、项目依赖的设定等。

在 Objective-C 时期,也可以通过 Xcode 进行 Command line 的开发,通过 new - project - macOS - Command Line Tool 既可以建立一个工程,Archive 后即可输出可执行文件。
但 OC 终究不是为命令行而生的,有很多局限性和短板。这也导致十几年来一直都有的这个功能,更多被用于 C/C++ 的调试入口。

目前 Swift 做命令行开发,依旧可以走之前的流程建立一个 .xcodeproj 工程进行开发,区别点是语言选项上,勾选 Swift 即可。
同时,上面的流程也显得有些落伍,毕竟如上面介绍,Package 具有和 .xcodeproj 同样的层级定义,我们可以通过 Package 来创建可执行程序。
这样的一个明显的好处,是不用理解 AppleId / Build Setting 等复杂的配置。显然,做为一个通用的命令行工具,我们可以提交到 brew 或者随意分发给有需要的朋友,哪里还需要 AppleId 开发者账号呢?

Swift Package 命令行的项目创建/编译/调试/运行,可以通过 swift xx 命令行操作。有了 Xcode 这个高度集成的工具,显然不用如此费力。
创建项目的流程为:new - Package - macOS - Command Line Tool / Executable。
这样创建的工程就是 Package 工程,而不再是 .xcodeproj 工程。Command Line Tool 和 Executable 基本没有区别,前者多了一个 ArgumentParser 这个 apple 自带的库依赖。也正因为这个依赖,推荐使用前者,这在命令行开发的过程中会方便很多。

命令行输入参数解析自动化

可以通过 CommandLine 模块做参数解析,提供了 argc 和 arguments 等 api,可以获取用户在执行命令的时候携带的参数。

不过后面,Apple 提供了 ArgumentParser 模块,这是可以快速搭建 命令行参数定义 的框架,比如命令中需要用户携带文件路径,通过@Argument(help: "Please Input File Path.") var path: String 这样的定义,就可以指定用户一定要传文件路径并会被赋值到 path 变量中。如果用户输入错误或者使用 xx --help,Please Input File Path. 这个注释也会输出到命令行中以提醒用户。
当然,ArgumentParser 模块的能力远远不止这些。但可以看出来,ArgumentParser 定义了一套命令行参数的标准,开发人员不用再通过 CommandLine 模块调用 api 进行解析。
更重要的是,ArgumentParser 提供了一套命令行开发的流程框架,这可以通过阅读官方的示例文档进行学习。

前面说到 Command Line Tool 和 Executable 两种方式创建的项目相比,前者多了 ArgumentParser 引入。这也是推荐使用前者的原因。

当然,对于 ArgumentParser 的使用,可以参考官方的示例 (Examples 文件夹)。也可以查阅更多相关资料。

善用 Xcode IDE 的调试技巧

命令行开发过程中,一样需要断点调试,同时也需要真实环境下调试。这两者往往是同时进行的。
这里有一个小技巧,即通过在 Xcode Build 过程中插入脚本,和 Xcode Run 过程中插入参数,来完成快速的开发调试。

插入脚本

可以在 Scheme - Build - Post actions 中嵌入 bash 脚本,从而将编译好的可执行文件自动复制到 /usr/local/bin 目录,这样写完代码 Build 完成后,既可以在终端中进行真实场景下的调试和测试。
值得注意的是,一定要将 Provide build settings from 选择为当前编译的项目,这样才可以使用 Xcode 预定义的脚本变量。

1
2
cp "$TARGET_BUILD_DIR/pcd" "/usr/local/bin"
echo "move $TARGET_BUILD_DIR/pcd to /usr/local/bin success."

插入参数

进度条的输出

进度条输出,和以往的 print 不太一样。因为 print 是叠加的,下一条输出和上一条会间隔一行。
而进度条的输出,是重写上一行。
这里需要一些小技巧,即:不换行更新上一次的输出结果

方案 1(不建议)

1
2
3
while true:
print("Count: \(counter)", terminator: "\r")
fflush(stdout)

通过 \r 将当前输出的 seek 切换到当前输出行的行首。这样下一次输出的时候,就从当前行的行首输出,对上一次的输出进行覆盖。
因为没有回车符了,而终端是识别到回车符,然后将回车符之前缓冲区的内容一次性进行输出的。
所以需要使用 fflush(stdout) 进行强制缓冲区刷新,即强制输出。

但上面介绍的这种方式有个缺点,即覆盖内容只能按照字节长度进行覆盖。如上一次输出是 abc,下一次输出是 12,此时显示的内容是 12c。
这在进度条中不容易出现,但面对复杂场景显然是不行的。

方案 2(建议)

这里推荐第二种方式,可以有效解决这个问题:

1
2
while true:
print("\u{1B}[1A\u{1B}[K\(progressBar.value)")

输出文本加上 \u{1B}[1A\u{1B}[K 前缀即可。技术方案和上面是一样的,但可以规避上面的问题。
不过这里有个小 case,即单纯这样使用,会发现第一次输出的时候,整个命令行已有的内容都消失了。所以还需要加一个补丁:

1
2
3
4
5
// the cursor is moved up before printing the progress bar.
// have to move the cursor down one line initially.
print("")
while true:
print("\u{1B}[1A\u{1B}[K\(progressBar.value)")

在进行输出的时候,先输出一个空内容,这样就不会有任何异常了。

异步等待

命令行执行完毕,进程就销毁了。如果需要进行网络请求等异步操作,就需要特别处理,以等待异步请求的结果。

这里可以通过 runloop 将当前线程永久激活,然后通过 exit 强制退出。

1
2
3
4
5
6
async {
...
exit(0/-1)
}

RunLoop.current.run()

显然,这不是一个好的方案,一来局限性很多。
在者,runloop 是 app/ipa/dmg 开发中的概念,虽然利用了 Swift api 完备性的优势,但在这里使用很不合适。

这里更好的解决方案是使用信号量:

1
2
3
4
5
6
7
let semaphore = DispatchSemaphore(value: 0)
async {
...
semaphore.signal()
}

semaphore.wait()

可以将信号量的操作,进行单独封装,后续需要 wait 的时候,只需要便捷的调用一些 api 即可。

当然,鉴于 GCD 强大的多线程管理能力,还可以使用 group 等方式,进行多个异步操作的并行操作等。

系统命令

当然,命令行开发过程中,不可避免需要使用到丰富的系统命令,如 ls/grep 等。
这可以通过 Process Api 来操作,但这里不做过多介绍,因为有强大的三方库可以使用。
详见:【Swift 三方源码 1】SwiftShell 高效的命令行工具

命令行小结

以上,就是通过 Swift 做命令行开发中的一些概要和部分细节的地方了。
如最开始说到的那样,使用 Swift 开发命令行,除了 UIKit/AppKit/SwiftUI 这些 UI 属性不能使用之外,其他并没有太多的 api 限制。
甚至可以使用 UserDefault 存储,这样可以极大的简化用户的 cookie 等信息的保存。

除此之外,大概就是对 Swift 高效和美丽的使用了。
Swift 这门语言虽然上手容易,但美丽的使用并不简单。
巧妙的使用 enum、protocol、async、combine 等高级功能,并且不搞乱代码,就很优秀。


什么都给了,又什么都没给。
什么都干了,又什么都没干。
什么都说了,又什么都没说。

放开了所有权限,又加上精细的卡口。

鸡和蛋的轮回,永不停息的魔咒。