Глубокие внутренние представления, состояние и производительность в SwiftUI

Или почему эти слова могут означать не то, что вы думаете.

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

Поэтому у нас есть статьи, в которых рассказывается, как использовать MVVM с SwiftUI. Или VIPER. Или чистый. Или те, кто настаивает на том, что нам нужно наложить поверх него архитектуру React / Redux.

Но у большинства из них есть проблема.

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

И проблема в том, что… SwiftUI - это не UIKit.

Это не работает и, безусловно, ведет себя иначе.

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

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

Код

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

Демонстрационный код в этой статье взят из базового приложения SwiftUI Master / Detail, к которому я добавил общую архитектуру Model-View-ViewModel. Я настоятельно рекомендую вам загрузить и запустить код, взаимодействовать с программой и при этом следить за журналами.

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

Давайте начнем.

Нет просмотра

Если вы какое-то время занимались разработкой iOS, у вас в голове есть UIView и UIViewController.

Вы понимаете, что представления и контроллеры представлений являются ссылочными типами, и вы понимаете довольно обширную иерархию классов UIKit, начиная с NSObject и далее.

Вы понимаете, как контроллеры представлений управляют представлениями, как они сохраняются в стеке навигации и все события жизненного цикла UIViewController. И вы понимаете, как конструируются и добавляются представления к подпредставлениям, которые добавляются к подпредставлениям, и как они сохраняются и управляются во всем дереве окон-представлений.

К сожалению, именно это укоренившееся, укоренившееся знание того, как работает разработка на основе UIKit, на самом деле вредно для понимания SwiftUI.

Apple не сильно помогла в этом отношении, поскольку разработчики SwiftUI в своей безмерной мудрости решили сказать вам, что все является «представлением». Вы создаете представления типа View. Ваше тело представления возвращает некоторое представление. Даже модификаторы возвращают представления.

Но Просмотр - это термин, который заставляет опытных разработчиков iOS думать об одном, в то время как на самом деле это что-то очень, очень, очень.

UIKit - это императивная структура. Мы создаем элементы интерфейса по частям, по одному элементу за раз, а затем соединяем все вместе, насколько это возможно, и перетасовываем данные туда и обратно по мере необходимости.

SwiftUI, с другой стороны, представляет собой декларативный фреймворк. Мы используем его для описания желаемого пользовательского интерфейса, а затем позволяем SwiftUI построить этот интерфейс для нас на основе нашего описания.

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

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

Вот и все. Представления SwiftUI - это просто определения.

Но от старых привычек трудно избавиться. Мы смотрим на SwiftUI View, видим, что это View, и в наших головах мы начинаем думать, что определение нашего представления будет сохраняться, как если бы оно было UIView. Не будет.

Мы думаем, что безопасно прикреплять к ним модели представлений и другие классы, как мы это делаем с UIViewController. Это не.

Мы думаем, что знаем, когда создается наше «представление», когда вызывается его переменная тела и как интерфейс строится на основе этого определения. Но мы этого не делаем.

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

Здесь есть много, что нужно распаковать, так что приступим.

Итак, если представление не является представлением, тогда что это такое?

SwiftUI - это структурированный, ориентированный на протокол, предметно-ориентированный язык, разработанный для описания пользовательских интерфейсов на устройствах Apple, начиная от iPhone и iPad, заканчивая часами Apple Watch, Apple TV и даже Mac.

В этом смысле он не зависит от платформы. Мы даем SwiftUI представление о том, чего мы хотим, и в целом позволяем ему делать то, что нужно, когда этот код выполняется на определенной платформе. В представлении Toggle говорится, что нам нужен переключатель, но то, как этот переключатель выглядит и даже как он работает, отличается от iOS, macOS и Apple TV.

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

Каждое созданное нами настраиваемое представление имеет переменную тела, которая при вызове возвращает другое представление. Возвращенное представление может быть примитивным типом представления, присущим SwiftUI, например Text или Image.

struct ItemDateView: View {
    var date: Date
    var body: some View {
        Text("\(date, formatter: dateFormatter)")
    }
}

Здесь описание интерфейса ItemDateView чрезвычайно простое. Мы просто хотим показать пользователю дату в текстовом формате. Возвращенное представление также может быть контейнером представлением, таким как List или VStack, которое отслеживает и управляет списком представлений.

struct DetailContentView: View {
    var model: DetailViewModel
    var body: some View {
        tracker {
            VStack(spacing: 16) {
                ItemDateView(item: model.item)
                DetailStateView()
                DetailBoilerplateView(text: model.boilerplate)
                Spacer()
            }
        }
    }    
}

Здесь наш DetailContentView также прост. Нам просто нужен вертикальный стек, в котором мы будем показывать содержимое наших ItemDateView DetailStateView и DetailBoilerplateView плюс еще один примитив Просмотр разделителя.

