VisionPro所需的SwiftUI


1. 背景

在我们开发 Vision Pro 开发之前,先大致了解下 SwiftUI 框架是非常有必要的,不然很多系统的新特性苹果往往只会提供 SwiftUI 的演示代码,甚至有些 API 只能 SwiftUI 才能使用。所以想要更好的适配 Vision Pro 最好的方式是用 SwiftUI 进行开发。

SwitUI 几个特点相比大家都有所耳闻:声明式编程、数据驱动、自适应布局。我们来看下数据驱动的 UI 框架一个经典的公式。

这个公式就一目了然了,UI 的展示就是数据状态的一个函数映射,所有的数据驱动的 UI 框架都遵循这个原则。那么 SwiftUI 的框架又是什么样子的呢?下面针对 SwiftUI 几个重要特征的由来做个介绍,限于篇幅详情的学习可以参看 官方文档

2. DSL语法


struct ContentView: View {
    
    var body: some View {
        Text("Hello world")
        Image(systemName: "shareplay")
        .foregroundColor(.white)
        .frame(width: 28,height: 28)
    }
}

对于熟悉 Swift 语言,但是初次接触 SwiftUI 的同学来说,上面的语法大概率会有下面的疑惑:“为啥 body 这个函数没有 return 任何变量?” 假如说我们把上面的这段代码,改为下面的形式,相信做过 iOS 开发的同学一眼都能看明白。


struct ContentView: View {
    
    var body: some View {
        let textView = Text("Hello world")
        let imageView = Image(systemName: "shareplay")
        .foregroundColor(.white)
        .frame(width: 28,height: 28)
        let container = View()
        container.addSubView(textView)
        container.addSubView(imageView)
        return container
    }
}

其实这种语法就是 SwifUI 描述视图布局的领域专用语言 (DSL),在编译时就会把这种 DSL 转换成我们上面可以看懂的视图布局方式。DSL 的好处就是简化语法,只需要描述问题即可不用提供问题的解决过程,然后框架层会自动生成解决问题的繁琐代码。这看起来其实有点类似数据库的 SQL 语句,只要描述下我要解决的问题即可,大大节省了代码量,让代码可读性也变的更好。

那么就有下个疑问了,SwiftUI 这种 DSL 语法是如何构建的呢?答案是通过 ViewBuilder 构建出来的。要了解 ViewBuilder 这里我们就需要了解 Swift 语言标记语法了,何为标记语法?说白了就是可以帮我们在语法树中插入一段我们自定义的代码,方便我们简化代码的结构,我简单的用下面的图示表示。

其中图 1 就代表正常的语法树,而图 2 就代表我们自定义的语法树,标记语法就相当于给了我们一个能力,把语法树上的一个节点替换成我们自定义的语法树的内容。


public protocol View {

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required ``View/body-swift.property`` property.
    associatedtype Body : View

    /// The content and behavior of the view.
    ///
    /// When you implement a custom view, you must implement a computed
    /// `body` property to provide the content for your view. Return a view
    /// that's composed of built-in views that SwiftUI provides, plus other
    /// composite views that you've already defined:
    ///
    ///     struct MyView: View {
    ///         var body: some View {
    ///             Text("Hello, World!")
    ///         }
    ///     }
    ///
    /// For more information about composing views and a view hierarchy,
    /// see <doc:Declaring-a-Custom-View>.
    @ViewBuilder @MainActor var body: Self.Body { get }
}

上面这段代码就是 body 的声明,可以看出来这个函数传递的参数是一个 ViewBuilder 。然后 SwiftUI 通过 ViewBuilder 的能力重新构造语法树,从而把添加视图的过程给实现了。ViewBuilder 是通过 Swift 语言中 ResultBuilder 这个标记语法能力构建的,ResultBuilder 是 Swift 语言提供给开发者构建自定义 DSL 的能力。而 ResultBuilder 这个语法又是从 FunctionBuilder 延伸出来的,他们的演化历史简单的表述如下:


function builder --> result builder --> view builder

如果你想要详细了解苹果标记语法的能力,建议你观看 apple result builder 这个 WWDC 的视频会有详细的讲解,这里不再赘述。

