sourcery

Swift 中的元编程

在开发 iOS 中, 我们有很多的模板代码, 很重复, 但是又不得不写的. 这时候, 如果能自动按我们的需求自动生成对应的代码, 那就再好不过了, 从维护一堆相似的代码, 到维护一个模板.

元编程的提出

就 Swift 来说, 在编译器级别, 已经有了可以拿到项目中全部代码的工具, swiftlint, 以前 Xcode 上的插件等, 都是基于这类方法做的.

这里就介绍一个工具, 通过代码来生成代码. —- [Sourcery](https://github.com/krzysztofzablocki/Sourcery).
Sourcery 是一个 Swift 代码生成的开源命令行工具,它 (通过 SourceKitten) 使用 Apple 的 SourceKit 框架,来分析你的源码中的各种声明和标注

其实在其 github 主页, 已经有了很明确的它是什么, 以及怎么使用的说明. 这里还是强烈建议下阅读里面推荐的这篇入门教程, 还有他的一个讲这个的演讲, 比较生动.

其实每个文章, 都已经举了简单的例子来说明这个工具的使用方法, 如果你看了之后还是会有所困惑, 那是因为你没有切实的用起来. 当你用起来后, 自己经历过就会感觉他的强大! 省了自己多少时间!

里面很多例子都用了 Equatable 来举例
在使用struct 时, 想要比较两个对象相等, 需要遵守这个协议, 然后实现==函数. 如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

struct Person {
var firstName: String
var lastName: String
var birthDate: Date
var age: Int {
return Calendar.current.dateComponents([.year],
from: birthDate,
to: Date()).year ?? -1
}
}

extension Person: Equatable {
static func ==(lhs: Person, rhs: Person) -> Bool {
guard lhs.firstName == rhs.firstName else { return false }
guard lhs.lastName == rhs.lastName else { return false }
guard lhs.birthDate == rhs.birthDate else { return false }
return true
}
}

代码看着少? 试想一下这个结构体里有十几个属性, 然后你有十几个这类型的结构体. 这就是一场屠杀…

这时候, Sourcery 就是救星…

Sourcery 能自动根据一些规则, 使用定义的模板来生成你想要的代码. 使用它, 你能获取整个项目中的某些数据, 这里用的最多的, 应该是你做了标注的数据, 这样将变化控制在自己的范围里. 这个标注后面会说到.

模板

这些模板可以使用几种语言来编写, 官方用的和比较推荐的是, 比较简单轻量的 [Stencil](https://stencil.fuller.li/en/latest/) 你甚至可以使用 Swift 来写模板, 实现更丰富复杂的逻辑, 但是这样的话, 也许应该考虑下项目的结构是不是有什么问题. 因为模板, 主要是用来生成一些模板代码的, 不是用来写逻辑的.

其实 Stencil 是弱化版的 Swift, 可以看到在语法上是基于 Swift 的, 但是有一些自己的特性

使用模板和 Sourcery 来生成上面那样的模板代码:

首先你需要写一个模板, 即你想生成的代码是什么样的, 其中的关键点已经在代码中注释出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//  这里是最重要的一行, 在这里, 我们遍历了项目中每个实现了`AutoEquatable` 协议的类型(type), 这些协议是我们定义的, 仅仅是作为一个 maeker
// protocol protocol AutoEquatable {}
// 我们通过这种标机方式, 来选择我们需要自动生成`Equatable` 实现的类
{% for type in types.implementing.AutoEquatable %}
// MARK: {{ type.name }} Equatable
extension {{type.name}}: Equatable { // 这样, 我们就能拿到这个类的名字, 给他添加一个扩展, 来实现`Equatable`
static func ==(lhs: {{type.name}}, rhs: {{type.name}}) -> Bool {
// 这里遍历了类型中所有的存储属性, 用来比较是否相等, 就像自己写的判断一样
{% for variable in type.storedVariables %}
guard lhs.{{variable.name}} == rhs.{{variable.name}} else {
return false
}
{% endfor %}
// 没有就返回 true
return true
}
}
{% endfor %}

以上, 我们就为项目中所有实现了AutoEquatable的类, 加了一个实现Equatableextension, 并且实现了要求的方法==, 在方法中比较了每个存储属性的值是否相等.(计算属性的不必比较)

我们来动手一下~

首先你需要有 Sourcery, 这个在其主页有安装方法, 现在假设你 已经安装好了.
按这篇文中的假设是, 他装到了项目的./Resources/sourcery 这个位置, 目录是和***.xcodeproj 同级目录

当然, 这个也可以使用brew install sourcery来安装.

现在, 运行这段代码:

1
2
3
4
./Resources/sourcery/bin/sourcery
--sources ./SourceryDemo
--templates ./SourceryDemo/Templates/
--output ./SourceryDemo/Autogenerated

我们一半监控项目的目录, 即和***.xcodeproj 同级的和项目同名的那个目录

这时, 你可以在输出文件中看到效果, 它生成了我们想要的代码!

自动重新生成

试想一下, 如果给某个类加了一些存储属性, 这时候, 生成的那些代码没有变, 你需要手动再运行下那个代码, 重新生成这些模板代码.

但是这样很烦啊有木有…
最好的方式就是我们改了模板, 对应的地方的代码会自动根据修改的模板自动做出改变.

Sourcery 给我们提供了这种方式, 就是在我们的命令中加上 --watch 这个选项, 它就会一直监听你的模板文件和对应的输出文件.

可以试试, 在保存模板文件的时候, 能实时的看到输出文件的改变, 这时候, 你用一个可以同时打开两个文件的编辑器, 就能看到这种效果, 很爽是把~

使用 VSCode 的话, 可以通过这个操作, 打开Stencil 的语法高亮
It’s worth noting that Visual Studio Code doesn’t ship with Stencil highlighting by default. To install it, press ⌘-T and then enter the command ext install stencil.

注意: 这里的监控仅仅是监控了这条命令执行时的模板文件和输出文件

Pre-Build 执行

一旦你有了一堆的模板, 使用这种方式去维护项目中的模板代码, 你一定不想再去手写任何一个模板代码了…

注意, 上面的一条说到, 监控的只是输入文件和输出文件, 并没有说监控整个项目的变化, 所以, 当你有一个新的类遵守了某个marker protocol (自动生成摩拜代码的协议) 时, Sourcery 并不会自动就给你创建代码, 你需要重新运行, 他才会从项目中拿到对应的所有类型并生成代码.

我们怎么保证没次我们的更改总是能够让 Sourcery 来生成对应的代码? 我们可以在 Xcode添加一个 pre-build step, 在 Xcode 中选中你的target -> Build Phase -> Run Script 添加一个新的脚本, 把上面运行的命令添加到里面.

这时候, 当我们没次 build 项目的时候, 这里的第一步都会刷新 Sourcery 的输出.

练习例子

这个文的博主还贴了他的一个做这个尝试的sample, 可以根据 git 的历史来一步步的跟着做, 他还有一篇文是讲 RxSwift 的, 这也很有用, 也有一步步跟着做的 sample~

ref: Soucery 的魔法

更多的姿势

参考

上面讲了什么是Sourcery, 以及他在一个完整项目中的应用和基本使用方法. 这里接下来讲讲他更多的使用姿势.
当然, 看 Sourcery 文档 是最好的最准确最全面的. 但是个人觉得, 需要解答疑惑时, 看文档才是最好的时机, 因其大而全, 先看了文档再去动手会错失很多时机, 反而看看别人介绍的怎么用, 用的最多的功能和常用的方法是开始入手一件事最好的方式~

在运营Sourcery 时, 你可以在命令行指定他的一些参数, 但是, 本人清冽认同参考帖子的作者, 使用配置文件将会使的配置更灵活, 更好维护, 而且能备份和分发.

Sourcery 支持使用配置文件, 他使用的配置文件使用YAML, 一种很适合配置文件的语言.

里面需要声明

  • sources 文件的位置
  • templates 文件的位置
  • output 输出的位置

这个配置文件我们需要放在和urProjName.xcodeproj同级的目录下.

我们在这里创建一个文件, 叫: .sourcery.yml(注意这种命名是隐藏文件), 然后打开它, 将下列内容粘贴进去

1
2
3
4
5
sources:
- SourceryTest(你项目中整个项目的文件夹)
templates:
- Templates(同级目录下存放模板文件的目录)
output: SourceryTest/Generated(在项目文件夹下保存生成代码的文件夹)

然后你需要创建一个叫Templates的模板文件夹, 注意还是同级目录.

插播一个..令人鸡冻的消息, Swift4.2 介绍了一个针对 Enum 的协议: CaseIterable, 在编译时自动 synthesizes 一个数组, 里面包含了所有的 case, 使用allCases调用, 我擦, 这不是之前很烦的, 需要自己写的一个东西吗?! 斯巴拉西, 这届的 Swift 真是我带过的最好的一届!

但是…如果你还没有使用到4.2的话, 当你调用.allCases 的话会爆一个 compile error, 你必须要手写这个方法, 或者用Sourcery 生成.

SourceryStencil 的结合, 给我们带来一些内建的值来编写模板.

我们可以通过types.enums.implementing.CaseIterable来获取实现相应协议的枚举

like this

1
2
3
4
5
6
7
8
9
protocol CaseIterable { }

{% for enum in types.enums.implementing.CaseIterable %}
extension {{ enum.name }} {
static let allCases: [{{ enum.name }}] = [
{% for case in enum.cases %} .{{ case.name }},
{% endfor %}]
}
{% endfor %}

***.xcodeproj 同级目录的终端下, 运行 sourcery --watch, 然后Soucery 就会在当前目录找到配置文件, 根据配置文件来启动服务. 然后根据模板文件生成代码到输出目录, 命名类似这个: CaseIterable.generated.swift, 类似这种全生成的文件不能手动去改变里面的内容, 因为每次修改模板的时候这里都会被重写.
当然, 你需要把这个目录拖到 xcode 项目中, 他需要和其他你项目中的代码一起起作用.

当你代码改变的时候, 这个文件也会相应的改变(如果监控的 sources 文件有变化的话. 然后生成的文件中. 会有遵守这个协议的枚举的扩展, 里面实现了一个属性allCases, 里面是一个数组包含了所有的case

但是这个在 Swift4.2 中会报错的, 因为已经有了这个方法了.

我们可以在代码中使用诸如下列注释来告诉编译器一些条件:

1
2
3
4
5
//  里面没有<的说法
#if swift(>=4.2)
// do thing here
#else
#endif

我们可以在模板中加入这里, 使得在生成的代码中包含这个编译器判断条件

还有一个问题, 这个获取到的所有的case, 也包含了一些值绑定的 case, 这个就不对了. 我们可以在模板语言中加入一句判断来排除掉这种 case

1
`{% if not enum.hasAssociatedValues %}` 在循环中加入这个判断就能排除不需要的类型.

还有这个模板, 会在最后一个 case 后面依然加入了一个逗号, 我们并不想这么做, 虽然不要紧. 但是就是为了好看, 不想这么做, 颜值即正义…

而且这样, 可以引申出一个 Sourcery 一个有用的功能: 在循环里面, 可以通过forloop.firs, forloop.last, forloop.counter, and forloop.counter0 来判断是否第一个或最后一个, 或者拿到基于0或者基于1的当前 item 的下标. 这个例子里是获取是否是最后一个 item, 不是的话就加上逗号.

1
{% for case in enum.cases %} .{{ case.name }}{% if not forloop.last %},{% endif %}

最后, 我们的模板是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#if swift(>=4.2)
// do thing here
#else
protocol CaseIterable { }

{% for enum in types.enums.implementing.CaseIterable %}
{% if not enum.hasAssociatedValues %}
extension {{ enum.name }} {
static let allCases: [{{ enum.name }}] = [
{% for case in enum.cases %} .{{ case.name }}{% if not forloop.last %},{% endif %}
{% endfor %}]
}
{% endif %}
{% endfor %}
#endif

题外话

gyb

参考

代码生成方式的另一个“流行”选择时 gyb (Generate Your Boilerplate)。gyb 严格来说就是一个 Python 脚本,它将预定义的值填充到模板中。这个工具被大量用于 Swift 项目本身的开发,标准库中有不少以 .gyb 作为后缀的文件,比如 Array 就是通过 gyb 生成的。

gyb 设计的最初目的主要是为了解决像是 Int8,Int16,Int32 等这一系列十分类似但又必须加以区分的类型中模板代码问题的。(鉴于 Apple 自己都有可能用其他工具来替换掉它,) 我们这里就不展开介绍了。如果你对 gyb 感兴趣,可以看看这篇简明教程

然后后面说到那些也没时间去了解了, 这里就不搬运了. 有兴趣可以自己去看看