SwiftUI 入门

SwiftUI 入门

SwiftUI  的官方入门教程:Creating and Combining Views

不得不说我觉得这个教程看起来很好但是实际真不咋滴,只说怎么用,不说为什么这么用,根本不考虑初学者的感受。另外该教程应该是 WWDC 2019 发布的,基于 Xcode 11。而在最新发布的 Xcode 12 beta 版本中,似乎 SwiftUI 有了自己的生命周期,不再依赖于 UIKit。

1. 项目结构

1.1 iOS 项目初始结构

在 Xcode 11.5 中新建一个 "iOS" - "Single View App" 项目,然后在 Xcode 左侧的 "Project Navigator" 可以看到项目的目录结构如下图所示:

• AppDelegate.swift 文件负责整个应用程序的生命周期。

在代码中可以看到对 AppDelegate 类使用了 @UIApplicationMain 特性(注意,我们通常将 Swift Attribute 翻译做“特性”,以与“属性” Property 区别开),该特性表示应用程序入口。

从 Delegate 这个名字可以看出它只是一个代理,底层还是依赖于 UIKit。

• SceneDelegate.swift 负责应用程序的场景显示。

AppDelegate 通过 Info.plist 中定义的 "Application Scene Manifest" 来找到启动场景,然后再由 SceneDelegate 来创建视图。我们可以在 SceneDelegate.swift 的代码中看到它创建了一个 ContentView 实例并设置为该场景的根视图控制器。

参考:iOS13 Scene Delegate详解

• ContentView.swift 就是要展示给用户看到的视图。对于 iOS 程序而言,应该是 App 》 Scene 》 Window 》 View。而对于 macOS 程序而言,就没有 Scene 在中间插一腿了(或者其实应该说是 macOS 程序只有一个默认场景)。

• Assets.xcassets 是资源目录,存放图标、图片等资源

• LaunchScreen.storyboard 是应用程序的启动界面

• Info.plist 是应用程序的配置属性,包括程序的版本、支持的设备、需要的系统权限等等。

• Preview Content 目录存放那些在 Preview 中要用到的而又不在主目录中的静态资源。有点像是 “mock” 资源,因为有些图片或者数据是只有程序运行时才获取的,如果想要在 Preview 中预览这些数据,就可以将它们放在这里。

• Products 目录存放的是最后编译生成的应用程序

1.2 macOS 项目初始结构

同样的在 Xcode 中新建一个 "macOS" - "App" 项目,得到的初始项目结构如下图:

• AppDelegate.swift 同样的,AppDelegate 也是 macOS 应用程序的入口。不过与 iOS App 不同的是,它用了 @NSApplicationMain 特性,依赖于 AppKit。并且在 applicationDidFinishLaunching() 函数中创建了 window 窗口,并设置根视图为 ContentView。

• ContentView.swift 就是要展示给用户的视图。

• Main.storyboard 文件

• LandmarksMac.entitlements 其实也是一个 plist 文件,定义了应用程序需要用到的系统服务。

2. 生命周期 LifeCycle

Xcode 11 版本的 SwiftUI 程序,我们可以在 AppDelegate.swift 文件中看出其生命周期其实是依赖于 UIKit (iOS App) 或者 AppKit (macOS App) 的。不知道未来正式发布的 Xcode 12 中的 SwfitUI 是否能彻底抛弃这些底层依赖。

3. 视图 View

SwiftUI 的核心概念应该就是视图了,它表示应用程序的用户界面。不管是自定义界面,还是文本、按钮、图标等这些基本 UI 控件也都必须满足 View 协议。

Show me the code,我们来看一下默认项目的视图代码吧。

import SwiftUI

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

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

这里面定义了两个结构体,一个是真正的视图 ContentView,一个是视图预览 ContentView_Previews。

struct ContentView: View 这一行很简单,表示 ContentView 实现 View 协议。(Swfit 语言中的协议 Protocol 等同于其他语言中的接口类型)

var body: some View { Text("Hello, World!") } 表示 body 是一个只读计算属性。some 关键字表示其类型是一个 Opaque Types,它要求 getter 函数返回一个满足 View 协议的实例。后面花括号是简化的 getter 函数体,展开的话应该是如下形式:

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

所以这个 body 计算属性得到的是一个 Text 实例,Text 结构体满足 View 协议。

3.1 视图修饰器 Modifier

教程的下一步,就是调用 Modifier 来修改视图的样式。要注意,Modifier 函数返回的是一个新的视图,而不是对原视图进行修改后返回。哦!对,因为这里的视图都是用结构体来声明的,在 Swift 中,结构体是值类型,而不是像类那样的引用类型!我之前还想说视图对象来着呢 =。=#,看来应该叫做视图实例,因为通常情况下“对象”这个词专门指代类的实例。

因为 Modifier 函数总会返回新的视图实例,所以可以通过链式调用的方法来连续调用多个 Modifier 函数:

Text("Turtle Rock")
    .font(.title)
    .foregroundColor(.green)

