精通 SwiftUI - iOS 17 版

第 14 章
使用 Combine 與 Environment 物件進行資料共享

在第 13 章中,你學到了使用 Form 元件來佈局表單。不過,目前表單還沒有功能,不論你選擇哪個選項,清單視圖都不會反映使用者偏好而有任何改變,這也是我們將在本章中討論與實作的內容。我們將繼續開發設定畫面,並依照使用者的個人偏好更新餐廳清單, 使 App 的功能完善。

具體而言,我們將在後面的小節討論下列主題:

  1. 如何使用列舉(enum)來組織程式碼。
  2. 如何使用 UserDefaults 來永久儲存使用者偏好。
  3. 如何使用 Combine 與 @EnvironmentObject 來共享資料。

如果你還沒有完成第 13 章作業,我鼓勵你花點時間練習。不過,如果你等不及要閱讀本章內容,你可以至下列網址下載範例專案:https://www.appcoda.com/resources/swiftui5/SwiftUIForm.zip

使用列舉重構程式碼

我們目前使用一個陣列來儲存「Display Order」的三個選項,它雖然能夠正常運作,不過還有一個更好的方式可以改善程式碼。

列舉為一組相關的值定義一般型別,並使你在程式碼中以型別安全的方式使用這些值。

- Apple 的官方文件 (https://docs.swift.org/swift-book/LanguageGuide/Enumerations.html)

由於這組固定值是和「Display Order」有關,因此我們可以使用列舉( Enum )來存放它們,每個情況(case )指定一個整數值,如下所示:

enum DisplayOrderType: Int, CaseIterable {
    case alphabetical = 0
    case favoriteFirst = 1
    case checkInFirst = 2

    init(type: Int) {
        switch type {
        case 0: self = .alphabetical
        case 1: self = .favoriteFirst
        case 2: self = .checkInFirst
        default: self = .alphabetical
        }
    }

    var text: String {
        switch self {
        case .alphabetical: return "Alphabetical"
        case .favoriteFirst: return "Show Favorite First"
        case .checkInFirst: return "Show Check-in First"
        }
    }
}

使用列舉的優點是我們可在程式碼中以型別安全的方式使用這些值。最重要的是, Swift 中的 Enum 本身就是一級型別,這表示你可以建立實例方法來提供與值相關的附加功能。稍後,我們將會加入一個處理篩選的功能。同時,我們建立一個名為 SettingStore. swift 的新 Swift 檔來儲存 Enum,如圖 14.1 所示,你可以在專案導覽處的 SwiftUIForm 資料夾點擊右鍵,並選取 「 New File... 」來建立這個檔案。

圖 14.1 建立一個新的 Swift 檔
圖 14.1 建立一個新的 Swift 檔

建立 SettingStore.swift 之後,將上列的程式碼片段插入檔案中。接下來,回到 Setting View.swift,我們將更新程式碼來使用 DisplayOrder 列舉,而不是使用 displayOrders 陣列。

首先,從 SettingView 刪除下列這行程式碼:

private var displayOrders = [ "Alphabetical", "Show Favorite First", "Show Check-in First"]

接下來,更新 selectedOrder 的預設值為 DisplayOrderType.alphabetical,如下所示:

@State private var selectedOrder = DisplayOrderType.alphabetical

這裡,我們預設顯示順序為依字母排列(alphabetical ),如果你與先前的值(即「0」) 進行比較,轉換為使用列舉後,程式碼更易於閱讀了。接下來,你還需要在「Sort Preference」區塊更改程式碼,具體而言,我們更新ForEach 迴圈中的程式碼:

Section(header: Text("SORT PREFERENCE")) {
    Picker(selection: $selectedOrder, label: Text("Display order")) {
        ForEach(DisplayOrderType.allCases, id: \.self) {
            orderType in
            Text(orderType.text)
        }
    }
}

由於我們在 DisplayOrder 列舉中採用 CaseIterable 協定,因此我們可存取 allCases 屬性(該屬性包含所有列舉情況的陣列)來找出所有的顯示順序。

現在,你可以再次測試設定畫面,它應該可正常運作且外觀相同,不過底層程式碼更易於管理及閱讀了。

在 UserDefaults 儲存使用者偏好

目前,App 還不能永久儲存使用者偏好。每當你重新啟動這個 App 時,設定畫面都會重置為預設設定。

有多種儲存設定的方式。要儲存少量資料(如 iOS 的使用者設定),內建的預設資料庫是方案之一。此預設系統讓App 以鍵值對的形式來儲存使用者偏好。要和這個預設資料庫互動,你可以使用一個名為 UserDefaults的可程式化介面(programmatic interface )。

SettingStore.swift 檔中,我們將會建立一個 SettingStore 類別,以提供一些方便的方法來儲存及載入使用者偏好,在 SettingStore.swift 中插入下列的程式碼片段:

final class SettingStore {

    init() {
        UserDefaults.standard.register(defaults: [
            "view.preferences.showCheckInOnly" : false,
            "view.preferences.displayOrder" : 0,
            "view.preferences.maxPriceLevel" : 5
        ])
    }

    var showCheckInOnly: Bool = UserDefaults.standard.bool(forKey: "view.preferences.showCheckInOnly") {
        didSet {
            UserDefaults.standard.set(showCheckInOnly, forKey: "view.preferences.showCheckInOnly")
        }
    }

    var displayOrder: DisplayOrderType = DisplayOrderType(type: UserDefaults.standard.integer(forKey: "view.preferences.displayOrder")) {
        didSet {
            UserDefaults.standard.set(displayOrder.rawValue, forKey: "view.preferences.displayOrder")
        }
    }

    var maxPriceLevel: Int = UserDefaults.standard.integer(forKey: "view.preferences.maxPriceLevel") {
        didSet {
            UserDefaults.standard.set(maxPriceLevel, forKey: "view.preferences.maxPriceLevel")
        }
    }

}

我來簡短解釋一下程式碼,在 init方法中,我們使用一些預設值來初始化預設系統。如果資料庫中找不到使用者偏好,才會使用這些值。

如前所述,你可以使用 UserDefaults,以鍵值對的形式儲存設定。在上列的程式碼中, 我們為此目的宣告了三個屬性,以特定的鍵,從預設系統中載入對應的值,在didSet中,我們使用 UserDefaultsset 方法,來將值儲存在使用者預設。

settingStore 準備好後,我們切換到 SettingView.swift 檔來實作「Save」操作。首先, 在 SettingView 中為 SettingStore 宣告一個屬性。

var settingStore: SettingStore

而「儲存」(Save )按鈕的程式更新如下:

至於 Save 按鈕,你可以將 Save 按鈕的程式碼(在 ToolbarItem(placement: .navigationBarTrailing) 塊中)並將之改為:

Button {
    self.settingStore.showCheckInOnly = self.showCheckInOnly
    self.settingStore.displayOrder = self.selectedOrder
    self.settingStore.maxPriceLevel = self.maxPriceLevel
    dismiss()

} label: {
    Text("Save")
        .foregroundStyle(.primary)
}

我們插入三行程式碼來儲存使用者偏好。要在帶入設定視圖時載入偏好,你可以加入一個 onAppear修飾器至NavigationView,如下列所示:

.onAppear {            
    self.selectedOrder = self.settingStore.displayOrder
    self.showCheckInOnly = self.settingStore.showCheckInOnly
    self.maxPriceLevel = self.settingStore.maxPriceLevel
}

當視圖出現時,onAppear 修飾器會被呼叫,因此我們在它的閉包中從預設系統載入使用者設定。

在測試其變化之前,你必須更新 #Preview,如下所示:

#Preview {
    SettingView(settingStore: SettingStore())
}

