SwiftUI 笔记 | MVVM In SwiftUI

译自 MVVM in SwiftUI

让我们用 MVVM (model-view-view model) 来构建一个应用,其中的每个 SwiftUI 视图都有自己的 model 。这会是一个拥有两个视图的 app : 一个电影列表以及一个用于添加电影的表单。新增的电影存在在 MovieStore ,它由两个 view models 共享。我们将通过 environment 来共享 MovieStore ,也就说,当我们需要时,会从 environment 中读取。

用 Movie 和 MovieStore 来表示数据

Movie 是一个很小的结构体,只存储了标题和评分。标题和评分都是可变的,因为我们需要在 AddMovieView 里更新它们。这个结构体也遵循 Identifiable 协议,因为我们将用 List 视图来展示所有的电影。List 需要能够标识内容中的每一项,而遵循这个协议是最简单的方式。

1
2
3
4
5
6
7
8
9
10
11
struct Movie: Equatable, Identifiable {
let id = UUID()
var fullTitle: String
var givenRating: Rating = .notSeen
}

extension Movie {
enum Rating: Int, CaseIterable {
case notSeen, terrible, poor, decent, good, excellent
}
}

MovieStore 也很简单,不过实际的 app 会包含更多的逻辑:持久化,删除等等。我们用 Published 属性包装器来为订阅者自动提供发布。

1
2
3
4
5
6
7
final class MovieStore {
@Published private(set) var allMovies = [Movie]()

func add(_ movie: Movie) {
allMovies.append (movie)
}
}

为了将共享的 MovieStore 插入环境,我们需要使用自定义的 EnvironmentKey 。自定义 key 仅仅只是一个遵循 EnvironmentKey 协议的自定义 key 。我们需要提供类型和默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct MovieStoreKey: EnvironmentKey {
typealias Value = MovieStore
static var defaultValue = MovieStore()
}

extension EnvironmentValues {
var movieStore: MovieStore {
get {
return self[MovieStoreKey]
}
set {
self[MovieStoreKey] = newValue
}
}
}

如果我们不插入自己的 MovieStore 实例到 environment ,那就会使用 defaultValue 默认值。典型情况下,我们会在视图体系之外初始化这个特定实例。

SceneDelegate 和 MovieScene 呈现

MovieStore 作为依赖项,在构造函数被传给 view model 。我们将使用存储在 SceneDelegate 的实例。再次申明,在实际的 app 中,这种依赖项很可能是处于一个独立的容器或者别的类似的东西。 MovieListView 是我们要呈现的第一个视图,因此我们会初始化 view model , view ,并且插入 MovieStore 实例到 environment ,以便后续使用。 (movieStore keypath 是通过 EnvironmentValues 的 extension 来定义的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let movieStore = MovieStore()

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
let viewModel = MovieListView.ViewModel(movieStore: movieStore)
let contentView = MovieListView(viewModel: viewModel).environment (\.movieStore, movieStore)

guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIHostingController(rootView: contentView)
self.window = window
window.makeKeyAndVisible ()
}
}

MovieListView 和对应的 ViewModel

在 SwiftUI 中,view model 遵循 ObservableObject 协议,使用 @Published 属性包装器。 ObservableObject 的默认实现提供了 objectWillChange publisher 。 @Published 属性包装器能在属性将要改变时自动发射这个 publisher 。在 MovieListView 中,我们用 @ObservedObject 属性包装器声明 view model 属性。这会使得该视图订阅 objectWillChange publisher ,并且在 objectWillChange 发动时自动刷新视图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
extension MovieListView {
final class ViewModel: ObservableObject {
private let movieStore: MovieStore
private var cancellables = [AnyCancellable]()

init(movieStore: MovieStore) {
self.movieStore = movieStore
cancellables.append (movieStore.$allMovies.assign (to: \.movies, on: self))
}

@Published private(set) var movies = [Movie]()
@Published var isPresentingAddMovie = false
}
}
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
29
struct MovieListView: View {
@Environment(\.self) var environment
@ObservedObject var viewModel: ViewModel

var body: some View {
NavigationView {
List(self.viewModel.movies) { movie in
Text(movie.fullTitle)
}.navigationBarTitle ("Movies")
.navigationBarItems (trailing: navigationBarTrailingItem)
}
}

private var navigationBarTrailingItem: some View {
Button(action: {
self.viewModel.isPresentingAddMovie = true
}, label: {
Image(systemName: "plus").frame (minWidth: 32, minHeight: 32)
}).sheet (isPresented: self.$viewModel.isPresentingAddMovie) {
self.makeAddMovieView ()
}
}

private func makeAddMovieView() -> AddMovieView {
let movieStore = environment [MovieStoreKey]
let viewModel = AddMovieView.ViewModel(movieStore: movieStore)
return AddMovieView(viewModel: viewModel)
}
}

你会注意到,MovieStore 时用了两份,一份在 view model 中,一份放在环境中。

AddMovieView 和它的 view model 是在用户点击导航栏上的加号按钮时被创建的。环境属性包装器可以被用于获取整个环境或者借助特定键获取某个值。在这个案例中我们访问了整个环境对象,然后在需要的时候借助 MovieStoreKey 访问 MovieStore 。或者你也可以使用 @Environment (.movieStore) var movieStore 来代替。

AddMovieView 和对应的 ViewModel

AddMovieView 的 view model 是随着 MovieStore 一同被初始化的,它内部呈现了一个 Movie 实例。 Published 属性包装器和 MovieListView 的 view model 里的用法相似。 内部的 movie 对象是一个私有的属性, TextField 和 Picker 都采用双向 Binding 。 Binding 是一种 view 和 model 间的双向连接方式。另外,还有一个 canSave 属性,它是用来控制导航栏上的保存按钮是否启用。保持按钮只有在标题有填充的时才启用。

简单复习一下视图更新的流程:TextField 或者 Picker 会利用 Binding 来更新私有属性 newMovie 。 因为 newMovie 属性使用了 @Published 属性包装器,它会发射 ObservableObject 的 objectWillChange publisher 。 SwiftUI 自动订阅 objectWillChange ,因为 view model 的属性用了 @ObservedObject 。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
extension AddMovieView {
class ViewModel: ObservableObject {
private let movieStore: MovieStore

init(movieStore: MovieStore) {
self.movieStore = movieStore
}

@Published private var newMovie = Movie(fullTitle: "")

lazy var title = Binding<String>(get: {
self.newMovie.fullTitle
}, set: {
self.newMovie.fullTitle = $0
})

lazy var rating = Binding<Movie.Rating>(get: {
self.newMovie.givenRating
}, set: {
self.newMovie.givenRating = $0
})

var canSave: Bool {
return !newMovie.fullTitle.isEmpty
}

func save() {
movieStore.add (newMovie)
}
}
}

struct AddMovieView: View {
@Environment(\.presentationMode) private var presentationMode
@ObservedObject var viewModel: ViewModel

var body: some View {
NavigationView {
Form {
titleSection
ratingSection
}.navigationBarTitle ("Add Movie", displayMode: .inline)
.navigationBarItems (leading: leadingBarItem, trailing: trailingBarItem)
.navigationViewStyle (StackNavigationViewStyle())

}
}

private var titleSection: some View {
Section() {
TextField("Title", text: viewModel.title)
}
}

private var ratingSection: some View {
Section() {
Picker(LocalizedStringKey("Rating"), selection: viewModel.rating) {
ForEach(Movie.Rating.allCases, id: \.rawValue) {
Text($0.localizedName).tag ($0)
}
}
}
}

private var leadingBarItem: some View {
Button(action: { self.presentationMode.wrappedValue.dismiss () }, label: {
Text("Cancel")
})
}

private var trailingBarItem: some View {
Button(action: {
self.viewModel.save ()
self.presentationMode.wrappedValue.dismiss ()
}, label: {
Text("Save").disabled (!self.viewModel.canSave)
})
}
}

总结

我们创建了一个只有两个视图的简单 app 。两个视图都有各自的 view model ,并且都依赖 MovieStore 。一个 view model 中触发了 MovieStore 的改变,这些改变会被另一个 view model 观察到。另外,我们还了解了 SwiftUI 的 environment 以及如何从 view model 中触发 view 更新。