(一) SwiftUI - 声明式语法分析

2,776 阅读8分钟

首先来说一下个人对 SwiftUI 的认知,相信很多人只是简单的以为 SwiftUI 是一个 UI 框架,采用声明式的语法可以轻松编写界面,如果 SwiftUI 只是一个 UI 框架,那就把它想的太简单了!!!当我学习 SwiftUI 之后,发现 SwiftUI 并不只是一个 UI 框架,更重要的是编程思想,其数据流式的编程思想与传统 UIKit 交互式编程,思想上有着天壤之别,结合 combine 编程思想,开发人员不再需要费力维护数据、视图之间的同步工作,可以把精力放到业务本身。

对于 SwiftUI 的介绍,会从其声明式的语法、数据流编程思想、语法中用到的 Swift 语言特性分开介绍:

SwiftUI 声明式语语法分析

一、声明式 API

随着编程思想进步,声明式思想已经成为主流。使用声明式开发只需要使用 DSL 描述页面是什么样,界面就搭建完成,而不用像传统 UIKit 框架一样,需要手动指导界面长什么样子:

图片替换文本

如果用 UIKit 显示一个标签则需要自己编写很多代码

func viewDidLoad() {
     super.viewDidLoad()
     let label = UILabel()
     label.text = "Hello World"
     view.addSubview(label)
 }

使用 SwiftUI 只需告诉框架,显示一个文本标签

struct BlogTextView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

框架内部会读取视图结构的描述,并渲染出界面。

由于上面只是对视图结构的描述,并不是真实显示的视图,所以我们不能直接拿到显示的控件,修改控件显示的内容。

如果需要修改控件中的内容,我们需要将控件的状态保存在变量中,例如下面这样:

struct BlogTextView: View {
    @State var text: String = "Hello World!"
    var body: some View {
        Text(text)
    }
}

当变量状态发生改变,SwiftUI 会更新声明部分的 UI,和之前的界面对比,进行高效绘制。

二、 语法细节

struct BlogTextView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

SwiftUI 能够利用上面简介的 DLS 语言搭建出界面,利用到了 Swift 语言的很多特性。

1. 不透明的返回值类型

上面例子中, View 只是 SwiftUI 中的一个协议(定义如下),

public protocol View {
    associatedtype Body : View
    @ViewBuilder var body: Self.Body { get }
}

根据 Swift 语法,带有 associatedtype 的协议只能用于类型约束,不能用于返回值。

// Error
func createView() -> View {

}

// OK
func createView<T: View>() -> T {
    
}

在 SWift 5.1 之前,var body: some View {} 的写法会编译报错(View can only be used as a generic constraint),body 的返回值类型必须指定具体的返回类型:

struct ContentView: View {
    var body: Text {
        Text("Hello, world!")
    }
}
// or
struct ContentView: View {
    var body: AnyView {
        AnyView(Text("Hello, world!"))
    }
}

引入 some 关键字后,可以返回遵守 View 的任何值,不必频繁修改 body 方法的具体返回值类型。而 var body: some View{} 方法能将 View 作为返回值,正是用了 some 关键字。这一特性是在 SE-0244 中被引入,some 关键字可以让方法、下标(subscripts)、计算属性声明为不透明的返回值。

2. 省略 return

struct ContentView : View {
    var body: some View {
        Text("Hello World")
    }
}

上面 body 里返回一个 Text,但并没有写 return 关键字。这是 Swift 的一个特性(SE-0255),出于语法简介性的考虑,当方法、计算属性或者闭包内只含有一个表达式时,return 关键字可以省略,上面代码的写法与下面写法效果是一样的:

struct ContentView: View {
    var body: some View {
        // Using an explicit return keyword
        return Text("Hello, world!")
    }
}

3. 尾随闭包

如果方法的最后一个参数是个闭包,那么闭包可以放到圆括号外面,例如,创建 VStack 的时候,下面的代码是一样的:

var body: some View { 
    VStack {         // 使用“尾随闭包”
        Text("Hello World")
        Text("Title")
    }
}

var body: some View {
    VStack(content:{ // 不使用“尾随闭包”
        Text("Hello World")
        Text("Title")
    })
}

4. function builder

了解 some 和省略 return 特性后,对于 body 返回单个标签的情况就很好理解了。界面上需要显示多个控件该怎么处理呢?由于 body 方法只是返回一个 View 类型,当要显示多个控件时,这就需要将多个控件组装到容器控件中了。例如这样:

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello World")
            Text("Title")
        }
    }
}

VStack 遵守 View 协议,上述 body 方法其实只是返回了一个 view (VStack)。 创建 VStack 的语法比较有意思,在 VStack 后的花括号中可以直接声明多个 Text 标签,为什么能够支持声明式语法呢?

看一下 VStack 的定义

public struct VStack<Content> : View where Content : View {
    @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)
    public typealias Body = Never
}

