1.3 在应用中管理模型数据

原文链接: https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app

1.3 在应用中管理模型数据

在应用的数据模型与视图之间建立连接。

概述

SwiftUI 应用可以显示人们能通过应用的用户界面(UI)修改的数据。为管理这些数据,应用会创建一个数据模型,也就是表示这些数据的自定义类型。数据模型把数据与操作数据的视图分隔开来。这种分离有助于模块化、提升可测试性,也让人更容易理解应用的工作方式。

让模型数据(即数据模型的一个实例)与屏幕上显示的内容保持同步可能很有挑战,尤其是当同一份数据同时出现在界面的多个视图中时。

借助 Observation,SwiftUI 帮助应用界面与数据的改动保持同步。有了 Observation,SwiftUI 中的视图可以与可观察的数据模型建立依赖,并在数据变化时更新界面。

注意:SwiftUI 中对 Observation 的支持从 iOS 17、iPadOS 17、macOS 14、tvOS 17 和 watchOS 10 开始提供。关于在已有应用中采用 Observation 的信息,参见从 Observable Object 协议迁移到 Observable 宏。

让模型数据可观察

要让数据变化对 SwiftUI 可见,请把 Observable() 宏应用到你的数据模型上。这个宏会在编译期生成代码,为你的数据模型添加观察支持,同时让你的数据模型代码专注于存储数据的属性。例如,下面的代码定义了一个用于书籍的数据模型:

1
2
3
4
5
@Observable class Book: Identifiable {
    var title = "Sample Book Title"
    var author = Author()
    var isAvailable = true
}

重要:Observable() 宏除了添加观察功能之外,还会让你的数据模型类型遵循 Observable 协议,以此向其他 API 表明你的类型支持观察。不要只给数据类型应用 Observable 协议,因为仅仅那样并不会添加任何观察功能。在为类型添加观察支持时,请始终使用 Observable 宏。

在视图中观察模型数据

在 SwiftUI 中,当视图的 body 属性读取某个可观察数据模型对象(例如 Book 的实例)的属性时,该视图就与该对象建立了依赖。如果 body 没有读取某个可观察数据模型对象的任何属性,视图就不会跟踪任何依赖。

当被跟踪的属性发生变化时,SwiftUI 会更新该视图。如果变化的是 body 没有读取的其他属性,视图不受影响,也就避免了不必要的更新。例如,下面代码中的视图只在书籍的 title 变化时更新,而在 author 或 isAvailable 变化时不更新:

1
2
3
4
5
6
7
struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

即使视图并不存储该可观察类型(例如使用全局属性或单例),SwiftUI 也会建立这种依赖跟踪:

1
2
3
4
5
6
7
var globalBook: Book = Book()

struct BookView: View {
    var body: some View {
        Text(globalBook.title)
    }
}

当计算属性用到了可观察属性时,Observation 也支持对计算属性进行跟踪。例如,下面代码中的视图会在可借书籍数量变化时更新:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Observable class Library {
    var books: [Book] = [Book(), Book(), Book()]
    
    var availableBooksCount: Int {
        books.filter(\.isAvailable).count
    }
}

struct LibraryView: View {
    @Environment(Library.self) private var library
    
    var body: some View {
        NavigationStack {
            List(library.books) { book in
                // ...
            }
            .navigationTitle("Books available: \(library.availableBooksCount)")
        }
    }
}

当视图与某个对象集合(任何集合类型)建立依赖时,视图会跟踪该集合本身发生的改动。例如,下面代码中的视图因为 body 读取了 books,所以与它建立了依赖。当 books 发生变化——例如向集合中插入、删除、移动或替换条目——SwiftUI 都会更新该视图。

1
2
3
4
5
6
7
8
9
struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            Text(book.title)
        }
    }
}

不过,LibraryView 并不会与属性 title 建立依赖,因为该视图的 body 并没有直接读取它。视图把 List 的内容闭包存为一个 @escaping 闭包,SwiftUI 会在惰性创建列表条目、让它们出现到屏幕上之前调用它。这意味着与 title 建立依赖的不是 LibraryView,而是列表中每个 Text 条目。对某个 title 的任何改动,都只会更新代表那本书的那个 Text,而不影响其他条目。

