使用@Observable包装器在Swift中更好的控制状态
所属分类:ios | 发布于 2025-01-17
这是一篇翻译的文章,感觉质量很好,借助翻译软件翻译到本博客来。
在 iOS 17 中,苹果引入了一个新 Observation 框架,提供了观察者设计模式的实现。
以下是 Apple 文档中概述此功能的部分摘录
观察框架提供以下功能:
- 将类型标记为可观察的
- 跟踪可观察类型实例内的变化
- 在其他地方观察和利用这些变化,例如在应用程序的用户界面中
简单来说,我们可以使用 @Observable 宏创建自定义类型并对其属性的任何更改做出反应。这种方法可以与SwiftUI 无缝协作,并解决了我们之前遇到的与跟踪嵌套类型相关的几个挑战。
现在,让我们考虑一个正在开发电子书阅读应用程序的场景。在初始屏幕上,有一本打开的书,以及一个显示第二个屏幕的选项,其中显示一些书籍设置,用户可以在其中更改字体大小(为了使本示例简单起见,我们只采用一个属性)。
struct Settings {
var fontSize: Int
}
当使用 @Observable 宏时,我们的 Settings 模型可能看起来像这样:
@Observable
class Settings {
var fontSize: Int
init(fontSize: Int) {
self.fontSize = fontSize
}
}
我们可以在第一个视图中利用模型来渲染一本字体大小可调的书。此外,我们可以使用 @Bindable 属性包装器将其注入到第二个视图中,这样我们就可以根据需要更新字体大小。
虽然这种方法看起来很有希望,但第一个视图可能需要比设置模型更多的功能。因此,我们可以创建一个 FirstViewModel 对象来处理与电子书阅读屏幕相关的状态管理和其他业务逻辑。
@Observable
final class FirstViewModel {
var settings: Settings = Settings(fontSize: 10)
...
}
还有 FirstView :
struct FirstView: View {
@State private var settingsPresented = false
private let viewModel: FirstViewModel
init(viewModel: FirstViewModel) {
self.viewModel = viewModel
}
var body: some View {
VStack {
...
}
.sheet(isPresented: $settingsPresented) {
SecondView(settings: viewModel.settings)
.presentationDetents([.medium])
}
}
}
为了简单起见,我们只需将 Settings 模型注入即可 SecondView。在此视图中,我们将包含一个"+"按钮,允许用户增加字体大小。
struct SecondView: View {
@Bindable var settings: Settings
var body: some View {
VStack {
...
Button(
action: {
settings.fontSize += 1
},
label: {
Text("+")
}
)
...
}
}
}
该模型的外观 Settings 看起来不错,并且满足了我们的目的。然而,现实情况比这个例子要复杂得多。
一个问题是我们的 Settings 模型不再是 struct 。在实际应用中,您通常有一个持久层并使用 ORM 作为模型,您还从后端检索数据,以及使用 Codable 等。这些不同功能的组合在混合时会使您的模型对象变得混乱。因此,我希望我的空闲模型是值类型而不是引用类型,并使用额外的包装器或扩展来实现此类功能。如果我希望将模型改 Settings 回结构体,但仍保留 @Observable 宏,那将是不可能的。
@Observable
struct Settings {
var fontSize: Int
init(fontSize: Int) {
self.fontSize = fontSize
}
}
在上面的例子中我将得到以下错误:
Error: @Observable' cannot be applied to struct type 'Settings'
没有办法 @Observable 与值类型一起使用,这是有道理的,因为观察不适用于值类型,因为它每次都会复制数据。
但是,我们已经 FirstViewModel 设置为 @Observable,因此我们可以在该上下文中使用原始内容。不幸的是,现在我们在绑定 struct Settings 中遇到了错误:SecondView @Bindable var settings
Error: 'init(wrappedValue:)' is unavailable: The wrapped value must be an object that conforms to Observable
我们有几种方法可以解决这个问题,我将重点介绍这两种方法:
1. 将整个 FirstViewModel 注入 @Bindable ,仍然符合 Observable 。
2. 将其包裹 Settings 在可注入的物体中。
在我们非常简单的示例中,选项 1 是可行的,但对于实际应用来说,这不是一个好方法。我们会承担一些 SecondView 不应该意识到的责任,而且我们不想过多地破坏这种隔离。
让我们看一下第二种方法的选项之一。这是一个简单的对象,可以用作我们模型的状态管理包装器:
@Observable
class ObservableState<Item> {
var item: Item
init(item: Item) {
self.item = item
}
}
有了这样的泛型类型,我们将能够在视图模型中使用它,如下所示:
@Observable
final class FirstViewModel {
var settingsState: ObservableState<Settings>
init() {
settingsState = ObservableState<Settings>(item: Settings(fontSize: 10))
}
...
}
现在我们可以使用我们的状态将其注入到 SecondView :
struct FirstView: View {
@State private var settingsPresented = false
private let viewModel: FirstViewModel
...
var body: some View {
VStack {
...
}
.sheet(isPresented: $settingsPresented) {
SecondView(settings: viewModel.settingsState)
.presentationDetents([.medium])
}
}
}
该 SeconView 将具有与以前几乎相同的实现:
struct SecondView: View {
private var state: ObservableState<Settings>
init(state: ObservableState<Settings>) {
self.state = state
}
var body: some View {
VStack {
...
Button(
action: {
state.item.fontSize += 1
},
label: {
Text("+")
}
)
...
}
}
}
更新后的方法 ObservableState 满足了我们将模型保留为值类型对象的需求。但是,我们现在还可以使用此包装器来实现观察框架未免费提供的附加功能。
例如,假设我们将我们的存储 Settings 在某个持久存储或后端中。这意味着我们需要在用户更改字体大小时更新它。使用以前的方法,我们需要通过注入额外的闭包或委托来实现这一点,该闭包或委托负责在 FirstViewModel 更新完成并且我们想要 Settings 在存储/后端中进行更新时通知我们。由于我们不想在用户每次点击 "+" 按钮时都更新它,因此我们不能使用任何类型的订阅来更新值(我们稍后会解决这种情况;框架也不清楚Observation)。
让我们使用新 ObservableState 对象直接向状态添加功能:
@Observable
class ObservableState<Item> {
var item: Item
var onFinish: (() -> Void)?
init(item: Item, onFinish: (() -> Void)? = nil) {
self.item = item
self.onFinish = onFinish
}
}
FirstViewModel 我们可以使用此闭包并有一个存储设置的逻辑:
@Observable
final class FirstViewModel {
var settingsState: ObservableState<Settings>
init() {
settingsState = ObservableState<Settings>(
item: Settings(fontSize: 10),
onFinish: {
// Store the updated settings
}
)
}
...
}
注意:这只是一个示例用例,您很可能会在那里使用自对象。因此,您需要一个单独的函数来 onFinish 在加载视图时注入闭包,[weak self] 如果您仍然以上述方式创建它,但在 init 之外进行适当的内存管理,则也需要使用它。
在第二个视图中,我们只需要 state.onFinish?() 在用户完成字体大小编辑时调用即可。
Settings 我们可以做的另一项改进是实时对 内部的变化做出反应 FirstViewModel 。该 Observation 框架与 SwiftUI 配合得很好,当用户更新字体大小时,它会自动更新 FirstView。但如果我们需要在每次 fontSize 值更改时运行一些逻辑怎么办?或者,如果我们需要完全在 SwiftUI 流程之外使用相同的功能怎么办?该 Observation 框架的使用不仅限于 SwiftUI,但它不会免费为我们提供这样的功能。
通过使用 withObservationTracking(\_:onChange:),我们可以订阅对象的属性更改 @Observable。但是,onChange 只会被调用一次,每次调用时我们都需要使用递归来再次订阅onChange。这种方法可能看起来像是一种 hack,看起来不像一些推荐的开箱即用的解决方案。
使用我们的 ObservableState 包装器,我们可以封装项目更新检查:
@Observable
class ObservableState<Item> {
var item: Item {
didSet {
onChange?()
}
}
var onFinish: (() -> Void)?
private var onChange: (() -> Void)?
init(
item: Item,
onChange: (() -> Void)? = nil,
onFinish: (() -> Void)? = nil
) {
self.item = item
self.onChange = onChange
self.onFinish = onFinish
}
}
在这种情况下,我们可以处理闭包 Settings 中的任何变化 onChange:
@Observable
final class FirstViewModel {
var settingsState: ObservableState<Settings>
init() {
settingsState = ObservableState<Settings>(
item: Settings(fontSize: 10),
onChange: {
// Run some logic on every update of fontSize
},
onFinish: {
// Store the updated settings
}
)
}
...
}
无需进行其他更改 SecondView。它们 onChange 可立即使用。
ObservableState 这些只是如何使用这种概念以及为什么这种方法可以带来一些价值的几个例子 。
我们可以做的最后一个改变是为每个状态使用一个附加类型,以使其在视图模型中更具可读性:
final class SettingsState: ObservableState<Settings> { }
差别不是很大,但是少写一点代码总是更好。
final class FirstViewModel {
var settingsState: SettingsState
...
}
最后,附上原文链接:Make @Observable Wrapper for Better State Control in Swift