Почему НЕ стоит использовать SwiftUI (во всяком случае, пока)

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привет! Меня зовут Максим Федоряка, я Software Engineer (iOS) в компании Innovecs. После недавнего ивент, который я проводил на тему SwiftUI, я решил опубликовать расширенную текстовую версию того, о чем рассказывал на стриме. Сегодня мы пройдёмся по новинкам SwiftUI 2.0, а так же узнаем, почему вы можете захотеть воздержаться от перехода на новую технологию прямо сейчас.

Все новое, что выпускает Apple, моментально окружается мощнейшим хайпом, будь это потребительский продукт или технология для девелоперов. Так случилось и со SwiftUI, новым декларативным UI фреймворком, анонсированным на WWDC 2019.

SwiftUI имеет ряд преимуществ над UIKit, в частности более простой для понимания и изучения новичками декларативный стиль написания интерфейса, наличие лайв превью, а также единство принципов построения UI, позволяющее использовать один и тот же SwiftUI код в приложениях для iOS, macOS, watchOS и tvOS.

В связи с этим уже сейчас есть достаточно большое количество разработчиков, которые начинают свой путь со SwiftUI, полностью пропуская UIKit. В этой статье я расскажу, почему по моему мнению этого делать не стоит, да и вообще нужно хорошо подумать прежде чем полностью переключаться на SwiftUI именно сейчас. Но начнем с хорошего, а именно второй версии фреймворка которая была представлена в 2020 году.

SwiftUI 2.0

В прошлом году вместе с iOS 14 увидела свет вторая версия нового UI фреймворка от Apple и могу сказать, что обновления весьма впечатляющие. Познакомившись со SwiftUI в его первой версии, я увидел что достаточно многие необходимые вещи отсутствовали или не были продуманы. К счастью, SwiftUI 2.0 исправляет часть этих косяков, но не все, о чем мы поговорим позже. Сейчас, как и обещал, о хорошем.

Наиболее весомым для меня стало появление LazyVStack и LazyHStack. Обычные стеквью первого SwiftUI были всем хороши, кроме одного момента. Они создавали и отображали всю иерархию сразу же, то есть если у вас было 10000 элементов, они все были созданы и отображены сразу же, что могло приводить к жутким тормозам. Для вертикальных списков можно было заменить VStack на List (под ковром это UITableView), но этот подход тоже имел свои недостатки. Теперь, все это терпеть не придется, ведь новые стеквью подгружают контент постепенно, а это значит никаких тормозов в огромных списках. Выглядит это все примерно вот так:

struct LazyStacksView: View {
    var body: some View {
        ScrollView(.vertical) {
            LazyVStack(spacing: 10) {
                ForEach(0..<10000, id: \.self) { index in
                    Text("\(index)")
                        .frame(width: 200, height: 100)
                        .background(Color.blue)
                        .cornerRadius(6)
                }
            }
            .padding()
        }
    }
}

Еще одним приятным нововведением стала возможность программно проскроллить ScrollView. Это у нас получится, если видоизменить код следующим образом:

struct LazyStacksView: View {
    var body: some View {
        ScrollView(.vertical) {
            ScrollViewReader { scrollView in
                LazyVStack(spacing: 10) {
                    ForEach(0..<10, id: \.self) { index in
                        Text("\(index)")
                            .frame(width: 200, height: 100)
                            .background(Color.blue)
                            .cornerRadius(6)
                    }
                    Text("Scroll to start")
                        .frame(width: 200, height: 100)
                        .background(Color.red)
                        .foregroundColor(.white)
                        .cornerRadius(6)
                        .onTapGesture {
                            scrollView.scrollTo(0, anchor: .top)
                        }
                }
                .padding()
            }
        }
    }
}

Также, очень сильно в SwiftUI не хватало аналога UICollectionView. Приходилось либо делать обертку над этим самым UICollectionView с помощью UIViewRepresentable, либо костылить что-то с VStack. Здесь снова SwiftUI 2.0 приходит на помощь с новыми компонентами LazyVGrid и LazyHGrid. Больше никаких проблем не возникнет и с этим:

struct GridsView: View {
    var columns: [GridItem] =
        Array(repeating: .init(.flexible(), alignment: .center), count: 3)
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 10) {
                ForEach(0...100, id: \.self) { index in
                    Text("Tab \(index)")
                        .frame(width: 110, height: 200)
                        .background(Color.blue)
                    .cornerRadius(8)
                }
            }.padding()
        }
    }
}