現在,切換至 ContentView.swift,並宣告 settingStore 屬性:

var settingStore: SettingStore

並更新 sheet 修飾器如下:

.sheet(isPresented: $showSettings) {
    SettingView(settingStore: self.settingStore)
}

最後,更新 #Preview 如下:

#Preview {
    ContentView(settingStore: SettingStore())
}

我們只是初始化一個 SettingStore,並將其傳送給 SettingView。這是必需的,因為我們已經在 SettingView 中加入 settingStore 屬性。

如果你現在編譯並執行該 App,Xcode 將顯示一個錯誤。在 App 可正常運作之前,我們還需要做一個修改。

圖 14.2. SwiftUIFormApp.swift 中出現錯誤
圖 14.2. SwiftUIFormApp.swift 中出現錯誤

SwiftUIFormApp.swift 並加入下列屬性來建立一個 SettingStore 實例:

var settingStore = SettingStore()

接著,將這行程式碼更改為下列程式碼,來修正錯誤:

ContentView(settingStore: settingStore)

現在,你應該能夠執行 App,並進行設定了。當你儲存設定之後,它將永久儲存在本地預設系統中。你可嘗試停止 App,然後再次啟動它,儲存的設定應已載入至設定畫面中,如圖 14.3 所示。

圖 14.3. 設定畫面應該已載入你的使用者偏好
圖 14.3. 設定畫面應該已載入你的使用者偏好

