Как стать автором
Обновить

MVI и SwiftUI – одно состояние

Время на прочтение 10 мин
Количество просмотров 6.8K

Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться.




Отредактировано: Подход доработан и улучшен, тут я описываю его подробнее https://habr.com/ru/post/583376/




MVI


Первым этот паттерн описал JavaScript разработчик Андрэ Штальц. С общими принципами можно ознакомиться по ссылке



Intent: ждет событий от пользователя и обрабатывает их
Model: ждет обработанные события для изменения состояния
View: ждет изменений состояния и показывает их
Custom element: подраздел View, который сам по себе является UI элементом. Может быть реализован как MVI или как веб-компонент. Необязательно использовать во View.


На лицо реактивный подход. Каждый модуль (function) ожидает какое-либо событие, а после его получения и обработки передает это событие в следующий модуль. Получается однонаправленный поток. Единое состояние View находится в Model, и таким образом решается проблема множества трудноотслеживаемых состояний.


Как это можно применить в мобильном приложении?


Мартин Фаулер и Райс Дейвид в книге "Шаблоны корпоративных приложений" писали, что паттерны – это шаблоны решения проблем, и вместо того, чтобы копировать один в один, лучше адаптировать их под текущие реалии. У мобильного приложения есть свои ограничения и особенности, которые надо учитывать. View получает событие от пользователя, а дальше его можно проксировать в Intent. Схема немного видоизменяется, но принцип работы паттерна остается прежним.



Реализация


Далее будет очень много кода.
Итоговый код можно посмотреть под спойлером ниже.


Реализация MVI

View


import SwiftUI

struct RootView: View {

    // Or @ObservedObject for iOS 13
    @StateObject private var intent: RootIntent

    var body: some View {
        ZStack {
            imageView()
                .cornerRadius(6)
                .shadow(radius: 2)
                .frame(width: 100, height: 100)
                .onTapGesture(perform: intent.onTapImage)
            errorView()
            loadView()
        }
        .overlay(RootRouter(screen: intent.model.routerSubject))
        .onAppear(perform: intent.onAppear)
    }

    static func build() -> some View {
        let model = RootModel()
        let intent = RootIntent(model: model)
        let view = RootView(intent: intent)
        return view
    }
}

// MARK: - Custom elements
private extension RootView {

    private func imageView() -> some View {
        guard let image = intent.model.image else { 
            return Color.gray.toAnyView() 
        }
        return Image(uiImage: image)
            .resizable()
            .toAnyView()
    }

    private func loadView() -> some View {
        guard intent.model.isLoading else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Loading")
        }.toAnyView()
    }

    private func errorView() -> some View {
        guard intent.model.error != nil else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Fail")
        }.toAnyView()
    }
}

Model


import SwiftUI
import Combine

protocol RootStateModel {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}

protocol RootDisplayModel {
    func dispalyLoading()
    func display(image: UIImage)
    func dispaly(loadingFailError: Error)
    func routeTodDescriptionImage()
}

// MARK: - RootModel & RootStateModel
class RootModel: ObservableObject, RootStateModel {

    @Published private(set) var image: UIImage?
    @Published private(set) var isLoading: Bool = true
    @Published private(set) var error: Error?

    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()
}

// MARK: - RootDisplayModel
extension RootModel: RootDisplayModel {

    func dispalyLoading() {
        isLoading = true
        error = nil
        image = nil
    }

    func display(image: UIImage) {
        self.image = image
        isLoading = false
    }

    func dispaly(loadingFailError: Error) {
        self.error = loadingFailError
        isLoading = false
        routerSubject.send(.alert(title: "Error",
                                  message: "It was not possible to upload a image"))
    }

    func routeTodDescriptionImage() {
        guard let image = self.image else {
            routerSubject.send(.alert(title: "Error", 
                                  message: "Failed to open the screen"))
            return
        }
        routerSubject.send(.descriptionImage(image: image))
    }
}

Intent


import SwiftUI
import Combine

class RootIntent: ObservableObject {

    let model: RootStateModel

    private var displayModel: RootDisplayModel
    private var cancellable: Set<AnyCancellable> = []

    init(model: RootModel) {
        self.model = model
        self.displayModel = model
        cancellable.insert(model.objectWillChange.sink { [weak self] in self?.objectWillChange.send() })
    }
}

