SwiftUI 視圖如何“乖巧地”自動刷新不可觀察(Unobservable)屬性?

概述

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

不過,出于某些原因我們需要禁止可觀察對象中的某些屬性被觀察,這是為什么?又該如何監(jiān)聽這些屬性的改變呢?

在本篇博文中,您將學到如下內容:

  1. 何為不可觀察屬性?它存在的目的是什么?
  2. 刷新不可觀察屬性的妙招
  3. 題外話:如何避免屬性刷新“污染”?
    源代碼

相信學完本課后,小伙伴們對不可觀察屬性的應對會更加游刃有余、得心應手。

那還等什么呢?讓我們馬上開始“觀察”大冒險吧!
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-)

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容