Каждое из этих трех настраиваемых представлений имеет свою собственную переменную тела, которая при вызове снова возвращает какое-то примитивное представление, представление контейнера или настраиваемое представление.

В SwiftUI мы промываем, повторяем и комбинируем все эти представления по мере необходимости, пока наконец не определим все наше приложение.

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

Ключевой момент №1: представление SwiftUI - это не представление. Это описание представления.

Состояние и график просмотра

Вы не можете много сделать с SwiftUI, не узнав, что он изначально разработан для управления состоянием.

Это состояние может быть таким же простым, как свойство представления. Или это может быть переменная @State, или переменная @Environment, или любой из нескольких других типов, предоставленных нам SwiftUI.

Каждая часть состояния является Источником истины для некоторой части нашего приложения.

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

Этот график определяет, как наше приложение выглядит в конкретный момент времени.

Запуск приложения

Итак, давайте запустим наше приложение и подробно рассмотрим процесс. Большинство приложений SwiftUI начинаются с ContentView, который назначается корневым представлением нашего приложения в нашем SceneDelegate. Или, в SwiftUI 2.0, в главном представлении Приложения.

@main
struct AppStateDemo: App {
    var body: some Scene {
        return WindowGroup {
            ContentView()
        }
    }
}

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

Итак, давайте предположим, что, наконец, пришло время отрисовать наш интерфейс и представить его пользователю. Для этого SwiftUI запросит у переменной body ContentView набор представлений, описывающих желаемый интерфейс.

Вот наш ContentView.

struct ContentView: View {
    @ObservedObject var master = MasterViewModel()
    var body: some View {
        NavigationView {
            MasterView()
                .environmentObject(master)
        }
    }
}

Это выглядит достаточно просто. Но давайте подумаем об этом.

Во-первых, очевидно, что MasterViewModel был создан при создании ContentView. Мы также обернули его в оболочку свойств ObservableObject, поскольку это ObservableObject. Пока все в порядке.

Затем нам сказали, что SwiftUI вызовет нашу переменную body, чтобы определить, что мы хотим построить, поэтому, работая наизнанку в порядке оценки, ContentView Переменная body сначала создаст экземпляр настраиваемой структуры MasterView. Затем мы указываем, что экземпляр нашей модели представления должен быть вставлен в environment для последующего использования ниже по потоку с помощью модификатора environmentObject, доступного в MasterView.

«представление», возвращаемое модификатором, передается в ViewBuilder, необходимый для функции инициализации NavigationView. Описание нашего ContentView теперь завершено, и функция body вернет полностью сконструированный NavigationView.

И это создает начальные узлы в нашем графе просмотра.

Проще простого.

За исключением того, что это SwiftUI, а в SwiftUI ничто не так просто.

Во-первых, и я собираюсь продолжить это, позвольте мне еще раз указать, что NavigationView, возвращаемый ContentView body, равен не UINavigationView.

Это действительно вызовет создание UINavigationView (по крайней мере, в iOS), но здесь это просто структура, которая сообщает SwiftUI, что при визуализации нашего приложения потребуется какая-то форма навигации.

Фактически, SwiftUI начнет построение нашей иерархии UINavigationView до того, как она даже вызовет переменную ContentView body. Это означает, что он не может использовать NavigationView нашего определения в качестве нашего UINavigationView.

Мы еще даже не построили его.

По сути, SwiftUI знает, что нам нужен UINavigationView еще до того, как мы сможем сказать ему, что мы хотим от NavigationView ! Но как это возможно?

Вывод типа

Короткий ответ прост: Вывод типа.

Несколько более длинный ответ: в SwiftUI View - это протокол со связанным типом Body, который определяется как результат переменной body.

public protocol View {
    associatedtype Body : View
    @ViewBuilder var body: Self.Body { get }
}

И если мы получим печать типа ContentView.Body в отладчике , мы увидим…

NavigationView<ModifiedContent<MasterView, 
  _EnvironmentKeyWritingModifier<Optional<MasterViewModel>>>>

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

Ведение журнала ContentView

Давайте посмотрим на слегка измененную версию ContentView.

struct ContentView: View {
    @ObservedObject var master = MasterViewModel()
    let tracker = InstanceTracker("ContentView")
    var body: some View {
        tracker {
            NavigationView {
                MasterView()
                    .environmentObject(master)
            }
        }
    }
}

Эта версия идентична предыдущей, за исключением того, что мы добавили в наше представление класс InstanceTracker, который позволяет нам узнать, когда он создается, и который также добавляет сквозную функцию, которая сообщает нам, когда наш вызывается переменная body представления. Каждый раз, когда создается экземпляр трекера, ему присваивается новый номер отслеживания.

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

AppStateDemo.init() #1
AppStateDemo.body #1 {
  MasterViewModel.init() #2
  ContentView.init() #3
}
UINavigationController.initWithRootViewController
ContentView.body #3 {
  MasterView.init() #4
}

