Приложение на SwiftUI в AppStore – сложности разработки

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

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 isNestScreenVisible = false
    @State var seconds = "0"

    var body: some View {
        NavigationView {
            VStack(spacing: 16) {
                Text("Seconds: \(seconds)")
                NavigationLink("Next screen", destination: NextView(), isActive: $isNestScreenVisible)
            }
        }.onAppear {
            Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { timer in
                self.seconds = String(Int(Date().timeIntervalSince1970) % 60)
            }
        }
    }
}

// MARK: - Scren NextView
struct NextView: View {

    @ObservedObject var intent: NextIntent

    init() {
        intent = ContentIntent()

        print("init NextView")
    }

    var body: some View {
        Text("Hello Weold!")
    }

Если открыть экран 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!")
    }

Одна проблема закроется, но возникнут другие:

  1. При передаче данных в Intent при инициализации будут возникать вопросы (существуют ли сейчас данные, если они уже есть, нужно ли их перезаписывать и т. д.)

  2. Singleton мы не можем уничтожить и он будет держаться в памяти всю сессию приложения 

  3. При создании второго, третьего и других последующих экранов у нас будет один Singleton на все эти экраны. 

SwiftUI в iOS 14 сильно расширен, чтобы не бороться с этими проблемами и получать больше возможностей, есть смысл делать минимальную версии iOS 14 для экранов SwiftUI.

Объект @StateObject нельзя инициализировать внутри init View

Еще одна особенность. Intent должен быть инициализирован вне конструктора View. Для этого я использую статическую функцию, в которую, если нужно, можно передать данные.

struct ContentView: View {

    @StateObject var intent: ContentInten

    var body: some View {
        Text("Hello, world!")
    }

    static func build() -> some View {
        let model = ContentModel()
        let intent = ContentInten(model: model)
        return ContentView(intent: intent)
    }

В общем, проблема множественного вызова конструктора создает много неудобств и накладывает большие ограничения на архитектуру для 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. Также рекомендую попробовать на своих личных проектах, не очень больших и сложных, где нет жестких требований к дизайну. 

Источник: https://habr.com/ru/post/534608/


Интересные статьи

Интересные статьи

Сегодня, в предпоследний день уходящего года, хочу рассказать о созданном мной сервисе, помогающем быстро проектировать, отлаживать и следить за работоспособностью ботов ...
Эту неделю я решил посвятить потокам данных в SwiftUI. В этой статье мы обсудим разницу между обертками свойств (property wrappers) @StateObject, @EnvironmentObject, и @O...
Российская отрасль инженерного программного обеспечения насчитывает более 50 компаний-разработчиков. По меркам мирового рынка САПР это уже заметная величина. Но знаете ли вы, что российский след ...
Привет, Хабр! Лето за окном пролетает для нас почти незаметно, потому что все эти месяцы мы посвятили работе над новым релизом 2019.2 нашей кросс-платформенной среды для разработки на C++ — CL...
Сейчас уже никого не удивить микроконтроллерами с энергонезависимой (чаще всего Flash) памятью объемом 512 килобайт и более. Их стоимость постепенно снижается, а доступность напротив, растет. Нал...