什么使 SwiftUI 的 DSL 成为可能?

Posted

技术标签:

【中文标题】什么使 SwiftUI 的 DSL 成为可能?【英文标题】:What enables SwiftUI's DSL? 【发布时间】:2019-06-03 21:07:11 【问题描述】:

Apple 的新 SwiftUI 框架似乎使用了一种新的语法,可以有效地构建元组,但有另一种语法:

var body: some View 
    VStack(alignment: .leading) 
        Text("Hello, World") // No comma, no separator ?!
        Text("Hello World!")
    

试图弄清楚这个语法到底是什么,我发现这里使用的 VStack 初始化器采用了 () -> Content 类型的闭包 作为第二个参数,其中Content 是符合View 的通用参数,通过闭包推断。为了找出 Content 被推断为什么类型,我稍微更改了代码,保留了它的功能:

var body: some View 
    let test = VStack(alignment: .leading) 
        Text("Hello, World")
        Text("Hello World!")
    

    return test

这样,test 显示自己属于VStack<TupleView<(Text, Text)>> 类型,这意味着Content 属于TupleView<Text, Text> 类型。查找TupleView,我发现它是源自SwiftUI 本身的包装器类型,只能通过传递它应该包装的元组来初始化。

问题

现在我想知道这个示例中的两个Text 实例到底是如何转换为TupleView<(Text, Text)> 的。这是否被入侵到 SwiftUI 并因此 无效的常规 Swift 语法? TupleViewSwiftUI 类型支持这个假设。或者这是有效的 Swift 语法吗?如果是,如何SwiftUI 之外使用它?

【问题讨论】:

developer.apple.com/documentation/swiftui/vstack/3278367-init 表示有一个“自定义属性”@ViewBuilderdeveloper.apple.com/documentation/swiftui/viewbuilder。 在forums.swift.org/t/pitch-introduce-custom-attributes/21335 和forums.swift.org/t/pitch-static-custom-attributes-round-2/22938 的 Swift 论坛上讨论过。 【参考方案1】:

As Martin says,如果您查看VStackinit(alignment:spacing:content:) 的文档,您可以看到content: 参数具有@ViewBuilder 属性:

init(alignment: HorizontalAlignment = .center, spacing: Length? = nil,
     @ViewBuilder content: () -> Content)

这个属性指的是ViewBuilder类型,如果你看一下生成的界面,看起来像:

@_functionBuilder public struct ViewBuilder 

    /// Builds an empty view from an block containing no statements, ` `.
    public static func buildBlock() -> EmptyView

    /// Passes a single view written as a child view (e..g, ` Text("Hello") `)
    /// through unmodified.
    public static func buildBlock(_ content: Content) -> Content 
      where Content : View

@_functionBuilder 属性是名为“function builders”的非官方功能的一部分,该功能已被称为pitched on Swift evolution here,专门为 Xcode 11 附带的 Swift 版本实现,允许在 SwiftUI 中使用.

标记类型@_functionBuilder 允许它用作各种声明的自定义属性,例如函数、计算属性以及在这种情况下为函数类型的参数。此类带注释的声明使用函数构建器来转换代码块:

对于带注释的函数,被转换的代码块就是实现。 对于带注释的计算属性,被转换的代码块是 getter。 对于函数类型的注释参数,被转换的代码块是任何传递给它的闭包表达式(如果有的话)。

函数构建器转换代码的方式由它的builder methods 实现定义,例如buildBlock,它采用一组表达式并将它们合并为一个值。

例如,ViewBuilder 为 1 到 10 个 View 一致性参数实现 buildBlock,将多个视图合并为一个 TupleView

@available(ios 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension ViewBuilder 

    /// Passes a single view written as a child view (e..g, ` Text("Hello") `)
    /// through unmodified.
    public static func buildBlock<Content>(_ content: Content)
       -> Content where Content : View

    public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) 
      -> TupleView<(C0, C1)> where C0 : View, C1 : View

    public static func buildBlock<C0, C1, C2>(_ c0: C0, _ c1: C1, _ c2: C2)
      -> TupleView<(C0, C1, C2)> where C0 : View, C1 : View, C2 : View

    // ...

这允许传递给VStack 的初始化程序的闭包中的一组视图表达式转换为对buildBlock 的调用,该调用采用相同数量的参数。例如:

struct ContentView : View 
  var body: some View 
    VStack(alignment: .leading) 
      Text("Hello, World")
      Text("Hello World!")
    
  

转化为对buildBlock(_:_:)的调用:

struct ContentView : View 
  var body: some View 
    VStack(alignment: .leading) 
      ViewBuilder.buildBlock(Text("Hello, World"), Text("Hello World!"))
    
  

