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

Adr*_*ien 10 swift swiftui combine

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

初始项目:

我有一个View显示 s 列表的第一个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() {}
}
Run Code Online (Sandbox Code Playgroud)

问题 :

当详细信息/编辑视图变得更加复杂时,事情就会变得更加复杂。它的属性数量增加,我们必须设置不涉及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
    }
}
Run Code Online (Sandbox Code Playgroud)

尝试

1. ListViewModel 中的 DetailViewModel 数组 + 组合

my 可以存储 a ,Array而不是存储 a 。因此在初始化期间它可以订阅s 上的更改:ItemListViewModel[DetailViewModel]DetailViewModel

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)
    }
}
Run Code Online (Sandbox Code Playgroud)

结果: 好的,这有效,尽管它并不是真正的双向绑定。但是 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)
                }
            }
Run Code Online (Sandbox Code Playgroud)

2. 给DetailViewModel ListViewModel的引用(Delegate风格)

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

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)
    }
}
Run Code Online (Sandbox Code Playgroud)

结果: 它有效,但是:1)这似乎是一种旧方法,与 SwiftUI 的风格不太匹配。2)我们必须将Item的索引传递给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)
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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))
    }
}
Run Code Online (Sandbox Code Playgroud)

问题 :

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

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

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

Cou*_*per 6

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

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

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

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

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

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

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

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

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

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

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

(请注意,我不会使用您的示例,而是创建一个具有更通用名称的新示例。因此,希望您可以看到您的示例与我的示例的映射位置)

所以,我们需要这些部件

  • 主视图
  • 主视图模型,
  • 详细视图
  • 详细视图模型
  • 用于分解多个方面和关注点分离的可能的附加视图

主视图:

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

    ... 
Run Code Online (Sandbox Code Playgroud)

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

此外,我们还有一个Selection属性,它是可选的,您可能会猜到它的含义:当它不为零时,它将渲染详细视图。如果为零,则不会渲染详细视图。挺容易。再次强调,我们要看看它是如何使用的以及它到底是什么。

当我们查看主视图的主体时,我们以特殊的形式实现了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)")
                    }
            }
        }
    }
Run Code Online (Sandbox Code Playgroud)

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

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

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

请参阅此处的文档

是该tag项目的唯一 ID(此处element.id)。参数selectionaBinding<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()
            }
        }
    }
Run Code Online (Sandbox Code Playgroud)

如您所见,link返回正确的绑定。然而,您可以在此处看到的一个重要事实是,我们不使用“双向绑定”。相反,我们将改变绑定的支持变量的操作路由到操作函数。这些操作最终将由视图模型执行,我们稍后会看到。

请注意两个操作函数:

selectDetail(:)

unselectDetail()

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

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

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

unselectDetail(mutatedItem: Item)

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

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

但这是什么Selection东西?

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

    struct Selection: Identifiable {
        var id: Item.ID
        var viewModel: DetailViewModel
    }
Run Code Online (Sandbox Code Playgroud)

所以,很容易。需要注意的是,有一个Detail View Model。由于主视图模型创建了这个“选择”,它还必须创建详细视图模型 - 正如我们上面的主张所述。

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

主视图模型

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

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
    }
}
Run Code Online (Sandbox Code Playgroud)

所以,基本上,它有一个,从视图ViewState的角度来看,它恰恰是“单一事实来源” ,它必须只渲染这个东西,而不提出任何问题。

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

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

selectDetail(:)unselect()

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

对于主视图模型没有太多其他事情可做。

详细视图

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

struct DetailView: View {
    let item: DetailViewModel.Item

    var body: some View {
        HStack {
            Text("\(item.id)")
            Text("\(item.name)")
            Text("\(item.description)")
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

您可能会注意到,它使用恒定的视图状态 ( 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)
    }

}
Run Code Online (Sandbox Code Playgroud)

注意:过于简单化!

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

接线

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

struct DetailContainerView: View {
    @ObservedObject private(set) var viewModel: DetailViewModel

    var body: some View {
        DetailView(item: viewModel.viewState.item)
    }
}
Run Code Online (Sandbox Code Playgroud)

这设置了视图状态,但也将详细视图与详细视图模型分开,因为视图不需要了解有关视图模型的任何信息。这使得将 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)
    }
}

Run Code Online (Sandbox Code Playgroud)

同样,将 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())

Run Code Online (Sandbox Code Playgroud)

玩得开心!;)