[Swift, Программирование, Разработка мобильных приложений, Разработка под iOS] MVI и SwiftUI – одно состояние
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться.
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
===========
Похожие новости:
- [IT-компании, Разработка под iOS, Смартфоны, Социальные сети и сообщества] Пользователь обнаружил, что приложение Instagram в iOS 14 нештатно использует камеру. Разработчик пояснил, что это баг
- [JavaScript, Node.JS, Программирование, Разработка веб-сайтов] Руководство по Deno: примеры работы со средой выполнения TypeScript (перевод)
- [Разработка мобильных приложений, Социальные сети и сообщества] Как написать простую Социальную сеть
- [Будущее здесь, Интервью, Искусственный интеллект, Машинное обучение, Программирование] Необычное собеседование: GPT-3 в роли кандидата (перевод)
- [Программирование микроконтроллеров, Производство и разработка электроники] История разработки одного дозиметра (Часть 2)
- [Анализ и проектирование систем, Программирование, Проектирование и рефакторинг, Управление разработкой] Методика проектирования архитектурных слоев на основе анемичной модели и DDD
- [CSS, HTML, JavaScript, Программирование, Разработка веб-сайтов] Как стать Front-End разработчиком
- [Разработка под Android, Разработка мобильных приложений, Kotlin] Top 10 Kotlin App Development Companies 2020-21
- [Dart, Flutter, Разработка под Android, Разработка под iOS] Детальный разбор навигации в Flutter
- [Python, Машинное обучение, Программирование] 10 вещей, которые вы могли не знать о scikit-learn (перевод)
Теги для поиска: #_swift, #_programmirovanie (Программирование), #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_pod_ios (Разработка под iOS), #_swiftui, #_arhitektura (Архитектура), #_mvi, #_swift, #_swift, #_programmirovanie (
Программирование
), #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
), #_razrabotka_pod_ios (
Разработка под iOS
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 26-Ноя 10:41
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Представим, нам нужно внести небольшую правку в работу экрана. Экран меняется каждую секунду, поскольку в нем одновременно происходит множество процессов. Как правило, чтобы урегулировать все состояния экрана, необходимо обратиться к переменным, каждая из которых живет своей жизнью. Держать их в голове либо очень трудно, либо вовсе невозможно. Чтобы найти источник проблемы, придется разобраться в переменных и состояниях экрана, да еще проследить, чтобы наше исправление не поломало что-то в другом месте. Допустим, мы потратили уйму времени и все-таки внесли нужную правку. Можно ли было решить эту задачу проще и быстрее? Давайте разбираться. 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() } }
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? }
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() } }
У этого подхода (менять состояния по очереди) есть недостаток: если атрибутов у Model много, то при смене атрибутов можно что-то забыть поменять. Одно из возможных решенийSPLprotocol 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>()
Чтобы не засорять основной 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) } }
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)) } }
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() } }
Заключение SwiftUI так же, как и MVI, построен на реактивности, поэтому они хорошо подходят друг другу. Есть сложности с навигацией и большим Intent при сложной логике, но все решаемо. MVI позволяет реализовывать сложные экраны и с минимальными усилиями, очень динамично менять состояние экрана. Эта реализация, конечно, не единственно верная, всегда существуют альтернативы. Однако паттерн прекрасно ложится на новый подход к UI от Apple. Один класс для всех состояний экрана значительно упрощает работу с экраном. Код из статьи можно посмотреть в GitHub =========== Источник: habr.com =========== Похожие новости:
Программирование ), #_razrabotka_mobilnyh_prilozhenij ( Разработка мобильных приложений ), #_razrabotka_pod_ios ( Разработка под iOS ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 26-Ноя 10:41
Часовой пояс: UTC + 5