Combine 框架

根据 “Separation of concerns” 关注点分离原则,最好分成 视图View 和 视图模型ViewModel 两部份组织代码,所以就会用到跨文件数据交互,所以有了combine框架。

// 使用前首先要在 swiftUI 文件中,导入 Combine 框架
// 现在好像不需要导入也能用 ?
import Combine

状态发布端

ObservableObject 协议

ObservableObject 是 Combine 框架中的协议。

// 遵循可观测对象协议
class MyModel: ObservableObject {
    @Published var value: Int = 0
}

@Published 属性包裹器

@Published 是与 ObservableObject 协议一起使用的 “属性包裹器”,属性包装器允许向属性、局部变量和函数参数添加行为

@Published@State 的运作方式非常相似:

如果在不运行 Main actor 时修改 @Published 属性会发生什么情况?结果可能是 Xcode 会抛出运行时警告。

  1. 如果需要更多的控制,最好放弃 @Published 并添加自己的属性观察器,再通过 objectWillChange.send() 一起发布
  2. 使用 @Published 会在属性之外创建一个发布者,因此您可以根据需要使用 $ 运算符来链接 Combine 运算符

objectWillChange.send 方法

ObservableObject 类中,当一个属性发生变化需要通知观察视图时,除了使用 @Published 属性包装器进行标记,还有一种方法是手动调用  objectWillChange.send()  方法发送更新通知,这意味着任何观察视图都将重新调用其 body 属性。类似强制刷新。

class UserAuthentication: ObservableObject {
    var username = "Taylor" {
        willSet {
            objectWillChange.send()
        }
    }
}

ObservableObject 对象发出 objectWillChange 通知时,那些使用 @StateObject@ObservedObject@EnvironmentObject 来订阅对象的视图,就会自动更新,类似强制要求观察者刷新。手动调用 objectWillChange.send 的方式可以让开发者更好地控制视图的更新时机。比如只有在满足某些条件时才更新视图。这种方式比单纯使用 @Published 属性包装器更灵活。


objectWillChange.send 和 @Published 的区别


状态接收端

@ObservedObject

//示例:
struct MyView: View {
    @ObservedObject private var userRegisViewModel = UserRegistrationViewModel()
}

@ObservedObject 的具体使用流程:

  1. 首先,用单独的代表 ViewModel 的文件(符合关注点分离原则),创建遵循 ObservableObject 协议的数据模型(Model),其中包含多个 @Published 标记的属性,以表示可观察的数据;
  2. 然后,在单独的 View 文件中,使用 @ObservedObject 属性包装器实作这个数据模型(Model),这将使视图成为数据模型的订阅者,可以在数据发生变化时得到通知;
  3. 最后,当数据模型的 @Published 属性发生变化时,使用到该属性的相关视图将自动更新;这种方式能够实现响应式的用户界面,无需手动刷新视图,而是让 SwiftUI 处理一切。这对数据交互密切的应用非常有用,如表单、列表等。
// 文件一:ViewModel
import Foundation
import Combine

class UserRegistrationViewModel: ObservableObject {
    
		// 这6个Published都是发布者
    // Input 代表用户输入的
    @Published var username = ""
    @Published var password = ""
    @Published var passwordConfirm = ""
    
    // Output 代表系统输出的
    @Published var isUsernameLengthValid = false
    @Published var isPasswordLengthValid = false
    @Published var isPasswordConfirmValid = false
    
    private var cancellableSet: Set<AnyCancellable> = []
    

		// 下面这三段代码,其实是构建上面“代表系统输出”的发布属性,因为他们要计算才能得出
		// 否则如果没有上面“代表输出部份”的属性,都不需要下面三段代码
    init() {
        
        // 1. 这个 $username 是想要监听的变化值的来源
        $username
            //呼叫 receive(on:) 函式來確認訂閱者 接收 主执行序列(也就是 RunLoop.main)的值
						//这一行指定接下来的操作在主线程上执行。这对于更新 UI 相关的操作很重要,因为 UI 更新必须在主线程上执行
            .receive(on: RunLoop.main)

						// 2. 这里用map方法作为返回值
            .map { username in
                return username.count >= 4
            }

            // 3. 这里将返回值赋值给订阅者(isUsernameLengthValid),Combine 提供了两种订阅者 “assign” 和 “sink”
						// 使用 .assign(to:on:) 操作符,将上面的检查结果(true 或 false)分配给名为 isUsernameLengthValid 的属性。
            .assign(to: \\.isUsernameLengthValid, on: self)

						// store 函式可以儲存 cancellable 的參照(reference)至一個集合(set),作為之後的清除
						// 使用 .store(in:) 操作符将 Combine 订阅关系存储在 cancellableSet 中
						// 以确保订阅在适当的时候取消,以避免内存泄漏,如果沒有儲存這個參照,App 可能會有記憶體洩漏的問題
            .store(in: &cancellableSet)
        
        
        // 构建第2个发布者的逻辑
        $password
            .receive(on: RunLoop.main)
            .map { password in
                return password.count >= 8
            }
            .assign(to: \\.isPasswordLengthValid, on: self)
            .store(in: &cancellableSet)
        

				// 构建第3个发布者的逻辑
				// 它是通过两个信息源的值里计算得出的,最后赋予了 isPasswordConfirmValid
        Publishers.CombineLatest($password, $passwordConfirm)
            .receive(on: RunLoop.main)
            .map { (password, passwordConfirm) in
                return !passwordConfirm.isEmpty && (passwordConfirm == password)
            }
						// 发布者isPasswordConfirmValid的值,是通过上面map方法赋予的
            .assign(to: \\.isPasswordConfirmValid, on: self)
            .store(in: &cancellableSet)
    }
}
// 文件二: 视图文件