Если мы посмотрим на журнал отладки с добавленным трекером, мы увидим, когда наша MasterViewModel была инициализирована, когда наш ContentView был инициализирован, а затем, благодаря символической точке останова в отладчик, мы видим, что UINavigationController был создан с использованием initWithRootViewController.

И все это произошло до того, как был вызван ContentView.body, и у него была возможность вернуть наше определение NavigationView / MasterView.

Теперь вас можно простить за то, что вы подумали, что SwiftUI просто так работает и что он всегда будет создавать UINavigationController как часть процесса запуска приложения, но мы можем опровергнуть это несколькими способами. Просто удалите NavigationView из кода, и SwiftUI не создаст его.

Или мы могли бы стереть результат типа Body с помощью AnyView. Если мы это сделаем, мы все равно получим UINavigationController, но он будет построен позже в процессе, после того, что у SwiftUI будет возможность получить и проверить ContentView набор определений представления .

AppStateDemo.init() #1
AppStateDemo.body #1 {
  MasterViewModel.init() #2
  ContentView.init() #3
}
ContentView.body #3 {
  MasterView.init() #4
}
UINavigationController.initWithRootViewController

Первоначальным корневым контроллером представления является наш старый друг UIHostingController, что указывает на то, что SwiftUI строит свою иерархию контроллеров представления почти так же, как мы это делали вручную в iOS 12 с помощью SceneDelegate.

Имейте в виду, что SwiftUI еще даже не заглянул внутрь MasterView, и все же он уже начал создание нашего стека навигации и пользовательского интерфейса.

MasterView

Далее наш ContentView сказал, что мы хотим показать MasterView, и для этого теперь нам нужно вызвать переменную тела MasterView, чтобы запросить его определение.

Вот MasterView, также оснащенный трекером и еще немного отладочного кода, чтобы мы могли отслеживать наш прогресс.

struct MasterView: View {
    @EnvironmentObject var master: MasterViewModel
    let tracker = InstanceTracker("MasterView")
    var body: some View {
        tracker("List(\(master.items.count) items)") {
            List {
                ForEach(master.items) { item in
                    NavigationLink(
                        destination: DetailView(item: item)
                    ) {
                        ItemDateView(date: item.date)
                    }
                }
            }
            .onAppear {
                tracker("MasterView.onAppear")
            }
            .navigationBarTitle(Text("Master"))
            .navigationBarItems(
                trailing: Button(action: { self.master.add() }) {
                    Image(systemName: "plus")
                }
            )
        }
    }
    
}

Обратите внимание, что MasterView содержит List. Списки в SwiftUI на iOS будут генерировать табличные представления для управления ими. В частности, UpdateCoalescingTableView с нашим представлением и его циклом ForEach в качестве источника данных.

Если мы продолжим нашу последовательность отладки, мы увидим, когда вызывается тело MasterView.

MasterView.body #4 {
  List(0 items)
}

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

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

И наконец мы видим…

MasterView.onAppear

Ключевой момент №2: создание интерфейса в SwiftUI - это постепенный процесс.

Это особенно верно в отношении стеков навигации с UIHostingController и списков, в основе которых лежат представления таблиц.

Просмотр иерархии

Давайте быстро взглянем на нашу иерархию представлений.

Серый вид в центре - это наша таблица. Представление с текстом «0 элементов» - это ListHostingView, который отображает содержимое этой конкретной ячейки.

Обратите внимание, что «0 элементов», показанное в этом конкретном примере, не UILabelView, и это важный момент.

До сих пор мы могли медленно убаюкать нас, думая, что существует прямое соответствие между представлениями SwiftUI и представлениями UIKit. В конце концов, наш NavigationView сгенерировал UINavigationController, а наше представление List сгенерировало табличное представление.

Так не будет ли представление Text одновременно UILabelView, а представление Image - UIImageView? Опять же, это было бы просто, легко и ... SwiftUI работает не так.

В SwiftUI текст, изображения и фигуры составляются и отображаются непосредственно на одном из UILayers основного представления. HStacks и VStacks, аналогично, не являются UIStackView, но в основном сводятся к расчетам позиционирования для вложенных «подпредставлений».

ZStack немного сложнее, поскольку каждый уровень в ZStack создает новый уровень в представлении хоста. То же самое для модификаторов background и overlay. Просто больше слоев.

Это возвращает нас к нашему первому ключевому моменту: представление SwiftUI - это не представление.

И это также подтверждает один из исходных ключевых моментов Apple: представления SwiftUI, во многих случаях, не являются представлениями. В отличие от UIKit, добавление еще одного представления в иерархию представлений не создает и не добавляет еще один тяжелый, неуклюжий основанный на NSObject UIView в цепочку визуализации представления и ответа.

