跳转至

第11章:了解属性封装器

在你的SwiftUI应用程序中,每一个可以改变的数据值或对象都需要一个单一的真相来源,以及一个机制来使视图改变或观察它。SwiftUI的属性包装器使你能够声明每个视图如何与可变数据进行交互。

在本章中,你将回顾你是如何用@State@Binding@EnvironmentObservableObject@StateObject@EnvironmentObject管理HIITFit中的数据值和对象的。而且,你将建立一个简单的应用程序,让你专注于如何使用这些属性包装器。你还将学习TextFieldenvironment修改器和@ObservedObject属性封装器。

为了帮助回答"结构还是类"的问题,你将看到为什么HistoryStore应该是一个类,而不是一个结构,并了解SwiftUI应用程序的自然架构:Model-View-ViewModel (MVVM)

开始工作

➤ 打开启动器文件夹中的TIL项目。项目名称TIL是"今天我学到了"的首字母缩写。或者,你可以把它想象成"我学到的东西"。下面是这个应用程序应该如何工作。用户点击+按钮来添加缩写词,如YOLOBTW,主屏幕就会显示这些。

TIL in action

这个应用程序将一个VStack嵌入到一个NavigationView中。这给了你一个导航栏,在那里你可以显示标题和+按钮。你将在第3节了解更多关于NavigationView的信息。

这个项目有一个ThingStore,像HIITFit中的HistoryStore。这个应用比HIITFit简单得多,所以你可以专注于如何管理数据。

记住你如何管理HIITFitHistoryStore的变化:

HIITFit: HistoryStore shared as EnvironmentObject

在第6章"为你的应用程序添加功能"中,你把HistoryStore从一个结构转换为符合ObservableObject的类,然后把它设置为@EnvironmentObject,这样ExerciseViewHistoryView就可以直接访问它。HistoryViewWelcomeView的一个子视图,但是你看到了使用@EnvironmentObject可以避免将HistoryStore传递给WelcomeView,后者并不使用它。如果你做了那一章的挑战,你也用@State@Binding来管理HistoryStore

在第9章"保存历史数据"中,你把HistoryStore的初始化从ContentView移到HIITFitApp,以便在有或没有保存历史数据的情况下初始化它。

ThingStore有属性things,它是一个String值的数组。和HIITFit第一个版本中的HistoryStore一样,它是一个结构。

在这一章中,你将首先使用@State@Binding来管理ThingStore结构的变化,然后将其转换为ObservableObject类并使用@StateObject@ObservedObject来管理变化:

TIL: ThingStore shared as Binding and as ObservedObject

你会了解到,这两种方法非常相似。

Note

我们的教程Property Wrappersbit.ly/3vLOpbl)扩展了这个项目,以使用ThingStore作为@EnvironmentObject

管理数据的工具

你已经知道,@State属性是一个真相的来源。拥有@State属性的视图可以将其值或其绑定传递给它的子视图。如果它向子视图传递了一个绑定,该子视图现在就有一个对真理之源的引用。这允许它更新该属性的值或在该值改变时重新绘制自己。当一个@State值发生变化时,任何对其有引用的视图都会使其外观失效,并重新绘制自身以显示新的状态。

你的应用程序需要管理两种数据的变化:

Managing UI values and model objects

  • 用户界面值,如显示或隐藏视图的布尔标志、文本字段文本、滑块或挑选器值。
  • 数据模型对象,通常是为应用程序的数据建模的对象集合,如完成练习的每日日志。

属性包装器

属性包装器将一个值或对象包装在一个有两个属性的结构中:

  • wrappedValue是基础值或对象。
  • projectedValue是对包裹值的绑定,或者是对对象的投影,可以创建对其属性的绑定。

Swift语法让你只写属性的名字,比如showHistory,而不是showHistory.wrappedValue。而且,它的绑定是$showHistory而不是showHistory.projectedValue

