Swift 中的 Function Builder 理解与运用

容器客
• 阅读 6231

Function Builder 是 Swift 5.1 引入的特性,大大增强了 Swift 语言构建内置 DSL 的能力。SwiftUI 声明式 UI 构建方式就是靠的 DSL 实现的。


从 DSL 说起

DSL 是 Domain Specific Language 的缩写,意思就是特定领域的语言。与之对应的就是我们熟悉的 C, Java, Swift 这些通用的语言,通用语言什么领域都可以去插一脚,无非就是适不适合,好不好用罢了。DSL 则是局限在某个特定场景的特别设计过的语言,因为专一,所以专业,它们往往能以非常轻量级的语法和易于理解的方式来解决特定问题。

举几个著名的 DSL 的例子:

  • 正则表达式

通过一些规定好的符号和组合规则,通过正则表达式引擎来实现字符串的匹配

  • HTML & CSS

虽然写的是类似XML 或者 .{} 一样的字符规则,但是最终都会被浏览器内核转变成Dom树,从而渲染到Webview上

  • SQL

诸如 create select insert 这种单词后面跟上参数,这样的语句实现了对数据库的增删改查一系列程序工作

那么这种语言内建的 DSL 有什么好处呢,我们先来看一个 HTML 的界面搭建:

<div>
  <p>Hello World!</p>
  <p>My name is KY!</p>
</div>

在 UIKit 里要搭建上述界面很明显要麻烦很多:

let container = UIStackView()
container.axis = .vertical
container.distribution = .equalSpacing

let paragraph1 = UILabel()
paragraph1.text = "Hello, World!"

let paragraph2 = UILabel()
paragraph2.text = "My name is KY!"

container.addSubview(paragraph1)
container.addSubview(paragraph2)

这就是声明式 UI 与 命令式 UI 的区别,声明式 UI 用 DSL 来描述 “UI 应该是什么样子的”,命令式 UI 则需要先创建一个 View,再指定这个 View 的特性,再指定这个 View 要放到哪里,一步一步来,显得较为笨重。

DSL 可以让 SwiftUI 以类似 HTML 的方式来搭建界面

构建 SwiftUI 的 DSL 语法的除了 Function Builder 还有 Property Wrapper, Opaque Return Type,链式调用 等特性,本文只讨论 Function Builder


Function Builder

Function Builder 本质上是语法糖,没有 Function Builder, Swift 依然可以构建 DSL,但是会麻烦不少。不用 Function Builder 来构建 DSL 可以参看这篇文章:Building DSLs in Swift

下面,跟随 Function Builders in Swift and SwiftUI 这篇文章通过构建一个 AttributedStringBuilder 来学会 FunctionBuilder (翻译)


理解 Function Builder

一个 Function Builder 是一个类型,它实现了一个内置的 DSL,这个 DSL 可以把一个函数内的表达式作为部分结果(partial results)收集起来合并成返回值

最小的 Function Builder 类型实现如下:

@_functionBuilder struct Builder {
    static func buildBlock(_ partialResults: String...) -> String {
        partialResults.reduce("", +)
    }
}

定义 Function Builder 要用 @_functionBuilder 来修饰,定义好之后就可以作为 Attribute 来用了。

注意下划线,表示这个功能还没有被正式采纳,仍然在开发中,以后可能会有变化

这个静态的 buildBlock() 方法是必须的。

一个 function builder attribute 可以被用在两种地方:

  1. func, var 或者 subscript 的声明上,前提是这些声明不是某个协议需要的。
  2. 作为一个函数的闭包参数,可以作为协议的一部分。

其实不论 function builder 用在哪里,它都是将跟在后面的表达式串作为参数传递给它的 buildBlock() 方法,用该方法的返回值就是被标注的实际值

让我们用上面定义的 @Builder 作为例子来体会下这两种使用场景:

用在声明里代码如下:

@Builder func abc() -> String {
    "Method: "
    "ABC"
}

struct Foo {
    @Builder var abc: String {
        "Getter: "
        "ABC"
    }
    
    @Builder subscript(_ anything: String) -> String {
        "sbscript"
        "ABC"
    }
}

用在闭包参数上代码如下:

func acceptBuilder(@Builder _ builder: () -> String) -> Void {
    print(builder())
}

运行测试代码:

func testBuilder() -> Void {
    print(abc())
    print(Foo().abc)
    print(Foo()[""])
    acceptBuilder {
        "Closure Argument: "
        "ABC "
    }
}

打印内容如下:

Method: ABC
Getter: ABC
Subscript: ABC
Closure Argument: ABC

funcion builder 要解决的问题就是构造多层次的异构数据结构。举两个例子来说就是:

  • 生成结构数据,如 XML, JSON 等
  • 生成 GUI 层次结构,如 SwiftUI, HTML 等

这就是 function builder 要做的事,那它是怎么工作的呢?


深入 Function Builder

如果我们将方法 abc() 生成的 AST dump 出来可以看到:

(func_decl range=[builder.swift:10:10 - line:13:1] "abc()" interface type='() -> String' access=internal
...
  (declref_expr implicit type='(Builder.Type) -> (String...) -> String' location=builder.swift:10:31 range=[builder.swift:10:31 - line:10:31] decl=builder.(file).Builder.buildBlock@builder.swift:5:17 function_ref=single)
  ...
    (string_literal_expr type='String' location=builder.swift:11:5 range=[builder.swift:11:5 - line:11:5] encoding=utf8 value="Method: " builtin_initializer=Swift.(file).String extension.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:) initializer=**NULL**)
    (string_literal_expr type='String' location=builder.swift:12:5 range=[builder.swift:12:5 - line:12:5] encoding=utf8 value="ABC" builtin_initializer=Swift.(file).String extension.init(_builtinStringLiteral:utf8CodeUnitCount:isASCII:) initializer=**NULL**)
...

我们可以发现最后调用的其实是:

Builder.buildBlock("Method: ", "ABC")

语法分析(semantic analysis)阶段,Swift 编译器会将function builder transforms这个东西 applies 到 parsed AST 上,就好像我们已经写了 Builder.buildBlock(<arguments>) 一样 (1, 2)

另一个例子是 function builder 用作闭包参数的时候。在这种情况下,Swift 编译器会 rewrite the closure to a closure with a single expression body containing the builder invocations.

在某些情况下,一个 function builder 需要提供下面这几个 building 方法来满足不同类型的变形(transformations)需要(1, 2):

  • buildBlock(_ parts: PartialResult...) -> PartialResult

将部分结果聚合成一个

  • ​buildDo(_ parts: PartialResult...) -> PartialResult

与 ​​buildBlock()​ 一样,只是作用于 ​​do​ 语句

  • ​buildIf(_ part: PartialResult?) -> PartialResult

作用于 ​​if​ 语句,true 时 ​​part 为后面跟的内容转换成的 ​​PartialResult​,false时 ​​part​ 为 ​​nil

  • ​buildEither(first: PartialResult) -> PartialResult​ 与 ​​buildEither(second: PartialResult) -> PartialResult

作用于 ​​if...else...​ 语句,必须同时实现

  • ​buildExpression(_ expression: Expression) -> PartialResult

把单个的非 ​​PartialResult​ 转换成 ​​PartialResult

  • ​buildOptional(_ part: PartialResult?) -> PartialResult

将一个可空 ​​PartialResult​ 转换成不可空的

  • ​buildFinalResult(_ parts: PartialResult...) -> Result

将多个 ​​PartialResult​ 转换成 ​​Result

所有这些方法都支持基于其参数类型的 overloads

所以呢,Swift 编译器在碰到 function builder 的时候会用上述方法来替换 DSL 语法内容。如果找不到相应的方法,就会报编译错误


实现定制的 Function Builder

让我们实现一个 ​​NSAttributedString​ 的 function builder 吧,代码如下:

@_functionBuilder struct AttributedStringBuilder {
    // 基本方法
    static func buildBlock(_ parts: NSAttributedString...) -> NSAttributedString {
        let result = NSMutableAttributedString(string: "")
        parts.forEach(result.append)
        return result
    }
    
    // String 转成 NSAttributedString
    static func buildExpression(_ text: String) -> NSAttributedString {
        NSAttributedString(string: text)
    }
    
    // 转 UIImage
    static func buildExpression(_ image: UIImage) -> NSAttributedString {
        NSAttributedString(attachment: NSTextAttachment(image: image))
    }
    
    // 转自己,不是很清楚为什么一定要这个方法,感觉有上面几个就够了呀,但是实践上没有这个会报错
    static func buildExpression(_ attrString: NSAttributedString) -> NSAttributedString {
        attrString
    }
    
    // 支持 if 语句
    static func buildIf(_ attrString: NSAttributedString?) -> NSAttributedString {
        attrString ?? NSAttributedString()
    }
    
    // 支持 if/else 语句
    static func buildEither(first: NSAttributedString) -> NSAttributedString {
        first
    }
    static func buildEither(second: NSAttributedString) -> NSAttributedString {
        second
    }
}

为了用起来,还需要一个添加 attributes 的方法和用这个 builder 的便利构造器:

extension NSAttributedString {
    // 帮助加 Attributes
    func withAttributes(_ attrs: [NSAttributedString.Key : Any]) -> NSAttributedString {
        let result = NSMutableAttributedString(attributedString: self)
        result.addAttributes(attrs, range: NSRange(location: 0, length: self.length))
        return result
    }
    
    // 以 DSL 方式来初始化
    convenience init(@AttributedStringBuilder builder: () -> NSAttributedString) {
        self.init(attributedString: builder())
    }
}

接下来我们要来测试一下这个新的 NSAttributedString,因为 NSAttributedString 是 UIKit 的,所以我们得把它放在 UILabel 里,再用 ​​UIViewRepresentable​ 包装一下才能用在 SwiftUI 里:

struct AttributedStringRepresentable: UIViewRepresentable {
    
    let attrbutedString: NSAttributedString
    
    func makeUIView(context: Context) -> UILabel {
        let label = UILabel()
        label.numberOfLines = 0
        label.attributedText = attrbutedString
        return label
    }
    
    func updateUIView(_ uiView: UILabel, context: Context) { }
}

SwiftUI 测试代码:

struct AttributedStringView: View {
    let optional = true
    
    var body: some View {
        AttributedStringRepresentable(
            attrbutedString: NSAttributedString {
                NSAttributedString {
                    "Folder"
                    UIImage(systemName: "folder")!
                }
                NSAttributedString { }
                "\n"
                NSAttributedString {
                    "Document"
                    UIImage(systemName: "doc")!
                }
                .withAttributes([
                    .font : UIFont.systemFont(ofSize: 32),
                    .foregroundColor : UIColor.red
                ])
                "\n"
                "Blue One".foregroundColor(.blue)
                    .background(.gray)
                    .underline(.cyan)
                    .font(UIFont.systemFont(ofSize: 20))
                "\n"
                if optional {
                    NSAttributedString {
                        "Hello "
                            .foregroundColor(.red)
                            .font(UIFont.systemFont(ofSize: 10.0))
                          "World"
                            .foregroundColor(.green)
                            .underline(.orange, style: .thick)
                    }
                    UIImage(systemName: "rays")!
                }
                "\n"
                if optional {
                    "It's True".foregroundColor(.magenta)
                        .font(UIFont.systemFont(ofSize: 28))
                } else {
                    "It's False".foregroundColor(.purple)
                }
            }
        )
        .frame(width: 250, height: 250)
    }
}