使用 @EnvironmentObject 在視圖間共享資料

現在,使用者偏好已經儲存在本地預設系統中,但是清單視圖並沒有依照使用者設定來變更。同樣的,有多種方式可以解決這個問題。

我們概括說明一下目前的情形,當使用者在設定畫面中點擊「Save」按鈕,我們儲存所選的選項至本地預設系統中,然後關閉設定畫面,App 將帶使用者回到清單視圖。因此, 我們指示清單視圖來重新載入設定,或者清單視圖能夠監控預設系統的變更,並觸發清單的更新。

隨著 SwiftUI 的推出,Apple 還發布了一個名為「Combine」的新框架,根據Apple 的說法,這個框架提供一個宣告式 API 來隨著時間推移處理值。在此範例的內容中,Combine 讓你輕鬆監控單一物件,並取得變更通知。與SwiftUI 一起使用時,我們甚至可不撰寫一行程式碼,就觸發視圖更新,一切都由 SwiftUI 與 Combine 在幕後處理。

那麼,清單視圖如何知道使用者偏好已被修改,並觸發更新呢?

我來介紹三個關鍵字:

  1. @EnvironmentObject - 以技術而言,這就是一個屬性包裹器( property wrapper ),不過,你可將此關鍵字視為一個特殊的標記,當你宣告一個屬性為環境物件時,SwiftUI 會監控該屬性的值,並在有任何改變時,使對應的視圖無效。@EnvironmentObject 的運作方式與 @State 幾乎相同,不過屬性被宣告為環境物件時, 整個 App 中的所有視圖皆可存取它。舉例而言,如果你的App 有很多共享相同資料(例如:使用者設定)的視圖,則環境物件在這種情況下可運作得很好,你不需要在視圖間傳送屬性,就可以自動存取它。
  2. ObservableObject - 這是一個 Combine框架的協定。當你宣告一個屬性為環境物件時, 該屬性的型別必須實作此協定。回到我們的問題:我們如何讓清單視圖知道使用者偏好已經變更?透過實作此協定,這個物件可以作為一個發布者,發出更改後的值。而那些監控值的訂閱者將會收到通知。
  3. @Published - 這是一個與 ObservableObject 一起使用的屬性包裹器。當一個屬性以 @Publisher 為前綴時,這表示發布者應該在值發生更改時通知所有訂閱者。

我知道這有點令人困惑。不過,當我們看完程式碼後,你將會更加了解。

我們從 SettingStore.swift 開始。設定視圖與清單視圖需要監控使用者偏好的變化,因此 SettingStore 應該實作 ObservableObject 協定,並宣布 defaults 屬性的變更。在 Setting Store.swift 檔的開始處,我們必須先匯入 Combine 框架:

import Combine

SettingStore 類別應該採用 ObservableObject 協定。更新類別宣告,如下所示:

final class SettingStore: ObservableObject {

接下來,如下,在所有的屬性的前面插入 @Published 標註:

@Published var showCheckInOnly: Bool = UserDefaults.standard.bool(forKey: "view.preferences.showCheckInOnly") {
    didSet {
        UserDefaults.standard.set(showCheckInOnly, forKey: "view.preferences.showCheckInOnly")
    }
}

@Published var displayOrder: DisplayOrderType = DisplayOrderType(type: UserDefaults.standard.integer(forKey: "view.preferences.displayOrder")) {
    didSet {
        UserDefaults.standard.set(displayOrder.rawValue, forKey: "view.preferences.displayOrder")
    }
}

@Published var maxPriceLevel: Int = UserDefaults.standard.integer(forKey: "view.preferences.maxPriceLevel") {
    didSet {
        UserDefaults.standard.set(maxPriceLevel, forKey: "view.preferences.maxPriceLevel")
    }
}

藉由使用 @Published 屬性包裹器,發布者將在屬性的值發生變化時通知訂閱者(例如:displayOrder 的更新)。

如你所見,使用 Combine 通知變更的值非常容易。實際上,我們還沒有編寫任何新程式碼,只有採用所需的協定,並插入一個標記。

現在,我們切換至 SettingView.swiftsettingStore 現在應該宣告為環境物件,以讓我們可以與其他視圖共享資料。更新 settingStore 變數,如下所示:

@EnvironmentObject var settingStore: SettingStore

你不需要更新和「Save」按鈕有關的程式碼。不過,當你設定一個新值至設定儲存區時(例如:更新showCheckInOnly,從 true 改為 false ),此更新將會發布,並讓所有訂閱者知道。

由於此變更,我們需要更新 #Preview 為下列內容:

#Preview {
    SettingView().environmentObject(SettingStore())
}

這裡,我們將 SettingStore 的實例注入至環境中,以進行預覽。

好的,發布方已經完成,那麼訂閱者呢?我們要如何監控 defaults 的變化,並相應更新UI 呢?

在這個範例專案中,清單視圖是訂閱方,它需要監控設定儲存區的變化,並重新渲染清單視圖,以反映使用者的設定。現在開啟 ContentView.swift 來做一些變更。和我們剛才所做的操作類似,settingStore 現在應該宣告為一個環境物件:

@EnvironmentObject var settingStore: SettingStore

由於這個變更,因此應要修改 sheet 修飾器中的程式碼,以獲取此環境物件:

.sheet(isPresented: $showSettings) {
    SettingView().environmentObject(self.settingStore)
}

另外,為了測試的目的,預覽程式碼應要相應更新,以注入環境物件:

#Preview {
    ContentView().environmentObject(SettingStore())
}

最後,開啟SwiftUIFormApp.swift ,並更新WindowGroup 內的程式碼,如下所示:

struct SwiftUIFormApp: App {

    var settingStore = SettingStore()

    var body: some Scene {
        WindowGroup {
            ContentView().environmentObject(settingStore)
        }
    }
}

這裡,我們呼叫 environmentObject 方法,將設定儲存區注入至環境。現在,設定儲存區的實例可用於 App 內的所有視圖。換句話說,設定與清單視圖皆可自動存取它了。

實作篩選選項

現在,我們已經實作了一個可以讓所有視圖存取的通用設定儲存區。最棒的是,只要設定儲存區中有任何更改,它會自動通知監控更新的視圖。儘管你看不出任何的視覺差異,但是當你更新設定畫面的選項時,設定儲存區會將變更通知至清單視圖。

我們最終任務是實作篩選與排序選項,以只顯示和使用者偏好相配的餐廳。我們從實作下列兩個篩選選項來開始:

  • 只顯示打卡過的餐廳(Show Check-in Only)。
  • 顯示低於某個價位級別的餐廳(Show restaurants below a certain price level)。

ContentView.swift 中,我們將建立一個名為 showShowItem 的新函數來處理篩選:

private func shouldShowItem(restaurant: Restaurant) -> Bool {
    return (!self.settingStore.showCheckInOnly || restaurant.isCheckIn) && (restaurant.priceLevel <= self.settingStore.maxPriceLevel)
}

