[Swift, Программирование, Разработка мобильных приложений, Разработка под iOS] MVI и SwiftUI – одно состояние

Автор Сообщение
news_bot ®

Стаж: 6 лет 2 месяца
Сообщений: 27286

Создавать темы news_bot ® написал(а)
26-Июл-2020 22:31


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

Intent: ждет событий от пользователя и обрабатывает их
Model: ждет обработанные события для изменения состояния
View: ждет изменений состояния и показывает их
Custom element: подраздел View, который сам по себе является UI элементом. Может быть реализован как MVI или как веб-компонент. Необязательно использовать во View.
На лицо реактивный подход. Каждый модуль (function) ожидает какое-либо событие, а после его получения и обработки передает это событие в следующий модуль. Получается однонаправленный поток. Единое состояние View находится в Model, и таким образом решается проблема множества трудноотслеживаемых состояний.
Как это можно применить в мобильном приложении?
Мартин Фаулер и Райс Дейвид в книге «Шаблоны корпоративных приложений» писали, что паттерны – это шаблоны решения проблем, и вместо того, чтобы копировать один в один, лучше адаптировать их под текущие реалии. У мобильного приложения есть свои ограничения и особенности, которые надо учитывать. View получает событие от пользователя, а дальше его можно проксировать в Intent. Схема немного видоизменяется, но принцип работы паттерна остается прежним.

Реализация
Прежде чем приступить к реализации, нам понадобится расширение для 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()
            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 {
        Group { () -> AnyView  in
    // 5
            if let image = intent.model.image {
                return Image(uiImage: image)
                    .resizable()
                    .toAnyView()
            } else {
                return Color.gray.toAnyView()
            }
        }
        .cornerRadius(6)
        .shadow(radius: 2)
        .frame(width: 100, height: 100)
    }
    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()
    }
}

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

Model
Model – держит у себя актуальное состояние экрана
import SwiftUI
// 1
protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}
class RootModel: ObservableObject, RootModeling {
    // 2
    @Published private(set) var image: UIImage?
    @Published private(set) var isLoading: Bool = true
    @Published private(set) var error: Error?
}

  • Протокол нужен для того, чтобы показывать View только то, что необходимо для отображения UI
  • @Published нужен для реактивной передачи данных во View

Intent
Inent – ждет событий от View для дальнейших действий. Работает с бизнес логикой и базами данных, делает запросы на сервер и т.д.
import SwiftUI
import Combine
class RootIntent: ObservableObject {
    // 1
    let model: RootModeling
    // 2
    private var rootModel: RootModel! { model as? RootModel }
    // 3
    private var cancellable: Set<AnyCancellable> = []
    init() {
        self.model = RootModel()
    // 3
        let modelCancellable = rootModel.objectWillChange.sink { self.objectWillChange.send() }
        cancellable.insert(modelCancellable)
    }
}
// MARK: - API
extension RootIntent {
    // 4
    func onAppear() {
    rootModel.isLoading = true
    rootModel.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?.rootModel.error = error ?? NSError()
                    self?.rootModel.isLoading = false
                }
                return
            }
            DispatchQueue.main.async {
       // 5
                self?.model.image = image
                self?.model.isLoading = false
            }
        }
        task.resume()
    }
}

  • Intent содержит в себе ссылку на Model, и когда это необходимо, меняет данные у Model. RootModelIng – это протокол, который показывает атрибуты Model и не дает их менять
  • Для того, чтобы изменить атрибуты в Intent, мы преобразуем RootModelProperties в RootModel
  • Intent постоянно ждет изменения атрибутов у Model и передает их View. AnyCancellable позволяет не держать в памяти ссылку на ожидание изменений от Model. Таким нехитрым способом View получает самое актуальное состояние
  • Эта функция получает событие от пользователя и скачивает картинку
  • Так мы меняем состояние экрана

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

Одно из возможных решений

