SwiftUI – это молодая и пока что не совсем изученная технология. С одной стороны, большое пространство для творчества и исследования, а с другой – неизвестность, нестабильность и проблемы.
Так ли просто писать на SwiftUI, как показывают на WWDC? Я расскажу о сложностях, с которыми столкнулся лично я во время написания собственного приложения. Оно полностью написано на SwiftUI и выложено в App Store.
Какие проблемы могут встретиться во время разработки? Давайте разбираться.
Проблема конструктора View
Главная и самая большая проблема, которая накладывает ограничения почти на любой архитектурный подход. Мое приложение построено на MVI, поэтому рассматривать буду в рамках этой архитектуры. Подробнее об MVI я писал в публикации MVI и SwiftUI – одно состояние
Сначала сделаю небольшое отступление:
@ObservedObject и @StateObject
В MVI есть модуль, который отвечает за бизнес логику, называется Intent. У View на Intent есть ссылка.
struct ContentView: View {
@ObservedObject var intent: ContentIntent
var body: some View {
Text("Hello, world!")
}
}
@ObservableObject нужен для того, чтобы можно было отслеживать все, что происходит в классе и сообщать об этом View, а тот, в свою очередь, опираясь на новые данные, меняет отображение UI элементов.
А теперь к проблеме.
View в SwiftUI обладает особенностью — когда требуется перерисовать View, он пересоздается, в таких случаях init вызывается повторно. Как правило, View пересоздается, когда у другой View, которая стоит по иерархии выше, меняются данные.
Давайте рассмотрим пример ниже, где одна из View постоянно пересоздается.
// MARK: - Screen ContentView
struct ContentView: View {
@State var isNextScreenVisible = false
@State var seconds = "0"
var body: some View {
NavigationView {
VStack {
Text("Seconds: \(seconds)")
NavigationLink("Next screen", destination: NextView(),
isActive: $isNextScreenVisible)
}
}.onAppear {
Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
let date = Date().timeIntervalSince1970
self.seconds = String(Int(date) % 60)
}
}
}
}
// MARK: - Scren NextView
struct NextView: View {
@ObservedObject var intent: NextIntent
init() {
intent = NextIntent()
print("init NextView")
}
var body: some View {
Text("Hello World!")
}
}
Если открыть экран NextView, то конструктор у него будет вызываться каждую секунду. В консоли мы увидим:
init NextView
init NextView
init NextView
init NextView
init NextView
init NextView
init NextView
init NextView
init NextView
Каждый раз когда вызывается init, все объекты внутри View пересоздаются и модуль, отвечающий за бизнес логику (в нашем случае Intent), также будет создан заново, все данные в нем сбросятся. В любой момент экран может вернуться к своему первоначальному состоянию, т. е. к состоянию на момент открытия.
Чтобы этого не происходило, @ObservedObject можно заменить на @StateObject и тогда Intent перестает зависеть от жизненного цикла View.
Как это работает?
Каждый раз когда View пересоздается, у всех модулей в нем вызывается конструктор (в MVI это Intent и Model, в MVVM это будет ViewModel).
После того, как Intent инициализирован, он помещается в контейнер @StateObject; если там уже лежит объект Intent, то новый созданный Intent удаляется. @StateObject очень похож на Singleton, только он удаляется из памяти.
Учитывая, что конструктор будет вызываться у Intent постоянно, даже если он будет @StateObject, в init у Intent не стоит писать запросы и любую другую логику кроме инициализации объектов.
@StateObject доступен с версии iOS 14.
Можно ли использовать @ObservedObject, но сделать его Singleton?
Допустим, во время инициализации мы будем подкладывать уже готовый объект.
struct ContentView: View {
@ObservedObject var intent: ContentInten
init() {
self.intent = ContentInten.shared
let model = ContentModel()
self.intent.update(model: model)
}
var body: some View {
Text("Hello, world!")
}
Одна проблема закроется, но возникнут другие:
При передаче данных в Intent при инициализации будут возникать вопросы (существуют ли сейчас данные, если они уже есть, нужно ли их перезаписывать и т. д.)
Singleton мы не можем уничтожить и он будет держаться в памяти всю сессию приложения
При создании второго, третьего и других последующих экранов у нас будет один Singleton на все эти экраны.
SwiftUI в iOS 14 сильно расширен, чтобы не бороться с этими проблемами и получать больше возможностей, есть смысл делать минимальную версии iOS 14 для экранов SwiftUI.
В общем, проблема множественного вызова конструктора создает много неудобств и накладывает большие ограничения на архитектуру для iOS 13.
Проблема onAppear и onDisappear
Мы не знаем, когда View удален и пользователь его не видит, или наоборот, когда View впервые показан. Есть мнение что, что onAppear и onDisappear вызываются один раз, когда экран создается и когда он удаляется. Это не так. И тот и другой метод может вызваться более одного раза у одного экрана.
Давайте посмотрим пример
struct ContentView: View {
@State var isVisibleHomeScreen = false
var body: some View {
NavigationView {
VStack {
Text("Hello, world!")
NavigationLink(destination: Text("Screen Home"),
isActive: $isVisibleHomeScreen,
label: { Text("Open screen") })
}.onAppear {
print("onAppear was called")
}.onDisappear {
print("onDisappear was called")
}
}
}
При открытии и закрытии экрана внутри NavigationView эти методы срабатывают более одного раза. В консоли мы увидим:
onDisappear was called
onAppear was called
Как правило, NavigationView указывается у одного экрана, первого, а у всех последующих уже нет. onAppear и onDisappear будут срабатывать в последующих экранах больше одного раза. Это надо держать в голове при реализации каких-либо архитектурных решений.
Навигация
Как организовать навигацию в приложении со SwiftUI? Этот вопрос стоит особо остро, так как навигация тут работает по другим правилам, и весь накопленный опыт сообщества работы с UIKit тут не работает. Можно найти немало статей и видео, посвященных теме навигации в SwiftUI. Все, что я видел, или неудобно, или громоздко. Я написал свой вариант, чтобы переходы делались ровно так, как нам показывали на WWDC. Это оказалось несложно, всю логику навигации нужно было вынести в отдельный "some View".
Во время разработки приложения этот вариант роутера показал себя очень хорошо, даже лучше, чем MVI. Так как это большая тема, под сполером я оставлю пример, а сам подход, возможно, опишу в другой статье.
Router
// ContentRouter.swift
//
// Copyright © 2020 Vyacheslav Ansimov. All rights reserved.
//
import SwiftUI
import Combine
// MARK: - Realization
struct ContentRouter: View {
enum ScreenType {
case sheetScreen(value: String)
case navigationScreen(value: String)
case exit
}
private class ScreenTypeHolder: ObservableObject {
@Published var type: ScreenType?
}
// API
let screen: PassthroughSubject<ScreenType, Never>
// private
@Environment(\.presentationMode) private var presentationMode
@StateObject private var screenType = ScreenTypeHolder()
// Life cycle
var body: some View {
displayView().onReceive(screen) { self.screenType.type = $0 }
}
private func displayView() -> some View {
let isVisible = Binding<Bool>(get: { screenType.type != nil },
set: { if !$0 { screenType.type = nil } })
// Screens
switch screenType.type {
case .sheetScreen(let value):
return AnyView(
Spacer().sheet(isPresented: isVisible) {
Text(value)
}
)
case .navigationScreen(let value):
return AnyView (
NavigationLink("", destination: Text(value), isActive: isVisible)
)
case .exit:
presentationMode.wrappedValue.dismiss()
return AnyView(EmptyView())
case .none:
return AnyView(EmptyView())
}
}
}
// MARK: - Example
struct ContentRouter_Previews: PreviewProvider {
static let routeSubject = PassthroughSubject<ContentRouter.ScreenType, Never>()
static var previews: some View {
NavigationView {
VStack {
Button(action: {
self.routeSubject.send(.sheetScreen(value: "Hello World!"))
}, label: { Text("Display Sheet Screen") })
Button(action: {
self.routeSubject.send(.navigationScreen(value: "Hello World!"))
}, label: { Text("Display NavigationLink Screen") })
}
.overlay(ContentRouter(screen: routeSubject))
}
}
}
Проблема прокси
Property wrapper (State, Binding, ObservedObject и др.) дают SwiftUI реактивности и делает удобным обновление UI.
А если мы хотим вынести эти свойства в отдельный класс. Тогда создается класс, подписывается под протокол ObservableObject и после этого его можно использовать во View
// MARK: Model
class ContentModel: ObservableObject {
@Publised var title = "Hello World!"
}
// MARK: View
struct ContentView: View {
@ObservedObject var model: ContentModel
var body: some View {
Text(model.title)
}
}
А если нужно этот класс со свойствами перенести в другой модуль, другой класс? Тут возникают сложности.
Давайте рассмотрим пример с Intent. Наш класс со свойствами будет находиться там, но при этом все изменения свойств должен видеть View.
// MARK: View
struct ContentView: View {
@StateObject var intent: ContentIntent
var body: some View {
Text(intent.model .title).onAppear {
self.intent.onAppear()
}
}
}
// MARK: Intent
class ContentIntent {
let model: ContentModel
...
func onAppear() {
model.title = "Hello World!"
}
}
// MARK: Model
class ContentModel: ObservableObject {
@Published var title = "Loaded"
}
В примере, UI элемент Text не будет получать актуальные данные и пользователь будет видеть надпись "Loaded", даже после того как все функции будут вызваны. Даже если поменять
let model: ContentModel
на
@Published var model: ContentModel
Работать не будет. ObservableObject у Model передает событие в Intent о том, что у него что-то изменилось, а не во View.
Для того, чтобы View узнал что в Model что-то поменялось, событие нужно передать из Intent дальше. Чтобы заработало, нам нужно написать дополнительный код в конструкторе Intent и сам класс подписать под протокол ObservableObject.
import Combine
class ContentIntent: ObservableObject {
let model: ContentModel
private var cancellable: Set<AnyCancellable> = []
init(model: ContentModel) {
self.model = model
cancellable.insert(model.objectWillChange.sink { [weak self] in
self?.objectWillChange.send()
})
}
...
}
Когда у Model что-то меняется, вызывается метод objectWillChange, который извещает Intent о том, что в Model есть изменения. Intent этот ивент получает и вызывает у себя метод objectWillChange.send(), передавая View изменения.
Протокол ObservableObject у Intent нужен для того,чтобы можно было вызывать objectWillChange.
Резимирую. Если захочется вынести свойства UI в отдельный класс и держать его не во View, нужно будет проксировать события.
Проблема UI
Не все, что можно сделать в UIKit, можно сделать в SwiftUI. Многих системных элементов в SwiftUI просто нет. В таких случаях приходится изобретать велосипед. А то, что есть, плохо кастомизируется.
Вот несколько примеров проблем с UI элементами:
Готового аналога UISearchBar в SwiftUI нет, придется писать логику поведения с нуля, если понадобится поисковая строка элемента.
Когда нужен UIPageControll. В SwiftUI что-то подобное можно сделать из TabBar, но кастомизировать его не получится, придется написать свой UIPageControll.
PikerView: нельзя уменьшить высоту элемента, он будет выходить за границы. На рисунке ниже, черной рамкой показан заданный размер PikerView. Видно, что размер элемента меньше стандартного, а фактически визуально с элементом ничего не происходит.
TextEditor: нельзя поменять background, только белый.
И это далеко не все! Не все элементы можно изменить под свои нужды. Таких мелочей встречается очень много. Приходится или мириться, или писать свой элемент, который по поведению похож на системный. Так как системные элементы плохо поддаются кастомизации, любой шаг в дизайне не по гайдлайнам Apple будет приносить множество проблем.
Заключение
Можно ли коммерческий проект полностью переводить на SwiftUI?
Точно нет. Будут проблемы с реализацией дизайнов, будут возникать архитектурные сложности. Так как технология свежая, придется много исследовать и сталкиваться с проблемами. С другой стороны, скорость разработки UI на SwiftUI в разы выше, чем UIKit, и открываются широкие возможности работы с анимациями – в SwiftUI очень красивые анимации и делать их очень просто.
Частично перевести на SwiftUI можно простые экраны, экраны категории Welcome или информационные. Здесь SwiftUI показывает себя хорошо, проблемы минимальны, а визуально с анимациями выглядит лучше, чем UIKit.
Также рекомендую попробовать на своих личных проектах, не очень больших и сложных, где нет жестких требований к дизайну.
varton86
А если сразу под iOS 14 писать? А там уже SwiftUI 2.0. По идее все, у кого iOS 13, быстро переходят на iOS 14, т.к. особого смысла оставаться на iOS 13 нет.
VAnsimov Автор
Какой-то процент пользователей будет оставаться на iOS 13, но на iOS 14 SwiftUI показывает себя лучше.