注意,在 Xcode 中,要打开预览面板后,才会在右边的 Inspectors 面板中看到 Attributes Inspector 面板,command + 点击代码中的 View 类型,才会出现 "Show SwiftUI Inspector", "Embed in ..." 等选项。

4. 在 Xcode 中预览 Preview

ContentView_Previews 结构体是为了在 Xcode 的 Canvas 面板中显示关联视图的预览。我们可以把这个结构体声明删除而不影响项目的编译结果。不过删除的话,在 Canvas 面板中就会提示 "No Preview"。点击 "Create Preview" 按钮,会自动重新生成 ContentView_Previews 代码。

4.1 在预览中进行调试

如果我们把 ContentView 的代码改成如下形式,让其在控制台中输出一条 "debug message":

import SwiftUI

struct ContentView: View {
    var body: some View {
        print("debug message")
        return Text("Hello, World!")
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        print("debug preview")
        return ContentView()
    }
}

默认的预览模式不会输出任何信息到控制台,除非直接运行代码。要想在预览的时候看到调试信息,可以右键点击(或者 control + 点击)预览面板的 Live Preview 按钮,即会弹出 Debug Preview 选项,选择 Debug Preview 即可。

(但是为什么会输出两次 "debug message" 我不是很懂=。 =? 我猜想是在 ContentView_Previews.previews 计算属性中创建了一次 ContentView 实例,这是应该的。然后后面又“莫名其妙”的创建了一次 ContentView,这次就不知道是为什么了。。。因为我试过把 ContentView_Previews 中的 return ContentView() 改成 return Text("abc"),它仍然会输出一次 "debug message",就是说它还是会构造一次 ContentView。。。ԅ། – ‸ – །ᕗ )

5. VStack

教程的下一步,是用一个 VStack 实例将两个 Text 包裹起来并返回给 ContentView.body。但是这个 VStack 的实例化调用看起来却特别奇怪。

struct ContentView: View {
    var body: some View {
        VStack {
            Text("Turtle Rock")
                .font(.title)
            Text("Joshua Tree National Park")
        }
    }
}

首先来看一下 VStack 构造函数的定义:

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

首先 VStack 是一个泛型,它实现 View 协议;同时后面的 where 字句表示它的泛型参数 Content 的实际类型也必须实现 View 协议。

该构造函数接受三个参数:

  • 第一个参数 alignment 的默认值为 HorizontalAlignment.center。
  • 第二个参数 spacing 的默认值为 nil。
  • 第三个参数 content 是一个闭包函数,该闭包函数无参并且返回 Content 实际类型的一个实例。

5.1 尾随闭包(Trailing Closures)

Swift 语法中规定如果函数的最后一个参数是闭包,那么在实际调用的时候,可以将闭包从 (实参列表) 这个括号中剥离出来放到括号后面。这样的用法就叫尾随闭包

func someFunctionThatTakesAClosure(a: String = "test", closure: () -> Void) {
    print(a + ",你好啊,我的第二个参数是个闭包")
    closure()
}

// 以下是不使用尾随闭包进行函数调用
someFunctionThatTakesAClosure(a: "hello", closure: {
    print("我是闭包1的函数体")
})

// 以下是使用尾随闭包进行函数调用
someFunctionThatTakesAClosure(a: "abc") {
    print("我是闭包2的函数体")
}

// 如果闭包是函数的唯一实参,那么甚至可以省略函数调用时候的括号()
someFunctionThatTakesAClosure {
    print("我是闭包3的函数体")
}

所以上面 VStack 的代码展开后就是:

struct ContentView: View {
    var body: some View {
        return VStack(alignment: HorizontalAlignment.center, spacing: nil, content: {
            Text("Turtle Rock")
                .font(.title)
            Text("Joshua Tree National Park")
        }) 
    }
}

5.2 @ViewBuilder

但是在上面代码中,在 VStack 构造器的闭包实参中并没有返回 Content 类型,而只是创建了两个 Text 结构体实例。这实际上是由于 content 参数前面标注的 @ViewBuilder 特性。这里不得不再吐槽一下苹果官方文档有多操蛋,找遍了 google 只在 stackoverflow 上找到关于 ViewBuilder 的稍微详细点的解释:stackoverflow

所以上面 VStack 的代码再展开后就是:

struct ContentView: View {
    var body: some View {
        return VStack(alignment: HorizontalAlignment.center,
                      spacing: nil,
                      content: {
                        return ViewBuilder.buildBlock(Text("Turtle Rock").font(.title),
                                                      Text("Joshua Tree National Park"))
        })
    }
}

另外由于编译器能明确从 return 语句中推断返回值类型是 TupleView<(Text, Text)>,所以可以省略 VStack<Content> 的这个类型参数。

所以上面 VStack 的代码完全展开就是:

struct ContentView: View {
    var body: some View {
        return VStack<TupleView<(Text, Text)>>(
            alignment: HorizontalAlignment.center,
            spacing: nil,
            content: {
                return ViewBuilder.buildBlock(Text("Turtle Rock").font(.title),
                                              Text("Joshua Tree National Park"))
        })
    }
}