VStack 的初始化方法中,alignmentspacing 参数很好理解,content 参数是一个 () -> Content 闭包类型,闭包内需要返回一个 Content,我们在创建 VStack 的时候传入的闭包中只是写了2个 Text ,闭包中什么都没有返回 Content,为什么不报错呢?

这里使用的是 Swift 5.1 的 function builder,最后一个参数 content 前面有一个 @ViewBuilder 标记,这是一个 用 @_functionBuilder 标记的结构体:

@_functionBuilder public struct ViewBuilder {
    public static func buildBlock() -> EmptyView
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}

ViewBuilder@_functionBuilder 标记后,就可以对其他内容进行标记,content 参数被 ViewBuilder 标记了,content 在使用时会调用 ViewBuilder 中相应的 static func buildBlock 方法并返回一个 Content。

VStack {   
   Text("Hello World")
   Text("Title")
}

上面的代码实际上会转化为下面的形式:

VStack {
  let _a = Text("Hello")
  let _b = Text("World")
  return ViewBuilder.buildBlock(_a, _b)
}

ViewBuilder 重载了很多 buildBlock 方法,上面的代码调用 ViewBuilder 中接收 2 个参数的 buildBlock 方法并返回一个 TupleView。在 ViewBuilder 内重载了很多buildBlock 方法,最多的方法可以接收 10 个参数,所以在 VStack 内部,最多可以声明 10 个控件。

ViewBuilder 的方法:

@_functionBuilder public struct ViewBuilder {
    public static func buildBlock() -> EmptyView
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
    
    public static func buildIf<Content>(_ content: Content?) -> Content? where Content : View

    public static func buildEither<TrueContent, FalseContent>(first: TrueContent) -> _ConditionalContent<TrueContent, FalseContent> where TrueContent : View, FalseContent : View

    public static func buildEither<TrueContent, FalseContent>(second: FalseContent) -> _ConditionalContent<TrueContent, FalseContent> where TrueContent : View, FalseContent : View

    public static func buildLimitedAvailability<Content>(_ content: Content) -> AnyView 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

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

    public static func buildBlock<C0, C1, C2, C3, C4>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> TupleView<(C0, C1, C2, C3, C4)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View
    public static func buildBlock<C0, C1, C2, C3, C4, C5>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> TupleView<(C0, C1, C2, C3, C4, C5)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6) -> TupleView<(C0, C1, C2, C3, C4, C5, C6)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View

    public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7, C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View, C7 : View, C8 : View, C9 : View
}

我们不能看到 ViewBuilder 内部的源码,我们也可以用 @_functionBuilder 修饰一个 struct, 实现自己的 builder,体验 functionBuilder 的功能。 我们可以自定义一个类型,用 @_functionBuilder 标注一下,然后实现实现一些指定的方法,让我们自定义的类型具有各种不同的功能。

例如,实现一个拼接字符串的功能:

@_functionBuilder
struct StringBuilder {
    static func buildBlock(_ items: String...) -> String {
        var string: String = ""
        items.forEach { string += $0 }
        return string
    }
}
@StringBuilder
func testStringBuilder() -> String {
    "1"
    "2"
    "3"
}

testStringBuilder() // "123"

buildBlock 方法接收字一系列符串,把字符串拼接起来并返回。当调用 testStringBuilder() 方法时,StringBuilder 中的 buildBlock 就会被调用。一个简单的 StringBuilder 就可以用来标记其他类型了。

Swift 并没有实现各种情况的条件判断,如果像下面这样使用 if 判断,编译器会报错。

let testCondition = false
@StringBuilder
func testStringBuilder() -> String {
    "1"
    "2"
// Error: Closure containing control flow statement cannot be used with function builder 'StringBuilder'
    if testCondition {
        "true"
    }
}

接下来,怎么能在闭包里添加条件判断功能呢? 如果想添加 if 判断,需要实现 buildIf 方法,编译器会对单独的 if 语句做转换。添加的方式就是这样

extension StringBuilder {
    static func buildIf(_ items: String?) -> String {
        items ?? ""
    }
}

添加完上述方法后,就可以正常编译了。但我们也需要支持 if/else 判断,这需要添加两个 buildEither 方法,一个方法里返回 first,另一个方法里返回 second,其实是对应 if/else 的两个分支。

static func buildEither(first: String) -> String {
    first
}
static func buildEither(second: String) -> String {
    second
}

这样,我们就可以添加 else 分支的语句了:

let testCondition = false
@StringBuilder
func testStringBuilder() -> String {
    "1"
    "2"
    if testCondition {
        "true"
    } else {
        "false"
    }
}

Swift 5.3 之后,实现 buildEither 方法后,就可以支持 switch 语句了,而且不需要添加其他方法。

三、 小结

可以看到,SwiftUI 能够支持声明式语法,利用了很多 Swift 的语言新特性,最重要的便是 function builders,正式利用了 Swift 众多语言特性,才使得声明式语法成为可能。