前言
SwiftUI與蘋果之前的UI框架的區(qū)別不僅僅在于如何定義視圖和其他UI組件,還在于如何在整個使用它的應用程序中管理視圖層級的狀態(tài)。
SwiftUI沒有使用委托、數(shù)據源或任何其他在UIKit和AppKit等命令式框架中常見的狀態(tài)管理模式,而是配備了一些屬性包裝器[1],使我們能夠準確地聲明我們的數(shù)據如何被我們的視圖觀察、渲染和改變。
本周,讓我們仔細看看這些屬性包裝器中的每一個,它們之間的關系,以及它們如何構成SwiftUI整體狀態(tài)管理系統(tǒng)的不同部分。
屬性狀態(tài)
由于SwiftUI主要是一個UI框架(盡管它也開始獲得用于定義更高層次結構(如應用程序和場景)的API),其聲明式設計不一定需要影響應用程序的整個模型和數(shù)據層——而只是直接綁定到我們各種視圖的狀態(tài)。
例如,假設我們正在開發(fā)一個SignupView,使用戶能夠通過輸入用戶名和電子郵件地址在應用程序中注冊一個新賬戶。我們將使用這兩個值形成一個用戶模型,并將其傳遞給一個閉包:
struct SignupView: View { var handler: (User) -> Void var username = “” var email = “” var body: some View { … }}
由于這三個屬性中只有兩個——username和email——實際上會被我們的視圖修改,而且這兩個狀態(tài)可以保持私有,我們將使用SwiftUI的State屬性包裝器來標記它們——像這樣:
struct SignupView: View { var handler: (User) -> Void @State private var username = “” @State private var email = “” var body: some View { … }}
這樣做將自動在這兩個值和我們的視圖本身之間建立一個連接——這意味著我們的視圖將在每次改變這兩個值的時候被重新渲染。在我們的主體中,我們將把這兩個屬性分別綁定到一個相應的TextField上,以使它們可以被用戶編輯:
struct SignupView: View { var handler: (User) -> Void @State private var username = “” @State private var email = “” var body: some View { VStack { TextField(“Username”, text: $username) TextField(“Email”, text: $email) Button( action: { self.handler(User( username: self.username, email: self.email )) }, label: { Text(“Sign up”) } ) } .padding() }}
因此,State 被用來表示SwiftUI視圖的內部狀態(tài),并在該狀態(tài)被改變時自動使視圖更新。因此,最常見的做法是將State屬性包裝器保持為私有,這可以確保它們只在該視圖的主體內被改變(試圖在其他地方改變它們實際上會導致運行時崩潰)。
雙向綁定
看一下上面的代碼樣本,我們將每個屬性傳入其TextField 的方式是在這些屬性名稱前加上$ 。這是因為我們不只是將普通的String 值傳入這些文本字段,而是與我們的State包裝的屬性本身綁定。
為了更詳細地探討這意味著什么,讓我們現(xiàn)在假設我們想創(chuàng)建一個視圖,讓我們的用戶編輯他們最初在注冊時輸入的個人資料信息。由于我們現(xiàn)在要修改外部狀態(tài)值,而不僅僅是私人狀態(tài)值,所以這次我們將username和email 屬性標記為Bingding:
struct ProfileEditingView: View { @Binding var username: String @Binding var email: String var body: some View { VStack { TextField(“Username”, text: $username) TextField(“Email”, text: $email) } .padding() }}
最酷的是,綁定不僅僅局限于單一的內置值,比如字符串或整數(shù),而是可以用來將任何Swift值綁定到我們的一個視圖中。例如,我們可以將用戶模型本身傳遞給ProfileEditingView ,而不是傳遞兩個單獨的username和email:
struct ProfileEditingView: View { @Binding var user: User var body: some View { VStack { TextField(“Username”, text: $user.username) TextField(“Email”, text: $user.email) } .padding() }}就像我們在將State和
就像我們在將State和Binding 包裝的屬性傳入各種TextField 實例時用$ 作為前綴一樣,我們在將任何State 值連接到我們自己定義的Binding屬性時也可以做同樣的事情。
例如,這里有一個ProfileView 的實現(xiàn),它使用一個Stage 包裝屬性來跟蹤一個用戶模型,然后在將上述ProfileEditingView 的實例作為工作表呈現(xiàn)時,將該模型傳遞一個綁定——這將自動同步用戶對該原始State屬性值的任何改變:
struct ProfileView: View { @State private var user = User.load() @State private var isEditingViewShown = false var body: some View { VStack(alignment: .leading, spacing: 10) { Text(“Username: “) .foregroundColor(.secondary) + Text(user.username) Text(“Email: “) .foregroundColor(.secondary) + Text(user.email) Button( action: { self.isEditingViewShown = true }, label: { Text(“Edit”) } ) } .padding() .sheet(isPresented: $isEditingViewShown) { VStack { ProfileEditingView(user: self.$user) Button( action: { self.isEditingViewShown = false }, label: { Text(“Done”) } ) } } }}
請注意,我們也可以通過給一個State 包裝的屬性分配一個新的值來改變它——比如我們在 “Done “按鈕的動作處理程序中把isEditingViewShown 設置為false。
因此,一個Binding 標記的屬性在給定的視圖和定義在該視圖之外的狀態(tài)屬性之間提供了一個雙向的連接,而Statr和Binding 包裝的屬性都可以通過在其屬性名前加上$來作為綁定物傳遞。
觀察對象
State和Bingding的共同點是,它們處理的是在SwiftUI視圖層次結構本身中管理的值。然而,雖然建立一個將所有的狀態(tài)都保存在其各種視圖中的應用程序是肯定可行的,但從架構和關注點分離的角度來看,這通常不是一個好主意,而且很容易導致我們的視圖變得相當龐大和復雜。
值得慶幸的是,SwiftUI還提供了一些機制,使我們能夠將外部模型對象連接到我們的各種視圖。其中一個機制是ObservableObject 協(xié)議,當它與ObservedObject屬性包裝器結合時,我們可以設置與我們視圖層之外管理的引用類型的綁定。
作為一個例子,讓我們更新上面定義的ProfileView ——通過將管理User 模型的責任從視圖本身轉移到一個新的、專門的對象中?,F(xiàn)在,我們可以用許多不同的方式來描述這樣一個對象,但由于我們正在尋找創(chuàng)建一個類型來控制我們的一個模型的實例——讓我們把它變成一個符合SwiftUI的ObservableObject協(xié)議的模型控制器[2]:
class UserModelController: ObservableObject { @Published var user: User …}
Published屬性包裝器用于定義對象的哪些屬性在被修改時應讓觀察通知被觸發(fā)。
有了上面的類型,現(xiàn)在讓我們回到ProfileView ,讓它觀察新的UserModelController 的實例,作為一個ObservedObject ,而不是用一個State 屬性包裝器來跟蹤我們的用戶模型。最重要的是,我們仍然可以很容易地將這個模型綁定到我們的ProfileEditingView 上,就像以前一樣,因為ObservedObject屬性包裝器也可以轉換為綁定:
struct ProfileView: View { @ObservedObject var userController: UserModelController @State private var isEditingViewShown = false var body: some View { VStack(alignment: .leading, spacing: 10) { Text(“Username: “) .foregroundColor(.secondary) + Text(userController.user.username) Text(“Email: “) .foregroundColor(.secondary) + Text(userController.user.email) Button( action: { self.isEditingViewShown = true }, label: { Text(“Edit”) } ) } .padding() .sheet(isPresented: $isEditingViewShown) { VStack { ProfileEditingView(user: self.$userController.user) Button( action: { self.isEditingViewShown = false }, label: { Text(“Done”) } ) } } }}
然而,我們的新實現(xiàn)與之前使用的基于狀態(tài)的實現(xiàn)之間的一個重要區(qū)別是,我們的UserModelController 現(xiàn)在需要作為初始化器的一部分被注入到ProfileView中。
除了 “迫使 “我們在代碼庫中建立一個更明確的依賴關系圖之外,原因是一個標有ObservedObject的屬性并不意味著對這個屬性所指向的對象有任何形式的所有權。
因此,雖然下面的內容在技術上可能會被編譯,但最終會導致運行時的問題——因為當我們的視圖在更新時被重新創(chuàng)建,UserModelController實例可能會被刪除(因為我們的視圖現(xiàn)在是它的主要所有者):
struct ProfileView: View { @ObservedObject var userController = UserModelController.load() …}
重要的是要記住: SwiftUI視圖不是對正在屏幕上渲染的實際UI組件的引用,而是描述我們的UI的輕量級值——因此它們沒有像UIView實例那樣的生命周期。
為了解決上述問題,蘋果在iOS 14和macOS Big Sur中引入了一個新的屬性包裝器,名為StateObject 。標記為StateObject 的屬性與ObservedObject的行為完全相同——此外,SwiftUI將確保存儲在此類屬性中的任何對象不會因為框架在重新渲染視圖時重新創(chuàng)建新實例而被意外釋放:
struct ProfileView: View { @StateObject var userController = UserModelController.load() …}
盡管從技術上來說,從現(xiàn)在開始可以只使用StateObject ——我仍然建議在觀察外部對象時使用ObservedObject ,而在處理視圖本身擁有的對象時只使用StateObject 。把StateObject和ObservedObject 看作是State和Binding的參考類型,或者SwiftUI版本的強和弱屬性。
觀察和修改環(huán)境變量
最后,讓我們來看看SwiftUI的環(huán)境系統(tǒng)如何被用來在兩個互不直接連接的視圖之間傳遞各種狀態(tài)。盡管在一個父視圖和它的一個子視圖之間創(chuàng)建綁定通常很容易,但在整個視圖層次結構中傳遞某個對象或值可能相當麻煩——而這正是環(huán)境變量旨在解決的問題類型。
有兩種主要的方法來使用SwiftUI的環(huán)境。一種是首先在想要檢索給定對象的視圖中定義一個EnvironmentObject 包裝的屬性——例如像這個ArticleView 如何檢索一個包含顏色信息的Theme對象:
struct ArticleView: View { @EnvironmentObject var theme: Theme var article: Article var body: some View { VStack(alignment: .leading) { Text(article.title) .foregroundColor(theme.titleTextColor) Text(article.body) .foregroundColor(theme.bodyTextColor) } }}
然后,我們必須確保在我們的視圖的某一個父類中提供我們的環(huán)境對象(在這種情況下是一個Theme 實例),然后SwiftUI會處理其余的事情。這是通過使用environmentalObject修飾符完成的,例如,像這樣:
struct RootView: View { @ObservedObject var theme: Theme @ObservedObject var articleLibrary: ArticleLibrary var body: some View { ArticleListView(articles: articleLibrary.articles) .environmentObject(theme) }}
請注意,我們不需要將上述修改器應用于將使用我們的環(huán)境對象的確切視圖——我們可以將其應用于我們的層次結構中任何在其之上的視圖。
使用 SwiftUI 環(huán)境系統(tǒng)的第二種方式是定義一個自定義的EnvironmentKey ——然后它可以被用來向內置的 EnvironmentValues 類型分配和檢索值:
struct ThemeEnvironmentKey: EnvironmentKey { static var defaultValue = Theme.default}extension EnvironmentValues { var theme: Theme { get { self[ThemeEnvironmentKey.self] } set { self[ThemeEnvironmentKey.self] = newValue } }}
有了上述內容,我們現(xiàn)在可以使用Enviroment 屬性包裝器(而不是EnvironmentObject )來標記我們視圖的theme屬性,并傳入我們希望檢索的環(huán)境鍵的鍵值路徑:
struct ArticleView: View { @Environment(.theme) var theme: Theme var article: Article var body: some View { VStack(alignment: .leading) { Text(article.title) .foregroundColor(theme.titleTextColor) Text(article.body) .foregroundColor(theme.bodyTextColor) } }}
上述兩種方法的一個明顯區(qū)別是,基于鍵的方法要求我們在編譯時定義一個默認值,而基于環(huán)境對象EnvironmentObject的方法則假設在運行時提供這樣一個值(如果不這樣做將導致崩潰)。
小結
SwiftUI管理狀態(tài)的方式絕對是該框架最有趣的方面之一,它可能需要我們稍微重新思考數(shù)據在應用中的傳遞方式——至少在涉及到將被我們的UI直接消費和修改的數(shù)據時是這樣。
我希望這篇指南能成為一個很好的方式來概述SwiftUI的各種狀態(tài)處理機制,盡管一些更具體的API被遺漏了,這篇文章中強調的概念應該涵蓋了所有基于SwiftUI的狀態(tài)處理的絕大多數(shù)用例。
感謝你的閱讀!