Связь между ViewModel View в SwiftUI

#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 для всего? единственное, для чего он вам действительно нужен, — это для увольнения. все остальное можно обработать в модели представления. Модель просмотра смутно похожа на UIKit UIViewController , а View раскадровка смутно похожа на раскадровку. Похоже, вы ожидаете View , что он сделает больше, чем должен

2. Сравнение сборника рассказов контроллера просмотра-это круто! Однако в этом случае система тостов не может (по крайней мере, насколько мне известно) быть доступна из модели представления. Это связано с тем, что это объект среды, и вы не можете передавать объекты среды в конструктор моделей представления. Первоначально я пытался сделать систему тостов одноэлементной, но это нарушило реактивность издателя (данные появятся, но swift ничего с этим не сделает).

3. С помощью объекта StateObject, а затем с помощью объекта environmentObject один и тот же экземпляр совместно используется со всеми представлениями таким образом, чтобы он был реактивным и работал. Если вы знаете, почему статический общий экземпляр системы тостов нарушает реактивность, и у вас есть какие-либо решения, которые я хотел бы услышать 🙂 Я думаю, это потому, что это выходит за рамки того, что отслеживает SwiftUI, но я не смог понять, почему.

4. Я не знаю, как выглядит ваш тост, но если вы сделаете это ViewModifier , то подключитесь к UIKit, чтобы показать его в контроллере RootViewController. Вы можете поместить его куда угодно, и он всегда будет отображаться сверху

5. Чтобы быть ясным, переменная среды тостов-это класс, у которого есть create , и remove поскольку это общедоступные методы, она затем координируется с помощью общедоступного массива тостов только для чтения, который подается в a ToastCoordindatorView , который фактически визуализирует/анимирует их. Стремясь услышать больше о 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.