6. List

在官方的第二篇教程《Building Lists and Navigation》中,使用了 List 控件,而这个 List 控件的构造器更奇怪了。

struct LandmarkList: View {
    var body: some View {
        List(landmarkData, id: \.id) { landmark in
            LandmarkRow(landmark: landmark)
        }
    }
}

我们先来看一下 List 的类型定义:

public struct List<SelectionValue, Content> : View where SelectionValue : Hashable, Content : View {

}

extension List where SelectionValue == Never {

    public init(@ViewBuilder content: () -> Content)
    
    public init<Data, ID, RowContent>(
        _ data: Data, 
        id: KeyPath<Data.Element, ID>, 
        @ViewBuilder rowContent: @escaping (Data.Element) -> RowContent ) 
        where Content == ForEach<Data, ID, HStack<RowContent>>, 
              Data : RandomAccessCollection, 
              ID : Hashable, RowContent : View
}

首先,List 是一个结构体泛型,并且满足 View 协议。它有两个泛型参数 SelectionValue 与 Conent,并且 SelectionValue 必须满足 Hashable, Conent 必须是一种 View。

然后在下面的扩展 List 中,where SelectionValue == Never 子句表示该扩展中不会用到 SelectionValue 泛型参数。或者说是将 List<SelectionValue, Content> 泛型限定为 List<Never, Content> 。

扩展 List 的第一个构造器用法如下:

List {
    LandmarkRow(landmark: landmarkData[0])
    LandmarkRow(landmark: landmarkData[1])
}

将上述代码展开的话就是如下形式:

List<Never, TupleView>(content: {
    return ViewBuilder.buildBlock(
        LandmarkRow(landmark: landmarkData[0]),
        LandmarkRow(landmark: landmarkData[1]))
})

扩展 List 的第二个构造器用法如下:

List(landmarkData, id: \.id) { landmark in
    LandmarkRow(landmark: landmark)
}

第一个实参 landmarkData 是一个 [Landmark] 数组,数组类型 Array<Element> 满足 RandomAccessCollection 协议。所以编译器能推断出这个 Data 泛型参数就是 Array<Landmark>,而 Data.Element 泛型参数实际就是 Landmark 类型。

第二个实参 \.id 是一个 Key-Path 表达式,同时省略了 Landmark 类型名,实际上应该写作 \Landmark.id。它会被编译器转换成一个 KeyPath<Landmark, Int> 类型实例。

第三个参数是一个 (Data.Element) -> RowContent 类型的闭包,即该闭包函数接受一个 Landmark 实例作为参数,返回一个 RowContent 实例。同时前面的 @escaping 表示该闭包是一个逃逸闭包

花括号内的 landmark in LandmarkRow(landmark: landmark) 则是闭包表达式的一种简写方式,把闭包的参数类型与返回值类型,还有 return 关键字都省略了。(wtf! 我真是服了 Swfit 了,太多简写省略推断和语法糖,说是方便程序员,实际上给初学者带来太大困扰了,我查了半天还以为这个 in 是什么 for 循环省略呢  (╯▔皿▔)╯ )它的完整形式应该是:

List(landmarkData, id: \Landmark.id) {
    (landmark_real: Landmark) -> LandmarkRow in
        return LandmarkRow(landmark: landmark_real)
}

如果还要再把 @ViewBuilder 特性展开的话,就是:

List(landmarkData, id: \Landmark.id) {
    (landmark_real: Landmark) -> LandmarkRow in
    return ViewBuilder.buildBlock(LandmarkRow(landmark: landmark_real))
}

然后 List 会根据 id 对 landmarkData 数组进行遍历,并调用这个逃逸闭包生成每一行视图。具体 SwiftUI 是怎么实现的我们就不得而知了。=。=

7. ForEach

ForEach 其实也是一种 View,它遍历一个集合并为每个元素生成一个 View,而 ForEach 就是这些子 View 的集合。这其实跟上面的 List 作用是一样的。不同的是 List 的子 View 可以是不同类型比如 Text、Button、Image的组合,而 ForEach 的子 View 应该只能是同一种的类型,毕竟它们都是根据同一个集合的元素生成的。参考:What is the difference between List and ForEach in SwiftUI?

下面是 ForEach 的定义:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public struct ForEach<Data, ID, Content> where Data : RandomAccessCollection, ID : Hashable {

    // 声明 data 数据集合
    public var data: Data

    // 声明 content 属性是一个 (Data.Element) -> Content 类型的闭包
    public var content: (Data.Element) -> Content
}

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
extension ForEach where Content : View {

    // 使用 id 遍历数据集 data,并对每个元素调用 content 闭包生成对应的 View。
    public init(_ data: Data, id: KeyPath<Data.Element, ID>, content: @escaping (Data.Element) -> Content)
}

8. SwiftUI 视图的数据传递

8.1 @State