Также добавление дополнительных HStacks и VStacks в макет не добавляет больше UIStackViews и тонны дополнительных NSLayoutConstraints.

Ключевой момент №3: нет прямого однозначного соответствия между определениями представлений SwiftUI и экземплярами представлений UIKit.

Просмотр модификаторов

В SwiftUI представления можно изменять различными способами с помощью модификаторов представления.

Text("Hello World")
   .forgroundColor(.red)
   .font(.title)

Но на самом деле модификатор представления - это просто еще одна структура, которая обертывает родительскую структуру, которая обертывает родительскую структуру и т. Д.

Но, как я упоминал выше, большинство модификаторов представления фактически не изменяют представление. На самом деле, как мы видели, часто нет представления, которое можно было бы изменить. Так что же происходит?

Короткий ответ заключается в том, что некоторые из доступных модификаторов представления изменяют не представление, а среду.

Ключевой момент №4: текущее состояние среды используется, когда пришло время визуализировать наш интерфейс.

Мы хотим визуализировать фрагмент текста, поэтому мы берем текущий шрифт из среды и текущий цвет переднего плана и используем эти значения для визуализации. Вот как, например, установка текущего цвета foregroundColor в VStack или Group влияет на каждое из содержащихся в нем значений Text.

VStack {
    Text("A")
    Text("B")
}
.foregroundColor(.red)

Каждый текстовый элемент, показанный в приведенном выше примере, будет красным.

Изменение состояния

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

Ключевой момент №5: при изменении состояния любое представление с прямой зависимостью от этого состояния будет восстановлено.

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

Обратите внимание, что этот единственный запрос может вызвать каскад вниз по всей ветви графа представления.

В нашем определении MasterView мы включили MasterViewModel как @EnvironmentObject, что делает MasterView напрямую зависимым от MasterViewModel. Если MasterViewModel изменяется, то MasterView необходимо перестроить, чтобы увидеть, что могло измениться.

struct MasterView: View {
    @EnvironmentObject var master: MasterViewModel
    var body: some View {
         ...
    }
}

Итак, давайте нажмем кнопку «плюс», чтобы добавить новую запись в наш список и наблюдать за результатами.

ContentView.body #3 {
  MasterView.init() #6
}
MasterView.body #6 {
  List(1 items)
}
...

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

В переменной тела ContentView создается новая измененная структура MasterView. Это, в свою очередь, в конечном итоге приведет к тому, что SwiftUI вызовет представление MasterView, тело снова получит это определение представления .

Это могло произойти в любом случае, поскольку, как мы уже несколько раз упоминали, MasterView также имеет прямую зависимость от MasterViewModel.

Обновления TableView

MasterView использует List, а List - это табличное представление, и теперь у нас есть некоторые данные для отображения ... так что теперь пора для табличного представления перезагрузите свои данные и создайте несколько ячеек из своего источника данных.

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

...
DetailView.init() #7
DetailViewModel.init() #8
ItemDateView.init() #9
ItemDateView.body #9 {
  2020-08-23 22:36:14 +0000
}
ItemDateView.body #9 {
  2020-08-23 22:36:14 +0000
}
ItemDateView.body #9 {
  2020-08-23 22:36:14 +0000
}
MasterView.deinit() #4

Чего-чего? Обратите внимание, что при построении нашей ячейки был создан экземпляр нашего DetailView… вместе с связанной с ним моделью представления!

Я подробно писал об этом в SwiftUI и как НЕ инициализировать привязываемые объекты. (Название должно сказать вам, как долго существует это конкретное поведение).

Суть в том, что каждое представление в SwiftUI - это структура. Структуры должны быть инициализированы при создании. Наше подробное представление немедленно создается и передается в NavigationLink в качестве параметра при отображении этого представления, и поэтому все, что мы создаем во время инициализации представления или как свойство представления, также создается. в то же время.

struct DetailView: View {
    @EnvironmentObject var master: MasterViewModel
    @ObservedObject var model: DetailViewModel
    init(item: Item) {
        self.model = DetailViewModel(item: item)
    }
    let tracker = InstanceTracker("DetailView")
    var body: some View {
        tracker {
            DetailContentView(model: model)
                .padding()
                .navigationBarTitle(Text("Detail"))
                .navigationBarItems( ... )
                .onAppear {
                    print("- DetailView onAppear")
                }
        }
    }
}

Как показано выше, я создал экземпляр модели представления с элементом, переданным в цикле ForEach, но из-за способа работы NavigationLink наш «легкий» DetailView теперь выполнит выделение кучи DetailViewModel в тот момент, когда мы создадим DetailView.

Это не хорошо.

Более того, что произойдет, если наш список вырастет до 1000 пунктов? Что ж, тогда у нас будет куча объектов DetailViewModel , которые будут постоянно создаваться и удаляться, пока мы прокручиваем страницу.