Дальше уже идут не такие мажорные изменения, но тем не менее важные. Добавили нормальный TextView, ранее для того чтобы получить элемент ввода с мультилайном, нужно было опять таки обращаться к UIViewRepresentable и заворачивать таким образом UITextView. Расширилась кастомизация Label, добавился ColorPicker и многое другое по мелочи.

Но основная проблема остается — SwiftUI из коробки умеет делать далеко не все, что умеет UIKit и как минимум по этой причине вам придется хоть немного выучить последний, даже если вы планировали ворваться в iOS разработку используя исключительно новый фреймворк.

Все плюшки SwiftUI 2.0 которые я показал выше, доступны только на iOS 14, соответственно если вы не хотите терять 12% рынка который занимает iOS 13, вам придется писать свои решения и для тех вещей, которые доступны в SwiftUI 2.0, для чего опять же потребуется UIKit.

Потенциальные проблемы и их решение

Итак, допустим мы все же хотим использовать SwiftUI в нашем новом проекте и конечно же не хотим терять 12% клиентов сидящих на iOS 13. С какими проблемами мы столкнёмся и что делать? На этот вопрос я и постараюсь ответить здесь.

Чаще всего встречается отсутствие нужного компонента в SwiftUI либо ограниченность его модификаторов. Такие вещи решаются простым использованием UIViewRepresentable. Допустим, мы хотим перекрасить UISwitch или использовать мультилайн элемент ввода (обе функции доступны только c iOS 14). Здесь нам поможет вот такой код, который можно написать за 15 секунд.

struct TextView: UIViewRepresentable {
    @Binding var text: String
    
    func makeUIView(context: Context) -> UITextView {
        UITextView()
    }
    
    func updateUIView(_ uiView: UITextView, context: Context) {
        uiView.text = text
    }
}

Пока не сильно страшно. Но давайте на примере задачи которая может встретиться вам с большой вероятностью в реальной жизни, разберем более сложные проблемы SwiftUI и их решения.

Итак, нам нужно сделать список с большим количеством элементов. Как мы уже помним, для этого лучше всего подходит List, т.к. нам недоступен LazyVStack из-за отсутствия оного на iOS 13, получим что-то подобное:

struct ContentView: View {
    var body: some View {
        List(0..<1000, id: \.self) { item in
            HStack {
                Text("\(item)")
                Spacer()
            }
            .padding()
            .frame(maxWidth: .infinity)
            .frame(height: 64)
            .background(Color.blue)
            .cornerRadius(6)
        }
    }
}

Но что если вы хотите убрать сепараторы? Данной опции в SwiftUI не предусмотрено, к счастью есть библиотека Introspect (github.com/...​teline/SwiftUI-Introspect), которая позволяет обращаться к UIKit компонентам, которые лежат в основе SwiftUI компонентов, в случае с List это UITableView.

        .introspectTableView { tableView in
            tableView.separatorStyle = .none
        }

К большому сожалению данное решение работает только на iOS 13, поэтому нам оно не подходит.

Единственное что остается — написать гибридный компонент, который будет использовать ScrollView + LazyVStack для iOS 14, а List для iOS 13. Мы добьемся таким образом одинаковой функциональности и внешнего вида для обеих платформ довольно простым способом.

Вот так будет выглядеть наш кастомный компонент:

struct MyList<Content: View>: View {
    let content: () -> Content
    
    var body: some View {
        if #available(iOS 14.0, *) {
            ScrollView {
                LazyVStack(spacing: 0) {
                    self.content()
                }
            }
        } else {
            List {
                self.content()
                    .listRowInsets(EdgeInsets())
            }
            .listStyle(GroupedListStyle())
            .introspectTableView { tableView in
                tableView.separatorStyle = .none
            }
        }
    }
}

И вот так его использование:

struct ContentView: View {
    var body: some View {
        MyList {
            ForEach(0..<1000, id: \.self) { item in
                HStack {
                    Text("\(item)")
                    Spacer()
                }
                .padding()
                .frame(maxWidth: .infinity)
                .frame(height: 64)
                .background(Color.blue)
                .cornerRadius(6)
            }
        }
    }
}

С этим справились, двигаемся дальше. Теперь мы хотим в каждой строке иметь счетчик и кнопку, которая увеличивает его значение, что по сути будет реагированием на изменение поля модели, находящейся в массиве.

Создадим непосредственно саму модель:

class MyModel: ObservableObject, Identifiable {
    @Published var id: Int
    @Published var counter: Int = 0
    
    init(id: Int) {
        self.id = id
    }
}

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

struct ContentView: View {
    @State var array: [MyModel] = (0..<1000).map { MyModel(id: $0) }

    var body: some View {
        MyList {
            ForEach(array, id: \.id) { item in
                HStack {
                    Text("\(item.counter)")
                    Spacer()
                    Button(action: {
                        item.counter += 1
                    }, label: {
                        Image(systemName: "plus.circle.fill")
                            .renderingMode(.template)
                            .resizable()
                            .foregroundColor(.black)
                            .frame(width: 25, height: 25)
                    })
                }
                .padding()
                .frame(maxWidth: .infinity)
                .frame(height: 64)
                .background(Color.blue)
                .cornerRadius(6)
            }
        }
    }
}

Проблема заключается в том, что при такой конфигурации вью будет перезагружаться только если изменение будет касаться самого массива, т.е. при операциях добавления и удаления. Но когда мы меняем свойство элемента массива, objectWillChange самого массива не будет слаться и соответственно мы останемся с изначальным списком нулей на экране.

Чтобы преодолеть это препятствие, нам понадобится написать класс-контейнер. Я находил множество решений на stackoverflow и других сайтах, но для простоты использования решил написать дженерик вариант, который и приведу тут.

class ArrayContainer<T: ObservableObject & Identifiable>: ObservableObject {
    @Published var items: [T] = []
    var subscriptions = Set<AnyCancellable>()
    
    init() {}
    
    init(items: [T]) {
        addItems(items: items)
    }
    
    func addItems(items: [T]) {
        subscriptions.removeAll()
        self.items.append(contentsOf: items)
        establishSubs()
    }
    
    func clear() {
        subscriptions.removeAll()
        items.removeAll()
    }
    
    private func establishSubs() {
        for item in items {
            item.objectWillChange
                .receive(on: RunLoop.main)
                .sink(receiveValue: { _ in self.objectWillChange.send() })
                .store(in: &subscriptions)
        }
    }
}

Кратко поясню, что мы здесь видим. Наш класс будет ObservableObject, то есть имеющий тот самый паблишер objectWillChange, при срабатывании которого будет перерисовываться UI. Внутри он содержит массив элементов и несколько методов работы с контейнером. Самый главный из них — establishSubs. Здесь мы ловим все objectWillChange каждого элементов массива и отправляем objectWillChange объекта-контейнера на каждое такое изменение, добиваясь желаемого результата. Разумеется, этот класс можно дополнить другими методами по работе с массивом (удаление конкретного элемента и т.д.), но для простоты примера обойдемся добавлением и очисткой.

Теперь вместо массива мы объявляем наш обьект-контейнер вот таким образом:

    @ObservedObject var arrayContainer = ArrayContainer<MyModel>(items: (0..<1000).map { MyModel(id: $0) })

И меняем конструкцию ForEach вот так:

ForEach(arrayContainer.items, id: \.id)

Теперь если запустить приложение, при нажатии на кнопку счетчик будет увеличиваться и новое значение отображаться на экране.

Последнее, что мы попытаемся перебороть сегодня — отсутствие Pull To Refresh в SwiftUI из коробки. Согласитесь, весьма часто встречающийся функционал, который скорее всего вам придется реализовывать.

Как вы помните, у нас гибридный список, который для iOS 13 использует List, а для iOS 14 связку ScrollView + LazyVStack. Соответственно, костылить Pull To Refresh придется к обоим. Здесь обратимся за помощью к создателям Introspect, они предлагают вот такой вариант Pull To Refresh для List, а так же поблагодарим сайт swiftui-lab за их имплементацию Pull To Refresh для ScrollView. Первая основана на использовании refreshControl из UITableView, которая лежит в основе List, вторая — на добавлении UIActivityIndicatorView в ScrollView.

Теперь эти решения надо прикрутить к нашему гибридному списку. Сам список приобретет следующий вид:

struct MyList<Content: View>: View {
    @Binding var refreshing: Bool
    let content: () -> Content
    
    var body: some View {
        if #available(iOS 14.0, *) {
            RefreshableScrollView(refreshing: $refreshing) {
                LazyVStack(spacing: 0) {
                    self.content()
                }
            }
        } else {
            List {
                self.content()
                    .listRowInsets(EdgeInsets())
            }
            .listStyle(GroupedListStyle())
            .introspectTableView { tableView in
                tableView.separatorStyle = .none
            }
            .background(PullToRefresh(action: {}, isShowing: $refreshing))
        }
    }
}

