SwiftUI 视图如何“乖巧地”自动刷新不可观察(Unobservable)属性?

0 阅读7分钟

在这里插入图片描述

概述

在 SwiftUI 早期版本中,我们可以通过观察遵守 ObservableObject 协议的对象来适时的刷新视图界面。从 SwiftUI 5.0(iOS 17)开始,苹果通过 Swift 5.9 引入了全新的 @Observable 宏让我们在监听状态的变化上更加大有可为。

在这里插入图片描述

不过,出于某些原因我们需要禁止可观察对象中的某些属性被观察,这是为什么?又该如何监听这些属性的改变呢?

在本篇博文中,您将学到如下内容:

  1. 何为不可观察属性?它存在的目的是什么?
  2. 刷新不可观察属性的妙招
  3. 题外话:如何避免属性刷新“污染”? 源代码

相信学完本课后,小伙伴们对不可观察属性的应对会更加游刃有余、得心应手。

那还等什么呢?让我们马上开始“观察”大冒险吧! 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-)