// MARK: - API
extension RootIntent {

    func onAppear() {
        displayModel.dispalyLoading()

        let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")
        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in
            guard let data = data, let image = UIImage(data: data) else {
                DispatchQueue.main.async {
                    self?.displayModel.dispaly(loadingFailError: error ?? NSError())
                }
                return
            }
            DispatchQueue.main.async {
                self?.displayModel.display(image: image)
            }
        }
        task.resume()
    }

    func onTapImage() {
        displayModel.routeTodDescriptionImage()
    }
}

Router


struct RootRouter: View {

    enum ScreenType {
        case alert(title: String, message: String)
        case descriptionImage(image: UIImage)
    }

    let screen: PassthroughSubject<ScreenType, Never>

    @State private var screenType: ScreenType? = nil

    var body: some View {
        displayView().onReceive(screen, perform: {
            self.screenType = $0
        })
    }
}

private extension RootRouter {

    private func displayView() -> some View {
        let isVisibleScreen = Binding<Bool> {
            screenType != nil
        } set: {
            if !$0 { screenType = nil }
        }

        // Screens
        switch screenType {
        case .alert(let title, let message):
            return Spacer().alert(isPresented: isVisibleScreen, content: {
                Alert(title: Text(title), message: Text(message))
            }).toAnyView()

        case .descriptionImage(let image):
            return Spacer().sheet(isPresented: isVisibleScreen, content: {
                DescriptionImageView.build(image: image)
            }).toAnyView()

        default:
            return EmptyView().toAnyView()
        }
    }
}

Теперь приступим к рассмотрению каждого модуля по отдельности.


Прежде чем приступить к реализации, нам понадобится расширение для View, которое упростит написание кода и сделает его более читабельным.


extension View {
    func toAnyView() -> AnyView {
        AnyView(self)
    }
}

View


View – принимает событие от пользователя, передает их в Intent и ждет изменения состояния от Model


import SwiftUI

struct RootView: View {

    // 1
    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
           // 4
            imageView()
                    .cornerRadius(6)
                    .shadow(radius: 2)
                    .frame(width: 100, height: 100)
            errorView()
            loadView()
        }
        // 3
        .onAppear(perform: intent.onAppear)
    }

    // 2
    static func build() -> some View {
        let intent = RootIntent()
        let view = RootView(intent: intent)
        return view
    }

    private func imageView() -> some View {
        // 5
        guard let image = intent.model.image else { 
             return Color.gray.toAnyView() 
         }
        return Image(uiImage: image)
            .resizable()
            .toAnyView()
    }

    private func loadView() -> some View {
       // 5
        guard intent.model.isLoading else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Loading")
        }.toAnyView()
    }

    private func errorView() -> some View {
       // 5
        guard intent.model.error != nil else {
            return EmptyView().toAnyView()
        }
        return ZStack {
            Color.white
            Text("Fail")
        }.toAnyView()
    }
}

  1. Все события, которые получает View, передаются в Intent. Intent держит ссылку на актуальное состояние View у себя, так как именно он меняет состояния. Обертка @ObservedObject нужна для того, чтобы передавать во View все изменения, происходящие в Model (подробнее чуть ниже)
  2. Упрощает создание View, таким образом проще принимать данные от другого экрана (пример RootView.build() или HomeView.build(articul: 42))
  3. Передает событие цикла жизни View в Intent
  4. Функции, которые создают Custom elements
  5. Пользователь может видеть разные состояния экрана, все зависит от того, какие сейчас данные в Model. Если булевое значение атрибута intent.model.isLoading – true, пользователь видит загрузку, если false, то видит загруженный контент или ошибку. В зависимости от состояния пользователь будет видеть разные Custom elements.

Model


Model – держит у себя актуальное состояние экрана


 import SwiftUI

// 1
protocol RootStateModel {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}

// 2
protocol RootDisplayModel {
    var image: UIImage? { get set }
    var isLoading: Bool { get set }
    var error: Error? { get set }
}

class RootModel: ObservableObject, RootStateModel {
    // 3
    @Published var image: UIImage?
    @Published var isLoading: Bool = true
    @Published var error: Error?
} 

  1. Протокол нужен для того, чтобы показывать View только то, что необходимо для отображения UI
  2. Протокол нужен для того, чтобы дать доступ Intent к данным. Для однонаправленного потока данных, лучше не давать доступ к свойствам Model, а в протоколе указывать функции и данные менять через эти функции
  3. @Published нужен для реактивной передачи данных во View

