Swift 中的关键字:用代码解释 Result builder

261 阅读8分钟

原文:Result builders in Swift explained with code examples

GitHub - carson-katri/awesome-result-builders: A list of cool DSLs made with Swift 5.4’s @resultBuilder

Swift 中的结果构造器允许你使用彼此排列的 “build block” 来构建结果。它们在 Swift 5.4 中引入,在 Xcode 12.5 及更高版本中可用。以前称为函数构建器,你可能已经通过在 SwiftUI 中构建视图堆栈来大量使用它们。

我必须承认:起初,我认为这是 Swift 中的一项相当高级的功能,我永远不会用自己来编写自定义解决方案来配置我的代码。然而,一旦我尝试并编写了一个在 UIKit 中构建视图约束的小解决方案,我发现这完全是为了理解结果构建器的力量。

什么是结果构造器?

结果构造器可以看作是一种嵌入式领域特定语言 (DSL),用于收集组合成最终结果的部分。一个简单的 SwiftUI 视图声明在底层使用 @ViewBuilder,它是结果构造器的实现:

struct ContentView: View {
     var body: some View {
         // This is inside a result builder
         VStack {
             Text("Hello World!") // VStack and Text are 'build blocks'
         }
     }
 }

每个子视图(在本例中为包含文本的 VStack)将合并为一个视图。换句话说,视图构建块内置于视图结果中。理解这一点很重要,因为它解释了结果构建器的工作原理。

如果我们查看 SwiftUI View 协议的声明,我们可以看到使用 @ViewBuilder 属性定义的 body 变量:

@ViewBuilder var body: Self.Body { get }

这正是您可以将自定义结果构造器用作函数、变量或下标的属性的方式。

创建自定义结果构造器

为了向您解释如何定义您自己的自定义结果构造器,我喜欢按照我自己一直在使用的示例进行操作。在代码中编写自动布局时,我通常会编写以下类型的逻辑:

var constraints: [NSLayoutConstraint] = [
     // Single constraint
     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
 ]

 // Boolean check
 if alignLogoTop {
     constraints.append(swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor))
 } else {
     constraints.append(swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor))
 }

 // Unwrap an optional
 if let fixedLogoSize = fixedLogoSize {
     constraints.append(contentsOf: [
         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width),
         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
     ])
 }

 // Add a collection of constraints
 constraints.append(contentsOf: label.constraintsForAnchoringTo(boundsOf: view)) // Returns an array

 // Activate
 NSLayoutConstraint.activate(constraints)

如您所见,我们有很多条件约束。这会使阅读复杂视图中的约束变得困难。

结果构造器是一个很好的解决方案,它允许我们编写上面的示例代码如下:

@AutoLayoutBuilder var constraints: [NSLayoutConstraint] {
     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor) // Single constraint
     
     if alignLogoTop {
         swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
     } else {
         swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor) // Single constraint
     }
     
     if let fixedLogoSize = fixedLogoSize {
         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)
         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
     }
     
     label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
 }

太棒了,对吧?

那么让我们看看如何构建这个自定义实现。

定义 AutoLayoutBuilder

我们首先定义我们的自定义 AutoLayoutBuilder 结构并添加 @resultBuilder 属性以将其标记为结果构造器:

@resultBuilder
struct AutoLayoutBuilder {
    // .. Handle different cases, like unwrapping and collections
}

要从所有构建块中构建结果,我们需要为每种情况配置处理程序,例如处理可选值和集合。但在此之前,我们先处理单个约束的情况。

这是通过以下方法完成的:

@resultBuilder
struct AutoLayoutBuilder {
    
    static func buildBlock(_ components: NSLayoutConstraint...) -> [NSLayoutConstraint] {
        return components
    }
}

该方法采用组件的可变参数,这意味着它可以是一个或多个约束。我们需要返回一个约束集合,这意味着,在这种情况下,我们可以直接返回输入组件。

这现在允许我们定义一组约束,如下所示:

@AutoLayoutBuilder var constraints: [NSLayoutConstraint] {
    // Single constraint
    swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
}

处理构建块的集合

接下来是将构建块的集合作为单个元素处理。在我们的第一个代码示例中,我们使用了一种便捷方法 constraintsForAnchoringTo (boundsOf:) 返回集合中的多个约束。如果我们将其用于我们当前的实现,则会发生以下错误:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2041a79e5c7b422f841aaa58be6ca67e~tplv-k3u1fbpfcp-zoom-1.image

自定义结果构造器一开始无法处理组件集合。

错误描述很好地解释了发生了什么:

Cannot pass array of type ‘[NSLayoutConstraint]’ as variadic arguments of type ‘NSLayoutConstraint’ 无法将 “[NSLayoutConstraint]” 类型的数组作为 “NSLayoutConstraint” 类型的可变参数传递