И посещаем ли мы эту страницу или нет.

Что, если бы эти модели выполняли какую-то обработку в init? Тоже не хорошо.

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

Изменения в нисходящем направлении

Ключевой момент №6: при изменении состояния любое представление, находящееся ниже по течению от этой прямой зависимости, будет восстановлено и восстановлено.

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

Ключевой момент № 7: в любой точке, где графики расходятся, SwiftUI обновит пользовательский интерфейс и график представления соответственно.

Поэтому, если шрифт, цвет или какое-либо свойство текста изменились, SwiftUI обновит соответствующий шрифт, цвет или текст и перерисует соответствующие разделы интерфейса.

SwiftUI может даже анимировать для нас определенные изменения интерфейса. Если, скажем, элемент удаляется из списка или стека VStack, то SwiftUI может анимировать элементы под ним вверх, закрывая пробел. Или наоборот, если добавлено другое представление.

Изменение может быть еще более радикальным, если, скажем, представленное представление должно быть показано или отклонено.

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

То же самое происходит снова и снова, когда мы снова меняем состояние. И следующее изменение. И следующее. Граф представления в приложении на основе SwiftUI находится в постоянном, нескончаемом состоянии изменения.

Просмотры эфемерны

Это подводит нас к другому ключевому моменту, который нельзя переоценить.

Ключевой момент №8: представления SwiftUI постоянно конструируются и оцениваются в течение всего жизненного цикла нашего приложения.

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

Это означает, что все, что прикреплено к одному из этих представлений, также исчезнет.

Задумайтесь на мгновение о последствиях этого.

Или еще лучше, давайте еще раз нажмем кнопку «плюс» и увидим результат.

ContentView.body #3 {
  MasterView.init() #11
}
MasterView.body #11 {
  List 2 items
}
DetailView.init() #12
DetailViewModel.init() #13
ItemDateView.init() #14
ItemDateView.body #14 {
  2020-09-09 00:06:24 +0000
}
ItemDateView.deinit() #9
DetailViewModel.deinit() #8
DetailView.deinit() #7
DetailView.init() #15
DetailViewModel.init() #16
ItemDateView.init() #17
ItemDateView.body #17 {
  2020-09-09 00:06:31 +0000
}
MasterView.deinit() #6

Обратите внимание, что наш реконструированный MasterView был снова выброшен и восстановлен. Хуже того, обратите внимание, что наш исходный DetailView и тщательно сконструированный DetailViewModel для нашего первого элемента данных также были бесцеремонно отброшены в пользу нового.

Легкие просмотры

Это приводит нас к новой паре правил.

Ключевой момент №9. Не выполняйте тяжелую вычислительную работу при инициализации представления.

Представления SwiftUI - это легкие структуры не просто так. НЕ перегружайте процесс инициализации представления распределением кучи или выполняйте большую обработку в этот момент времени.

Кроме того, обратите внимание, что каждая из наших переменных ItemDateView body вызывалась три раза за штуку во время создания и отображения каждого List ячейка.

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

Итак, если вы не знаете когда будет вызван блок кода, и если вы не знаете, как часто он будет вызываться, если вы поместить туда критически важный код или код с интенсивным использованием данных?

Особенно когда это может существенно повлиять на производительность вашего приложения?

Я так не думаю. Для всего есть время и место, но инициализация представления и внутри переменной body вашего представления не то время и не место.

Ключевой момент №10. Не выполняйте тяжелые вычисления и в теле представления.

Корневые зависимости

Обратите внимание, что некоторые из отмеченных нами моментов требуют дальнейшего рассмотрения.

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

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

И наоборот, зависимость, расположенная в корне или на верхнем уровне нашей иерархии представлений, может приводить к регенерации всего нижележащего графа представления и сравнения результатов каждый раз при изменении зависимого состояния. Как мы только что продемонстрировали в разделе Восстановление графика просмотра.

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

Таким образом, следует избегать стиля управления глобальным состоянием React / Redux «может быть только один», поскольку любое изменение состояния приведет к регенерации всего, что зависит от центрального хранилища данных. Опять же, в основном весь график просмотра приложения.

Ключевой момент №11: Избегайте больших коллекций данных о состоянии. По возможности сохраняйте локализацию штата.

Фактически, я довольно подробно писал об этом в предыдущей статье Микросервисы SwiftUI.

Обратите внимание, что последний пункт заканчивается фразой «где возможно». Хотя следует избегать корневых зависимостей, их также не следует опасаться. При условии, конечно, что вы начали следовать моему совету, чтобы ваши инициализации представления и тела были как можно более легкими и производительными.

В конце концов, именно так был разработан SwiftUI.

Асинхронные обновления

Ранее я упоминал, что изменение состояния в конечном итоге приведет к созданию нового графа представления. Я тщательно подбираю эти слова, так как ключевое слово в этом предложении - в конечном итоге.

Ключевой момент №12: обновления пользовательского интерфейса происходят после изменения состояния во время асинхронного обновления, запланированного на следующий цикл выполнения.

Я уже упоминал об этом ранее, но изменение нескольких переменных @Published в ObservableObject вызовет несколько событий. objectWillChange (). Эти события объединяются и в конечном итоге, а не сразу, запускают цикл обновления представления.

Это может быть сложно разобрать без примера, поэтому давайте рассмотрим один из них.

Давайте рассмотрим часть нашей модели MasterView, в которой есть несколько приемников Combine, определенных в нашей функции init исключительно для целей отслеживания.

class MasterViewModel: ObservableObject {
    @Published var update1 = 0
    @Published var update2 = 0
    var cancellable0: AnyCancellable!
    var cancellable1: AnyCancellable!
    var cancellable2: AnyCancellable!
    init() {
        cancellable0 = objectWillChange.sink { (value) in
            self.tracker.log("Sink 0 recived change notification")
        }
        cancellable1 = $update1.sink { (value) in
            self.tracker.log("Sink 1 recived value = \(value)")
        }
        cancellable2 = $update2.sink { (value) in
            self.tracker.log("Sink 2 recived value = \(value)")
        }
    }
    func update() {
        update1 += 1
        update2 += 1
        update1 += 1
        update2 += 1
    }
    ...

Обратите внимание на два элемента @Published: update1 и update2, которые мы активируем позже из DetailView с помощью вызов функции update при нажатии Button. Ранее мы видели версию DetailView, теперь вот свернутый код внутри кнопки панели навигации.

    .navigationBarItems(
        trailing: Button(action: {
            DispatchQueue.main.async {
                self.tracker.log("\n### Begin Update Cycle\n")
            }
            self.tracker.log("\n### Begin Update")
            self.master.update()
            self.tracker.log("### End Update\n")
        }) {
            Text("Update")
        }
    )

Обратите внимание на функцию регистрации асинхронных событий, которую мы помещаем в стек в начале действия кнопки.

Теперь мы подпишемся на те же изменения модели представления в нашем MasterView с помощью onReceive.

    var body: some View {
        tracker("List \(master.items.count) items") {
            List {
                ...
            }
            .onReceive(master.objectWillChange) { () in
                tracker.log("MasterView Changed")
            }
            .onReceive(master.$update1) { count in
                tracker.log("MasterView Update 1 Received \(count)")
            }
            .onReceive(master.$update2) { count in
                tracker.log("MasterView Update 2 Received \(count)")
            }
            ...
       }
    }

Теперь все готово. Давайте коснемся первого элемента в нашем списке и перейдем к DetailView.

DetailView.body #12 {
  DetailContentView.init() #18
}
DetailContentView.body #18 {
  ItemDateView.init() #19
  DetailStateView.init() #20
  DetailBoilerplateView.init() #21
}
ItemDateView.body #19 {
  2020-09-09 00:06:24 +0000
}
DetailStateView.body #20 {
  C3901189-FC7F-444C-A17F-C4C1781954CE
}
DetailBoilerplateView.body #21 {
  You're viewing model #13.
}
DetailView onAppear

Достаточно прямолинейно. Наш DetailView отображает DetailContentView, который отображает три элемента, которые мы проиллюстрировали в начале этой статьи. Для записи обратите внимание, что мы отображаем информацию из экземпляра DetailViewModel № 13.

Теперь давайте нажмем кнопку «Обновить» и посмотрим на файл журнала, созданный, когда мы фактически вызываем нашу функцию update. Вот первая часть ...

### Begin Update
Sink 0 recived change notification
Sink 1 recived value = 1
Sink 0 recived change notification
Sink 2 recived value = 1
Sink 0 recived change notification
Sink 1 recived value = 2
Sink 0 recived change notification
Sink 2 recived value = 2
### End Update
MasterView Changed
MasterView Update 1 Received 1
MasterView Changed
MasterView Update 2 Received 1
MasterView Changed
MasterView Update 1 Received 2
MasterView Changed
MasterView Update 2 Received 2
### Begin Update Cycle
...

Обратите внимание, что мы дважды обновили каждый из наших опубликованных элементов с помощью функции update и сразу же получаем уведомления об обновлении приемника, как мы и ожидали. (Если вы использовали другие реактивные библиотеки, такие как RxSwift, такое поведение является ожидаемым.) Мы также видим наши события objectWillChange, отправляемые перед каждым из них, как и следовало ожидать от формального определения этого значения Apple. . Объект будет измениться. Затем объект изменился.

Я хотел бы здесь отметить один незначительный момент: вы должны быть осторожны при подписке на издателя ObservableObject objectWillChange. Как видно из журнала, если вы сделаете это, вы будете получать события для каждого изменения каждого опубликованного значения.

Двигаясь дальше, обратите внимание, что уведомления об изменении onReceive и обновлении данных нашего представления принимаются после, когда мы вышли из функции обновления (сообщение регистрируется в конец нашего обработчика кнопок).

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

Говоря об этом, мы теперь видим сообщение цикла обновления, которое мы поместили в главную очередь событий. Обратите внимание, что следующий цикл обновления происходит после регистрации нашего асинхронного события, что, в свою очередь, указывает на то, что циклы обновления являются асинхронными событиями, которые запускаются из основного цикла выполнения. (Что подтверждает отладка.)

Итак, давайте рассмотрим этот цикл обновления ...

Восстановление графика просмотра

Я включаю сюда оставшуюся часть всего журнала, чтобы вы могли увидеть, какие катастрофические силы были вызваны нашим циклом обновления.

...
// BUILDING THE VISIBLE DETAIL VIEW
DetailView.body #12 {
  DetailContentView.init() #22
}
DetailContentView.deinit() #18
DetailContentView.body #22 {
  ItemDateView.init() #23
  DetailStateView.init() #24
  DetailBoilerplateView.init() #25
}
ItemDateView.deinit() #19
DetailStateView.deinit() #20
DetailBoilerplateView.deinit() #21
ItemDateView.body #23 {
  2020-09-09 00:35:14 +0000
}
DetailStateView.body #24 {
  09E1696F-9C5B-441E-8CDA-7C499439E78B
}
DetailBoilerplateView.body #25 {
  You're viewing model #13.
}
// REBUILDING MASTER VIEW
ContentView.body #3 {
  MasterView.init() #26
}
MasterView.body #26 {
  List 2 items
}
/// REBUILDING MASTER VIEW LIST / TABLEVIEW CELLS
DetailView.init() #27
DetailViewModel.init() #28
ItemDateView.init() #29
ItemDateView.body #29 {
  2020-09-09 00:35:15 +0000
}
DetailView.init() #30
DetailViewModel.init() #31
ItemDateView.init() #32
ItemDateView.body #32 {
  2020-09-09 00:35:14 +0000
}
MasterView.onAppear // BUG!!!
MasterView.deinit() #11
ItemDateView.deinit() #14
ItemDateView.deinit() #17
DetailViewModel.deinit() #16
DetailView.deinit() #15
// REBUILDING OUR VISIBLE DETAIL VIEW
DetailView.body #30 {
  DetailContentView.init() #33
}
DetailContentView.deinit() #22
DetailContentView.body #33 {
  ItemDateView.init() #34
  DetailStateView.init() #35
  DetailBoilerplateView.init() #36
}
ItemDateView.deinit() #23
DetailStateView.deinit() #24
DetailBoilerplateView.deinit() #25
ItemDateView.body #34 {
  2020-09-09 00:35:14 +0000
}
DetailStateView.body #35 {
  09E1696F-9C5B-441E-8CDA-7C499439E78B
}
DetailBoilerplateView.body #36 {
  You're viewing model #31.
}

Сначала был регенерирован наш видимый в данный момент DetailView. Затем, как мы видели ранее, все, что зависело от MasterViewModel, было перестроено, включая видимые элементы нашего представления таблицы.

Затем снова был вызван наш DetailView для отображения нашей новой информации.

Только теперь это другой DetailView. С другой DetailViewModel!

Помните, когда я упоминал, что мы отображали данные из экземпляра DetailViewModel # 13 ? Что ж, теперь мы отображаем данные из DetailView # 30 и DetailViewModel экземпляр # 31 .

SwiftUI при перестроении табличного представления перестроил нашу первую ячейку табличного представления, которая создала экземпляр нового DetailView, который создал экземпляр новой DetailViewModel. Затем он передал новое представление хост-контроллеру для подробного экрана, и теперь мы видим результаты этого представления и той модели представления.

Наше «представление», которое определяло наш экран DetailView и управляло им, было заменено.

И это одна из причин, почему подвешивание моделей представлений на основе классов с ваших представлений с помощью @ObservedObject проблематично .

Если вы не будете очень осторожны, они могут быть убиты и будут убиты тогда, когда вы меньше всего их ожидаете. Наряду с любым внутренним состоянием или данными, которые они могли содержать.

Ваши взгляды - это не ваши взгляды.

Местное государство

Выберем из прошлого журнала еще что-нибудь интересное.

...
DetailStateView.body #24 {
  09E1696F-9C5B-441E-8CDA-7C499439E78B
}
...
DetailStateView.body #35 {
  09E1696F-9C5B-441E-8CDA-7C499439E78B
}
...

А вот и соответствующий DetailStateView.

struct DetailStateView: View {
    @State var uuid = UUID()
    let tracker = InstanceTracker("DetailStateView")
    var body: some View {
        tracker("\(uuid)") {
            Text("\(uuid)")
                .font(.footnote)
                .foregroundColor(.secondary)
        }
    }
}

Обратите внимание, что наша переменная состояния сохранялась во всех экземплярах представления при обновлении графа представления, сохраняя тот же номер UUID 09E1xxx.

Так почему же @State сохранился, а наш ObservedObject - нет?

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

С другой стороны, объекты, прикрепленные к @ObservedObject, НЕ управляются SwiftUI, и приложение управлять их хранилищем и ссылками на них.

Все это делает использование @ObservedObject проблематичным. Когда вы присваиваете им ценности? Как вы управляете ссылками? Как сделать так, чтобы циклы обновлений не разрушили весь ваш мир?

Добро пожаловать StateObject

В SwiftUI 2.0 Apple предоставила новую оболочку свойств, предназначенную для решения некоторых из этих проблем: @StateObject. Давайте посмотрим на пример и посмотрим, как он решает некоторые наши проблемы с помощью слегка переработанного DetailView.

struct DetailView: View {
    @StateObject var model: DetailViewModel
    @EnvironmentObject var master: MasterViewModel
//    init(item: Item) {
//        self.model = DetailViewModel(item: item)
//    }

Мы заменяем наш @ObservedObject на @StateObject, и теперь мы передаем нашу модель представления непосредственно в него через нашу ссылку навигации.

    NavigationLink(
        destination: DetailView(model: DetailViewModel(item: item))
    ) {
        ItemDateView(item: item)
    }

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

На самом деле это не так. Давайте посмотрим на инициализатор для @ StateObject.

@inlinable public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType)

wrappedValue фактически заключен в @autoclosure, и это закрытие не вызывается, пока что-то не попытается получить доступ к обернутому значению. В этот момент будет создан экземпляр нашей DetailViewModel с использованием захваченного значения item.

Вот журнал после добавления одного элемента.

MasterView Changed
MasterView.body #3 {
  List 1 items
}
DetailView.init() #6
ItemDateView.init() #7
ItemDateView.body #7 {
  2020-09-09 22:23:48 +0000
}

Обратите внимание, что мы создали экземпляр DetailView, но не DetailViewModel. Теперь перейдем на эту страницу.

DetailViewModel.init() #8
DetailView.body #6 {
  DetailContentView.init() #9
}
DetailContentView.body #9 {
  ItemDateView.init() #10
  DetailStateView.init() #11
  DetailBoilerplateView.init() #12
}
ItemDateView.body #10 {
  2020-09-09 22:23:48 +0000
}
DetailStateView.body #11 {
  4C61F292-8A47-480C-BFCF-92591AD49928
}
DetailBoilerplateView.body #12 {
  You're viewing model #8.
}
DetailView.onAppear

Теперь мы видим, как создан экземпляр нашей DetailViewModel вместе с нашим знакомым циклом обновления. Интересно, что если мы перейдем назад к основному списку, а затем еще раз коснемся нашего единственного элемента списка, SwiftUI создаст совершенно новую модель DetailViewModel.

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

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

Если какая-либо из этих проблем вызывает беспокойство, можно просто обернуть целевое представление следующим образом:

struct WrappedDetailView: View {
    let item: Item
    var body: some View {
        return DetailView(model: DetailViewModel(item: item))
    }
}

И, конечно же, использование @StateObject означает, что ваша минимальная цель развертывания на iOS теперь iOS 14.

Завершение блока

Чтобы добраться сюда, потребовалось некоторое время, и в некотором смысле мы только начали свой путь к SwiftUI.

Но теперь вы должны лучше понимать, что происходит в вашем приложении SwiftUI, где могут возникнуть узкие места производительности и где потенциальные ошибки могут подстерегать, готовые удалить все ваше приложение.

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

Я потратил значительную часть этой статьи, рассказывая вам, чего не делать.

  • Не думайте, что взгляды сохранятся.
  • Не выполняйте тяжелую работу во время инициализации представления и в ваших телах представления.
  • Не думайте, что вы знаете, когда будут созданы ваши представления.
  • Не думайте, что вы знаете, когда будут вызваны ваши тела просмотра.
  • Не думайте, что вы знаете, как часто будут вызываться ваши тела просмотра.
  • Избегайте @ObservedObject.

С другой стороны, вы не можете создать отличное приложение на основе того, чем не заниматься. И я тоже пытался помочь в этом.

  • Сделайте просмотры легкими и производительными.
  • Держите состояние как можно ниже в иерархии.
  • По возможности используйте распределенное состояние и микросервисы.
  • При необходимости (и если возможно) используйте @StateObject.

Появляются новые статьи о том, как правильно создавать приложения SwiftUI, учитывающие все эти принципы.

Но пока, и если вы больше ничего не помните, запомните, пожалуйста, это ...

Не относитесь к SwiftUI как к UIKit.

Это не.