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()
来看下两段代码运行的结果:左一就是第一段代码生成的视图,右一是第二段代码的视图。
为什么会不同呢?分析这个视图的构建过程,SwiftUI 的视图构建是一个递归的过程,根据视图树上面所有子视图的尺寸决定视图的大小,再根据链式调用自下而上的构建相应的视图,每个链式操作其实都会创建一个子视图,第一段代码的构建过程如下:
可以看出来确定完视图大小后,会先放置 .backgound
这个子视图,然后再放置它的子视图 .padding
和 .color
,最后的 .text
是属于 .padding
的子视图,所以自然背景色范围就比较广。下图就是第二段代码的构建过程。
可以看出来是先构建了 .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 来扩展有什么大的区别呢?这里简单描述下用法的区别。
-
如果你代码重用的部分比较复杂,涉及到很多条件创建不同的 View,这时候就适合 ViewModifer ,因为在性能方面,ViewModifier它可以利用 SwiftUI 的优化机制,避免不必要的视图层次重建。而我们自定义的扩展就没这个能力了。
-
如果你定义的方法只用在特定的一个 View 上,不需要大量的重用,并且扩展相对比较简单,其实就没必要使用 ViewModifer。
像上面这种需要用 switch case 来创建各种不同的视图来修饰的话,就比较适合用 ViewModifer 了,视图树的构建方式讲到这里,那么数据如何驱动这些视图渲染呢?下面就开始分析下 SwiftUI 的状态管理。
4. 状态管理
我们先来看下面这段代码,SwiftUI 是如何映射数据状态到视图树上面的?
@state var showChinese = false
Text(showChinese ? "你好" : "Hello")
.padding()
.backgound(showChinese ? Color.red : Color.blue)
我简单的画了一个数据和视图树的依赖关系图。
从上面的代码可以看出,模型是通过 @state
这种语法来映射到 SwiftUI 的视图树上的,那么 @state
的作用我们下面来简单分析下。
4.1 数据驱动的标记语法
SwiftUI 中@state
这种语法,其实属于属性包装器 (Property Wrapper),也是 Swift 语言的一个语法特性。如果想要详细了解这个语法的话,可以参看 官方文档。它的作用就是当标记的数据有变化时,通知视图树来刷新 UI。
类似这种属性包装器的语法,SwiftUI 还有很多个,@State @StateObject @Binding @ObservedObject
。记住这些语法都是用来做数据驱动 UI 使用的。至于这些属性包装器具体如何使用,下面列出来了一些规则可供参考。
- 初始化属性
如果属性无法在声明时进行初始化,而是需要接收父节点的数据,应该考虑使用
@Binding
或@ObservedObject
,而不是@State
或@StateObject
。 - 普通属性 vs. 属性包装器 尽可能在视图中使用普通属性,当仅需将值传递到视图中时,不需要使用属性包装器。
- @State
当视图需要对一个值进行读写访问,并且该值作为本地的私有视图状态时,应该使用
@State
。 - @Binding
当视图需要对一个值进行读写访问,但该值不属于视图本身,而是由外部传递进来时,应该使用
@Binding
。 - @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 前后状态驱动语法的变化。
- iOS17 之前
类型 | 根状态 | 子状态 |
---|---|---|
值类型 | @State | @Binding |
引用类型 | @StateObject | @ObservedObject |
- 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 层级问题时就会遇到很多问题。