SwiftUI 中 ViewModel + View 之间的通信



我是Combine的新手,正在为一些关于沟通的概念而苦苦挣扎。我来自网络背景,在此之前是UIKit,所以与SwiftUI不同。

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

我遇到的问题是,当ViewModel发生某些事情时,将事件传递给View的最佳方法是什么。我知道视图应该是状态的反映,但对于事件驱动的事情,它需要一堆我认为很混乱的变量,并且非常热衷于获得其他方法。

下面的示例是一个ForgotPasswordView。它显示为工作表,当成功重置时,它应该关闭 + 显示成功 toast。如果失败,应该显示一个错误 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,可以使其更加优雅,以便只需要使用单个侦听器 + 变量。但是,枚举不处理必须清理变量的事实。

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

不幸的是,您似乎不能将onRecievePassthroughSubject一起使用。如果我将其设置为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中有很多样板变量为代价)

谢谢

可以将重置密码请求的结果传递到视图模型的@Published属性。SwiftUI 将在状态更改时自动更新关联的视图。

在下面,我编写了一个类似于您的密码重置表单,其中包含视图和基础视图模型。视图模型有一个state,其中包含嵌套State枚举中的四个可能值:

  • idle作为初始状态或更改用户名后。
  • loading何时执行重置请求。
  • successfailure重置请求的结果何时已知。

我使用一个简单的延迟发布者模拟了密码重置请求,当检测到无效的用户名时,该发布者会失败(为简单起见,只有包含@的用户名才被视为有效)。发布者结果使用.assign(to: &$state)直接分配给已发布的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 项目中进行测试。

最新更新