通过上面的分析过程,我们其实可以归纳出,支持高阶函数的语言,本质上构建这种语法应该都不困难。在前端开发看来,SwiftUI 这种 DSL 是他们很早就具备的能力,例如 React 和 VUE 都有自己定义的 DSL。并且像 google 的跨平台框架 Flutter 也是用的声明式的标记语法实现的,还有最近华为的纯血鸿蒙系统,前端 UI 构建也采用了声明式的框架。可见声明式的 UI 框架还是非常受欢迎的。

3. 视图树

下面我们就来看下 SwiftUI 的视图树是如何通过上述的 DSL 构建的。

3.1 链式语法

我们来看一个简单的例子如下:


Text("Hello")
.padding()
.backgound(Color.blue)

我们再来看下这段代码,我们把链式调用的位置交换了下,代码如下:


Text("Hello") 
.background(Color.blue) 
.padding()

来看下两段代码运行的结果:左一就是第一段代码生成的视图,右一是第二段代码的视图。 Image Description

为什么会不同呢?分析这个视图的构建过程,SwiftUI 的视图构建是一个递归的过程,根据视图树上面所有子视图的尺寸决定视图的大小,再根据链式调用自下而上的构建相应的视图,每个链式操作其实都会创建一个子视图,第一段代码的构建过程如下: Image Description 可以看出来确定完视图大小后,会先放置 .backgound 这个子视图,然后再放置它的子视图 .padding.color,最后的 .text 是属于 .padding 的子视图,所以自然背景色范围就比较广。下图就是第二段代码的构建过程。 Image Description 可以看出来是先构建了 .padding 这个子视图,然后再放置 .backgound 子视图,自然背景就被裁剪掉了。 每个链式调用本质上就是创建一个子视图,然后自下而上构建视图的 ,记住这个构建规则,对于你写 SwiftUI 的代码会有很大的帮助。那我们可以自定义链式语法的函数么?这就涉及到另一个概念「修饰器」,下面我们来看下。

3.2 修饰器 (ViewModifer)

修饰器相当于给视图增加了扩展能力。下面就是扩展视图背景的一个例子。


enum BGType:Int {
    case clear = 0
    case glass = 1
    case meterial = 2
    case pureColor = 3
}

extension View {
func dtBackgroundEffect(
        effectType:BGType = .clear
    ) -> some View {
        self.modifier(
            dtBackgroundEffectModifier(effectType: effectType)
        )
    }
}

private struct dtBackgroundEffectModifier: ViewModifier {
    var effectType:BGType = .clear
    func body(content: Content) -> some View {
        switch effectType {
        case .clear:
            content.background(.clear)
        case .glass:
            content
                .glassBackgroundEffect(displayMode: .always)
        case .meterial:
            content
                .background(.regularMaterial)
        case .pureColor:
            content
                .background(Color(uiColor: UIColor.dt_color(withHexString: "29231F")))
        }
    }
}

这段代码的 switch case 语法给修饰器创建了不同的视图,这样调用的地方就只要一行代码 Text("name").dtBackgroundEffect(effectType:effect) 就可以了,避免了写大量这种重复的代码,对于代码复用这种方式非常有用。不过看到这里你可能会有疑问, 扩展视图能力类似下面这样的写法不是也可以么?


extension View {
func dtBackgroundEffect(
        effectType:BGType = .clear
    ) -> some View {
        var effectType:BGType = .clear
        switch effectType {
        case .clear:
            return self.background(.clear)
        case .glass:
            return self
                .glassBackgroundEffect(displayMode: .always)
        case .meterial:
            return self
                .background(.regularMaterial)
        case .pureColor:
            return self
                .background(Color(uiColor: UIColor.dt_color(withHexString: "29231F")))
        }
    }
}

这段代码初看没啥问题,但是编译的话就会报错 Function declares an opaque return type 'some View', but the return statements in its body do not have matching underlying types 。从编译报错里面可以明确看出,虽然返回的都是 some View 但是 Swift 语言对类型检测非常严格的,我们来看下 Background 定义。


@inlinable public func background<S>(_ style: S, ignoresSafeAreaEdges edges: Edge.Set = .all) -> some View where S : ShapeStyle

这里返回的 some view 类型会有一个传递的模版参数 S 的定义,如果模版类型不一致的话 Swift 就会认为他们类型本质上不一样的。如何来解决这个问题,其实我们只要增加 ViewBuilder 标记语法,用如下的方式就可以编译通过了。