注意:Observation 会跟踪出现在视图 body 属性执行作用域中的任何可观察属性的变化。

你也可以把可观察的模型数据对象共享给另一个视图。如果接收方视图在其 body 中读取了该对象的任何属性,它就建立了依赖。例如,在下面代码中,LibraryView 把一个 Book 实例共享给 BookView,而 BookView 显示该书的名字 title。如果书名 title 变化,SwiftUI 只会更新 BookView,不会更新 LibraryView,因为只有 BookView 读取了 title 属性。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            BookView(book: book)
        }
    }
}

struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

如果视图没有任何依赖,数据变化时 SwiftUI 不会更新该视图。这种做法让可观察的模型数据对象可以穿过视图层级的多层,而不必让每个中间视图都建立依赖。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// `book` 的任何属性变化时都不会更新。
struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]
    
    var body: some View {
        List(books) { book in 
            LibraryItemView(book: book)
        }
    }
}

// `book` 的任何属性变化时都不会更新。
struct LibraryItemView: View {
    var book: Book
    
    var body: some View {
        BookView(book: book)
    }
}

// `book.title` 变化时会更新。
struct BookView: View {
    var book: Book
    
    var body: some View {
        Text(book.title)
    }
}

不过,存储对可观察对象引用的视图会在引用变化时更新。这发生的原因是,所存储的引用是视图自身值的一部分,而不是因为该对象可观察。例如,如果下面代码中对 book 的引用发生变化,SwiftUI 会更新该视图:

1
2
3
4
5
6
7
struct BookView: View {
    var book: Book
    
    var body: some View {
        // ...
    }
}

视图也可以与通过另一个对象访问到的可观察数据模型对象建立依赖。例如,下面代码中的视图会在作者的 name 变化时更新:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct LibraryItemView: View {
    var book: Book
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(book.title)
            Text("Written by: \(book.author.name)")
                .font(.caption)
        }
    }
}

为模型数据创建数据源

要创建并存储模型数据的数据源,请声明一个私有变量,并用可观察数据模型类型的实例初始化它。然后加上 State() 宏,表明由 SwiftUI 管理该属性。例如,下面代码把数据模型类型 Book 的实例存储在状态变量 book 中:

1
2
3
4
5
6
7
struct BookView: View {
    @State private var book = Book()
    
    var body: some View {
        Text(book.title)
    }
}

用 State() 包装 book,就等于告诉 SwiftUI 管理该实例的存储。SwiftUI 每次重新创建 BookView 时,都会把 book 变量连接到被管理的实例,从而为视图提供模型数据的单一数据源。

你也可以在顶层 App 实例中,或者在应用的某个 Scene 实例中创建状态对象。例如,下面代码在应用的顶层结构中创建一个 Library 实例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@main
struct BookReaderApp: App {
    @State private var library = Library()
    
    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}

在整个视图层级中共享模型数据

如果你有一个想在整个应用中共享的数据模型对象(例如 Library),可以:

  • 把该数据模型对象传给视图层级中的每一个视图;或者
  • 把该数据模型对象加入视图的环境。

在视图层级较浅时(例如某个视图不与子视图共享该对象),逐个传递模型数据很方便。不过,你通常并不知道某个视图是否需要把对象传给子视图,也可能不知道层级深处某个子视图是否需要这份模型数据。

要在整个视图层级中共享模型数据而不必逐个传递,请把模型数据加入视图的环境。你可以用 environment(::) 或 environment(_:) 修饰符传入模型数据,把它加入环境。

在使用 environment(::) 修饰符之前,你需要创建一个自定义的 EnvironmentKey。然后扩展 EnvironmentValues,加入一个自定义环境属性,用来读写该自定义键的值。例如,下面代码为 library 创建了一个环境键和属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
extension EnvironmentValues {
    var library: Library {
        get { self[LibraryKey.self] }
        set { self[LibraryKey.self] = newValue }
    }
}

