SwiftUI 中 ViewModel + View 之间的通信
Posted
技术标签:
【中文标题】SwiftUI 中 ViewModel + View 之间的通信【英文标题】:Communication between ViewModel + View in SwiftUI 【发布时间】:2021-07-25 08:12:14 【问题描述】:我是 Combine 的新手,并且正在为一些关于沟通的概念而苦苦挣扎。我来自网络背景,在此之前是 UIKit,因此与 SwiftUI 不同。
我非常热衷于使用MVVM
使业务逻辑远离View
层。这意味着任何不是可重用组件的视图都有一个ViewModel
来处理 API 请求、逻辑、错误处理等。
我遇到的问题是当ViewModel
发生某些事情时,将事件传递给View
的最佳方式是什么。我知道视图应该是状态的反映,但是对于事件驱动的事物,它需要一堆变量,我认为这些变量很混乱,因此渴望获得其他方法。
下面的例子是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
只是简单的反映数据和调用方法。到目前为止一切都很好。
现在,为了处理服务器响应的 success
和 failed
状态,并将该信息发送到 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
变量不需要清理。
不幸的是,您似乎不能将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
中的变量)
谢谢
【问题讨论】:
为什么要使用.onReceive
?你唯一真正需要的似乎是解雇。其他一切都可以在 ViewModel 中处理。 ViewModel 有点像 UIKit UIViewController
,View
有点像故事板。您似乎期望View
做的比它应该做的更多
故事书+视图控制器对比很酷!但是,在这种情况下,不能(至少据我所知)无法从视图模型访问 toast 系统。这是因为它是一个环境对象,您不能将环境对象传递给视图模型构造函数。我最初试图让 toast 系统成为单例,但这破坏了 Publisher 的反应性(数据会出现,但 swift 不会对它做任何事情)
通过使用 StateObject 然后使用 environmentObject,同一个实例以一种反应性和有效的方式与所有视图共享。如果您知道为什么 toast 系统的静态共享实例会破坏反应性并有任何解决方案,我很想听听他们 :) 我认为这是因为它超出了 SwiftUI 的跟踪范围,但我无法弄清楚原因。
我不知道你的 toast 是什么样子但是如果你把它变成一个ViewModifier
,它会利用 UIKit 在 rootViewController 中显示它。你可以把它放在任何地方,它总是显示在顶部
明确地说,toast 环境变量是一个具有create
和remove
的类,因为它是公共方法,然后它通过一个公共只读toasts 数组进行协调,该数组被馈送到@987654368 @ 实际渲染/动画它们。渴望听到更多关于ViewModifier
解决方案的信息,我如何从ViewModel
中调用它? :)
【参考方案1】:
您可以将重置密码请求的结果发送到视图模型的@Published
属性。当状态发生变化时,SwiftUI 会自动更新关联的视图。
在下文中,我编写了一个类似于您的密码重置表单,其中包含一个视图和一个底层视图模型。视图模型有一个state
,其中包含来自嵌套State
枚举的四个可能值:
idle
作为初始状态或用户名更改后。
loading
正在执行重置请求时。
success
和 failure
当重置请求的结果已知时。
我用一个简单的延迟发布者模拟了密码重置请求,当检测到无效用户名时失败(为简单起见,只有包含 @ 的用户名被认为是有效的)。发布者结果使用.assign(to: &$state)
直接赋值给发布的state
属性,很方便的将connect publishers组合在一起:
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 项目中测试。
【讨论】:
以上是关于SwiftUI 中 ViewModel + View 之间的通信的主要内容,如果未能解决你的问题,请参考以下文章
SwiftUI:从 ViewModel 更改视图 @State 属性