// 文件这里要实作这个类,否则这个模型不存在
@ObservedObject private var userRegisViewModel = UserRegistrationViewModel()

var body: some View {
	TextField("Enter a username", text: $userRegisViewModel.username)
	RequirementTextView(icon: "flag.circle.fill", text: "A minimun of 4 characters")
	  .foregroundColor(userRegisViewModel.usernameLengthValid ? .gray : .red)
}

@StateObject

@StateObject 属性包装器是 @ObservedObject 的一种特殊形式,具有所有相同的功能,但它应该用于创建观察到的对象,而不仅仅是在外部存储传递的对象。

class Counter: ObservableObject {
    @Published var count = 0
}

struct CounterView: View {
		// 在该视图内实作,并且生命周期和该视图保持一致。视图被销毁,它也会被销毁
    @StateObject private var counter = Counter()
    var body: some View {
        VStack {
            Text("Count: \\(counter.count)")
            Button("Increment") {
                counter.count += 1
            }
        }
    }
}

@EnvironmentObject

//示例
struct ContentView: View {
    @EnvironmentObject var store : DataStore
}

@EnvironmentObject 的基本使用流程如下:

  1. 首先,新建一个可被观察的类,遵循 ObservableObject 协定。在类里加 @Published 关键词的属性,和初始化函数。加上了 @Published 的属性,就是可以被全局传递的数据;同时包含它的 SettingStore 类,会一直监听自身的这些属性,但数据发生变化时,它将通知其它订阅者们新的属性值。

    //Swift 文件一:View Model
    //final关键字在大多数的编程语言中都存在,表示不允许对其修饰的内容进行继承或者重新操作 
    //Swift中,final关键字可以在class、func和var前修饰
    //通常认为使用final可以更好地对代码进行版本控制,发挥更佳的性能,同时使代码更安全
    
    //新建类,遵循 ObservableObject 协议
    final class SettingStore: ObservableObject {
    
    	//下面 UpdateItem 是提前定义好的一个 对象结构
    	@Published var updates : [UpdateItem]
    
    	//类需要初始化函数,一般这样写
    	init(updates: [UpdateItem]) {
            self.updates = updates
      }
    }
    
  2. 然后,到需要用这些 @Published 属性的每个页面,用 @EnvironmentObject 关键词声明一个属于前面模型类别的属性,但不需要实作。然后在具体视图中采用点语法访问需要的属性即可,这个值就是之前模型里加了 @Published 关键词的属性的值。

    //文件1
    struct DetailView: View {
        @EnvironmentObject var myConfi: Configuration
        var body: some View {
            Text("Detail View")
            Text(myConfi.theme)
        }
    }
    
    //文件2
    struct Detail2View: View {
        @EnvironmentObject var myConfi: Configuration
        var body: some View {
            Text("Detail View2")
            Text(myConfi.name)
        }
    }
    

    <aside> 💡 这些文件声明的 @EnvironmentObject 属性,让它会在环境中自动查找一个 Configuration 实例,并且把找到的结果放进 myConfi属性里。注意:如果环境中找不到实例,你的应用就会崩溃。

    </aside>

  3. 修复预览:声明完后这时就会报错,因为我们还没有实作模型,所以缺失这个模型对象。解决方法是通过 .environmentObject 修饰符在预览视图加上环境对象,并实作一个赋值;

    #Preview {
        Detail2View().environmentObject(Configuration())
    }
    
  4. 同理,要在项目真正的代码上实作该类。一般来说都是在项目顶级视图(例如contentView)中,实作这个模型对象。这样实际环境中就有对象了,不会报缺失。

    struct ContentView: View {
        
        var myConfi: Configuration = Configuration()
        
        var body: some View {
    		    WindowGroup {
    		        NavigationStack{
    		            VStack(spacing: 20) {
    		                Text(myConfi.theme)
    		                // 这里引用了两个子视图。要通过 environmentObject 修饰符,往环境注入上面实作的模型
    		                DetailView().environmentObject(myConfi)
    		                Detail2View().environmentObject(myConfi)
    		            }
    		        }
            }
        }
    }
    
    // 另外一种写法更好:把 myConfi 放到父视图的环境修饰符中
    // 因为 DetailView 和 Detail2View 都是 ContentView 的子视图,所以它们自动继承了 ContentView 的环境
    // 如果需要向环境中添加多个对象,则应该添加多个 environmentObject() 修饰符
    VStack {
        DetailView()
        Detail2View()
    }
    .environmentObject(myConfi)
    

<aside> 💡 你可能会好奇 SwiftUI 是如何在 .environmentObject(user)@EnvironmentObject var user: User 之间建立联系的?它怎么知道要对象正确地塞进哪个属性?是这样的,你已经学习过字典。一个类型存键,另一个类型存值。

环境可以很有效地让我们以数据的类型本身作为键,该类型的实例作为值。这个做法一开始有点费解,你可以这么想:键是 IntStringBool 这样的东西,而值是像 5,“Hello” 和 true 这样的东西。也就是说,假如我们讲 “给我 Int”,那我们会得到 5。

</aside>


@ObservedObject 和 @StateObject 的区别

<aside> 💡 它们的主要区别是:它们的生命周期和使用场景。您应该使用 @StateObject 在某处创建可观察对象,并且在传递该对象的所有后续位置中应该使用 @ObservedObject 。

</aside>

@StateObject

// 示例
class ViewModel: ObservableObject {
    @Published var data: String = "Hello, World!"
}

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    var body: some View {
        Text(viewModel.data)
    }
}