#ios #mvvm #swiftui
Вопрос:
Я новичок в объединении и борюсь с несколькими концепциями, связанными с общением. Я родом из веб — среды, а до этого был UIKit, так что пейзаж отличается от SwiftUI.
Я очень заинтересован в том, чтобы использовать MVVM
бизнес — логику подальше от View
слоя. Это означает, что любое представление, которое не является повторно используемым компонентом, должно ViewModel
обрабатывать запросы API, логику, обработку ошибок и т.д.
Проблема, с которой я столкнулся, заключается в том, как лучше всего передавать события View
, когда что-то происходит в ViewModel
. Я понимаю, что представление должно отражать состояние, но для вещей, которые управляются событиями, требуется множество переменных, которые я считаю беспорядочными и поэтому стремлюсь найти другие подходы.
Приведенный ниже пример представляет собой ForgotPasswordView
. Он представлен в виде листа, и при успешном сбросе он должен закрыться показать тост за успех. В случае сбоя должен быть показан тост с ошибкой (для контекста глобальный координатор тоста управляется с помощью @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
}
}
}
}
The above ViewModel
has all the logic, and the View
simply reflects the data and calls methods. Everything good so far.
Now, in order to handle the success
and failed
states of the server response, and get that information to the UI, is where I run into issues. I can think of a few ways, but I either dislike, or seem not possible.
With variables
Create individual @Published
variables for each state e.g.
@Published var networkError: String? = nil
then set them is the different states
case let .failed(error):
// Handle failure
self.networkError = error.description
}
In the View
I can then subscribe to this via onRecieve
and handle the response
.onReceive(self.viewModel.$networkError, perform: { error in
if error {
// Call `closeSheet` and display toast
}
})
This works, but this is a single example and would require me to create a @Published
variable for every state. Also, these variables would have to be cleaned up too (setting them back to nil.
This could be made more graceful by using an enum
with associated values, so that there is only a single listener variable that needs to be used. The enum doesn’t however deal with the fact that the variable has to be cleaned up.
With PassthroughSubject
Building on this, I went looking at PassthroughSubject
thinking that if I create a single @Publisher
like
@Publisher var events: PassthoughSubject = PassthroughSubject<Event, Never>
And publish events like this:
.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)
}
}
Then I could listen to it like this
.onReceive(self.viewModel.$events, perform: { event in
switch event {
case .passwordReset:
// close sheet and display success toast
case let .apiError(error):
// show error toast
})
This is better that the variable, as the events are sent with .send
and so the events
variable doesn’t need cleaning up.
Unfortunately though, it doesn’t seem that you can use onRecieve
with a PassthroughSubject
. If i made it a Published
variable but with the same concept, then I would run into the having to clean it up again issue that the first solution had.
With everything in the view
The last scenario, that I have been trying to avoid is to handle everything in the 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
так, как это грязно. Кроме того, в этом случае событие успеха / неудачи идеально соответствует событиям, которые необходимо обработать в пользовательском интерфейсе, хотя не каждое представление попадет в эту категорию, поэтому может потребоваться еще большая обработка.
Я столкнулся с этой загадкой почти для каждого представления, у которого есть модель, если что-то происходит в том ViewModel
, что является базовым событием, как это должно быть сообщено View
. Я чувствую, что должен быть более приятный способ сделать это, что также заставляет меня думать, что я делаю это неправильно.
Это была большая стена текста, но я стремлюсь к тому, чтобы архитектура приложения была удобной для обслуживания, легко тестируемой, а представления были сосредоточены на отображении данных и вызове мутаций (но не за счет множества стандартных переменных в ViewModel
)
Спасибо
Комментарии:
1. Зачем использовать
.onReceive
для всего? единственное, для чего он вам действительно нужен, — это для увольнения. все остальное можно обработать в модели представления. Модель просмотра смутно похожа на UIKitUIViewController
, аView
раскадровка смутно похожа на раскадровку. Похоже, вы ожидаетеView
, что он сделает больше, чем должен2. Сравнение сборника рассказов контроллера просмотра-это круто! Однако в этом случае система тостов не может (по крайней мере, насколько мне известно) быть доступна из модели представления. Это связано с тем, что это объект среды, и вы не можете передавать объекты среды в конструктор моделей представления. Первоначально я пытался сделать систему тостов одноэлементной, но это нарушило реактивность издателя (данные появятся, но swift ничего с этим не сделает).
3. С помощью объекта StateObject, а затем с помощью объекта environmentObject один и тот же экземпляр совместно используется со всеми представлениями таким образом, чтобы он был реактивным и работал. Если вы знаете, почему статический общий экземпляр системы тостов нарушает реактивность, и у вас есть какие-либо решения, которые я хотел бы услышать 🙂 Я думаю, это потому, что это выходит за рамки того, что отслеживает SwiftUI, но я не смог понять, почему.
4. Я не знаю, как выглядит ваш тост, но если вы сделаете это
ViewModifier
, то подключитесь к UIKit, чтобы показать его в контроллере RootViewController. Вы можете поместить его куда угодно, и он всегда будет отображаться сверху5. Чтобы быть ясным, переменная среды тостов-это класс, у которого есть
create
, иremove
поскольку это общедоступные методы, она затем координируется с помощью общедоступного массива тостов только для чтения, который подается в aToastCoordindatorView
, который фактически визуализирует/анимирует их. Стремясь услышать больше оViewModifier
решении, как бы я назвал это изViewModel
? 🙂
Ответ №1:
Результат запроса на сброс пароля может быть доставлен в @Published
свойство вашей модели представления. SwiftUI автоматически обновит соответствующее представление при изменении состояния.
Ниже я написал форму сброса пароля, аналогичную вашей, с представлением и базовой моделью представления. Модель представления state
содержит четыре возможных значения из вложенного State
перечисления:
idle
в качестве начального состояния или после изменения имени пользователя.loading
когда выполняется запрос на сброс.success
иfailure
когда известен результат запроса на сброс.
Я смоделировал запрос на сброс пароля с помощью простого отложенного издателя, который завершается неудачей при обнаружении недопустимого имени пользователя (для простоты допустимыми считаются только имена пользователей, содержащие@). Результат издателя напрямую присваивается опубликованному state
свойству с помощью .assign(to: amp;$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: amp;$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.