Intent


Inent – ждет событий от View для дальнейших действий. Работает с бизнес логикой и базами данных, делает запросы на сервер и т.д.


import SwiftUI
import Combine

class RootIntent: ObservableObject {

    // 1
    let model: RootStateModel

    // 2
    private var displayModel: RootDisplayModel

    // 3
    private var cancellable: Set<AnyCancellable> = []

    init() {
        self.model = RootModel()
        self. displayModel = RootModel()

      // 3
        let modelCancellable = model.objectWillChange.sink { [weak self] in self?.objectWillChange.send() }
        cancellable.insert(modelCancellable)
    }
}

// MARK: - API
extension RootIntent {

    // 4
    func onAppear() {
      displayModel.isLoading = true
      displayModel.error = nil

        let url: URL! = URL(string: "https://upload.wikimedia.org/wikipedia/commons/f/f4/Honeycrisp.jpg")
        let task = URLSession.shared.dataTask(with: url) { [weak self] (data, _, error) in
            guard let data = data, let image = UIImage(data: data) else {
                DispatchQueue.main.async {
               // 5
                    self?.displayModel.error = error ?? NSError()
                    self?.displayModel.isLoading = false
                }
                return
            }
            DispatchQueue.main.async {
           // 5
                self?.displayModel.image = image
                self?.displayModel.isLoading = false
            }
        }

        task.resume()
    }
} 

  1. Intent содержит в себе ссылку на Model, и когда это необходимо, меняет данные у Model. RootStateModel – это протокол, который показывает атрибуты Model и не дает их менять
  2. Общение с Model и изменение данных происходит через протокол. Для того, чтобы Intent имел доступ к нужным ему данным и не имел доступа к данным для View
  3. Intent постоянно ждет изменения атрибутов у Model и передает их View. AnyCancellable позволяет не держать в памяти ссылку на ожидание изменений от Model. Таким нехитрым способом View получает самое актуальное состояние
  4. Эта функция получает событие от пользователя и скачивает картинку
  5. Так мы меняем состояние экрана

У этого подхода (менять состояния по очереди) есть недостаток: если атрибутов у Model много, то при смене атрибутов можно что-то забыть поменять. Есть одно из возможных решений, которое еще и обеспечивает однонаправленный поток данных.


Одно из возможных решений
protocol RootStateModel {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}

protocol RootDisplayModel {
    func dispalyLoading()
    func display(image: UIImage)
    func dispaly(loadingFailError: Error)
}

// MARK: - RootModel & RootStateModel
class RootModel: ObservableObject, RootStateModel {

    @Published private(set) var image: UIImage?
    @Published private(set) var isLoading: Bool = true
    @Published private(set) var error: Error?
}

// MARK: - RootDisplayModel
extension RootModel: RootDisplayModel {

    func dispalyLoading() {
        isLoading = true
        error = nil
        image = nil
    }

    func display(image: UIImage) {
        self.image = image
        isLoading = false
    }

    func dispaly(loadingFailError: Error) {
        self.error = loadingFailError
        isLoading = false
    }
}

// MARK: - API
extension RootIntent {