Swift 中的可变参数不允许我们传入数组,即使这样做看起来合乎逻辑。相反,我们需要定义一个自定义方法来将集合处理为组件输入。查看可用的方法,您可能认为我们需要以下方法:

https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/4f87d681101b45ab9b21b4822c1bdd37~tplv-k3u1fbpfcp-zoom-1.image

自定义结果构建器定义中的可用方法列表。

不幸的是,正如方法描述所述,这仅支持将结果组合成单个结果的循环。我们不使用迭代器,而是使用一种方便的方法来直接返回一个集合,因此我们需要以不同的方式更改我们的代码。

相反,我们将重写构建块方法以获取数组:

static func buildBlock(_ components: [NSLayoutConstraint]...) -> [NSLayoutConstraint] {
    components.flatMap { $0 }
}

此更改将破坏我们已有的初始实现,因为我们无法再传入单个元素。要添加对单个元素的支持,我们需要添加一个转换方法,可以使用表达式构建方法来实现:

@resultBuilder
struct AutoLayoutBuilder {

    static func buildBlock(_ components: [NSLayoutConstraint]...) -> [NSLayoutConstraint] {
        components.flatMap { $0 }
    }

    /// Add support for both single and collections of constraints.
    static func buildExpression(_ expression: NSLayoutConstraint) -> [NSLayoutConstraint] {
        [expression]
    }

    static func buildExpression(_ expression: [NSLayoutConstraint]) -> [NSLayoutConstraint] {
        expression
    }
}

这两种方法允许我们将单个布局约束转换为约束集合。换句话说,我们可以将这两种类型合并为一个通用类型 [NSLayoutConstraint],该类型被接受为可变数组值。

buildBlock 方法中,我们使用 flatMap 映射到单个约束集合。如果你不知道 flatMap 做了什么或者为什么我们不使用 compactMap,你可以阅读我的文章 CompactMap vs flatMap: The differences explained

最后,我们可以更新我们的实现以使用我们新的集合构建块处理程序:

@AutoLayoutBuilder var constraints: [NSLayoutConstraint] {
     // Single constraint
     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
     
     label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
 }

处理可选项的展开

我们需要处理的另一种情况是展开可选项。如果值存在,这允许我们有条件地添加约束。

我们通过将 buildOptional (..) 方法添加到我们的函数构建器来做到这一点:

@resultBuilder
struct AutoLayoutBuilder {

    static func buildBlock(_ components: [NSLayoutConstraint]...) -> [NSLayoutConstraint] {
        components.flatMap { $0 }
    }

    /// Add support for both single and collections of constraints.
    static func buildExpression(_ expression: NSLayoutConstraint) -> [NSLayoutConstraint] {
        [expression]
    }

    static func buildExpression(_ expression: [NSLayoutConstraint]) -> [NSLayoutConstraint] {
        expression
    }

    /// Add support for optionals.
    static func buildOptional(_ components: [NSLayoutConstraint]?) -> [NSLayoutConstraint] {
        components ?? []
    }
}

如果值不存在,它返回约束集合或空集合。

这现在允许我们在我们的构建块定义中解包一个可选的:

@AutoLayoutBuilder var constraints: [NSLayoutConstraint] {
     // Single constraint
     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
     
     label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
     
     // Unwrapping an optional
     if let fixedLogoSize = fixedLogoSize {
         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)
         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
     }
 }

处理条件语句

另一个要处理的常见情况是条件语句。基于您要添加一个或另一个约束的布尔值。这个构建块处理程序的工作原理基本上是能够处理条件检查中的第一个或第二个组件:

@AutoLayoutBuilder var constraints: [NSLayoutConstraint] {
     // Single constraint
     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
     
     label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
     
     // Unwrapping an optional
     if let fixedLogoSize = fixedLogoSize {
         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)
         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
     }
     
     // Conditional check
     if alignLogoTop {
         // Handle either the first component:
         swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
     } else {
         // Or the second component:
         swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor)
     }
 }

这反映回构建我们需要添加到我们的函数构建器的处理程序:

@resultBuilder
struct AutoLayoutBuilder {

    static func buildBlock(_ components: [NSLayoutConstraint]...) -> [NSLayoutConstraint] {
        components.flatMap { $0 }
    }

    /// Add support for both single and collections of constraints.
    static func buildExpression(_ expression: NSLayoutConstraint) -> [NSLayoutConstraint] {
        [expression]
    }

    static func buildExpression(_ expression: [NSLayoutConstraint]) -> [NSLayoutConstraint] {
        expression
    }

    /// Add support for optionals.
    static func buildOptional(_ components: [NSLayoutConstraint]?) -> [NSLayoutConstraint] {
        components ?? []
    }

    /// Add support for if statements.
    static func buildEither(first components: [NSLayoutConstraint]) -> [NSLayoutConstraint] {
        components
    }

    static func buildEither(second components: [NSLayoutConstraint]) -> [NSLayoutConstraint] {
        components
    }
}

在两个 buildEither 处理程序中,我们只是简单地转发接收到的受限集合。这是使我们的示例代码正常工作所需的最后两个构建处理程序,太棒了!

处理 for..in 循环

虽然我们的示例已经可以运行,但我们可以添加更多方法来使我们的结果生成器更加完整。例如,我们可以添加对循环的支持:

/// Add support for loops.
static func buildArray(_ components: [[NSLayoutConstraint]]) -> [NSLayoutConstraint] {
    components.flatMap { $0 }
}

这样做允许我们在结果生成器中使用循环:

NSLayoutConstraint.activate {
    for _ in (0..<Int.random(in: 0..<10)) {
        swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor) // Single constraint
    }
}

尽管上面的示例并不是很有用,但它确实演示了将循环与结果生成器结合使用。

支持可用性 API

我们之前添加了对 if 语句的支持,允许我们使用可用性 API 编写 if 语句,如下所示:

if #available(iOS 13, *) {
    swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor) // Single constraint
}

因为我们在这个例子中没有使用任何不可用的 API,所以代码编译得很好。但是,如果要为所有可用性语句做准备,可以按如下方式添加支持:

/// Add support for #availability checks.
static func buildLimitedAvailability(_ components: [NSLayoutConstraint]) -> [NSLayoutConstraint] {
    components
}

使用结果构建器作为函数参数

使用结果生成器的一个好方法是将它们定义为函数的参数。这样,我们就可以真正受益于我们自定义的 AutoLayoutBuilder

例如,我们可以在 NSLayoutConstraint 上做这个扩展,让它更容易激活约束:

extension NSLayoutConstraint {
    /// Activate the layouts defined in the result builder parameter `constraints`.
    static func activate(@AutoLayoutBuilder constraints: () -> [NSLayoutConstraint]) {
        activate(constraints())
    }
}

使用它看起来如下:

NSLayoutConstraint.activate {
     // Single constraint
     swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
     
     label.constraintsForAnchoringTo(boundsOf: view) // Returns an array
     
     // Unwrapping an optional
     if let fixedLogoSize = fixedLogoSize {
         swiftLeeLogo.widthAnchor.constraint(equalToConstant: fixedLogoSize.width)
         swiftLeeLogo.heightAnchor.constraint(equalToConstant: fixedLogoSize.height)
     }
     
     // Conditional check
     if alignLogoTop {
         // Handle either the first component:
         swiftLeeLogo.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
     } else {
         // Or the second component:
         swiftLeeLogo.centerYAnchor.constraint(equalTo: view.centerYAnchor)
     }
 }

现在我们有了这个方法,我们还可以在 UIView 上创建一个方便的方法来直接添加带有约束的子视图:

protocol SubviewContaining { }
 extension UIView: SubviewContaining { }

 extension SubviewContaining where Self == UIView {
     
     /// Add a child subview and directly activate the given constraints.
     func addSubview<View: UIView>(_ view: View, @AutoLayoutBuilder constraints: (Self, View) -> [NSLayoutConstraint]) {
         addSubview(view)
         NSLayoutConstraint.activate(constraints(self, view))
     }
 }

我们可以按如下方式使用:

let containerView = UIView()
 containerView.addSubview(label) { containerView, label in
     
     if label.numberOfLines == 1 {
         // Conditional constraints
     }
     
     // Or just use an array:
     label.constraintsForAnchoringTo(boundsOf: containerView)
     
 }

当我们使用泛型时,我们可以根据 UIView 的输入类型进行条件检查。在这种情况下,如果我们的标签只有一行文本,我们可以添加不同的约束。

如何提出自定义结果构造器实现?

我听到您在想:我怎么知道结果生成器对特定代码段有用?

每当您看到一段代码由多个条件元素构建并变成一个通用的返回类型片段时,您可能会考虑编写结果构建器。但是,只有当您知道需要更频繁地编写它时才这样做。

当您在代码中编写自动布局约束时,您会在很多地方这样做。因此,值得为它编写一个自定义结果生成器。一旦您将每个约束集合(无论是否单一)视为一个单独的构建块,约束也会由多个 “构建块” 构建。

最后,我想引用这个包含函数构建器示例的存储库,现在称为结果构建器。

总结

结果构造器是 Swift 5.4 的一个超级强大的补充,它允许我们编写自定义领域特定语言,从而真正改进我们编写代码的方式。我希望在阅读本文后,您可以更轻松地想到可以在实现级别简化代码的自定义函数构建器。

如果您想进一步提高 Swift 知识,请查看 Swift 类别页面。如果您有任何其他提示或反馈,请随时与我联系或在 Twitter 上发推文给我。

谢谢!