【发布时间】: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 有点像 UIKitUIViewController,View有点像故事板。您似乎期望View做的比它应该做的更多 -
故事书+视图控制器对比很酷!但是,在这种情况下,不能(至少据我所知)无法从视图模型访问 toast 系统。这是因为它是一个环境对象,您不能将环境对象传递给视图模型构造函数。我最初试图让 toast 系统成为单例,但这破坏了 Publisher 的反应性(数据会出现,但 swift 不会对它做任何事情)
-
通过使用 StateObject 然后使用 environmentObject,同一个实例以一种反应性和有效的方式与所有视图共享。如果您知道为什么 toast 系统的静态共享实例会破坏反应性并有任何解决方案,我很想听听他们 :) 我认为这是因为它超出了 SwiftUI 的跟踪范围,但我无法弄清楚原因。
-
我不知道你的 toast 是什么样子但是如果你把它变成一个
ViewModifier,它会利用 UIKit 在 rootViewController 中显示它。你可以把它放在任何地方,它总是显示在顶部 -
明确地说,toast 环境变量是一个具有
create和remove的类,因为它是公共方法,然后它通过一个公共只读toasts 数组进行协调,该数组被馈送到@987654368 @ 实际渲染/动画它们。渴望听到更多关于ViewModifier解决方案的信息,我如何从ViewModel中调用它? :)