玩转 Swift 高级玩具 "Sourcery"

3,293 阅读7分钟

Sourcery GitHub:Sourcery GitHub

Sourcery 官方的文档:Sourcery

Sourcery 入门的博客:Sourcery - Swift元编程实践,告别样板代码

Sourcery 大神的博客:不同角度看问题 - 从 Codable 到 Swift 元编程

Sourcery 是一个 Swift 代码生成的开源命令行工具,它 (通过 SourceKitten) 使用 Apple 的 SourceKit 框架,来分析你的源码中的各种声明和标注,然后套用你预先定义的 Stencil 模板 (一种语法和 Mustache 很相似的 Swift 模板语言) 或者 swifttemplate模版 (一种语法和 EJS 相似的模版) 进行代码生成。

Stencil 语法

语法总览

语法和Django一样,可以参考:模版语法

  1. {{ ... }}:变量语法,将中间的部分作为变量(或变量的表达式)来解析,解析后的值会作为结果插入到模板中的相应位置上。
  2. {% ... %}:标签语法(Tag),标签用来表示一些具有特殊功能的语法,比如用来实现判断的if和循环的for。
  3. {# ... #}:注释语法,不会出现在解析后的结果中。

除此之外还有一个名为Filter的概念,它的语法是这样的:{{ "stencil"|uppercase }},符号|左边是输入的变量,右边就是一个Filter,这里输出了字符串的大写形式。Filter可以有参数,Filter的参数跟随冒号之后并且总是以双引号包含,如:{{ bio|truncatewords:"30" }},这个将显示变量 bio 的前30个词。

常见语法

if的使用

{% if condition %}
     ... display
{% endif %}

{% if condition1 %}
   ... display 1
{% elif condition2 %}
   ... display 2
{% else %}
   ... display 3
{% endif %}

{% if %} 标签接受 and , or 或者 not 关键字来对多个变量做判断 ,或者对变量取反( not ),例如:

{% if athlete_list and not coach_list %}
     athletes 是可用的 和 coaches 变量是不可用的。
{% endif %}

for的使用

{% for athlete in athlete_list %}
    {{ athlete.name }}
{% endfor %}

for 可以和 where 一起使用,来更加准确的遍历

{% for type in types.enums where type.implements.AutoHashable or type|annotated:"AutoHashable" %}
{% endfor %}

上面的循环意思为,遍历所有enum类型,并且enum实现了 AutoHashable 或者 增加了 // sourcery: AutoHashable 注释

Swifttemplate 语法

语法参考 EJS

比Stencil模版更加强大灵活,可以写swift代码

举个小🌰

很多时候我们会有想要得到 enum 中所有 case 的集合,以及确定一共有多少个 case 成员的需求。如果纯手写的话,大概是这样的:

enum HogwartsHouse {
    // ...
    static let all: [HogwartsHouse] = [
        .gryffindor,
        .hufflepuff,
        .ravenclaw,
        .slytherin
    ]
    
    static let count = 4
}

显然这么做对于维护很不友好,没有人能确保时刻记住在添加新的 case 后一定会去更新 all 和 count。对其他有同样需求的 enum,我们也需要重复劳动。Sourcery 就是为了解决这样的需求而生的,相对于手写 all 和 count,我们可以定义一个空协议 EnumSet,然后让 HogwartsHouse 遵守它:

protocol EnumSet {}
extension HogwartsHouse: EnumSet {}

这个定义为 Sourcery 提供了一些提示,Sourcery 需要一些方式来确定为哪部分代码进行代码生成,“实现了某个协议”这个条件就是一个很有用的提示。现在,我们可以创建模板文件了,在同一个文件夹中,新建 enumset.stencil,并书写下面的内容:

{% for enum in types.implementing.EnumSet|enum %}
extension {{ enum.name }} {
    {% if not enum.hasAssociatedValues %}
    static let all: [{{ enum.name }}] = [
    {% for case in enum.cases %}    .{{ case.name }}{% if not forloop.last %},{% endif %}
    {% endfor %}]
    {% endif %}
    static let count: Int = {{ enum.cases.count }}
}
{% endfor %}

第一行:{% for enum in types.implementing.EnumSet|enum %}

即“选取那些实现了 EnumSet 的类型,滤出其中所有的 enum 类型,然后对每个 enum 进行枚举”。接下来,我们对这个选出的 enum 类型,为它创建了一个 extension,并对其所有 case 进行迭代,生成 all 数组。最后,将 count 设置为成员个数。

一开始你可能会有不少疑问,types.implementing 是什么,我怎么能知道 enum 在有 name, cases,hasAssociatedValues 之类的属性?Sourcery 有非常详尽的文档,对上述问题,你可以在 Types 和 Enum 的相关页面找到答案。在初上手写模板逻辑时,参照文档是不可避免的。

一切就绪,现在我们可以将源文件喂给模板,来生成最后的代码了:

sourcery --sources ./source.swift --templates ./enumset.stencil

--sources 和 --templates 都可以接受文件夹,Sourcery 会按照后缀自行寻找源文件和模板文件,所以也可以用 sourcery --sources ./ --templates ./ 来替代上面的命令。不过实际操作中还是建议将源文件和模板文件放在不同的文件夹下,方便管理。

在同一个文件夹下,可以看到生成的 enumset.generated.swift 文件:

extension HogwartsHouse {
    static let all: [HogwartsHouse] = [
        .gryffindor,
        .hufflepuff,
        .ravenclaw,
        .slytherin
    ]
    static let count: Int = 4
}

也可以用swifttemplate来实现:

<%_ for type in types.enums where type.implements["EnumSet"] != nil { -%> 
extension <%= type.name %> {
    <%_ if !type.hasAssociatedValues {-%>
    static let all: [<%= type.name %>] = [
    <%_ for (index, typecase) in type.cases.enumerated() { -%>
    .<%= typecase.name -%>
    <%_ if index < type.cases.count-1 { %> , <% } %>
    <%_ } -%>
    ]
    <%_ } -%>

    static let count: Int = <%= type.cases.count  %>
}
<%_ } -%>

在 Xcode 中使用 Sourcery

安装

通过Cooapods安装pod 'Sourcery',Sourcery 会安装在 $PODS_ROOT/Sourcery/bin/sourcery

也可以通过 Homebrew 安装 brew install sourcery

使用

Sourcery 是一个命令行工具,在 Xcode 项目制定 Target 的 build phase 增加脚本:

./sourcery --sources <sources path> --templates <templates path> --output <output path>

注意:如果使用 Cocoapods 安装,则将 ./sourcery 替换为 "$PODS_ROOT/Sourcery/bin/sourcery"

也可以使用配置文件来指定 sourcery 参数,在项目根目录创建文件 .sourcery.yml ,文件内容:

# sourcery 运行在制定项目
project:
  file: xxxx.xcodeproj
  target:
    name: xxxx

# 模版的路径,可以是相对路径
templates:
  - Templates

# 输出配置
output:
  # 输出目录或文件,如果是文件,则所有生成的内容都会写进这个文件
  path: xxx/xx
  # 将输出目录或文件连接到项目,只会连接到项目的根目录
  link:
    project: xxxx.xcodeproj
    target: xxxx
    group: xx

详情配置请看:Sourcery Usage

在 Xcode 项目制定 Target 的 build phase 增加脚本:

./sourcery

也可以通过 ./sourcery --config xxx.yml参数指定配置文件。

实战🌰

模仿文章开头大佬的博客实现用Sourcery模型转字典的功能,具体需求如下:

  1. 模型可以一键转字典,模型属性名为字典里的 key,模型属性值为字典的 value
  2. 模型里的可选属性为 null 时,不插入到字典里
  3. 可以主动忽略模型里的某一个属性

假如有以下一个结构体:

struct UserInfo {
    let id: int
    let age: int?
    let weight: String?
}

把它转成字典,并且忽略'id'属性和为 null 的属性,如下代码:

let userInfo = UserInfo(id: 1, age: 18, weight: null)
// 转成[String: Any]类型字典
let dic = userInfo.value
// dic = ["age": 18]

上面代码中使用 userInfo.value 获取模型对应的字典,这个'value'属性就是使用 Sourcery 生生成,下面我们分解一下让 Sourcery 帮我们生成 value 属性的步骤:

  1. 我们需要告诉 Sourcery 哪些类型(我们自己创建的类、结构体、枚举等)需要有 value 属性
    • 为了解决这个问题,我们创建一个空的协议 protocol DictionaryConvertible {}
    • 让所有需要有 value 属性的类型实现该协议 DictionaryConvertible
    • 在 Stencil 中使用 types.implementing.DictionaryConvertible 就可以找到所有想要生成 value 属性的类型
  2. 我们需要告诉 Sourcery 哪些属性需要忽略
    • 上面我们提到过,Sourcery 支持注解,我们给需要忽略的属性增加注解 // sourcery: skipConvertDic
    • 在 Stencil 中使用 annotated:"skipConvertDic" 可以判断属性是否有 "skipConvertDic" 注解
  3. Sourcery 需要处理可选类型的属性
    • 在 Stencil 中使用 isOptional 可以判断属性是否时可选类型
  4. 基本数据类型(String、Int、Bool、Double、Float等)也要有 value 属性
    • 扩展这些基本类型,给它们增加 value 属性

我们先完成第 1、4 步骤:

protocol DictionaryConvertible {}

extension Int { var value: Int { return self } }
extension String { var value: String { return self } }
extension Bool { var value: Bool { return self } }
extension Double { var value: Double { return self } }
extension Float { var value: Float { return self } }

第2、3步骤就直接上 Stencil 代码了,代码里有详细注释:

{# 找到所有实现了 DictionaryConvertible 的类型 #}
{% for t in types.implementing.DictionaryConvertible %}
{# 为该类型 t 创建 extension #}
// MARK: - {{ t.name }} DictionaryConvertible
extension {{ t.name }} {
    var value: [String: Any] {
        var dicValue: [String: Any] = [String: Any]()

        {# 对类型中的所有存储属性迭代,并跳过不需要转换的属性 #}
        {% for val in t.storedVariables where val|!annotated:"skipConvertDic" %}
        {# 非可选值 #}
        {% if not val.typeName.isOptional %}
        {% if val.isArray %} {# 如果变量是数组,map 其中的值进行嵌套 #}
        dicValue["{{val.name}}"] = {{val.name}}.map { $0.value }
        {% elif val.isDictionary %} {# 如果变量是字典,mapValues 字典值进行嵌套 #}
        dicValue["{{val.name}}"] = {{val.name}}.mapValues { $0.value }
        {% else %} {# 非容器类型,如果是其他的 DictionaryConvertible,则调用 value, 否则直接使用变量即可 #}
        dicValue["{{val.name}}"] = {% if val.type.implements.DictionaryConvertible %}{{val.name}}.value{% else %}{{val.name}}{% endif %}
        {% endif%}
        {% else %}

        if let {{val.name}} = {{val.name}} {
            {% if val.isArray %} {# 如果变量是数组,map 其中的值进行嵌套 #}
            dicValue["{{val.name}}"] = {{val.name}}.map { $0.value }
            {% elif val.isDictionary %} {# 如果变量是字典,mapValues 字典值进行嵌套 #}
            dicValue["{{val.name}}"] = {{val.name}}.mapValues { $0.value }
            {% else %} {# 非容器类型,如果是其他的 DictionaryConvertible,则调用 value, 否则直接使用变量即可 #}
            dicValue["{{val.name}}"] = {% if val.type.implements.DictionaryConvertible %}{{val.name}}.value{% else %}{{val.name}}{% endif %}
            {% endif%}
        }
        {% endif %}
        {% endfor %}

        return dicValue
    }
}

{# 对Array进行扩展 #}
extension Array where Element == {{ t.name }} {
    var value: [Any] {
        return map { $0.value }
    }
}

{% endfor %}

这是上面 UserInfo 类型应该如下:

struct UserInfo {
    // sourcery: skipConvertDic
    let id: int
    let age: int?
    let weight: String?
}

extension UserInfo: DictionaryConvertible {}

let userInfo = UserInfo(id: 1, age: 18, weight: null)
// 转成[String: Any]类型字典
let dic = userInfo.value
// dic = ["age": 18]

扩展

github上有一个这样的库StencilSwiftKit,为Stencil扩展了许多更加便捷的语法。