首页 > 解决方案 > SwiftUI 中 ViewModel + View 之间的通信

问题描述

我是 Combine 的新手,并且正在努力解决一些关于沟通的概念。我来自网络背景,在此之前是 UIKit,因此与 SwiftUI 不同。

我非常热衷于使用MVVM来使业务逻辑远离View层。这意味着任何不是可重用组件的视图都ViewModel必须处理 API 请求、逻辑、错误处理等。

我遇到的问题是将事件传递ViewViewModel. 我知道视图应该是状态的反映,但是对于事件驱动的事物,它需要一堆变量,我认为这些变量很混乱,因此渴望获得其他方法。

下面的例子是一个ForgotPasswordView. 它以表格形式呈现,当成功重置时,它应该关闭 + 显示成功敬酒。在失败的情况下,应该会显示一个错误 toast(对于上下文,全局 toast 协调器是通过@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我可以通过订阅onRecieve并处理响应

.onReceive(self.viewModel.$networkError, perform: { error in
    if error {
        // Call `closeSheet` and display toast
    }
})

这可行,但这是一个示例,需要我@Published为每个状态创建一个变量。此外,这些变量也必须被清理(将它们设置回 nil。

这可以通过使用enum带有关联值的 an 来变得更加优雅,因此只需要使用一个侦听器 + 变量。然而,枚举并没有处理必须清理变量的事实。

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变量不需要清理。

不幸的是,您似乎不能onRecieve使用PassthroughSubject. 如果我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)

谢谢

标签: iosmvvmswiftui

解决方案


您可以将重置密码请求的结果传递到@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 项目中进行测试。


推荐阅读