private struct LibraryKey: EnvironmentKey {
    static let defaultValue: Library = Library()
}

有了自定义环境键和属性之后,视图就可以把模型数据加入它的环境。例如,LibraryView 用 environment(::) 修饰符把 Library 实例的数据源加入环境:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@main
struct BookReaderApp: App {
    @State private var library = Library()
    
    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(\.library, library)
        }
    }
}

要从环境中取出 Library 实例,视图定义一个本地变量来存储对该实例的引用,然后用 Environment 属性包装器包装该变量,并传入自定义环境值的键路径。

1
2
3
4
5
6
7
struct LibraryView: View {
    @Environment(\.library) private var library

    var body: some View {
        // ...
    }
}

你也可以用 environment(_:) 修饰符,把模型数据直接存入环境,而无需定义自定义环境值。例如,下面代码用这个修饰符把一个 Library 实例加入环境:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@main
struct BookReaderApp: App {
    @State private var library = Library()
    
    var body: some Scene {
        WindowGroup {
            LibraryView()
                .environment(library)
        }
    }
}

要从环境中取出该实例,另一个视图定义一个本地变量来存储该实例,并用 Environment 属性包装器包装它。不过,这里不必提供环境值的键路径,而是可以直接提供模型数据类型,如下面代码所示:

1
2
3
4
5
6
7
struct LibraryView: View {
    @Environment(Library.self) private var library
    
    var body: some View {
        // ...
    }
}

默认情况下,用对象类型作为键从环境读取对象时返回的是非可选对象。这个默认行为假定当前层级中此前有某个视图用 environment(_:) 修饰符存入了该类型的非可选实例。如果某个视图试图用类型取出对象、但该对象不在环境中,SwiftUI 会抛出异常。

在无法保证环境中存在该对象的情况下,请改为取出该对象的可选版本,如下面代码所示。如果环境中没有该对象,SwiftUI 会返回 nil,而不会抛出异常。

1
@Environment(Library.self) private var library: Library?

在视图中修改模型数据

在大多数应用中,人们可以修改应用呈现的数据。当数据变化时,任何显示该数据的视图都应更新以反映变化。有了 SwiftUI 中的 Observation,视图无需使用属性包装器或绑定就能支持数据改动。例如,下面代码在按钮的动作闭包中切换书籍的 isAvailable 属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
struct BookView: View {
    var book: Book
    
    var body: some View {
        List {
            Text(book.title)
            HStack {
                Text(book.isAvailable ? "Available for checkout" : "Waiting for return")
                Spacer()
                Button(book.isAvailable ? "Check out" : "Return") {
                    book.isAvailable.toggle()
                }
            }
        }
    }
}

不过,有时视图需要先拿到绑定,才能修改某个可变属性的值。要提供绑定,请用 Bindable 属性包装器包装模型数据。例如,下面代码用 @Bindable 包装 book 变量,然后用 TextField 修改书籍的 title 属性、用 Toggle 修改 isAvailable 属性,并用 $ 语法为每个属性传入绑定。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct BookEditView: View {
    @Bindable var book: Book
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        VStack() {
            HStack {
                Text("Title")
                TextField("Title", text: $book.title)
                    .textFieldStyle(.roundedBorder)
                    .onSubmit {
                        dismiss()
                    }
            }
            
            Toggle(isOn: $book.isAvailable) {
                Text("Book is available")
            }
            
            Button("Close") {
                dismiss()
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

你可以对指向 Observable 对象的属性和变量使用 Bindable 属性包装器,包括全局变量、SwiftUI 类型之外的属性,甚至局部变量。例如,你可以在视图的 body 中创建一个 @Bindable 变量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct LibraryView: View {
    @State private var books = [Book(), Book(), Book()]

    var body: some View {
        List(books) { book in 
            @Bindable var book = book
            TextField("Title", text: $book.title)
        }
    }
}

@Bindable 变量 book 提供的绑定把 TextField 连接到书籍的 title 属性,使人们可以直接修改模型数据。