@State 特性是 SwiftUI 定义的一个属性修饰器 (propertyWrapper),它用来修饰一个 View 结构体的属性。被 @State 修饰的属性允许在结构体方法中进行修改,因为在 Swift 的语法中,结构体作为值类型,默认是不允许在它的实例方法中修改自己的属性。另外,这些被 @State 修饰的属性,它们的值实际上并不存放在结构体内部,而是由 SwiftUI 统一管理,当它们的值发生变化时候,SwiftUI 会自动刷新视图。

Structures and enumerations are value types. By default, the properties of a value type cannot be modified from within its instance methods.

from: docs.swift.org

视图的 @State 属性应当只在本视图内部使用,所以通常建议将 @State 属性设置为视图类型的私有属性。

You should only access a state property from inside the view’s body, or from methods called by it. For this reason, declare your state properties as private, to prevent clients of your view from accessing them.

from: developer.apple.com

考虑下面这个 ContentView 代码,我们的本意是当按下 Change 按钮后,修改 name 属性,并刷新到视图的 Text 中。

import SwiftUI

struct ContentView: View {
    var name = "funway"
    
    var body: some View {
        VStack {
            Text("Hello, \(name)!")
                .frame(width: 300.0, height: 200.0)
            
            Button(action: { self.changeName() }) {
                Text("Change")
            }
        }
    }
    
    func changeName() {
        print("change name to zoe")
//        self.name = "zoe"
    }
}

如果取消 self.name = "zoe" 这一行的注释,Xcode 会报如下错误:

按照提示修改 changeName() 方法:

mutating func changeName() {
    print("change name to zoe")
    self.name = "zoe"
}

会发现报错转移到了 Button 的 action 逃逸闭包上了:“Cannot use mutating member on immutable value: 'self' is immutable”。因为在闭包函数体中的 self,指代当前的 ContentView 实例,它是 immutable 的。而我们无法用 mutating 关键字修饰闭包,因为 mutating 是用在类或者结构体内部方法上的。

正确的做法,应该是用 @State 修饰 name 属性:

struct ContentView: View {
    @State private var name = "funway"
    
    var body: some View {
        VStack {
            Text("Hello, \(name)!")
                .frame(width: 300.0, height: 200.0)
            
            Button(action: { self.changeName() }) {
                Text("Change")
            }
        }
    }
    
    func changeName() {
        print("change name to zoe")
        self.name = "zoe"
    }
}

8.2 @Binding

在上面的例子中,我们虽然在子视图 Button 的 action 闭包中修改了父视图的 @State 属性值,但并没有将 @State 属性传递给子视图 Button。如果想将父视图的 @State 属性“按引用传递”到子视图,使得父子视图对该属性的修改都能互相影响,那么就要用到 @Binding 特性:在子视图中用 @Binding 修饰目标属性,在传递属性时,需要在父视图的源属性名字前加上 $ 符号。

除了绑定父视图的 @State 属性,@Binding 也可以用来绑定父视图中 @ObservedObject 对象与 @EnvironmentObject 对象的 @Published 属性。(注意,并不能直接绑定 @ObservedObject 对象)

以下面的代码为例:

// 用户点击按钮,将会修改按钮的 isOn 属性,并切换按钮上的文字
// 同时由于 isOn 属性绑定了父视图的 showGreeting 属性,所以该属性也会随之变化,
// 从而切换父视图的 Text 显示。

struct ToggleButton: View {
    // 声明子视图中的 Binding 属性
    @Binding var isOn: Bool

    var body: some View {
        Button(action: {
            self.isOn.toggle()
        }){
            if isOn {
                Text("On")
            } else {
                Text("Off")
            }
        }
    }
}

struct ContentView: View {
    // 声明父视图的 State 属性
    @State var showGreeting = false
    
    var body: some View {
        VStack {
            // Binding 属性传递时候,需要在源属性名前加上 $ 符号
            ToggleButton(isOn: $showGreeting)
            
            if showGreeting {
                Text("Hello, World!")
            }
            
        }.frame(width: 300.0, height: 200.0)
    }
}

8.3 @ObservedObject 与 ObservableObject 协议

@State 特性通常只是用来修饰在视图内部定义的简单类型(Int、String、Bool)属性。在项目实践中,我们通常会把用户数据定义在视图外部,单独一个类或者一个模块。这时候要想让 SwiftUI 监听数据的变更并反应到视图中,就必须用到 ObservableObject 协议与 @ObservedObject 特性:

  1. 定义一个满足 ObservableObject 的类(不能是结构体),表示该类型的实例对象是可以被 SwiftUI 监听的。
  2. 在类型定义中用 @Published 修饰需要被 SwiftUI 监听的属性,这些 @Published 属性值的修改将会实时刷新到视图上。
  3. 在视图中声明该类型的属性,并用 @ObservedObject 修饰。当视图实例初始化并创建该对象属性时,就会通知 SwiftUI 监听该对象。
import SwiftUI

class Book: ObservableObject {
    
    // 普通属性,可以被修改,但是该属性的变化不会引起视图刷新
    var identifier = UUID()
    
    // 只有被 @Published 修饰的属性才会被 SwiftUI 监听,并将其值的变化反应到相关视图
    @Published var title: String = ""
    