Мы добавили привязку для свойства, индуцирующего статус рефреша, которое указывает на то, отображать лоадер или нет. Для iOS 14 заменяем ScrollView на RefreshableScrollView, а для iOS13 добавляем Pull To Refresh через background.

Чтобы симулировать длительную операцию загрузки, создадим «вью модель».

class MyViewModel: ObservableObject {
    @Published var loading: Bool = false {
        didSet {
            if oldValue == false && loading == true {
                DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                    self.loading = false
                }
            }
        }
    }
}

И приведем наш контент в финальный вид:

struct ContentView: View {
    @ObservedObject var model = MyViewModel()
    @ObservedObject var arrayContainer = ArrayContainer<MyModel>(items: (0..<1000).map { MyModel(id: $0) })
    
    var body: some View {
        MyList(refreshing: $model.loading) {
            ForEach(arrayContainer.items, id: \.id) { item in
                HStack {
                    Text("\(item.counter)")
                    Spacer()
                    Button(action: {
                        item.counter += 1
                    }, label: {
                        Image(systemName: "plus.circle.fill")
                            .renderingMode(.template)
                            .resizable()
                            .foregroundColor(.black)
                            .frame(width: 25, height: 25)
                    })
                }
                .padding()
                .frame(maxWidth: .infinity)
                .frame(height: 64)
                .background(Color.blue)
                .cornerRadius(6)
            }
        }
    }
}

Выводы

Что же стоит извлечь из всего вышесказанного. Столкнулись ли мы с чем-то совершенно нерешаемым? Нет. Хочется ли разработчику, менеджеру и заказчику тратить кучу времени на решение тривиальных задач? Тоже нет. В связи с этим всех, кто хочет изучить SwiftUI сейчас, отговаривать я не буду. Но не тяните этот фреймворк в продакшн проекты, пока Apple не прогонит его еще через пару версий, в которых подобные проблемы будут устранены. Опыт разработки, который я имел со SwiftUI был ужасно фрустрирующим именно из-за того, что ты часто ищешь решение проблемы, которой у зрелой технологии быть вообще не должно.

Не поймите меня неправильно, SwiftUI это определенно наше будущее, зная Apple и их подход к таким вещам. Если они решили на что-то перейти — это надолго, если решили что-то выбросить — оно не вернется (кроме обычных клавиатур на макбуках XD). Пройдет пару лет и мы все будем кодить на SwiftUI и горя не знать, но пока этой технологии по моему мнению место есть только в личных сайд-проектах или чем-то экспериментальном, для серьезного продакшена рановато.

👍НравитсяПонравилось8
В избранноеВ избранном2
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Я бы переименовал заголовок на «Почему стоит использовать SwiftUI и, иногда, совсем немножко UIKit» :)

Вот абсолютно верное мнение. Не тестируйте новинки на себе. Даже когда лучшее не враг хорошего, то уж точно враг удовлетворительного. Пусть этим занимаются анонирующие на всё яблочное, но пока этого не допилит сам Apple — ну и не надо это трогать.

Сколько раз принимал подобные решения, и ни разу не помню чтобы отказаться от свежины оказалось неправильным. Свежее потом ещё сотню раз меняют и хрен знает сколько раз депрекейтят, хотя могли бы отстрелить ещё на стадии альфа-тестов — но низя, это ж поставит под сомнение имидж авторитетов.

В конце концов, за 5 лет меняется всё, за 100 лет — ничего. Вспомните об этом, держа в руках мышь и клавиатуру, которым уж сколько раз пророчили революцию, но всё заканчивалось тестом на энтузиастах и потерей денег ушастых гонщиков за новизной.

Хочу поблагодарить автора за настойчивость и, надеюсь, периодичность, с которой появляются новые материалы. Если не вдаваться в суть написанного, то очень нравится слог и вообще стиль оформления статьи — читать приятно. Создается впечатление живого доклада.
Заголовок немного сбил с толку, ведь по сути статья не воспринимается как итог всестороннего анализа подходов и существующих решений, зато помогает новичкам SwiftUI и тем, кто еще только присматривается, не унывать и расширить свой кругозор парой лайфхаков.
Хотелось бы пожелать автору творческих успехов.

Тому що є Flutter

Тому що є React Native

Подписаться на комментарии