該函數帶入一個餐廳物件,並告訴呼叫者是否應該顯示餐廳。在上列的程式碼中,我們檢查「Show Check-in Only」選項是否被選取,並檢驗指定餐廳的價位級別。

接下來,使用 if 語句包裹 BasicImageRow,如下所示:

if self.shouldShowItem(restaurant: restaurant) {
        BasicImageRow(restaurant: restaurant)
            .contextMenu {

                 ...

            }
}

這裡,我們首先呼叫剛才實作的 shouldShowItem 函數,來檢查是否應該顯示餐廳。

現在按 Play 在模擬器執行 App 並快速測試。在設定畫面中,設定「Show Check-in Only」選項為「ON」, 並配置價位級別選項,以顯示價位級別為 3(即$$$ )或以下的餐廳,如圖 14.4 所示。當你點擊「Save」按鈕後,清單視圖應會自動更新(使用動畫),並顯示篩選後的紀錄。

圖 14.4. 當你更改篩選偏好時,清單視圖現在會更新其項目
圖 14.4. 當你更改篩選偏好時,清單視圖現在會更新其項目

實作排序選項

現在,我們已經完成篩選選項的實作,我們來繼續處理排序選項。在 Swift 中,你可以使用 sort(by:) 方法對一個序列中的元素排序。當使用此方法時,你需要提供一個述詞(predicate )給它,在第一個元素應排在第二個元素之前,該述詞會回傳 true

舉例而言,要將 restaurants 陣列依字母排序,則可以使用 sort(by:)方法,如下所示:

restaurants.sorted(by: { $0.name < $1.name })

這裡,$0 是第一個元素,$1 是第二個元素。在這個例子中,名稱為「Upstate」的餐廳大於名稱為「Homei」的餐廳,因此「Homei」將依順序放在「Upstate」的前面。

反之,如果你想要以字母降冪來排序餐廳,你可以編寫程式碼如下:

restaurants.sorted(by: { $0.name > $1.name })

我們如何排序陣列來顯示「check-in」優先,或顯示「favorite」優先呢?我們可以使用相同的方法,但是提供不同的述詞,如下所示:

restaurants.sorted(by: { $0.isFavorite && !$1.isFavorite })
restaurants.sorted(by: { $0.isCheckIn && !$1.isCheckIn })

為了更加組織程式碼,我們可以將這些述詞放在 DisplayOrder 列舉中。至 SettingStore.swift 檔,於 DisplayOrderType 中加入一個新函數,如下所示:

func predicate() -> ((Restaurant, Restaurant) -> Bool) {
    switch self {
    case .alphabetical: return { $0.name < $1.name }
    case .favoriteFirst: return { $0.isFavorite && !$1.isFavorite }
    case .checkInFirst: return { $0.isCheckIn && !$1.isCheckIn }
    }
}

此函數僅回傳對應顯示順序的述詞(即一個閉包)。現在,我們準備進行最後的變更。回到 ContentView.swift,並將 ForEach 敘述從:

ForEach(restaurants) {
  ...
}

變更為:

ForEach(restaurants.sorted(by: self.settingStore.displayOrder.predicate())) {
  ...
}

如此,你可以測試 App,並變更排序偏好。當你更新排序選項時,這個清單視圖將得到通知,並相應地重新排序餐廳。

下一章的主題

你知道 SwiftUI 與 Combine 可幫助我們撰寫出更好的程式碼嗎?在本章最後兩節中,我們並沒有撰寫很多的程式碼來實作篩選及排序選項。Combine 處理事件處理的繁重工作, 將它與 SwiftUI 搭配使用時,它的功能更加強大,並節省你開發實作來監控物件的狀態變化與觸發 UI 更新的時間。一切幾乎是自動的,並由這兩個新框架來負責。

在下一章中,我們將會繼續透過「建立註冊畫面」來探索 Combine 。你將進一步了解 Combine 如何幫助你寫出更簡潔與模組化的程式碼。

在本章所準備的範例檔中,有完整的專案可供下載: