概述
在 SwiftUI 早期版本中,我们可以通过观察遵守 ObservableObject 协议的对象来适时的刷新视图界面。从 SwiftUI 5.0(iOS 17)开始,苹果通过 Swift 5.9 引入了全新的 @Observable 宏让我们在监听状态的变化上更加大有可为。
不过,出于某些原因我们需要禁止可观察对象中的某些属性被观察,这是为什么?又该如何监听这些属性的改变呢?
在本篇博文中,您将学到如下内容:
- 何为不可观察属性?它存在的目的是什么?
- 刷新不可观察属性的妙招
- 题外话:如何避免属性刷新“污染”? 源代码
相信学完本课后,小伙伴们对不可观察属性的应对会更加游刃有余、得心应手。
那还等什么呢?让我们马上开始“观察”大冒险吧! Let's go!!!;)
1. 何为不可观察属性?它存在的目的是什么?
在 SwiftUI 5.0 之前,对于自定义类来说我们必须让其遵守 ObservableObject 协议才能将它们融入到视图可观察的世界里:
class OldModel: ObservableObject {
@Published var laserIntensity = 0.0
var darkEnergy = 0
}
如上代码所示,在 ObservableObject 对象中只有被 @Published 显式修饰的属性才是可观察的。所谓可观察是指:该属性内容的改变会引起 SwiftUI 视图的刷新,即重新渲染(Rerender)。相反的,未用 @Published 修饰的属性则不是可观察的,视图 UI 对它们的改变将“一无所知”。
从 SwiftUI 5.0 开始,苹果新引入的可观察框架(Observation)中的 Observable 对象也有异曲同工之妙。
在下面的代码中,我们用 @Observable 宏同样创建了一个可观察对象的类,只不过和上面 ObservableObject 类所不同的是,默认 @Observable 宏修饰的可观察对象里所有属性都是可观察的,如果不希望它们被观察我们则需要显式用 @ObservationIgnored 来修饰:
@Observable
class Model {
var laserIntensity = 0.0
@ObservationIgnored
var darkEnergy = 0
}
那么为什么我们会将某些属性设置为不可观察呢?原因很简单:
- 这些对象会频繁改变,可能造成渲染引擎“压力山大”;
- 这些对象无需参与到视图的刷新渲染中,它们只是表示模型的逻辑判定;
- 这些属性可能在系统框架或第三方库的某些类里面,它们只是没有被设置为可观察,仅此而已;
那么,如果当这些不可观察属性发生改变时,我们想在 SwiftUI 视图的界面里对其做出相应反馈,又该如何是好呢?
2. 刷新不可观察属性的妙招
为了更好的向大家表明我们的意图,让我们先将之前的 Model 类做一番扩展:
import Combine
@Observable
class Model {
var laserIntensity = 0.0
@ObservationIgnored
var darkEnergy = 0
private var cancel: AnyCancellable?
init(){
cancel = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect().sink { _ in
self.darkEnergy += Int.random(in: 0...10)
}
}
deinit {
cancel?.cancel()
}
}
如您所见,我们在 Model 对象的创建后自动累增了不可观察属性 darkEnergy 的值,但是如果我们在 SwiftUI 视图中“嵌入” Model 对象的 darkEnergy 属性,则并不会造成界面的刷新:
struct ContentView: View {
@Environment(Model.self) var model
var body: some View {
NavigationStack {
Form {
Section("可观察属性") {
LabeledContent("激光强度") {
VStack {
Text("\(model.laserIntensity, specifier: "%0.1f")")
.font(.system(size: 99, weight: .black, design: .rounded))
.foregroundStyle(.red)
Button("增加激光强度") {
withAnimation {
model.laserIntensity += 0.1
}
}
.font(.largeTitle)
.buttonStyle(.borderedProminent)
.tint(.pink)
.containerRelativeFrame(.horizontal, alignment: .center)
}
}
}
Section("不可观察属性") {
LabeledContent("暗能量威能") {
Text("\(model.darkEnergyWrap)")
.font(.system(size: 99, weight: .black, design: .rounded))
.foregroundStyle(.primary)
.animation(.bouncy, value: model.darkEnergyWrap)
}
}
}
}
}
}
从运行的结果可以看到:即使 darkEnergy 属性的值在不断改变,但视图 UI 仍会置若罔闻,这就是不可观察属性原有的样子啊!只有当我们改变可观察属性 laserIntensity 时,由于视图刷新所产生的副作用(刷新污染),darkEnergy 属性的改变才会“顺带”展现出来。
那么,我们如何让 darkEnergy 属性自己在界面里保持自动刷新呢?
我们有很多种解决方案,但不外乎都需要增加另一个可观察属性作为触发器来驱动 darkEnergy 属性的刷新。
其实,SwiftUI 本身就为我们提供了解决之道,那就是内置的 TimelineView
原生视图:
TimelineView 视图可以作为容器,按照我们秃头码农要求的时间间隔渲染内部的子视图。比如,如果我们希望 TimelineView 中的内容每隔 1 秒刷新一次,则可以这么撸码:
TimelineView(.periodic(from: startDate, by: 1)) { context in
AnalogTimerView(date: context.date)
}
其实,只要遵守 TimelineSchedule 协议,我们就能传入任何类型的实例作为 TimelineView 的进度(schedule)实参以达到按需刷新的目的。
比如,如果我们希望让 SwiftUI 运行时(Runtime)来决定以“最优”的间隔来刷新视图,则 AnimationTimelineSchedule 类型的值可能会更加恰如其分一些:
通过上面的讨论,现在利用 TimelineView 视图我们可以非常 Nice 的让原本不可观察的属性自动刷新啦:
Section("不可观察属性") {
// 让 SwiftUI 自动决定最优的刷新间隔
TimelineView(.animation()) { _ in
LabeledContent("暗能量威能") {
Text("\(model.darkEnergy)")
.font(.system(size: 99, weight: .black, design: .rounded))
.foregroundStyle(.primary)
.animation(.bouncy, value: model.darkEnergy)
}
}
}
运行看一下美美哒的效果吧:
3. 题外话:如何避免属性刷新“污染”?
通过上面的讨论,我们注意到这样一个细节:当 Model 中的可观察属性 laserIntensity 发生改变时,会间接导致其不可观察属性 darkEnergy 在界面上的刷新,这称之为“刷新污染”,在某些情况下这是不可接受的!
在大多数理想情况下,我们希望各个(可观察)属性的改变在 SwiftUI 视图界面中不会影响其它无关属性的显示。
一种简单的方法是:将这些属性限制在特定的自定义子视图中。
struct LaserIntensityView: View {
@Environment(Model.self) var model
var body: some View {
VStack {
Text("\(model.laserIntensity, specifier: "%0.1f")")
.font(.system(size: 99, weight: .black, design: .rounded))
.foregroundStyle(.red)
Button("增加激光强度") {
withAnimation {
model.laserIntensity += 0.1
}
}
.font(.largeTitle)
.buttonStyle(.borderedProminent)
.tint(.pink)
.containerRelativeFrame(.horizontal, alignment: .center)
}
}
}
在上面的代码里,我们将原先放在主视图 ContentView 内 model.laserIntensity 属性对应的 UI 描述代码,单独“拎出来”构成一个独立的自定义视图 LaserIntensityView,然后再将其作为子视图嵌入原来的位置:
Section("可观察属性") {
LaserIntensityView()
}
这样一来,可观察属性 model.laserIntensity 的改变只会局限在 LaserIntensityView 内部,而不会导致父视图 ContentView 其它部分的刷新:
就像大家看到的那样:现在当我们增加 laserIntensity 属性的值时,其它无关属性的改变并不会对界面有任何影响,棒棒哒!💯
源代码
全部源代码在此:
import SwiftUI
import Combine
class OldModel: ObservableObject {
@Published var laserIntensity = 0.0
var darkEnergy = 0
private var cancel: AnyCancellable?
init() {
cancel = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect().sink { _ in
self.darkEnergy += Int.random(in: 0...10)
self.laserIntensity += 0.1
}
}
deinit {
cancel?.cancel()
}
}
@Observable
class Model {
var laserIntensity = 0.0
@ObservationIgnored
var darkEnergy = 0
@ObservationIgnored
private var cancel: AnyCancellable?
init(){
cancel = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect().sink { _ in
self.darkEnergy += Int.random(in: 0...10)
}
}
deinit {
cancel?.cancel()
}
}
struct LaserIntensityView: View {
@Environment(Model.self) var model
//@EnvironmentObject var model: OldModel
var body: some View {
VStack {
Text("\(model.laserIntensity, specifier: "%0.1f")")
.font(.system(size: 99, weight: .black, design: .rounded))
.foregroundStyle(.red)
Button("增加激光强度") {
withAnimation {
model.laserIntensity += 0.1
}
}
.font(.largeTitle)
.buttonStyle(.borderedProminent)
.tint(.pink)
.containerRelativeFrame(.horizontal, alignment: .center)
}
}
}
struct ContentView: View {
@Environment(Model.self) var model
//@EnvironmentObject var model: OldModel
var body: some View {
NavigationStack {
Form {
Section("可观察属性") {
LaserIntensityView()
}
Section("不可观察属性") {
TimelineView(.animation()) { _ in
LabeledContent("暗能量威能") {
Text("\(model.darkEnergy)")
.font(.system(size: 99, weight: .black, design: .rounded))
.foregroundStyle(.primary)
.animation(.bouncy, value: model.darkEnergy)
}
}
}
}
.scrollContentBackground(.hidden)
.background(Color.teal.gradient)
.contentTransition(.numericText(countsDown: false))
.navigationTitle("自动刷新不可观察属性")
.toolbar {
Text("大熊猫侯佩 @ \(Text("CSDN").foregroundStyle(.red))")
.font(.headline.bold())
.foregroundStyle(.gray)
}
}
}
}
#Preview {
ContentView()
.environment(Model())
//.environmentObject(OldModel())
}
总结
在本篇博文中,我们介绍了何为“不可观察属性”以及它的应用场景,并随后讨论了如何“怡然自得”的自动刷新原本不可观察属性的改变。
感谢观赏,再会啦!8-)