SwiftUI提供了一些工具--主要是属性包装器--来创建和修改数值和对象的单一真理源:

  • 用户界面值:使用@State@Binding来显示影响视图外观的值,例如showHistory。底层类型必须是一个值类型,如BoolIntStringExercise。使用@State在一个视图中创建一个真相来源,然后将@Binding传递给子视图的这个属性。一个视图可以作为@Environment属性或使用environment(_:_:)视图修改器访问内置的@Environment值。

  • 数据模型对象:对于像HistoryStore这样为你的应用程序的数据建模的对象,使用@StateObject@ObservedObjectenvironmentObject(_:)@EnvironmentObject。底层对象类型必须是符合ObservableObject的引用类型--一个类,并且它应该至少发布一个值。然后,要么使用@StateObject@ObservedObject,要么声明一个@EnvironmentObject,其类型与environmentObject(_:)视图修改器创建的环境对象相同。

在对你的应用程序进行原型设计时,你可以用结构来模拟你的数据,并使用@State@Binding。当你弄清楚数据需要如何在你的应用程序中流动时,你可以重构你的应用程序以适应需要符合ObservableObject的数据类型。

这就是你在本章要做的,以巩固你对如何使用这些属性包装器的理解。

保存/存在的应用程序或场景状态

还有两个你用过的属性包装器。@AppStorage包装了UserDefault值。在第8章"保存设置"中,你使用@AppStorage来保存UserDefaults中的练习评级,并在应用程序启动时加载它们。

在同一章中,你用@SceneStorage来保存和恢复场景的状态--iPad模拟器中的窗口,每个窗口显示不同的练习。

管理UI状态值

@State@Binding值属性主要用于管理你的应用程序的用户界面的状态。

视图是一个结构,所以你不能改变一个属性值,除非你把它包装成一个@State@Binding属性。

拥有@State属性的视图负责初始化它。@State属性包装器在视图结构之外为该值创建持久性存储,并在视图重绘时保留其值。这意味着初始化只发生一次。

在第6章"为你的应用程序添加功能"中,你已经有很多关于@State@Binding的练习了:

  • selectedTab控制TabView
  • showHistory, showSuccess, showTimer, timerDone显示或隐藏视图。
  • ratingtimeRemaining值必须能够改变。

在该章的挑战中,你用@State@Binding来管理HistoryStore的变化。这只是一个证明它是可能的练习,这也是你可以采取的一种原型设计方法。对于大多数应用程序,你的最终数据模型将涉及ObservableObject类。

@State@Binding来管理ThingStore

TIL是一个非常简单的应用程序,这使得我们很容易研究管理应用程序数据的不同方法。首先,你会像在你的应用程序的视图之间共享任何其他可变的值一样,管理ThingStore

➤ 在ContentView.swift中,运行实时预览并点击+按钮:

Starter TIL

TIL使用一个布尔标志,showAddThing,来显示或隐藏AddThingView。这是一个@State属性,因为当你点击+按钮时,它的值会发生变化,而且ContentView拥有它。

➤ 在ContentView.swift中,给ContentView添加这一行:

@State private var myThings = ThingStore()

你将向myThings.things添加项目,所以myThings必须是一个封装的属性。在这种情况下,它是@State,因为ContentView拥有它并初始化了它。

➤ 现在,删除临时数组:

let tempThings = ["YOLO", "BTW"]  // delete this line

你将在myThings.things中存储字符串,所以你不再需要这个数组。

➤ 然后,更新ForEach参数:

ForEach(myThings.things, id: \.self) { thing in

你在things数组上循环,而不是tempThings

➤ 刷新预览:

Nothing to see here

现在,没有什么可显示的,因为myThings初始化时是一个空的things数组。如果你在用户第一次启动你的应用程序时显示一条信息,而不是这个空白页面,那么用户体验会更好。

➤ 在ContentView.swift中,在VStack的顶部,在ForEach行之前添加这段代码:

if myThings.things.isEmpty {
  Text("Add acronyms you learn")
    .foregroundColor(.gray)
}

First-time empty-array screen

你给你的用户一个提示,告诉他们可以用你的应用程序做什么。文本是灰色的,所以他们知道这只是一个占位符,直到他们添加自己的数据。

AddThingView需要修改myThings,所以在AddThingView中需要一个@Binding

➤ 在AddThingView.swift中,给AddThingView添加这个属性:

@Binding var someThings: ThingStore

你很快就会从ContentView中传递这个绑定。

➤ 你还会添加一个文本字段,但现在,只是为了在你点击Done时发生一些事情,在解散这个工作表之前,向按钮动作添加这一行:

someThings.things.append("FOMO")

你把一个特定的字符串追加到数组中。

➤ 修复此视图的 previews

AddThingView(someThings: .constant(ThingStore()))

你为ThingStore的恒定初始值创建一个绑定。

➤ 现在,回到ContentView.swift中,修复对AddThingView()的调用:

AddThingView(someThings: $myThings)

你给ContentView``@State属性传递一个绑定给AddThingView

Note

传递一个绑定给子视图对ThingStore中的所有内容的写入权限。在这种情况下,ThingStore只有things数组,但是,如果它有更多的属性,并且你想限制对其things数组的写访问,你可以传递$myThings.things--只对things数组进行绑定。你需要为AddThingView的预览初始化一个String数组。

➤ 开始实时预览,点+然后点Done

Adding a string works.

很好,你已经通过ThingStore使数据从AddThingView流向了ContentView!

现在为了从用户那里获得输入,你将在AddThingView中添加一个TextField

➤ 首先,钉住ContentView的预览,这样当你准备测试你的TextField时它就在那里。

使用一个TextField

许多UI控件通过绑定一个参数到视图的@State属性来工作。这些控件包括Slider, Toggle, PickerTextField

为了通过TextField获得用户输入,你需要一个可变的String属性来存储用户的输入。

➤ 在AddThingView.swift中,将这个属性添加到AddThingView中:

@State private var thing = ""

这是一个@State属性,因为它必须在视图重绘时持续存在。AddThingView拥有这个属性,所以它负责初始化thing。你把它初始化为空字符串。

➤ 现在,在VStack中添加你的TextField,在Done按钮的上方:

TextField("Thing I Learned", text: $thing)  // 1
  .textFieldStyle(RoundedBorderTextFieldStyle())  // 2
  .padding()  // 3
  1. 标签"我学到的东西"是占位符文本。它在TextField中显示为灰色,作为对用户的提示。你给thing传递一个绑定,所以TextField可以将这个值设置为用户输入的内容。
  2. 你用一个圆形的边框来装扮这个TextField
  3. 添加padding,这样从视图的顶部到按钮就有了一些空间。

➤ 然后,编辑按钮动作附加的内容:

if !thing.isEmpty {
  someThings.things.append(thing)
}

而不是"FOMO",你将用户的文本输入附加到你的things数组中,然后检查它不是空字符串。

➤ 在ContentView预览中刷新实时预览,然后点击+。在文本字段中输入一个缩写词,如YOLO。它会自动将第一个字母大写,但你必须按住Shift键来输入其余的字母。点Done

TextField input

ContentView显示你的新首字母缩写。

有时,应用程序会自动纠正你的缩写。FTW改为GETFOMO改为DINO

➤ 将此修改器添加到TextField

.disableAutocorrection(true)

访问环境值

视图可以访问许多环境值,如accessibilityEnabledcolorSchemelineSpacingfontpresentationMode。苹果的SwiftUI文档在apple.co/37cOxak中列出了环境值的完整清单。

视图的环境是一种继承机制。视图从其祖先视图继承环境值,其子视图继承其环境值。

➤ 要看到这一点,请打开ContentView.swift并点击这一行的任何地方:

Text("Add acronyms you learn")

➤ 现在,打开属性检查器:

Text view attributes: Many are inherited.

字体、重量、行数限制、填充和框架大小是继承的。如果你没有将字体颜色设置为灰色,也会被继承。

视图可以重写一个继承的环境值。为一个堆栈设置默认字体,然后为堆栈的子视图中的文本覆盖它是很常见的。你在第3章"主视图的原型设计"中就是这样做的,当时你让第一个页码比其他页码大:

HStack {
  Image(systemName: "1.circle")
    .font(.largeTitle)
  Image(systemName: "2.circle")
  Image(systemName: "3.circle")
  Image(systemName: "4.circle")
}
.font(.title2)

修改环境值

AddThingView已经使用了presentationMode环境值,与HIITFit的SuccessView一样被声明为视图属性。但是,你也可以通过修改视图来设置环境值。

首字母缩写应该以大写字母出现,但很容易忘记按住Shift键。实际上,你可以设置一个环境值来自动将文本转换为大写。

➤ 在TILApp.swift中,将此修改器添加到 ContentView()

.environment(\.textCase, .uppercase)

你将uppercase设置为ContentView及其所有子视图的textCase的默认值。

Note

textCase(.uppercase)也可以,但.environment语法突出了textCase是一个环境值的事实。

➤ 要在实时预览中看到它,还要在ContentView.swift中向previews中的ContentView()添加这个修改器。

➤ 刷新实时预览,添加缩略语,不必费力地保持所有字母的大写。只需输入yolofomo。点选Done。注意这个标签和占位符文本现在都是大写的:

Automagic uppercase

Note

如果占位符文本不全是大写字母,请按Shift-Command-K来清理构建文件夹。

你的字符串会自动转换为大写字母。

这个环境值适用于你的应用程序中的所有文本,这看起来有点奇怪。没问题 - 你可以覆盖它。

➤ 在AddThingView.swift中,将这个修改器添加到VStack中:

.environment(\.textCase, nil)

你把这个值设置为nil,所以这个VStack所显示的文本都不会被转换为大写字母。

➤ 刷新实时预览,点击+,输入icymi然后点击完成:

No upper case conversion in AddThing

现在,按钮标签和占位符文本又恢复了正常。在主屏幕上,uppercase环境默认值仍然将你的字符串转换为全大写。

管理模型数据对象

@State, @Binding@Environment 只对值数据类型起作用。简单的内置数据类型如Int, BoolString对于定义你的应用程序的用户界面的状态很有用。

你可以使用自定义的值数据类型,如structenum来模拟你的应用程序的数据。而且,你可以使用@State@Binding来管理这些值的更新,正如你在本章前面所做的那样。

大多数应用程序也使用类来为数据建模。SwiftUI提供了一种不同的机制来管理类对象的变化。ObservableObject@StateObject@ObservedObject@EnvironmentObject。为了练习使用@ObservedObject,你将重构TIL以使用@StateObject@ObservedObject来更新ThingStore,它符合ObservableObject。你会看到与使用@State@Binding有很多相似之处,也有一些区别。

Note

你可以把一个类对象包装成@State属性,但它的"值"是它在内存中的地址,所以只有当它的地址发生变化时--例如,当应用程序重新初始化时,依赖性视图才会重新绘制自己。

类和结构

但是,这一节并不只是为了练习管理对象。ThingStore实际上应该是一个类,而不是一个结构。

@State@Binding工作得很好,从AddThingView更新ContentView中的ThingStore真值来源。但是ThingStore并不是结构的最自然的使用。对于你的应用程序使用ThingStore的方式,一个类是更合适的。

当你需要像HistoryStoreThingStore那样的共享的可改变的状态时,类更适合。当你需要多个独立的状态时,结构更适合,如ExerciseDay结构。

对于一个类对象,变化是正常的。一个类对象期望其属性发生变化。对于一个结构实例来说,变化是例外的。一个结构实例需要提前通知,一个方法可能会改变一个属性。

一个类对象期望被共享,任何引用都可以被用来改变它的属性。一个结构实例允许自己被复制,但其副本的变化是独立于它和彼此的。

你会在第15章"结构、类和协议 "中了解到更多关于类和结构的信息。

StateObjectObservedObject管理ThingStore

你已经在第6章"为你的应用程序添加功能"中使用了@EnvironmentObject,以避免将HistoryStore通过WelcomeView到达HistoryView

为了把它用作@EnvironmentObject,你把HistoryStore从一个结构转换为符合ObservableObject的类。这也是你在使用@StateObject@ObservedObjectThingStore之前的第一步。一旦完成,你将把它作为一个@StateObject来创建,并把它传递给一个子视图,把它作为一个@ObservedObject来使用。听起来很像"创建一个@State属性并传递它的@Binding",不是吗?

Note

你可以把@State值或@StateObject作为@Binding@ObservedObject属性传递给子视图,即使该子视图只需要读取访问。这使得子视图能够在@State值或ObservableObject改变时重新绘制自己。在第6章"为你的应用程序添加功能"中,你在HeaderView中的selectedTab做了这个。

➤ 在ContentView.swift中,将ThingStore结构替换为以下内容:

final class ThingStore: ObservableObject {
  @Published var things: [String] = []
}

就像你对HistoryStore所做的那样,你让ThingStore成为一个类而不是一个结构,然后让它符合ObservableObject。你把这个类标记为final,告诉编译器它不需要检查任何重写属性或方法的子类。

HistoryStore一样,ThingStore发布它的数据阵列。视图通过声明为@StateObject@ObservedObject@EnvironmentObject来订阅这个发布者。things的任何变化都会通知订阅者的视图来重新绘制自己。

你在第7章"观察对象"中使用了@EnvironmentObject。在HIITFit中,ExerciseViewHistoryView声明对HistoryStore对象的依赖:

@EnvironmentObject var history: HistoryStore

如果一个视图使用@EnvironmentObject,你必须通过在祖先视图上调用environmentObject(_:)修改器来创建模型对象。你首先在ContentView中创建了HistoryStore对象,将修改器应用于TabView

TabView(selection: $selectedTab) {
...
}
.environmentObject(HistoryStore())

然后,在第9章"保存历史数据"中,你把它的初始化提升了一个层次到HIITFitApp,并把它声明为@StateObject

Note

environmentObject修改器中初始化HistoryStore在你进行原型设计时是有效的。为了确保应用程序永远不会重新初始化环境对象,将其声明并初始化为@StateObject,然后在environmentObject修改器中传递属性。

TIL中,AddThingView将使用@ObservedObject,所以你必须在祖先视图中把模型对象实例化为@StateObject,然后把它作为参数传递给其子视图。拥有的视图会精确地创建一次@StateObject

➤ 在ContentView中,用这一行替换@State private var myThings = ThingStore()

@StateObject private var myThings = ThingStore()

ThingStore现在是一个类,而不是一个结构,所以你不能使用@State属性包装器。相反,你可以使用@StateObject

@StateObject属性包装器确保myThings只被实例化一次。当ContentView重新绘制自己时,它将持续存在。

➤ 在对AddThingView(someThings:)的调用中,删除绑定符号$

AddThingView(someThings: myThings)

你不需要为myThings创建一个引用。作为一个类对象,它已经是一个引用。

➤ 在AddThingView.swift中,将AddThingView中的@Binding替换为@ObservedObject

@ObservedObject var someThings: ThingStore

Note

如果ThingStore有更多的属性,并且你想限制对其things数组的写入权限,你可以将$myThings.things传递给AddThingView,它将有一个@Binding someThings: [String]属性。

➤ 并修复它的previews

AddThingView(someThings: ThingStore())

参数不再是一个绑定。

➤ 刷新实时预览,点击+,输入yolo然后点击完成:

TIL in action

没有惊喜:这款应用的工作方式仍然和以前一样。

MVVM

Model-View-Controller

你可能对其他场合的应用程序的Model-View-Controller (MVC)架构很熟悉,比如Web应用程序。你的数据模型对你的应用程序如何向用户展示它一无所知。视图并不拥有数据,而控制器则在模型和视图之间进行调解。

SwiftUI应用程序的一个常用架构是Model-View-View Model (MVVM)。没有控制器,所以视图模型为视图准备了模型数据来显示。

Model-View-ViewModel

视图模型的属性可以包括一个文本字段的当前文本或一个特定按钮是否被启用。在一个视图模型中,你还可以指定视图可以执行的动作,比如点击按钮或手势。

一个用户动作是一个触发视图模型更新模型的事件。如果模型连接到后端数据库,数据可以独立于用户动作而变化。视图模型使用这些来更新视图的状态。

当一个视图显示一个对象或值的集合时,它的视图模型会管理数据的收集。在HIITFitTIL这样的简单应用中,这是视图模型的唯一工作。所以,视图模型的名字中经常包括Store一词。

MVVM in HIITFit

HIITFit的视图模型,HistoryStore,保存和加载用户的运动历史。该模型由ExerciseExerciseDay结构组成。HistoryStore发布了exerciseDays数组。ExerciseViewHistoryView订阅HistoryStoreExerciseViewtap-Done事件更新exerciseDays数组,从而改变HistoryView的状态。

MVVM in TIL

TIL的视图模型,ThingStore,保存了用户的缩写数组。该模型只是一个String,视图模型发布了things数组。ContentViewAddThingView订阅ThingStoreAddThingView的tap-Done事件更新了things数组,从而改变了ContentView的状态。

在第3节的应用程序,RWFreeView中,视图模型存储了一个Episode实例的集合。它负责从raywenderlich.com下载数据,并将数据解码为Episode

包裹属性包装器

这里有一个总结,可以帮助你对属性包装器进行思考。

首先,决定你是在管理一个值的状态还是一个对象的状态。值主要是用来描述你的应用程序的用户界面的状态。如果你能用值数据类型来模拟你的应用程序的数据,你就很幸运了,因为你有更多的属性封装器选项来处理值。但在某种程度上,大多数应用程序都需要引用类型来为其数据建模,通常是为了从一个集合中添加或删除项目。

Property wrappers for values and objects

值包装

@State@Binding是值属性包装器的主力。如果一个视图没有从任何父级视图那里收到该值,那么它就拥有该值。在这种情况下,它是一个@State属性--唯一的真理来源。当一个视图第一次被创建时,它会初始化其@State属性。当一个@State值发生变化时,视图就会重绘自己,重设除@State属性以外的一切。

所有的视图可以将@State值作为普通的只读值或作为读写的@Binding传递给子视图。

当你在制作应用程序的原型并尝试使用子视图时,你可能会把它写成一个只有@State属性的独立的视图。后来,当你把它放到你的应用程序中时,你只需把@State改为@Binding,就可以得到来自父视图的值。

你的应用程序可以访问内置的@Environment值。一个环境值在你所附加的视图的子树中持续存在。通常,这只是一个像VStack的容器,你使用环境值来设置默认值,如字体大小。

Note

你也可以定义你自己的环境值,例如,将一个视图的属性暴露给祖先的视图。这超出了本书的范围,但请查阅《SwiftUI教程》第9章"状态与数据流--第二部分"(bit.ly/39bz5vv)。

你可以在@AppStorage@SceneStorage字典中存储一些值。@AppStorage值在UserDefaults中,所以它们在应用程序关闭后会持续存在。你使用@SceneStorage值来恢复应用程序重新打开时的场景状态。在iOS环境下,场景最容易被看作是iPad上的多个窗口。

包裹对象

当你的应用程序需要改变和响应参考类型的变化时,你创建一个符合ObservableObject的类,并发布相应的属性。在这种情况下,你使用@StateObject@ObservedObject的方式与@State@Binding的值相同。你在一个视图中把你的发布者类实例化为@StateObject,然后把它作为@ObservedObject传递给子视图。当拥有的视图重绘自己时,它不会重置其@StateObject属性。

如果你的应用程序的视图需要更灵活地访问该对象,你可以把它提升到视图子树的环境中,仍然作为一个@StateObject。你必须在这里将其实例化。如果你忘记创建它,你的应用程序会崩溃。然后你使用.environmentObject(_:)修改器将其附加到一个视图上。视图子树中的任何视图都可以通过声明该类型的@EnvironmentObject来订阅该发布者对象。

要使你的应用程序中的每个视图都能使用环境对象,在App创建其WindowGroup时将其附加到根视图。

关键点

  • 每一个可以改变的数据值或对象都需要一个单一的真理源和一个机制来使视图更新它。
  • 使用@State@Binding来管理用户界面值的变化。
  • 作为@Environment视图属性或通过使用environment视图修改器访问@Environment值。
  • 使用@StateObject@ObservedObject来管理数据模型对象的变化。对象类型必须符合ObservableObject,并且应该至少发布一个值。
  • 如果只有几个子视图需要访问ObservableObject,可以将其实例化为@StateObject,然后在 environmentObject视图修改器中传递它。在任何需要访问它的子视图中声明一个@EnvironmentObject属性。
  • 当对你的应用程序进行原型设计时,你可以使用@State@Binding来模拟你应用程序的数据结构。当你确定了数据需要如何在你的应用程序中流动时,你可以重构你的应用程序以适应需要符合ObservableObject的数据类型。
  • SwiftUI应用程序的一个常用架构是Model-View-View Model (MVVM),其中视图模型是一个ObservableObject。对视图模型发布的属性的改变会导致模型和视图的更新。