    init(_ title: String) {
        self.title = title
    }
}

let BookTitles = ["钢铁是怎样炼成的", "三国志", "西厢记", "1984", "美丽新世界", "原则"]

struct ContentView: View {
    // 用 @ObservedObject 修饰要监听的属性
    @ObservedObject var book = Book(BookTitles.randomElement()!)

    var body: some View {
        VStack {
            Text(self.book.title)

            Button(action: {
                self.book.title = BookTitles.randomElement()!
                
                print("《\(self.book.title)》 id: \(self.book.identifier)")
                
                // 打印变量内存地址
                withUnsafePointer(to: self.book) {print($0)}
            }, label: {
                Text("换一本")
            })
        }.frame(width: 500.0, height: 300.0)
    }
}

8.4 @EnvironmentObject

@State 与 @ObservedObject 修饰的属性通常只用在单个视图及其子视图中。有时候,我们需要用到所有视图都能共享的对象,比如说一个应用程序的“配置”对象。@EnvironmentObject 的用法与 @ObservedObject 有些类似,都要先定义一个 ObservableObject 类型;然后在视图中通过 @EnvironmentObject 修饰该类型的属性,但不需要在视图内部实例化该 EnvironmentObject 属性,而是在视图的外部定义一个该 EnvironmentObject 对象,然后通过视图的 .environmentObject(_:) 方法传递给视图。以下面代码为例:

import SwiftUI

// 定义一个 ObservableObject 类型
class UserSettings: ObservableObject {
    // 用 @Published 修饰需要被 SwiftUI 监听的属性
    @Published var score = 10
}

struct ContentView: View {
    // 在视图中声明一个 @EnvironmentObject 修饰的属性
    // 那么在实例化该视图的时候,只要通过 .environmentObject(_: ) 方法绑定该视图的“环境对象”即可
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        NavigationView {
            VStack {
                Text("Score: \(settings.score)")
                
                Button(action: {
                    self.settings.score += 1
                }) {
                    Text("Increase Score")
                }

                NavigationLink(destination: DetailView()) {
                    Text("Show Detail View")
                }
            }
        }
    }
}

struct DetailView: View {
    // 同样,DetailView 视图中也可以声明一个 @EnvironmentObject 修饰的属性
    @EnvironmentObject var settings: UserSettings

    var body: some View {
        // A text view that reads from the environment settings
        Text("Score: \(settings.score)")
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        // 在实例化视图的时候,通过 environmentObject(_: ) 方法传递需要共享的环境对象
        ContentView().environmentObject(UserSettings())
    }
}

然后还需要修改 SceneDelegate.swift,在场景中定义一个 ObservableObject 对象,然后在实例化主视图的时候通过 environmentOjbect(_: ) 方法传递该对象给视图:

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    // 定义一个 ObservableObject 对象
    var settings = UserSettings()


    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // 实例化主视图时候,通过 environmentObject(_: ) 方法传递需要共享的环境对象
        let contentView = ContentView().environmentObject(settings)

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: contentView)
            self.window = window
            window.makeKeyAndVisible()
        }
    }

8.5 视图重建

8.5.1 @State 属性值的变化导致 body 中子视图重建

先说结论吧:

如果视图中的 @State 属性值发生变化,会导致其 body 属性重新计算(获取),那么 body 中的所有子视图都会被重建。

以下面代码为例:

import SwiftUI

struct ViewReconstruct: View {
    private var uuid = UUID()
    @State var stateValue = 1
    
