1. Result builders in Swift explained with code examples

wift中的结果构建器允许你使用一个接一个的“构建块”来构建结果。它们是在Swift 5.4中引入的,在Xcode 12.5和更高版本中也可以使用。它们以前被称为函数构建器,您可能已经在swifttui中构建了一组视图,多次使用过它们。

我必须承认:首先,我认为这是一个相当高级的特性在Swift中,我永远不会用自己来编写定制的解决方案来配置我的代码。然而,当我尝试并编写了一些在UIKit中构建视图约束的解决方案时,我发现关键是在于理解Result builders的力量。

1.1. What are result builders?

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

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

每个子视图(在本例中是一个包含Text的VStack)将被组合成一个视图。换句话说,视图构建块被构建到一个视图结果中。理解这一点很重要,因为它解释了结果构建器是如何工作的。

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

@ViewBuilder var body: Self.Body { get }

这正是您可以使用自定义result builder作为函数、变量或下标的属性的方式。

1.2. Creating a custom result builder

为了向您解释如何定义自己的自定义result builder,我喜欢跟随我自己使用的一个例子。当在代码中编写自动布局时,我通常会编写以下类型的逻辑:

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)

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

result builder 是一个很好的解决方案,并允许我们编写上面的示例代码如下:

 @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
}

惊人的,对吧? 让我们看看如何构建这个自定义实现。

1.3. Defining the Autolayout builder

我们首先定义自定义的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)
} 

1.4. Handling an collection of build blocks

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

@AutolayoutBuilder var constraints: [NSLayoutConstraint] {
    // Single constraint
    swiftLeeLogo.centerXAnchor.constraint(equalTo: view.centerXAnchor)
    label.constraintsForAnchoringTo(boundsOf:view)
    /// error: Cannot pass array of type ‘[NSLayoutConstraint]’ as variadic arguments of type ‘NSLayoutConstraint’
} 

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

avatar

不幸的是,正如方法描述所述,这只支持将results合并为单个result的循环。我们不使用迭代器,而是使用一个方便的方法直接返回集合,因此我们需要编写更多的自定义代码。

我们可以通过定义一个由单个NSLayoutConstraint和一组约束实现的新协议来解决这个问题:

protocol LayoutGroup {
    var constraints: [NSLayoutConstraint] { get }
}
extension NSLayoutConstraint: LayoutGroup {
    var constraints: [NSLayoutConstraint] { [self] }
}
extension Array: LayoutGroup where Element == NSLayoutConstraint {
    var constraints: [NSLayoutConstraint] { self }
} 

该协议允许我们将单个约束和约束集合转换为约束数组。换句话说,我们可以将这两种类型合并到一个公共类型[NSLayoutConstraint]中。

现在我们可以重写我们的结果生成器实现,并允许它接收我们的LayoutGroup协议:

@resultBuilder
struct AutolayoutBuilder {
    
    static func buildBlock(_ components: LayoutGroup...) -> [NSLayoutConstraint] {
        return components.flatMap { $0.constraints }
    }
} 

我们使用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
} 

1.5. Handling unwrapping of optionals

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

为此,我们将buildOptional(..)方法添加到函数构建器中:

@resultBuilder
struct AutolayoutBuilder {
    
    static func buildBlock(_ components: LayoutGroup...) -> [NSLayoutConstraint] {
        return components.flatMap { $0.constraints }
    }
    
    static func buildOptional(_ component: [LayoutGroup]?) -> [NSLayoutConstraint] {
        return component?.flatMap { $0.constraints } ?? []
    }
} 

它尝试将结果映射到约束集合中,如果值不存在,则返回一个空集合。

现在我们可以在构建块定义中打开一个可选的组件:

@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)
    }
} 

1.6. Handling conditional statements

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

@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: LayoutGroup...) -> [NSLayoutConstraint] {
        return components.flatMap { $0.constraints }
    }
    
    static func buildOptional(_ component: [LayoutGroup]?) -> [NSLayoutConstraint] {
        return component?.flatMap { $0.constraints } ?? []
    }
    
    static func buildEither(first component: [LayoutGroup]) -> [NSLayoutConstraint] {
        return component.flatMap { $0.constraints }
    }

    static func buildEither(second component: [LayoutGroup]) -> [NSLayoutConstraint] {
        return component.flatMap { $0.constraints }
    }
}

在两个buildeEither处理程序中,我们都使用了相同的LayoutGroup协议方法来获取约束,并将它们平展返回。

这是让我们的示例代码工作所需的最后两个构建处理程序,太棒了!

然而,我们还没有完成。通过在函数内部使用结果构建器,我们可以让这段代码更好一点。

1.7. Using result builders as function parameters

利用结果构建器的一个好方法是将它们定义为函数的参数。这样,我们就可以从自定义的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的输入类型进行条件检查。在本例中,如果我们的标签只有一行文本,我们可以添加不同的约束。

1.8. How to come up with custom result builder implementations?

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

当您看到一段由几个条件元素构建并转换为返回类型的单一公共部分的代码时,您可以考虑编写结果构建器。但是,只有当你知道你需要更频繁地写它时才这样做。

当你在代码中编写自动布局约束时,你在很多地方都在这样做。因此,值得为它编写一个定制的结果构建器。当你将每个约束集合(不管是单一的还是非单一的)视为一个单独的构建块时,约束也会由多个“构建块”构建而成。

1.9. Conclusion

结果生成器是Swift 5.4的一个超级强大的补充,它允许我们编写自定义的领域特定语言,这可以真正改善我们编写代码的方式。我希望在阅读了本文之后,您可以更容易地考虑自定义函数构建器,它可以在实施阶段上简化您的代码。