导致opaque result type some ViewTupleView&lt;(Text, Text)&gt; 满足。

你会注意到ViewBuilder只定义了buildBlock最多10个参数,所以如果我们尝试定义11个子视图:

  var body: some View 
    // error: Static member 'leading' cannot be used on instance of
    // type 'HorizontalAlignment'
    VStack(alignment: .leading) 
      Text("Hello, World")
      Text("Hello World!")
      Text("Hello World!")
      Text("Hello World!")
      Text("Hello World!")
      Text("Hello World!")
      Text("Hello World!")
      Text("Hello World!")
      Text("Hello World!")
      Text("Hello World!")
      Text("Hello World!")
    
  

我们收到编译器错误,因为没有构建器方法来处理此代码块(请注意,由于此功能仍在进行中,因此围绕它的错误消息不会有太大帮助)。

实际上,我不相信人们会经常遇到这种限制,例如,上面的示例最好使用ForEach 视图代替:

  var body: some View 
    VStack(alignment: .leading) 
      ForEach(0 ..< 20)  i in
        Text("Hello world \(i)")
      
    
  

如果您确实需要超过 10 个静态定义的视图,您可以使用 Group 视图轻松解决此限制:

  var body: some View 
    VStack(alignment: .leading) 
      Group 
        Text("Hello world")
        // ...
        // up to 10 views
      
      Group 
        Text("Hello world")
        // ...
        // up to 10 more views
      
      // ...
    

ViewBuilder 还实现了其他函数构建器方法,例如:

extension ViewBuilder 
    /// Provides support for "if" statements in multi-statement closures, producing
    /// ConditionalContent for the "then" branch.
    public static func buildEither<TrueContent, FalseContent>(first: TrueContent)
      -> ConditionalContent<TrueContent, FalseContent>
           where TrueContent : View, FalseContent : View

    /// Provides support for "if-else" statements in multi-statement closures, 
    /// producing ConditionalContent for the "else" branch.
    public static func buildEither<TrueContent, FalseContent>(second: FalseContent)
      -> ConditionalContent<TrueContent, FalseContent>
           where TrueContent : View, FalseContent : View

这使它能够处理 if 语句:

  var body: some View 
    VStack(alignment: .leading) 
      if .random() 
        Text("Hello World!")
       else 
        Text("Goodbye World!")
      
      Text("Something else")
    
  

变成:

  var body: some View 
    VStack(alignment: .leading) 
      ViewBuilder.buildBlock(
        .random() ? ViewBuilder.buildEither(first: Text("Hello World!"))
                  : ViewBuilder.buildEither(second: Text("Goodbye World!")),
        Text("Something else")
      )
    
  

(为了清楚起见,向 ViewBuilder.buildBlock 发出冗余的 1 参数调用)。

【讨论】:

ViewBuilder 只定义 buildBlock 最多 10 个参数 - 这是否意味着 var body: some View 不能有超过 11 个子视图? @LinusGeffarth 实际上,我认为人们不会经常遇到这种限制,因为他们可能希望使用类似ForEach 的视图来代替。但是,您可以使用 Group 视图来解决此限制,我已编辑我的答案以显示这一点。 @MandisaW - 您可以将视图分组到您自己的视图中并重复使用它们。我看不出有什么问题。实际上,我现在在 WWDC,并与 SwiftUI 实验室的一位工程师进行了交谈 - 他说这是 Swift 目前的一个限制,他们选择了 10 作为一个明智的数字。一旦可变参数泛型被引入到 Swift 中,我们将能够拥有尽可能多的“子视图”。 也许更有趣,buildEither 方法的意义何在?看来您需要同时实现两者,并且都具有相同的返回类型,为什么它们不都只返回有问题的类型? 跟进我对 ASTPrinter 错误的评论,this will be fixed on master once the function builders PR has been merged。【参考方案2】:

What's New in Swift WWDC video 在关于 DSL 的部分中描述了类似的事情(从 ~31:15 开始)。该属性由编译器解释并翻译成相关代码:

【讨论】:

以上是关于什么使 SwiftUI 的 DSL 成为可能?的主要内容,如果未能解决你的问题,请参考以下文章

如何使按钮的可点击区域成为按钮框架

如果超过视图高度,SwiftUI + ScrollView 无法添加视图

SwiftUI 致命错误:未找到“”类型的 ObservableObject

如何在 SwiftUI 中使用 Realm

Ruby 中的简单 DSL

路由交换学习第八天:SW1成为所有VLAN的主根,SW2成为所有VLAN的备份根