    func onAppear() {
       displayModel.dispalyLoading()
... 

Верю, что это не единственное решение и можно решить проблему другими способами.


Есть еще один недостаток – класс Intent может сильно вырасти при большом количестве бизнес логики. Это проблема решается разбиением бизнес логики на сервисы.


А что с навигацией? MVI+R


Если удается все делать во View, то проблем, скорее всего, не будет. Но если логика усложняется, возникает ряд трудностей. Как оказалось, сделать Router с передачей данных на следующий экран и возвратом данных обратно во View, который вызвал этот экран, не так-то просто. Передачу данных можно сделать через @EnvironmentObject, но тогда доступ к этим данным будут у всех View ниже иерархии, что нехорошо. От этой идеи отказываемся. Так как состояния экрана меняются через Model, обращение к Router делаем через эту сущность.


protocol RootStateModel {
    ...

    // 1
    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}

class RootModel: ObservableObject, RootStateModel {
    ...

    // 1
    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>() 

  1. Точка входа. Через этот атрибут будем обращаться к Router

Чтобы не засорять основной View, все, что касается переходов на другие экраны, выносим отдельным View



 struct RootView: View {

    @ObservedObject private var intent: RootIntent

    var body: some View {
        ZStack {
            imageView()
                .cornerRadius(6)
                .shadow(radius: 2)
                .frame(width: 100, height: 100)
           // 2
                .onTapGesture(perform: intent.onTapImage)
            errorView()
            loadView()
        }
      // 1
        .overlay(RootRouter(screen: intent.model.routerSubject))
        .onAppear(perform: intent.onAppear)
    }
} 

  1. Отдельный View, в котором находится вся логика и Custom elements, относящиеся к навигации
  2. Передает событие цикла жизни View в Intent

Intent собирает все необходимые данные для перехода


// MARK: - API
extension RootIntent {

    func onTapImage() {
        guard let image = displayModel.image else {
          // 1
            displayModel.routerSubject.send(.alert(title: "Error",  message: "Failed to open the screen"))
            return
        }
        // 2
        displayModel.routerSubject.send(.descriptionImage(image: image))
    }
} 

  1. Если по каким-либо причинам картинки нет, тогда передает все необходимые данные в Model для показа ошибки
  2. Передает необходимые данные в Model для открытия экрана с подробным описанием картинки

import SwiftUI
import Combine

struct RootRouter: View {

    // 1
    enum ScreenType {
        case alert(title: String, message: String)
        case descriptionImage(image: UIImage)
    }

    // 2
    let screen: PassthroughSubject<ScreenType, Never>

    // 3
    @State private var screenType: ScreenType? = nil

    var body: some View {
        displayView()
                // 3
                .onReceive(screen) {
                    self.screenType = $0
                }
    }
}

private extension RootRouter {

    private func displayView() -> some View {
        // 4
        let isVisibleScreen = Binding<Bool> {
            screenType != nil
        } set: {
            if !$0 { screenType = nil }
        }

        // 3
        switch screenType {
        case .alert(let title, let message):
            return Spacer().alert(isPresented: isVisibleScreen, content: {
                Alert(title: Text(title), message: Text(message))
            }).toAnyView()

        case .descriptionImage(let image):
            return Spacer().sheet(isPresented: isVisibleScreen, content: {
                // 6
                DescriptionImageView.build(image: image)
            }).toAnyView()

        default:
            // 5
            return EmptyView().toAnyView()
        }
    }
}

  1. Enum с необходимыми данными для экранов
  2. Через этот атрибут будут передаваться события. По событиям мы будем понимать, какой экран надо показывать
  3. Это атрибут нужен для хранения данных экрана который будет показан. И для о пределение какой экран показывать.
  4. Когда меняем State private var screenType: ScreenType? меняется get значение, если true экран показывается, если false, то скрывается. Пока у нас есть какое-то значение в screenType: ScreenType? мы видим новый экран, когда значение nil, новые экраны скрыты.
  5. Если данных нет то ничего не показываем. Не храним в памяти лишние экраны.
  6. Таким образом (.build(image: image)) можно передавать данные другим экранам

Заключение


SwiftUI так же, как и MVI, построен на реактивности, поэтому они хорошо подходят друг другу. Есть сложности с навигацией и большим Intent при сложной логике, но все решаемо. MVI позволяет реализовывать сложные экраны и с минимальными усилиями, очень динамично менять состояние экрана. Эта реализация, конечно, не единственно верная, всегда существуют альтернативы. Однако паттерн прекрасно ложится на новый подход к UI от Apple. Один класс для всех состояний экрана значительно упрощает работу с экраном.


Код из статьи, а также Шаблоны для Xcode можно посмотреть в GitHub.


p.s.
Важное дополнение.


В примерах я использовал @ObservedObject, он привязан к циклу жизни View. В SwiftUI у View конструктор (init) может вызываться несколько раз и Intent, при каждом вызове, будет сбрасываться. Чтобы отвязать Intetn от цикла жизни View, надо использовать @StateObject.


StateObject доступен с iOS 14, для iOS 13 оптимального решения этой проблемы мне найти пока не удалось.


Отредактировано: Подход доработан и улучшен, тут я описываю подробнее https://habr.com/ru/post/583376/

Теги:
Хабы:
+1
Комментарии 5
Комментарии Комментарии 5

Публикации

Истории

Работа

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн