SwiftUI 和 Combine 中 ViewModel 的通信方式(ObservableObject vs Binding)

10

这是有关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的数组+Combine

与其存储ItemArray,我的 ListViewModel 可以存储一个 [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)
    }
}

结果: 好的,这样做可以工作,但它并不真正是双向绑定。 但是 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. 将ListViewModel的引用传递给DetailViewModel(委托样式)

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

结果: 一方面它看起来仍然像是一种旧的方法,另一方面它似乎符合“一个视图-一个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中(因此在一个OO中有另一个OO)。 但对我来说似乎更不相关。

无论如何,一旦我们离开了简单的Model-View架构,一切似乎都变得非常复杂:在那里,一个简单的@Binding就足够了。

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


@Published var data: [Item] 更改为 @Published var data: [DetailViewModel] 并且将你的 DetailView 改为使用 @ObservedObject var item: Item - lorem ipsum
@loremipsum:Item是一个结构体。你的意思是第一次尝试没问题吗? - Adrien
1个回答

5
我希望对您的架构提出一些建议。免责声明:以下实现方案是如何解决主从问题的建议之一,有无数种方法可供选择,此方案只是我建议中的其中一个。当应用变得更加复杂时,您可能更喜欢在视图模型和视图之间采用单向数据流方式。这基本上意味着视图状态没有双向绑定。单向意味着SwiftUI视图基本上处理常量外部状态,无需询问即可渲染。视图不直接改变双向绑定的支撑变量,而是发送操作(也称为事件)到视图模型。视图模型处理这些事件,并发送出一个新的视图状态,考虑整个逻辑。顺便说一下,这种单向数据流集成在MVVM模式中。因此,当您使用视图模型时,不应使用可以改变"视图状态"的双向绑定。否则,这将不是MVVM,使用"视图模型"这个术语将是错误或至少会引起混淆。结果是,您的视图不会执行任何逻辑,所有逻辑都委托给视图模型。在您的 Master-Detail 问题中,这意味着 NavigationLinks 不会直接由 Master View 执行。相反,作为一个操作,表明用户已经点击了 NavigationLink 将被发送到视图模型。然后,视图模型决定是否显示详细视图,或者不显示,或者要求显示警报、模态表等不透明视图。同样,如果用户点击"返回"按钮,则视图将不会立即从导航堆栈中弹出。相反,视图模型接收操作。再次,它决定要做什么。这种方法让您在战略上重要的"位置"截获数据流,让您更轻松地处理情况并以正确的方式处理它。在主-细节问题中,特别是在您的示例中尚未做出架构决策的情况下,总会有一个问题,即哪个组件负责创建详细视图模型(如果需要),哪部分组成了详细视图和详细视图模型,并在完成后(如果需要)动态地将其置于视图系统中并将其移除。如果我们提出一个建议,即视图模型应该创建详细视图模型,这在我看来是合理的,并且如果我们进一步假设用户可以发出最终显示详细视图的操作,并且考虑到之前的建议,在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 返回了正确的绑定。然而,您可以在这里看到一个重要的事实,我们没有使用“双向绑定”。相反,我们将会更改绑定的后端变量的操作路由到action函数中。这些操作最终将由视图模型执行,稍后我们将会看到。

请注意这两个action函数:

selectDetail(:)

unselectDetail()

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

上述内容以及这两个action函数的实现足以使导航栈的推入和弹出正常工作。

需要编辑项目或从详细视图传递一些数据到主视图?只需使用以下代码:

unselectDetail(mutatedItem: Item)

以及详细视图中的内部@State 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,从视图的角度来看,这正是“唯一的真相源泉”,它必须只是渲染这个东西,而不需要问任何问题。

这个视图状态还包含“Selection”值。老实说,我们可以争论这是否是视图状态的一部分,但我让它变得简短,并将其放入视图状态中,因此,视图模型仅发布一个值,即视图状态。这使得这个实现更适合被重构成一个通用的...,但我不想让你感到压力。

当然,视图模型实现了操作函数的影响

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

}

注意:过于简化!

在这个例子中,这两个视图模型之间没有相互通信。在更实际的解决方案中,您可能需要解决更复杂的问题,其中涉及这些视图模型之间的通信。您可能不会直接在视图模型中实现此功能,而是实现具有输入、状态和可能输出的“存储”(Stores),使用有限状态机执行其逻辑,并且可以相互连接,因此您拥有一个由最终组成“AppState”的系统的“状态”,它将其状态发布给视图模型,后者将其转换为其视图的视图状态。

连接

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

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

    var body: some View {
        DetailView(item: viewModel.viewState.item)
    }
}

这将设置视图状态,但也会将DetailView与DetailView Model分离,因为视图不需要了解有关视图模型的任何信息。这使得更容易将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())

玩得开心!;)


首先,您建议“使用此:unselectDetail(mutatedItem: Item)”。但是我真的不知道如何在NavigationLink中实现这一点,因为“selection”是一个Binding<Element.ID?>:在getter中,我们最终得到一个ID。 - Adrien
1
@Adrien 是的,unselectDetail(mutatedItem: Item) 不起作用 - 那是我的疏忽。在这里,您需要从详细视图模型中提取值 - 或者依赖于视图模型之间的通信。然后您的主视图会自动更新。对于创建或修改值的情况,模态呈现的视图提供了更好的 API。 - CouchDeveloper
1
@Adrien,“store”可以是一个发布者,发出“模型状态”。它还有输入(实现为PassthroughSubjects),可以连接操作(转换为枚举案例)。在“主存储”和“子存储”设置中,所有事件首先发送到主存储,可选择处理,然后发送到子存储。视图模型仅提供方便函数来将操作转换为事件,并订阅存储的状态,并定义一个函数f(modelState),该函数返回视图状态。 - CouchDeveloper
1
@Adrien 一个商店也可能只有详细值。在这种情况下,您只需要一个商店,主视图模型和详细视图模型将详细的Store.Item转换为各自的“OverviewItem”和“DetailItem”。这已经“远离”您的视图了,您可以看到您可以更改存储实现的详细信息,可能由CoreData或Realm支持,或直接访问HTTP端点。 - CouchDeveloper
1
@Adrien 不将此类存储实现到视图模型中的决定是因为VM已经足够了解视图的_渲染内容_并提供适当的视图状态,设置所有操作并将它们连接到存储,而存储确实处理数据方面,包括发送/处理更改通知、设置依赖项、使用/管理/清除缓存、访问HTTP端点、CoreData等麻烦事。 - CouchDeveloper
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接