SwiftUI中ViewModel和View之间的通信

3
我是新手,正在学习Combine,并且在沟通方面遇到了一些问题。我的背景是Web开发和UIKit,与SwiftUI完全不同。
我非常希望使用MVVM将业务逻辑与视图层分离。这意味着除了可重用组件之外的任何视图都有一个ViewModel来处理API请求、逻辑、错误处理等。
我遇到的问题是,在ViewModel中发生某些事情时,将事件传递给View的最佳方法是什么?我知道View应该是状态的反映,但对于需要事件驱动的内容,它需要一堆变量,我认为这很混乱,因此很想尝试其他方法。
下面的示例是一个ForgotPasswordView,它作为一个sheet展示,当成功重置密码时,它应该关闭+显示一个成功的toast。如果失败,应该显示一个错误的toast(为上下文,全局的toast coordinator由根应用程序注入的@Environment变量管理)。
以下是一个有限的示例
View
struct ForgotPasswordView: View {

    /// Environment variable to dismiss the modal
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    /// The forgot password view model
    @StateObject private var viewModel: ForgotPasswordViewModel = ForgotPasswordViewModel()

    var body: some View {
        NavigationView {
            GeometryReader { geo in
                ScrollView {
                    // Field contents + button that calls method
                    // in ViewModel to execute the network method. See `sink` method for response
                }
            }
            .navigationBarTitle("", displayMode: .inline)
            .navigationBarItems(leading: self.closeButton /* Button that fires `closeSheet` */)
        }
    }

    /// Close the presented sheet
    private func closeSheet() -> Void {
        self.presentationMode.wrappedValue.dismiss()
    }
}

ViewModel

class ForgotPasswordViewModel: ObservableObject {

    /// The value of the username / email address field
    @Published var username: String = ""

    /// Reference to the reset password api
    private var passwordApi = Api<Response<Success>>()

    /// Reference to the password api for cancelling
    private var apiCancellable: AnyCancellable?

    init() {
        self.apiCancellable = self.passwordApi.$status
        .receive(on: DispatchQueue.main)
        .sink { [weak self] result in
            guard let result = result else { return }

            switch result {
            case .inProgress:
                // Handle in progress

            case let .success(response):
                // Handle success

            case let .failed(error):
                // Handle failure
            }
        }
    }
}

上述的 ViewModel 拥有所有逻辑,而 View 仅反映数据并调用方法。 目前为止一切都很好。

现在,为了处理服务器响应的 successfailed 状态,并将该信息传递到 UI 中,这就是我遇到问题的地方。 我可以想到一些方法,但要么不喜欢,要么似乎不可能。

使用变量

为每个状态创建单独的 @Published 变量,例如

@Published var networkError: String? = nil

然后在不同的状态下设置它们

case let .failed(error):
   // Handle failure
   self.networkError = error.description
}

View中,我可以通过onReceive订阅它并处理响应。
.onReceive(self.viewModel.$networkError, perform: { error in
    if error {
        // Call `closeSheet` and display toast
    }
})

这种方法是可行的,但这只是一个示例,需要我为每个状态创建一个@Published变量。而且,这些变量也必须进行清理(将它们设置回nil)。

通过使用带有关联值的enum可以使这个过程更加优雅,这样只需要使用单个监听器+变量。但是,枚举类型并不解决必须清理变量的问题。

使用PassthroughSubject

基于此,我研究了PassthroughSubject,认为如果我创建一个单一的@Publisher,如下:

@Publisher var events: PassthoughSubject = PassthroughSubject<Event, Never>

并发布此类事件:

.sink { [weak self] result in
            guard let result = result else { return }

            switch result {
            case let .success(response):
                // Do any processing of success response / call any methods
                self.events.send(.passwordReset)

            case let .failed(error):
            // Do any processing of error response / call any methods
                self.events.send(.apiError(error)
            }
        }

然后我可以像这样收听它

.onReceive(self.viewModel.$events, perform: { event in
    switch event {
    case .passwordReset:
      // close sheet and display success toast
    case let .apiError(error):
      // show error toast
})

这比使用变量更好,因为事件是通过 .send 发送的,所以不需要清理 events 变量。

不幸的是,似乎不能使用 PassthroughSubject 来使用 onRecieve。如果我将其设置为与第一个解决方案相同但为 Published 变量,则会遇到再次清理的问题。

将所有内容放在视图中

最后一种情况是,我一直试图避免的情况,就是处理所有内容都在 View 中。

struct ForgotPasswordView: View {

    /// Environment variable to dismiss the modal
    @Environment(\.presentationMode) var presentationMode: Binding<PresentationMode>

    /// Reference to the reset password api
    @StateObject private var passwordApi = Api<Response<Success>>()

    var body: some View {
        NavigationView {
            GeometryReader { geo in
                ScrollView {
                    // Field contents + button that all are bound/call
                    // in the view.
                }
            }
            .navigationBarTitle("", displayMode: .inline)
            .navigationBarItems(leading: self.closeButton /* Button that fires `closeSheet` */)
            .onReceive(self.passwordApi.$status, perform: { status in
                guard let result = result else { return }
                    switch result {
                    case .inProgress:
                        // Handle in progress
                    case let .success(response):
                        // Handle success via closing dialog + showing toast
                    case let .failed(error):
                        // Handle failure via showing toast
                    }
            })
        }
    }
}

以上是一个简单的例子,但如果需要进行更复杂的处理或数据操作,我不希望将其放在 View 中,因为那会很混乱。此外,在这种情况下,成功/失败事件完全匹配需要在 UI 中处理的事件,但并不是每个视图都属于该类别,因此可能需要进行更多的处理。
对于几乎每个具有模型的视图,我都遇到了这个难题,如果 ViewModel 中发生了基本事件,应该如何将其传达给 View。我觉得应该有一种更好的方法来解决这个问题,并且这也让我想到我可能做错了。
那是一大段文字,但我很想确保应用程序的架构是可维护的,易于测试,并且视图专注于显示数据和调用变异(但不能以在 ViewModel 中存在大量样板变量为代价)。
谢谢

1
为什么要在所有地方使用 .onReceive?你真正需要它的唯一事情似乎只是用于 dismiss。其他所有事情都可以在 ViewModel 中处理。ViewModel 在某种程度上类似于 UIKit 的 UIViewController,而 View 在某种程度上类似于 storyboard。看起来你期望 View 做的事情比它应该做的更多。 - lorem ipsum
故事书+视图控制器的比较很酷!但在这种情况下,烤面包系统无法从视图模型中访问(至少我不知道如何访问)。这是因为它是一个环境对象,你不能将环境对象传递到视图模型的构造函数中。我最初尝试将烤面包系统设置为单例,但这破坏了发布者的反应性(数据会出现,但Swift不会对其进行任何操作)。 - Eli
通过使用StateObject,然后使用environmentObject,可以以一种响应式且有效的方式与所有视图共享相同的实例。如果您知道为什么静态共享的toast系统实例会破坏响应性,并且有任何解决方案,我很乐意听取您的建议 :)我认为这是因为它在SwiftUI跟踪范围之外,但我无法弄清楚原因。 - Eli
真实但可能与在UIKit中呈现吐司没有什么不同,特别是当处理表单时。有趣的是,如何在密码重置屏幕上将吐司模态显示在表单模态之上。 - lorem ipsum
如果您将ZStack放在最外层View中,您也可以在ViewModifer中使用它。但是,当您将其放置在内部视图中时,问题就出现了。它将覆盖该内部视图。 - lorem ipsum
显示剩余5条评论
1个回答

3
您可以将重置密码请求的结果传递到视图模型的@Published属性中。当状态发生更改时,SwiftUI会自动更新相关的视图。
以下是我编写的类似于您的密码重置表单的示例,包括视图和基础视图模型。视图模型具有一个state,其中包含来自嵌套State枚举的四个可能值:
  • idle:初始状态或用户名更改后。
  • loading:正在执行重置请求时。
  • successfailure:当重置请求的结果已知时。
我使用简单的延迟发布者模拟了密码重置请求,当检测到无效用户名时(仅考虑包含@的用户名)会失败。发布者的结果直接分配给已发布的state属性,使用.assign(to: &$state)非常方便,可以连接发布者在一起。
import Combine
import Foundation

final class ForgotPasswordViewModel: ObservableObject {
    enum State {
        case idle
        case loading
        case success
        case failed(message: String)
    }
    
    var username: String = "" {
        didSet {
            state = .idle
        }
    }
    
    @Published private(set) var state: State = .idle
    
    // Simulate some network request to reset the user password
    private static func resetPassword(for username: String) -> AnyPublisher<State, Never> {
        return CurrentValueSubject(username)
            .delay(for: .seconds(.random(in: 1...2)), scheduler: DispatchQueue.main)
            .map { username in
                return username.contains("@") ? State.success : State.failed(message: "The username does not exist")
            }
            .eraseToAnyPublisher()
    }
    
    func resetPassword() {
        state = .loading
        Self.resetPassword(for: username)
            .receive(on: DispatchQueue.main)
            .assign(to: &$state)
    }
}

视图本身实例化并存储视图模型作为@StateObject。用户可以输入他们的名称并触发请求重置密码。每当视图模型状态更改时,将自动触发body更新,使视图适当调整:
import SwiftUI

struct ForgotPasswordView: View {
    @StateObject private var model = ForgotPasswordViewModel()
    
    private var statusMessage: String? {
        switch model.state {
        case .idle:
            return nil
        case .loading:
            return "Submitting"
        case .success:
            return "The password has been reset"
        case let .failed(message: message):
            return "Error: \(message)"
        }
    }
    
    var body: some View {
        VStack(spacing: 40) {
            Text("Password reset")
                .font(.title)
            TextField("Username", text: $model.username)
            Button(action: resetPassword) {
                Text("Reset password")
            }
            if let statusMessage = statusMessage {
                Text(statusMessage)
            }
            Spacer()
        }
        .padding()
    }
    
    private func resetPassword() {
        model.resetPassword()
    }
}

上述代码可以在Xcode项目中轻松测试。


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