上面代码里的 .foregroundColor 这些来自于 String 和 NSAttributedString 的 modifiers 扩展

最后展示效果如下:
Swift 中的 Function Builder 理解与运用

最后,这里有老外做的一堆 awesome-function-builders,有依赖注入的,有HTTP Request的,有用来测试的等等,插个眼,有需要以后可以用

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
美凌格栋栋酱 美凌格栋栋酱
6个月前
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
九路 九路
4年前
Gradle技术之一 Groovy语法精讲
Gradle技术之一Groovy语法精讲gradle脚本是基于groovy语言开发的,想要学好gradle必须先要对groovy有一个基本的认识1.Groovy特点groovy是一种DSL语言,所谓的DSL语言,就是专门针对某一特定领域的语言,专精而不专广是一种基于JVM的开发语言,也是编译成class字节码文件结合和Pytho
Elasticsearch查询及聚合类DSL语句宝典
随着使用es场景的增多,工作当中避免不了去使用es进行数据的存储,在数据存储到es当中以后就需要使用DSL语句进行数据的查询、聚合等操作,DSL对SE的意义就像SQL对MySQL一样,学会如何编写查询语句决定了后期是否能完全驾驭ES,所以至关重要,本专题主要是分享常用的DSL语句,拿来即用。
Easter79 Easter79
3年前
SwiftUI 实战:从 0 到 1 研发一个 App
心得感悟起初看到WWDC上的演示SwiftUI时,我就觉得SwiftUI有种陌生的熟悉感(声明式语法),所以体验下,看看有没有什么启发。先说下整体项目完成下来的感受:用SwiftSwiftUI开发iOS项目效率很高,本人之前没有接触过Swift语言,这次是从0开始学swift语言以及swi
Stella981 Stella981
3年前
Elasticsearch Query DSL概述与查询、过滤上下文
从本节开始,先详细介绍ElasticsearchQueryDSL语法,该部分是SearchAPI的核心基础之一。Elasticsearch提供了一个基于JSON的完整查询DSL(领域特定语言)来定义查询。把查询DSL看作是查询的AST(抽象语法树),由两种类型的子句组成:Leafqueryclauses(叶查询字句)叶子
Wesley13 Wesley13
3年前
FLV文件格式
1.        FLV文件对齐方式FLV文件以大端对齐方式存放多字节整型。如存放数字无符号16位的数字300(0x012C),那么在FLV文件中存放的顺序是:|0x01|0x2C|。如果是无符号32位数字300(0x0000012C),那么在FLV文件中的存放顺序是:|0x00|0x00|0x00|0x01|0x2C。2.  
Stella981 Stella981
3年前
Gradle之介绍
Gradle是基于JVM构建工具的新一代版本。它从现有的构建工具如Ant和Maven中学到了很多东西,并且把它们的最优思想提升到更高层次。遵循基于约定的构建方式,Gradle可以用一种声明式的方式为你的问题领域建模,它使用一种强大的且具有表达性的基于Groovy的领域特定语言(DSL),而不是XML,因为Gradle是基于JVM的,它允许你使用自己最喜欢的J
Easter79 Easter79
3年前
SwiftUI 跨组件数据传递
作者:Cyandev,iOS和MacOS开发者,目前就职于字节跳动0x00前言众所周知,SwiftUI的开发模式与React、Flutter非常相似,即都是声明式UI,由数据驱动(产生)视图,视图也会与数据自动保持同步,框架层会帮你处理“绑定”的问题。在声明式UI中不存在命令式地让一个视图变成xxx
为什么mysql不推荐使用雪花ID作为主键
作者:毛辰飞背景在mysql中设计表的时候,mysql官方推荐不要使用uuid或者不连续不重复的雪花id(long形且唯一),而是推荐连续自增的主键id,官方的推荐是auto_increment,那么为什么不建议采用uuid,使用uuid究