ViewModels 与 SwiftUI 和 Combine 之间的通信(ObservableObject vs Bin



这是一个关于 SwiftUI 和架构的一般性问题,所以我会举一个简单但有问题的例子。

初始项目 :

我有一个第一个View,它显示Item的列表。此列表由一个类管理(我在这里称之为ListViewModel)。在第二个视图中,我可以修改其中一个Item,并使用"保存"按钮保存这些修改。在简化版本中,我可以使用@Binding轻松完成此操作。感谢 SwiftUI:

struct ListView: View {
@StateObject var vm = ListViewModel()
var body: some View {
NavigationView {
List(Array(vm.data.enumerated()), id: .1.id) { index, item in
NavigationLink(destination: DetailView(item: $vm.data[index])) {
Text(item.name)
}
}
}
}
}
struct DetailView: View {
@Binding var initialItem: Item
@State private var item: Item
init(item: Binding<Item>) {
_item = State(initialValue: item.wrappedValue)
_initialItem = item
}
var body: some View {
VStack {
TextField("name", text: $item.name)
TextField("description", text: $item.description)
Button("save") {
initialItem = item
}
}
}
}
struct Item: Identifiable {
let id = UUID()
var name: String
var description: String
static var fakeItems: [Item] = [.init(name: "My item", description: "Very good"), .init(name: "An other item", description: "not so bad")]
}
class ListViewModel: ObservableObject {
@Published var data: [Item] = Item.fakeItems
func fetch() {}
func save() {}
func sort() {}
}

问题:

当细节/编辑视图变得更加复杂时,事情会变得更加复杂。它的属性数量增加,我们必须设置与View(网络、存储等)无关的代码,可能是 FSM,所以我们有另一种class来管理DetailView(在我的示例中:DetailViewModel)。

现在,两个视图之间的通信,使用@Binding变得如此简单,设置起来变得复杂。在我们的示例中,这两个元素没有链接,因此我们必须设置一个双向绑定:

class ListViewModel: ObservableObject {
@Published var data: [Item]     <-----------
func fetch() {}                             |
func save() {}                              |
func sort() {}                              |
}                                               | /In Search Of Binding/
|
class DetailViewModel: ObservableObject {       |
@Published var initialItem: Item <----------
@Published var item: Item

init(item: Item) {
self.initialItem = item
self.item = item
}
func fetch() {}
func save() {
self.initialItem = item
}
}

尝试

1. 列表视图模型中的详细视图模型数组 + 组合

我的ListViewModel可以存储[DetailViewModel],而不是存储ItemArray。因此,在初始化期间,它可以订阅DetailViewModels上的更改:

class ListViewModel: ObservableObject {
@Published var data: [DetailViewModel]
var bag: Set<AnyCancellable> = []
init(items: [Item] = Item.fakeItems) {
data = items.map(DetailViewModel.init(item:))
subscribeToItemsChanges()
}
func subscribeToItemsChanges() {
data.enumerated().publisher
.flatMap { (index, detailVM) in
detailVM.$initialItem
.map{ (index, $0 )}
}
.sink { [weak self] index, newValue in
self?.data[index].item = newValue
self?.objectWillChange.send()
}
.store(in: &bag)
}
}

结果:好的,这有效,尽管它不是真正的双向绑定。 但是,ViewModel 包含一系列其他 ViewModel 真的相关吗? a) 闻起来很奇怪。b) 我们有一个引用数组(没有数据类型)。c) 我们最终在视图中得到:

List(Array(vm.data.enumerated()), id: .1.item.id) { index, detailVM in
NavigationLink(destination: DetailView(vm: detailVM)) {
Text(detailVM.item.name)
}
}

2. 为详细视图模型提供列表视图模型的引用(委托样式)

由于DetailViewModel不包含Item的数组,并且由于它处理的Item不再具有@Binding:我们可以将ListViewModel(包含数组)传递给每个DetailViewModel

protocol UpdateManager {
func update(_ item: Item, at index: Int)
}
class ListViewModel: ObservableObject, UpdateManager {
@Published var data: [Item]
init(items: [Item] = Item.fakeItems) {
data = items
}
func update(_ item: Item, at index: Int) {
data[index] = item
}
}
class DetailViewModel: ObservableObject {
@Published var item: Item
private var updateManager: UpdateManager
private var index: Int
init(item: Item, index: Int, updateManager: UpdateManager) {
self.item = item
self.updateManager = updateManager
self.index = index
}
func fetch() {}
func save() {
updateManager.update(item, at: index)
}
}

结果:它有效,但是:1)这似乎是一种与SwiftUI风格不太匹配的旧方法。2)我们必须将项目的索引传递给DetailViewModel。

3. 使用封盖

与其传递对整个ListViewModel的引用,我们可以将闭包(onSave)传递给DetailViewModel

class ListViewModel: ObservableObject {
@Published var data: [Item]
init(items: [Item] = Item.fakeItems) {
data = items
}
func update(_ item: Item, at index: Int) {
data[index] = item
}
}
class DetailViewModel: ObservableObject {
@Published var item: Item
var update: (Item) -> Void
init(item: Item, onSave update: @escaping (Item) -> Void) {
self.item = item
self.update = update
}
func fetch() {}
func save() {
update(item)
}
}

结果:一方面,它看起来仍然像一种旧方法,另一方面它似乎与"一个视图 - 一个视图模型"的方法相匹配。如果我们使用 FSM,我们可以想象发送事件/输入。

变体:我们可以使用组合并传递PassthroughSubject而不是闭包:

class ListViewModel: ObservableObject {
@Published var data: [Item]
var archivist = PassthroughSubject<(Int, Item), Never>()
var cancellable: AnyCancellable?
init(items: [Item] = Item.fakeItems) {
data = items
cancellable = archivist
.sink {[weak self ]index, item in
self?.update(item, at: index)
}
}
func update(_ item: Item, at index: Int) {
data[index] = item
}
}
class DetailViewModel: ObservableObject {
@Published var item: Item
var index: Int
var archivist: PassthroughSubject<(Int, Item), Never>
init(item: Item, saveWith archivist: PassthroughSubject<(Int, Item), Never>, at index: Int) {
self.item = item
self.archivist = archivist
self.index = index
}
func fetch() {}
func save() {
archivist.send((index, item))
}
}

问题:

我也可以在我的ObservableObject中使用@Binding,甚至将我的Item数组包装在其他ObservableObject中(因此在 OO 中有一个 OO)。但这似乎与我无关。

无论如何,一旦我们离开一个简单的模型视图架构,一切似乎都非常复杂:一个简单的@Binding就足够了。

所以我请求你的帮助: 对于这种情况,您有什么建议? 你认为什么最适合 SwiftUI? 你能想到更好的方法吗?

我想对您的架构提出一些改进建议。

免責聲明: 请注意,以下实现是如何处理主从问题的建议。还有无数的方法,这只是我建议的几种方法之一。

当事情变得更加复杂时,您可能更喜欢在视图模型和视图之间采用单向数据流方法。这基本上意味着,视图状态没有双向绑定

单向意味着,您的 SwiftUI 视图基本上处理恒定的外部状态,它们无需询问即可呈现。视图不是直接从双向绑定中改变支持变量,而是将操作(也称为事件)发送到视图模型。视图模型处理这些事件,并在考虑整个逻辑的情况下发出新的视图状态。

顺便说一下,这种单向数据流是 MVVM 模式固有的。因此,使用视图模型时,不应使用改变"视图状态"的双向绑定。否则,这将不是 MVVM,并且使用术语视图模型将不正确或至少令人困惑。

结果是,您的视图将不执行任何逻辑,所有逻辑都委托给视图模型。

在"主 - 详细信息"问题中,这也意味着"导航链接"不会由"主视图"直接执行。相反,用户点击导航链接的事实将作为操作发送到视图模型。然后,视图模型决定是否显示详图视图,或者要求显示警报、模式表或它认为视图必须呈现的任何内容。

同样,如果用户点击"后退"按钮,视图不会立即从导航堆栈中弹出。相反,视图模型接收操作。同样,它决定做什么。

这种方法使您可以在具有重要战略意义的"位置"截获数据流,并让您更轻松地以正确的方式处理情况。

在主-细节问题中,特别是在尚未做出架构决策的示例中,始终存在一个问题,即谁(哪个组件)负责创建详图视图模型(如果需要),哪个部分组成详图视图和详图视图模型,并将其动态放入视图系统(以某种方式)并在完成后再次将其删除(如果需要)。

如果我们提出这样的主张,即视图模型应该创建一个细节视图模型,恕我直言,这是合理的,如果我们进一步假设,用户可以发出最终显示详细信息视图的操作,并且根据之前提出的建议,SwiftUI 中可能的解决方案可能如下所示:

(注意,我不会使用您的示例,而是创建一个具有更通用名称的新示例。所以,希望你能看到你的示例映射到我的例子中的位置)

所以,我们需要这些零件

  • 主视图
  • 主视图模型,
  • 详细信息视图
  • 详图视图模型
  • 可能用于分解几个方面和分离关注点的其他视图

主视图:

struct MasterView: View {
let items: [MasterViewModel.Item]
let selection: MasterViewModel.Selection?
let selectDetail: (_ id: MasterViewModel.Item.ID) -> Void
let unselectDetail: () -> Void
... 

主视图使用"状态",该状态由它应在列表视图中绘制的项目组成。此外,它还具有两个动作功能selectDetailunselectDetail.我很确定很清楚这些含义,但我们稍后将看到主视图如何使用它们。

此外,我们还有一个Selection属性,这是一个可选属性,您可能会猜到它的含义:当它不为 nil 时,它将呈现详细信息视图。如果为 nil,则不会呈现详图视图。很简单。同样,请坚持我们看到它是如何使用的以及它到底是什么。

当我们查看主视图的主体时,我们以特殊形式实现 NavigationLink,以便满足单向数据流要求:

var body: some View {
List {
ForEach(items, id: .id) { element in
NavigationLink(
tag: element.id,
selection: link()) {
if let selection = self.selection {
DetailContainerView(
viewModel: selection.viewModel)
}
} label: {
Text("(element.name)")
}
}
}
}

导航链接使用"可选目的地"表单,其签名为

init<V>(tag: V, selection: Binding<V?>, destination: () -> Destination, label: () -> Label)

这将创建一个导航链接,当绑定选择变量等于给定标记值时,该链接将显示目标视图。

请参阅此处的文档。

tag是项目的唯一 ID(此处element.id)。selection参数是一个Binding<Item.ID?>,是函数link()的结果

,如下所示:
func link() -> Binding<MasterViewModel.Item.ID?> {
Binding {
self.selection?.id
} set: { id in
print("link: (String(describing: id))")
if let id = id {
selectDetail(id)
} else {
unselectDetail()
}
}
}

如您所见,link返回正确的绑定。但是,您可以在这里看到的一个关键事实是,我们不使用"双向绑定"。相反,我们将操作路由到操作函数,这些操作会将绑定的支持变量变异。这些操作最终将由视图模型执行,我们将在后面看到。

请注意两个操作函数:

selectDetail(:)

unselectDetail()

绑定的 getter 像往常一样工作:它只返回项目的id

以上这个,以及这两个操作的实现,足以使导航堆栈中的推送和弹出工作。

需要编辑项目,或将一些数据从详细信息视图传递到主视图? 只需使用这个:

unselectDetail(mutatedItem: Item)

以及详细信息视图中的内部@Sate var item: Items以及详细信息视图控制器中的逻辑,或者让主视图和详细信息视图模型相互通信(见下文)。

有了这些部分,主视图就完成了。

但这Selection是什么东西?

此值将由主视图模型创建。它的定义如下:

struct Selection: Identifiable {
var id: Item.ID
var viewModel: DetailViewModel
}

所以,很容易。需要注意的重要一点是,有一个细节视图模型。由于主视图模型创建了这种"选择",因此它也必须创建细节视图模型 - 正如我们的主张上面所述。

在这里,我们假设视图模型在正确的时间有足够的信息来创建完全配置的细节(或子)视图模型。

主视图模型

此视图模型有几个职责。我将展示代码,它应该是不言自明的:

final class MasterViewModel: ObservableObject {
struct ViewState {
var items: [Item] = []
var selection: Selection? = nil
}
struct Item: Identifiable {
var id: Int
var name: String
}
struct Selection: Identifiable {
var id: Item.ID
var viewModel: DetailViewModel
}
@Published private(set) var viewState: ViewState
init(items: [Item]) {
self.viewState = .init(items: items, selection: nil)
}
func selectDetail(id: Item.ID) {
guard let item = viewState.items.first(where: { id == $0.id } ) else {
return
}
let detailViewModel = DetailViewModel(
item: .init(id: item.id,
name: item.name,
description: "description of (item.name)",
image: URL(string: "a")!)
)
self.viewState.selection = Selection(
id: item.id,
viewModel: detailViewModel)
}
func unselectDetail() {
self.viewState.selection = nil
}
}

所以,基本上,它有一个ViewState,从观点的角度来看,这恰恰是"单一的真理来源",它必须只呈现这个东西,而不问任何问题。

此视图状态还包含"选择"值。老实说,我们可能会争论这是否是视图状态的一部分,但我把它缩短了,把它放在视图状态中,因此,视图模型只发布一个值,即视图状态。这使得这个实现更适合重构为泛型...,但我不想苦恼。

当然,视图模型实现了动作函数的效果

selectDetail(:)unselect().

它还必须创建详细信息视图模型。在这个例子中,它只是伪造它。

对于主视图模型,没有太多其他操作。

详细信息视图

详细信息视图仅用于演示,并尽可能简短:

struct DetailView: View {
let item: DetailViewModel.Item
var body: some View {
HStack {
Text("(item.id)")
Text("(item.name)")
Text("(item.description)")
}
}
}

您可能会注意到,它使用常量视图状态 (let item)。 在您的示例中,您可能希望具有操作,例如"保存"或用户执行的操作。

详细视图模型

另外,非常简单。在这里,在您的问题中,您可能希望在其中放置更多处理用户操作的逻辑。

final class DetailViewModel: ObservableObject {
struct Item: Identifiable {
var id: Int
var name: String
var description: String
var image: URL
}
struct ViewState {
var item: Item
}
@Published private(set) var viewState: ViewState

init(item: Item) {
self.viewState = .init(item: item)
}
}

注意:过度简化!

在此示例中,两个视图模型不相互通信。在更实用的解决方案中,您可能需要解决更复杂的问题,这涉及这些视图模型之间的通信。您可能不会直接在视图模型中实现这一点,而是实现具有输入、状态和可能的输出的"存储",使用有限状态机执行其逻辑,并且可以互连,因此您有一个"状态"系统,最终组成您的"AppState",将其状态发布到视图模型, 这反过来又将其转换为其视图的视图状态。

接线

在这里,一些帮助程序视图开始发挥作用。它们只是帮助将视图模型与视图连接起来:

struct DetailContainerView: View {
@ObservedObject private(set) var viewModel: DetailViewModel
var body: some View {
DetailView(item: viewModel.viewState.item)
}
}

这将设置视图状态,但也将详图视图与详图视图模型分开,因为视图不需要知道有关视图模型的任何信息。这样可以更轻松地将详细信息视图作为组件重用。

struct MasterContainerView: View {
@ObservedObject private(set) var viewModel: MasterViewModel
var body: some View {
MasterView(
items: viewModel.viewState.items,
selection: viewModel.viewState.selection,
selectDetail: viewModel.selectDetail(id:),
unselectDetail: viewModel.unselectDetail)
}
}

同样,在此处,将主视图与主视图模型分离,并设置操作和视图状态。

对于您的游乐场:

struct ContentView: View {
@StateObject var viewModel = MasterViewModel(items: [
.init(id: 1, name: "John"),
.init(id: 2, name: "Bob"),
.init(id: 3, name: "Mary"),
])
var body: some View {
NavigationView {
MasterContainerView(viewModel: viewModel)
}
.navigationViewStyle(.stack)
}
}
import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())

玩得开心!;)

最新更新