
概述
在 SwiftUI 早期版本中,我們可以通過觀察遵守 ObservableObject 協(xié)議的對象來適時的刷新視圖界面。從 SwiftUI 5.0(iOS 17)開始,蘋果通過 Swift 5.9 引入了全新的 @Observable 宏讓我們在監(jiān)聽狀態(tài)的變化上更加大有可為。

不過,出于某些原因我們需要禁止可觀察對象中的某些屬性被觀察,這是為什么?又該如何監(jiān)聽這些屬性的改變呢?
在本篇博文中,您將學到如下內容:
- 何為不可觀察屬性?它存在的目的是什么?
- 刷新不可觀察屬性的妙招
- 題外話:如何避免屬性刷新“污染”?
源代碼
相信學完本課后,小伙伴們對不可觀察屬性的應對會更加游刃有余、得心應手。
那還等什么呢?讓我們馬上開始“觀察”大冒險吧!
Let's go!??!;)
1. 何為不可觀察屬性?它存在的目的是什么?
在 SwiftUI 5.0 之前,對于自定義類來說我們必須讓其遵守 ObservableObject 協(xié)議才能將它們融入到視圖可觀察的世界里:
class OldModel: ObservableObject {
@Published var laserIntensity = 0.0
var darkEnergy = 0
}
如上代碼所示,在 ObservableObject 對象中只有被 @Published 顯式修飾的屬性才是可觀察的。所謂可觀察是指:該屬性內容的改變會引起 SwiftUI 視圖的刷新,即重新渲染(Rerender)。相反的,未用 @Published 修飾的屬性則不是可觀察的,視圖 UI 對它們的改變將“一無所知”。
從 SwiftUI 5.0 開始,蘋果新引入的可觀察框架(Observation)中的 Observable 對象也有異曲同工之妙。
在下面的代碼中,我們用 @Observable 宏同樣創(chuàng)建了一個可觀察對象的類,只不過和上面 ObservableObject 類所不同的是,默認 @Observable 宏修飾的可觀察對象里所有屬性都是可觀察的,如果不希望它們被觀察我們則需要顯式用 @ObservationIgnored 來修飾:
@Observable
class Model {
var laserIntensity = 0.0
@ObservationIgnored
var darkEnergy = 0
}
那么為什么我們會將某些屬性設置為不可觀察呢?原因很簡單:
- 這些對象會頻繁改變,可能造成渲染引擎“壓力山大”;
- 這些對象無需參與到視圖的刷新渲染中,它們只是表示模型的邏輯判定;
- 這些屬性可能在系統(tǒng)框架或第三方庫的某些類里面,它們只是沒有被設置為可觀察,僅此而已;
那么,如果當這些不可觀察屬性發(fā)生改變時,我們想在 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 對象的創(chuàng)建后自動累增了不可觀察屬性 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 屬性的改變才會“順帶”展現(xiàn)出來。

那么,我們如何讓 darkEnergy 屬性自己在界面里保持自動刷新呢?
我們有很多種解決方案,但不外乎都需要增加另一個可觀察屬性作為觸發(fā)器來驅動 darkEnergy 屬性的刷新。
其實,SwiftUI 本身就為我們提供了解決之道,那就是內置的 TimelineView 原生視圖:

TimelineView 視圖可以作為容器,按照我們禿頭碼農要求的時間間隔渲染內部的子視圖。比如,如果我們希望 TimelineView 中的內容每隔 1 秒刷新一次,則可以這么擼碼:
TimelineView(.periodic(from: startDate, by: 1)) { context in
AnalogTimerView(date: context.date)
}
其實,只要遵守 TimelineSchedule 協(xié)議,我們就能傳入任何類型的實例作為 TimelineView 的進度(schedule)實參以達到按需刷新的目的。
比如,如果我們希望讓 SwiftUI 運行時(Runtime)來決定以“最優(yōu)”的間隔來刷新視圖,則 AnimationTimelineSchedule 類型的值可能會更加恰如其分一些:

通過上面的討論,現(xiàn)在利用 TimelineView 視圖我們可以非常 Nice 的讓原本不可觀察的屬性自動刷新啦:
Section("不可觀察屬性") {
// 讓 SwiftUI 自動決定最優(yōu)的刷新間隔
TimelineView(.animation()) { _ in
LabeledContent("暗能量威能") {
Text("\(model.darkEnergy)")
.font(.system(size: 99, weight: .black, design: .rounded))
.foregroundStyle(.primary)
.animation(.bouncy, value: model.darkEnergy)
}
}
}
運行看一下美美噠的效果吧:

3. 題外話:如何避免屬性刷新“污染”?
通過上面的討論,我們注意到這樣一個細節(jié):當 Model 中的可觀察屬性 laserIntensity 發(fā)生改變時,會間接導致其不可觀察屬性 darkEnergy 在界面上的刷新,這稱之為“刷新污染”,在某些情況下這是不可接受的!
在大多數(shù)理想情況下,我們希望各個(可觀察)屬性的改變在 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 其它部分的刷新:

就像大家看到的那樣:現(xiàn)在當我們增加 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-)