    init() {
        NSLog("🌞 主视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌞 主视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
            HStack {
                Text("stateValue: \(stateValue)")
                Stepper("", onIncrement: {
                    self.stateValue = self.stateValue + 1
                }, onDecrement: {
                    self.stateValue = self.stateValue - 1
                })
            }
            Divider()
            
            ViewReconstructSubView()
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}


struct ViewReconstructSubView: View {
    private var uuid = UUID()
    
    init() {
        NSLog("🌍 子视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌍 子视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
        }
    }
}

运行结果如图:

8.5.2 子视图 @Binding 属性的变化导致父视图 body 中所有子视图重建

结论:

如果子视图有一个 @Binding 属性绑定了父视图中的某个属性,那么即使只是在子视图中修改 @Binding 属性值,实际上改动的是父视图中被绑定的那个属性,所以父视图的 body 会重新计算,所有子视图都被重建。

测试代码如下:

import SwiftUI

struct ViewReconstruct: View {
    private var uuid = UUID()
    @State var stateValue = 1
    
    init() {
        NSLog("🌞 主视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌞 主视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
            HStack {
                Text("stateValue: \(stateValue)")
                Stepper("", onIncrement: {
                    self.stateValue = self.stateValue + 1
                }, onDecrement: {
                    self.stateValue = self.stateValue - 1
                })
            }
            Divider()
            
            ViewReconstructSubView()
            
            Divider()
            
            ViewReconstructBindingSubView(parentStateValue: $stateValue)
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}


struct ViewReconstructSubView: View {
    private var uuid = UUID()
    
    init() {
        NSLog("🌍 子视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌍 子视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
        }
    }
}


struct ViewReconstructBindingSubView: View {
    private var uuid = UUID()
    
    @Binding var parentStateValue: Int
    
    init(parentStateValue: Binding<Int>) {
        NSLog("🌛 Binding 子视图实例 初始化")
        self._parentStateValue = parentStateValue
    }
    
    var body: some View {
        VStack {
            Text("🌛 Binding 子视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
            
            HStack {
                Text("parent stateValue: \(parentStateValue)")
                Stepper("", onIncrement: {
                    self.parentStateValue = self.parentStateValue + 1
                }, onDecrement: {
                    self.parentStateValue = self.parentStateValue - 1
                })
            }
        }
    }
}

运行结果如图:

8.5.3 @ObservedObject.@Published 属性的变化导致子视图重建

同 @State 一样,主视图中的 @ObservedObject 对象的 @Published 属性的变化,也会导致主视图 body 中所有子视图的重建,从而刷新视图。(@ObservedObject 对象的非 @Published 属性也是运行被修改的,但是它们的变化不会导致视图重建)

同样,如果子视图中通过 @Binding 绑定了父视图 @ObservedObject 对象的一个 @Published 属性,那么在子视图中修改该属性,实际就是对父视图中相应对象的 @Published 属性做修改,也会导致父视图 body 中所有子视图的重建。

以如下代码为例:

import SwiftUI

class ObservableTest: ObservableObject {
    var uuid = UUID()
    @Published var score = 10
}

struct ViewReconstruct: View {
    private var uuid = UUID()
    @ObservedObject var observedObj = ObservableTest()
    
    init() {
        NSLog("🌞 主视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌞 主视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
            HStack {
                Text("observedObj: \(observedObj.score) [\(observedObj.uuid)]")
                Stepper("", onIncrement: {
                    self.observedObj.score = self.observedObj.score + 1
                }, onDecrement: {
                    self.observedObj.uuid = UUID()
                    // 可以修改非 @Published 属性,但是该属性的变化不会引起 body 重建
                    NSLog("observedObj: \(self.observedObj.score) [\(self.observedObj.uuid)]")
                })
            }
            
            Divider()
            
            ViewReconstructSubView()
            
            Divider()
            
            ViewReconstructBindingSubView(parentScore: $observedObj.score)
            
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}


struct ViewReconstructSubView: View {
    private var uuid = UUID()
    
    init() {
        NSLog("🌍 子视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌍 子视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
        }
    }
}


struct ViewReconstructBindingSubView: View {
    private var uuid = UUID()
    
    @Binding var parentScore: Int
    
    init(parentScore: Binding<Int>) {
        NSLog("🌛 Binding 子视图实例 初始化")
        self._parentScore = parentScore
    }
    
    var body: some View {
        VStack {
            Text("🌛 Binding 子视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
            
            HStack {
                Text("parent observedObj score: \(self.parentScore)")
                Stepper("", onIncrement: {
                    self.parentScore = self.parentScore + 1
                }, onDecrement: {
                    self.parentScore = self.parentScore - 1
                })
            }
        }
    }
}

8.5.4 @EnvironmentObject.@Published 属性的变化导致子视图重建

结论:

主视图中 @EnvironmentObject.@Published 属性的变化,会导致 body 重新计算,所有子视图重建。

如果是在子视图中修改了 @EnvironmentObject.@Published 属性,那么实际上修改的应该是回溯到通过 .environmentObject() 传递 EnvironmentObject 进来的那一层父视图,从这一层父视图开始,它的 body 中所有子视图都会被重建。(该父视图以及再往上的祖先视图不会被重建)

以如下代码为例:

import SwiftUI

class ObservableTest: ObservableObject {
    var uuid = UUID()
    @Published var score = 10
}

struct ViewReconstruct: View {
    private var uuid = UUID()
    @EnvironmentObject var envObj: ObservableTest
    
    init() {
        NSLog("🌞 主视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌞 主视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
            HStack {
                Text("envObj: \(envObj.score) [\(envObj.uuid)]")
                Stepper("", onIncrement: {
                    self.envObj.score = self.envObj.score + 1
                }, onDecrement: {
                    self.envObj.score = self.envObj.score - 1
                })
            }
            
            Divider()
            
            ViewReconstructSubView()
            
            Divider()
            
            ViewReconstructBindingSubView()
            
        }.frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}


struct ViewReconstructSubView: View {
    private var uuid = UUID()
    
    init() {
        NSLog("🌍 子视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌍 子视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
        }
    }
}


struct ViewReconstructBindingSubView: View {
    private var uuid = UUID()
    
    @EnvironmentObject var envObj: ObservableTest
    
    init() {
        NSLog("🌛 子视图实例 初始化")
    }
    
    var body: some View {
        VStack {
            Text("🌛 子视图 \(self.uuid)").font(.system(.callout, design: .monospaced))
            
            HStack {
                Text("envObj: \(envObj.score) [\(envObj.uuid)]")
                Stepper("", onIncrement: {
                    self.envObj.score = self.envObj.score + 1
                }, onDecrement: {
                    self.envObj.score = self.envObj.score - 1
                })
            }
        }
    }
}

9. SwiftUI 的数据存储

9.1 UserDefaults

UserDefaults 是苹果提供的一种存储键值对数据的轻量级“数据库”,其本质上是一个 plist 文件。UserDefaults 通常用来存储少量的简单的数据,比如应用程序的用户配置。不建议用 UserDefaults 存储大量数据,因为 UserDefaults 实例化时候系统会加载整个 plist 文件,如果存储数据过多会导致加载缓慢,大量的数据存储还是建议选择 Core Data、SQLite 、Realm 等。

UserDefaults 数据的存储路径是在应用程序沙盒的 Library/Preferences/ 目录下。

不管是 macOS 还是 iOS 应用程序,都有自己的应用程序沙盒目录。沙盒根目录下可由程序员操作的目录包括:

  1. Documents,建议用来保存少量需要持久化的重要数据。 iTunes备份和恢复的时候,会包括此目录。
  2. Library,包含两个子目录:Caches 和 Preferences。Caches 用来保存那些需要持久化但又不是特别重要的数据。Preferences 是偏好设置,可以通过 UserDefaults 来读取和设置。
  3. tmp,存放 APP 退出后不再需要的临时文件,系统会自动清理该目录。(macOS 程序的 tmp 目录不在程序沙盒中,而在系统通用的临时文件目录)

  • 打印应用程序沙盒根目录: print( NSHomeDirectory() )    
  • 获取沙盒 Documents 目录: NSSearchPathForDirectoriesInDomains( .documentDirectory, .userDomainMask, true ).first!
  • 获取沙盒 Library 目录: NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first!
  • 获取沙盒 tmp 目录: NSTemporaryDirectory()   

通常我们可以直接使用 UserDefaults.standard 来获取默认的 UserDefaults 实例,它的默认文件名是“\(项目的 Bundle Identifier).plist”。当然也可通过 UserDefaults.init?(suiteName: String) 构造器来获取一个指定数据库名的 UserDefaults 实例,那么它的文件名就是 "suiteName.plist"。如果 plist 文件不存在,UserDefaults 会自动创建之,已存在的话则读取该文件。

举个例子。新建一个 macOS 的 app 项目,然后新建 UserSettings.swift 文件,通过该 UserSettings 类来读写 UserDefaults。

import Foundation

class UserSettings: ObservableObject {
    
    // 这是一个 Published 属性,SwiftUI 可以监听该属性值的变化来自动更新视图
    @Published var username: String {
        // 定义 didSet 属性观察器,当该属性值发生变化后自动调用该函数(初次赋值不会触发 didSet)
        didSet {
            // 将该属性值写入 UserDefaults.standard 的 username 键值对
            UserDefaults.standard.set(username, forKey: "username")
        }
    }
    
    init() {
        // 从 UserDefaults.standard 中读取 username 键值
        self.username = UserDefaults.standard.object(forKey: "username") as? String ?? ""
    }
}

然后修改 ContentView:

struct ContentView: View {
    // 将该属性声明为 ObseredObject,这样当该属性发生变化时候会自动刷新视图
    @ObservedObject var userSettings = UserSettings()
    
    // 获取 UserDefaults 的存储路径
    let storagePath = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true)[0]
    
    var body: some View {
            VStack {
                TextField("Username", text: $userSettings.username)
                
                Text("UserDefaults 存储路径: \(storagePath)/Preferences/")
            }
            .padding(.all, 20.0)
    }
}

9.2 Core Data

Core Data 是苹果提供的对象数据存储框架。直观的说,就是本地数据库。开发者可以直接在 Xcode 上创建 Data Model,这可以理解为创建一个数据库。然后在 Data Model 中创建 Entity,这可以理解成数据库中的一张表,一个 Entity 就是一个 Model Class,Xcode 会在编译阶段自动为我们生成与 Entity 同名的类,而不需要开发者在代码中定义。

举个例子。新建一个 macOS - App 项目,并勾选 “Use Core Data”。

在新建项目的 Project navigator 面板可以发现这个新项目比不使用 Core Data 的项目多了一个 Bookworm.xcdatamodeld 文件。这就是 Core Data 的数据定义文件。

然后在自动生成的 AppDelegate.swift 代码中,多了一个懒加载的存储属性 persistentContainer。注意 lazy var persistentContainer: NSPersistentContainer = { /* ... */ }() 的写法,后面是一个闭包调用,并不是 getter 函数。persistentContainer 按字面翻译就是持久化容器,或者可以理解为就是应用程序的内部数据库。

在 AppDelegate 中创建主视图 ContentView 时候,通过 .environment 方法在视图的“环境”中绑定了这个“数据库”:let contentView = ContentView().environment(\.managedObjectContext, persistentContainer.viewContext)

但是!这行代码其实是一个 BUG 啊啊啊!!!因为 persistentContainer 是懒加载的,这会导致 SwiftUI 在持久化存储加载之前就创建了 ContentView 视图,然后我们后面运行的时候就会报错说 “No NSEntityDescriptions in any model claim the NSManagedObject subclass 'Student' so +entity is confused.  Have you loaded your NSManagedObjectModel yet ?” 即视图无法在 environment 中找到数据对象。要修复这个错误,需要把这句语句拆开:

// 把持久化存储上下文的获取独立出来,这样 persistentContainer 的懒加载现在就会开始执行了
// 然后才将 context 传递给视图的 environment
let context = persistentContainer.viewContext
let contentView = ContentView().environment(\.managedObjectContext, context)

// 这一句是 Xcode 的BUG啊!不知道后面新版本的修复没有。iOS 的模版就是将这句拆开的,只有 macOS 的模版有这个问题。。
// let contentView = ContentView().environment(\.managedObjectContext, persistentContainer.viewContext)

现在我们首先要做的就是在 Data Model 中创建一个实体类型。双击项目导航面板的 Bookworm.xcdatamodeld,点击 “Add Entity” 按钮,并双击新生成的 Entity,将其改名为 Student。然后点击右边 Attributes 栏下面的 ➕ 号,为 Student 类型添加两个属性:id: UUID,name: String。这样我们就定义好了一个名为 Student 的实体类型,在项目编译的时候,Xcode 会自动为我们生成这个 Student 类。

打开 ContentView.swift 文件,我们需要为 ContentView 添加一个 students 属性,通过它从 Core Data 中获取持久化的 Student 对象。并将 students 的名字在视图中显示出来:

import SwiftUI

struct ContentView: View {
    @FetchRequest(entity: Student.entity(), sortDescriptors: []) var students: FetchedResults<Student>
    
    var body: some View {
        VStack {
            List {
                ForEach(students, id: \.id) { student in
                    // student.name 是可选类型,可能为 nil
                    // ?? 两个问号是 Swift 语言中的空和运算符
                    Text(student.name ?? "Unknown")
                }
            }
        }
    }
}

编译运行,但是什么也没有显示,因为毕竟此时 Core Data 中还没有 Student 数据。

(我的 Xcode 11.5 编译时候会提示错误 “Use of undeclared type 'Student'”,说找不到 Student 类,实际上这是 Xcode 的 BUG,只要关闭项目再打开,然后再次编译就好了。凸(-。-;

另外,对于 macOS 项目而言,我不知道如何在预览模式给视图加载这个 NSManagedObjectContext,所以我把 ContentView_Previews 代码注释掉了)

为了创建 Student 对象,我们需要在 ContentView.swift 中再加入一些代码:

struct ContentView: View {
    // 从“数据库”中取得所有 Student 实体对象,赋值给 students 属性
    @FetchRequest(entity: Student.entity(), sortDescriptors: []) var students: FetchedResults<Student>
    
    // moc 指向传递给该视图的持久化存储上下文 (persistentContainer.viewContext)
    @Environment(\.managedObjectContext) var moc

    var body: some View {
        VStack {
            List {
                ForEach(students, id: \.id) { student in
                    // student.name 是可选类型,可能为 nil
                    // 两个问号 ?? 是 Swift 语言中的空和运算符
                    Text(student.name ?? "Unknown")
                }
            }
            
            Button("Add") {
                let firstNames = ["Ginny", "Harry", "Hermione", "Luna", "Ron", "John"]
                let lastNames = ["Granger", "Lovegood", "Potter", "Weasley", "Wick"]

                let chosenFirstName = firstNames.randomElement()!
                let chosenLastName = lastNames.randomElement()!
                
                // 新建一个 Student 实体
                let student = Student(context: self.moc)
                student.id = UUID()
                student.name = "\(chosenFirstName) \(chosenLastName)"
                
                try? self.moc.save()
            }
        }
    }
}

Core Data “数据库”文件存储路径在应用程序沙盒的 Library/Application Support/Project Name 目录下:

可以看出,Core Data 底层确实是依赖于 sqlite 的。这三个文件中,只有 .sqlite 后缀的文件是存储数据用的,可以使用任意 sqlite 管理器打开该文件。其中 ZSTUDENT 表中就是我们刚刚添加的 Student 数据。

9.3 SQLite

嗯。。。用了一下 SQLite.swift,感觉比 Core Data 好用多了=。=:

  1. 直接用 SQLite 生成的数据库其字段就是我们定义的那几个字段,而 Core Data 生成的数据库是被“修饰”过的。我们可以用任意支持 SQLite 的数据库管理工具来查看,编辑,预填充数据。对于 Core Data 生成的数据库,虽然也能查看或者修改,但是预填充数据则是不敢做的。(虽然也有专门针对 Core Data 的第三方数据库管理工具,但是要钱啊。这就是 Apple 的错了)
  2. 然后在使用上,SQLite 也比 Core Data 简单多了。不需要用 environment 这些扰人的概念,直接上一个全局实例就行。

发表评论

电子邮件地址不会被公开。 必填项已用*标注