SPL
protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
}
class RootModel: ObservableObject, RootModeling {
    enum StateType {
        case loading, show(image: UIImage), failLoad(error: Error)
    }
    @Published private(set) var image: UIImage?
    @Published private(set) var isLoading: Bool = true
    @Published private(set) var error: Error?
    func update(state: StateType) {
        switch state {
        case .loading:
            isLoading = true
            error = nil
            image = nil
        case .show(let image):
            self.image = image
            isLoading = false
        case .failLoad(let error):
            self.error = error
            isLoading = false
        }
    }
}
// MARK: - API
extension RootIntent {
    func onAppear() {
     rootModel?.update(state: .loading)
...


Верю, что это не единственное решение и можно решить проблему другими способами.
Есть еще один недостаток – класс Intent может сильно вырасти при большом количестве бизнес логики. Это проблема решается разбиением бизнес логики на сервисы.
А что с навигацией? MVI+R
Если удается все делать во View, то проблем, скорее всего, не будет. Но если логика усложняется, возникает ряд трудностей. Как оказалось, сделать Router с передачей данных на следующий экран и возвратом данных обратно во View, который вызвал этот экран, не так-то просто. Передачу данных можно сделать через @EnvironmentObject, но тогда доступ к этим данным будут у всех View ниже иерархии, что нехорошо. От этой идеи отказываемся. Так как состояния экрана меняются через Model, обращение к Router делаем через эту сущность.
protocol RootModeling {
    var image: UIImage? { get }
    var isLoading: Bool { get }
    var error: Error? { get }
    // 1
    var routerSubject: PassthroughSubject<RootRouter.ScreenType, Never> { get }
}
class RootModel: ObservableObject, RootModeling {
    // 1
    let routerSubject = PassthroughSubject<RootRouter.ScreenType, Never>()

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

Чтобы не засорять основной View, все, что касается переходов на другие экраны, выносим отдельным View
struct RootView: View {
    @ObservedObject private var intent: RootIntent
    var body: some View {
        ZStack {
            imageView()
       // 2
                .onTapGesture(perform: intent.onTapImage)
            errorView()
            loadView()
        }
    // 1
        .overlay(RootRouter(screen: intent.model.routerSubject))
        .onAppear(perform: intent.onAppear)
    }
}

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

Intent собирает все необходимые данные для перехода
// MARK: - API
extension RootIntent {
    func onTapImage() {
        guard let image = rootModel?.image else {
        // 1
            rootModel?.routerSubject.send(.alert(title: "Error", message: "Failed to open the screen"))
            return
        }
        // 2
        model.routerSubject.send(.descriptionImage(image: image))
    }
}

  • Если по каким-либо причинам картинки нет, тогда передает все необходимые данные в Model для показа ошибки
  • Передает необходимые данные в 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
    // 4
    @State private var isFullImageVisible = false
    @State private var isAlertVisible = false
    var body: some View {
    Group {
            alertView()
            descriptionImageView()
        }
    // 2
        .onReceive(screen, perform: { type in
            self.screenType = type
            switch type {
            case .alert:
                self.isAlertVisible = true
            case .descriptionImage:
                self.isFullImageVisible = true
            }
        }).overlay(screens())
    }
    private func alertView() -> some View {
    // 3
        guard let type = screenType, case .alert(let title, let message) = type else {
            return EmptyView().toAnyView()
        }
        // 4
        return Spacer().alert(isPresented: $isAlertVisible, content: {
            Alert(title: Text(title), message: Text(message))
        }).toAnyView()
    }
    private func descriptionImageView() -> some View {
    // 3
        guard let type = screenType, case .descriptionImage(let image) = type else {
            return EmptyView().toAnyView()
        }
        // 4
        return Spacer().sheet(isPresented: $isFullImageVisible, onDismiss: {
            self.screenType = nil
        }, content: {
            DescriptionImageView.build(image: image)
        }).toAnyView()
    }
}

  • Enum с необходимыми данными для экранов
  • Через этот атрибут будут передаваться события. По событиям мы будем понимать, какой экран надо показывать
  • Это атрибут нужен для хранения данных для открытия экрана
  • Меняем с false на true и нужный экран открывается

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

Похожие новости: Теги для поиска: #_swift, #_programmirovanie (Программирование), #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_pod_ios (Разработка под iOS), #_swiftui, #_arhitektura (Архитектура), #_mvi, #_swift, #_swift, #_programmirovanie (
Программирование
)
, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
)
, #_razrabotka_pod_ios (
Разработка под iOS
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 07-Май 03:42
Часовой пояс: UTC + 5