ViewModel 与 SwiftUI 和 Combine 之间的通信(ObservableObject 与 Binding)

Communication between ViewModels with SwiftUI and Combine (ObservableObject vs Binding)

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

初始项目:

我有第一个 View,它显示 Item 的列表。此列表由 class(我在这里称为 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 + Combine

中的一组 DetailViewModel

我的 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, [=12=] )}
            }
            .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引用ListViewModel(Delegate风格)

由于 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) 我们必须将 Item 的索引传递给 DetailViewModel。

3。使用闭包

我们可以将闭包 (onSave) 传递给 DetailViewModel.

而不是传递对整个 ListViewModel 的引用
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)
    }
}

结果: 一方面,它看起来仍然像一种旧方法,另一方面,它似乎符合“一个视图 - 一个 ViewModel”的方法。如果我们使用 FSM,我们可以想象发送一个事件/输入。

变体: 我们可以使用 Combine 并传递 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 中(因此在哦)。但它似乎与我无关。

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

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

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

DISCLAIMER: Note that the following implementation is a suggestion how to approach the Master-Detail problem. There are countless more approaches and this one is just one of severals which I would suggest.

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

单向意味着,您的 SwiftUI 视图基本上处理 常量 外部状态,它们在不询问的情况下呈现。视图不是直接从双向绑定改变支持变量,而是将 actions(又名事件)发送到视图模型。视图模型处理这些事件并发送一个新的视图状态,将整个逻辑考虑在内。

顺便说一句,这种单向数据流是 MVVM 模式所固有的。因此,当您使用视图模型时,您不应该使用改变“视图状态”的两种方式绑定。否则这就不是 MVVM,并且使用视图模型这个术语是不正确的,或者至少是令人困惑的。

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

在您的 Master - Detail 问题中,这也意味着 NavigationLinks 不会直接由 Master View 执行。相反,用户点击 NavigationLink 的事实将作为一个动作发送到视图模型。然后视图模型决定是否显示详细视图,或要求显示警报、模态 sheet 或它认为必要的视图必须呈现的内容。

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

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

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

如果我们提出一个视图模型应该创建一个细节视图模型的提议,恕我直言,这是合理的,并且如果我们进一步假设,用户可以发出一个最终会显示细节视图的动作,并且根据之前提出的建议,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)")
                    }
            }
        }
    }

NavigationLink 使用“可选目的地”形式,其签名为

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

这会创建一个导航 link,当绑定的选择变量等于给定的标记值时,它会显示目标视图。

参见 docs here

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 returns 正确绑定。但是,您在这里可以看到的一个重要事实是,我们不使用“双向绑定”。相反,我们将改变绑定支持变量的操作路由到 action 函数。这些动作最终会由视图模型来执行,我们稍后会看到。

请注意两个动作函数:

selectDetail(:)

unselectDetail()

绑定的 getter 照常工作:它只是 returns 项目的 id

以上内容以及两个操作的实现足以使导航堆栈中的推送和弹出工作正常进行。

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

unselectDetail(mutatedItem: Item)

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

有了这些部分,Master View 就完成了。

但这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 == [=14=].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,从 view 的角度来看,这恰恰是“单一事实来源”,它只需要渲染这个事情,不问任何问题。

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

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

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)
    }
}

这设置了视图状态,但也将详细视图与详细视图模型分开,因为视图不需要了解视图模型的任何信息。这使得将 DetailView 作为组件重用变得更加容易。

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)
    }
}

同样,将 MasterView 与 MasterViewModel 分离,并设置操作和视图状态。

对于您的游乐场:

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())

玩得开心! ;)