extension View {
@ViewBuilder
func dtBackgroundEffect(
        effectType:BGType = .clear
    ) -> some View {
        var effectType:BGType = .clear
        switch effectType {
        case .clear:
            self.background(.clear)
        case .glass:
            self
                .glassBackgroundEffect(displayMode: .always)
        case .meterial:
            self
                .background(.regularMaterial)
        case .pureColor:
            self
                .background(Color(uiColor: UIColor.dt_color(withHexString: "29231F")))
        }
    }
}

这里就可以看出来 ViewBuilder 其实会重新组装我们的 View 成为一个确定的类型,就不会报错了。那么问题来了,ViewModifer 和用普通的 Extension Function 来扩展有什么大的区别呢?这里简单描述下用法的区别。

  1. 如果你代码重用的部分比较复杂,涉及到很多条件创建不同的 View,这时候就适合 ViewModifer ,因为在性能方面,ViewModifier它可以利用 SwiftUI 的优化机制,避免不必要的视图层次重建。而我们自定义的扩展就没这个能力了。

  2. 如果你定义的方法只用在特定的一个 View 上,不需要大量的重用,并且扩展相对比较简单,其实就没必要使用 ViewModifer。

像上面这种需要用 switch case 来创建各种不同的视图来修饰的话,就比较适合用 ViewModifer 了,视图树的构建方式讲到这里,那么数据如何驱动这些视图渲染呢?下面就开始分析下 SwiftUI 的状态管理。

4. 状态管理

我们先来看下面这段代码,SwiftUI 是如何映射数据状态到视图树上面的?

@state var showChinese = false
Text(showChinese ? "你好" : "Hello")
.padding()
.backgound(showChinese ? Color.red : Color.blue)

我简单的画了一个数据和视图树的依赖关系图。

Image Description

从上面的代码可以看出,模型是通过 @state 这种语法来映射到 SwiftUI 的视图树上的,那么 @state 的作用我们下面来简单分析下。

4.1 数据驱动的标记语法

SwiftUI 中@state 这种语法,其实属于属性包装器 (Property Wrapper),也是 Swift 语言的一个语法特性。如果想要详细了解这个语法的话,可以参看 官方文档。它的作用就是当标记的数据有变化时,通知视图树来刷新 UI。

类似这种属性包装器的语法,SwiftUI 还有很多个,@State @StateObject @Binding @ObservedObject 。记住这些语法都是用来做数据驱动 UI 使用的。至于这些属性包装器具体如何使用,下面列出来了一些规则可供参考。

  1. 初始化属性 如果属性无法在声明时进行初始化,而是需要接收父节点的数据,应该考虑使用 @Binding@ObservedObject,而不是 @State@StateObject
  2. 普通属性 vs. 属性包装器 尽可能在视图中使用普通属性,当仅需将值传递到视图中时,不需要使用属性包装器。
  3. @State 当视图需要对一个值进行读写访问,并且该值作为本地的私有视图状态时,应该使用 @State
  4. @Binding 当视图需要对一个值进行读写访问,但该值不属于视图本身,而是由外部传递进来时,应该使用 @Binding
  5. @StateObject 和 @ObservedObject 当视图需要以对象的形式拥有状态,并且这个状态是本地私有的视图状态时,应该使用 @StateObject。当需要从外部传入一个对象时,应该使用 @ObservedObject

注意:上面这样原则其实是 iOS17 之前需要遵守的,之后语法有了相应的更新,使用起来会更加的简单。等下我们就会讲到 iOS17 带来的更新。

在我们定义模型对象时,还有个重要的概念要讲 Identifiable Protocol (身份协议)。因为 SwiftUI 所有的视图树都是值类型,不同于 UIKit 每个 UIView 在内存中都有唯一的地址。而 SwiftUI 中的 View 是和映射的模型绑定到一起,框架是依靠模型的不同来确定是否为不同的视图,而如何确定模型的身份就是靠 Identifiable Protocol 这个协议,我们我就拿官网的示例代码展示下,Identifiable 作用。


enum Animal { case dog,cat }

struct Pet:Identifiable {
    var name:String
    var kind:Animal
    var id: UUID { UUID() }
    var dataBaseID:Int
}

struct FavoritePets: View {
    var pets:[Pet]
    var body: some View {
        List {
            ForEach(pets) {
                PetView {$0}
            }
        }
    }
}

上面这个方式构建的 SwiftUI 的视图会出现一个问题,每次向 pets 中添加新的模型时,整个列表都会刷新,所有的视图重新创建了。再看下面的代码。


struct FavoritePets: View {
    var pets:[Pet]
    var body: some View {
        List {
            ForEach(pets,id:.\dataBaseID) {
                PetView {$0}
            }
        }
    }
}

如果在创建视图时,改成上面的实现方式,给一个确定的 id 例如 dataBaseID 是一个持久化到数据库中。就不会出现上述的问题,所以我们在实现模型的 Identifiable 协议时,一定要牢记他就是对应 SwiftUI 的视图。

如果想要详细了解数据如何驱动视图树的原理,建议参看苹果 WWDC 揭开 SwiftUI 的神秘面纱

4.2 iOS17 之后的变化

为什么要讲 iOS17 之后 SwiftUI 的变化呢?因为 visionOS 使用的 SwiftUI 就是 iOS17 之后的。我们来看下对比 iOS17 前后状态驱动语法的变化。

  1. iOS17 之前
类型 根状态 子状态
值类型 @State @Binding
引用类型 @StateObject @ObservedObject
  1. iOS17 之后
类型 根状态 子状态
值类型 @State @Binding
引用类型 @State @Bindable

从上面可以看出来 iOS17 之后 SwiftUI 状态绑定的用法更简单了。iOS17 之前如果你想要一个全局的数据源,驱动 UI 变化需要写下面的代码?


import SwiftUI
import Combine

// 1. 定义全局数据源
class GlobalData: ObservableObject {
    @Published var counter: Int = 0
    static let shared = GlobalData() // 单例对象
}

struct ContentView: View {
    // 2. 订阅数据变化
    @ObservedObject var globalData = GlobalData.shared
    
    var body: some View {
        VStack {
            Text("Counter: \(globalData.counter)")
            Button("Increment") {
                GlobalData.shared.counter += 1 // 修改全局数据源的值
            }
        }
    }
}

iOS17 之后的 Swift 语法有个重大更新,就是支持了宏。然后在 SwiftUI 中, 新增了 @Observable 宏定义,开发者完全不用使用 Combine 框架的数据绑定能力了,只需要简单的写下面的代码:


@Observable
class GlobalData {
    var counter: Int = 0
    static let shared = GlobalData() // 单例对象
}

再使用 iOS17 之后 SwiftUI 为 View 新增的 environment API,创建 View 的时候简单的调用 ContentView.environment(self.GlobalData.shared),然后视图就可以用下面的代码监听数据变化。


extension View {
    @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
    public func environment<T>(_ object: T?) -> some View where T : AnyObject, T : Observable
}
struct ContentView: View {
    // 订阅数据变化
    @Environment(GlobalData.self) private var globalData: GlobalData
    
    var body: some View {
        VStack {
            Text("Counter: \(globalData.counter)")
            Button("Increment") {
                GlobalData.shared.counter += 1 // 修改全局数据源的值
            }
        }
    }
}

那如果说我们创建的 Model 某些属性不需要监听怎么办?比较好的方式定义为 let 常量,如果说需要变量存储中间状态可以使用 @ObservationIgnored 这种标记语法。

@Observable
class GlobalData {
    var counter: Int = 0
    @ObservationIgnored private var tempValue = false
    static let shared = GlobalData() // 单例对象
}

这样当 tempValue 变化时就不会触发通知,对控制 UI 频繁刷新很有用途。

5. 总结

如果上面说的这些能力都掌握的话,开发 SwiftUI 基本就没什么问题了。虽然说 SwiftUI 可以大大节省 UI 的开发量。但是开发过程中遇到的棘手问题就是调试。在 UIKit 所有的 UIView 都有内存地址,来确定某个视图什么时候创建、销毁、以及放置到那里。所以 UIKit 调试的时候,只要看下内存地址就很方便定位是那个视图,但是 SwiftUI 中的视图 View 你无法打印内存地址,就像上文说的你只能跟踪他的模型状态,但是在 Debug 过程中,往往视图的变化堆栈不会有模型变化的堆栈,在调试一些 UI 层级问题时就会遇到很多问题。

如果你喜欢这篇文章,谢谢你的赞赏

图3

如有疑问请联系我