Как избежать повторных обновлений представлений SwiftUI

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

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

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

Состав состояния представления

Источники запуска обновлений представления называются источниками истины. К ним относятся:

  • Переменные, объявленные с помощью оберток свойств, таких как @State и @StateObject.
  • Параметры построения типов представлений по протоколу View.
  • Источники событий, такие как «onReceive».

Состояние представления  —  нечто составное, это представление с несколькими типами источников истины.

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

Обертки свойств с соответствием протоколу «DynamicProperty»

В первый же день изучения SwiftUI почти каждому пользователю встречаются обертки свойств, такие как @State и @Binding, которыми запускаются обновления представлений.

Таких оберток все больше. Начиная с версии SwiftUI 4.0, известны следующие: @AccessibilityFocusState, @AppStorage, @Binding, @Environment, @EnvironmentObject, @FetchRequest, @FocusState, @FocusedBinding, @FocusedObject, @FocusedValue, @GestureState, @NSApplicationDelegateAdaptor, @Namespace, @ObservadObject, @ScaledMetric, @SceneStorage, @SectionedFetchRequest, @State, @StateObject, @UIApplicationDelegateAdaptor, @WKApplicationDelegateAdaptor и @WKExtentsionDelegateAdaptor. У всех оберток свойств, с помощью которых переменная становится источником истины, имеется одна общая характеристика  —  соответствие протоколу «DynamicProperty».

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

Механизм работы «DynamicProperty»

В Apple предоставили не много информации об этом протоколе. Единственный его общедоступный метод  —  «update». Вот полные требования метода по протоколу:

public protocol DynamicProperty {
static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
static var _propertyBehaviors: UInt32 { get }
mutating func update()
}

Сердцевина протокола  —  метод _makeProperty, которым при загрузке представления в дерево представлений необходимые данные  —  значения, методы, ссылки и т. д.  —  сохраняются в управляемом пуле данных SwiftUI и представление привязывается к источнику истины в AttributeGraph. Так делается возможной реакция представления на его изменения, когда данными в пуле данных SwiftUI сигнализируется об изменении, обновлении представления.

Как пример возьмем @State:

@propertyWrapper public struct State<Value> : DynamicProperty {
internal var _value: Value
internal var _location: SwiftUI.AnyLocation<Value>? // Ссылка на данные в управляемом пуле данных SwiftUI
public init(wrappedValue value: Value)
public init(initialValue value: Value) {
_value = value // При создании экземпляра временно сохраняется только исходное значение
}
public var wrappedValue: Value {
get // guard let _location else { return _value} ...
nonmutating set // Возможно только изменение данных, на которые указывается в «_location»
}
public var projectedValue: SwiftUI.Binding<Value> {
get
}
// Этот метод вызывается, когда представление загружается в дерево представлений, для завершения привязывания
public static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
}
  • Когда инициализируется State, initialValue сохраняется только во внутреннем свойстве _value экземпляра State. Пока значение, обернутое в State, не сохраняется в управляемом пуле данных SwiftUI и не привязано к представлению как источник истины в гра́фе свойства.
  • Когда представление загружается в дерево представлений, вызовом _makeProperty в SwiftUI завершается операция сохранения данных в управляемый пул данных и создания связанной операции в гра́фе свойства, а также сохраняется ссылка на данные в управляемом пуле данных в _location. AnyLocation  —  это подкласс AnyLocationBase и ссылочный тип.
  • Методы get и set wrappedValue и projectedValue  —  это операции над _location. Когда из дерева представлений удаляется представление, очищается и связанный с ним пул данных SwiftUI. В результате время жизни обернутой в State переменной точно такое же, как у представления, и при ее изменении в SwiftUI автоматически обновляется, пересчитывается соответствующее представление. Многие в SwiftUI обеспокоились вопросом: «Почему нельзя поменять значение переменной, обернутой в State, в конструкторе представления?». Ответ  —  в понимании описанного выше процесса:
struct TestView: View {
@State private var number: Int = 10
init(number: Int) {
self.number = 11 // Изменение неэффективно
}
var body: some View {
Text("\(number)") // При первом прогоне отображается «10»
}
}

При задании в конструкторе значения self.number = 11 представление еще не загружено, а значение _location пустое, поэтому присвоение для операции задания wrappedValue не выполняется.

Что касается оберток свойств для ссылочных типов, таких как @StateObject, в SwiftUI представление согласно протоколу «ObservableObject» привязывается к экземпляру объекта, оборачиваемому свойством. Когда издателем objectWillChange(ObjectWillChangePublisher) отправляются данные, представление обновляется. Любые операции с objectWillChange.send чреваты переоцениванием представления независимо от того, изменено ли содержимое свойств экземпляра:

@propertyWrapper public struct StateObject<ObjectType> : DynamicProperty where ObjectType : ObservableObject {
internal enum Storage { // Для указания, загружено ли представление и проконтролированы ли данные пулом данных, используется определяемое внутри перечисление.
case initially(() -> ObjectType)
case object(ObservedObject<ObjectType>)
}

internal var storage: StateObject<ObjectType>.Storage
public init(wrappedValue thunk: @autoclosure @escaping () -> ObjectType) {
storage = .initially(thunk) // Инициализация, представление еще не загружено.
}
@_Concurrency.MainActor(unsafe) public var wrappedValue: ObjectType {
get
}
@_Concurrency.MainActor(unsafe) public var projectedValue: SwiftUI.ObservedObject<ObjectType>.Wrapper {
get
}
// В методах, необходимых «DynamicProperty», реализуются сохранение экземпляра в управляемом пуле данных и привязывание представления к «objectWillChange» управляемого экземпляра.
public static func _makeProperty<V>(in buffer: inout _DynamicPropertyBuffer, container: _GraphValue<V>, fieldOffset: Int, inputs: inout _GraphInputs)
}

Самое большое отличие @ObservedObject от @StateObject  —  первым не сохраняется ссылка на экземпляр объекта в управляемом пуле данных SwiftUI, а только привязывается представление к objectWillChange ссылочного объекта в экземпляре типа представления:

@ObservedObject var store = Store() // Каждый раз, когда создается экземпляр типа представления, создается новый экземпляр «Store».

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

Как избежать ненужных объявлений

Объявление в типе представления любого источника истины  —  обертки свойств с соответствием протоколу «DynamicProperty»,  —  изменяемого вне текущего представления, чревато обновлением текущего представления при каждом его сигнале обновления независимо от того, используется ли он в body представления.

Вот пример:

struct EnvObjectDemoView:View{
@EnvironmentObject var store:Store
var body: some View{
Text("abc")
}
}

Хотя свойства или методы экземпляра store в текущем представлении не вызываются, при каждом вызове метода экземпляра objectWillChange.send, например когда меняется свойство, обернутое в @Published, все привязанные к нему представления, в том числе текущее, обновляются переоцениванием body.

Подобное случается также с @ObservedObject и @Environment:

struct MyEnvKey: EnvironmentKey {
static var defaultValue = 10
}

extension EnvironmentValues {
var myValue: Int {
get { self[MyEnvKey.self] }
set { self[MyEnvKey.self] = newValue }
}
}
struct EnvDemo: View {
@State var i = 100
var body: some View {
VStack {
VStack {
EnvSubView()
}
.environment(\.myValue, i)
Button("change") {
i = Int.random(in: 0...100)
}
}
}
}
struct EnvSubView: View {
@Environment(\.myValue) var myValue // объявлено, но не используется в «body»
var body: some View {
let _ = print("sub view update")
Text("Sub View")
}
}

Даже если myValue не используется в body в EnvSubView, оно все равно обновляется, потому что myValue изменяется его представлением-предком в EnvironmentValues.

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

Другие предложения

  • При переключении между иерархиями представлений использовать Environment или EnvironmentObject.
  • Что касается отношений слабо связанных состояний, для разделения состояний использовать несколько EnvironmentObject, внедренных в одну иерархию представлений.
  • В соответствующих сценариях вместо @Published использовать objectWillChange.send.
  • Чтобы разделить состояние и уменьшить вероятность обновления представления, использовать сторонние библиотеки.
  • Не исключать избыточные вычисления полностью, найти баланс между удобством внедрения зависимостей, производительностью приложения и сложностью тестирования.
  • Нет идеального решения, даже для популярных проектов вроде TCA найдутся очевидные узкие места производительности при высокой детализированности и многоуровневом вырезании State.

Параметры построения представлений

Дорабатывая поведение с повторными вычислениями в представлениях SwiftUI, разработчики часто фокусируются на обертках свойств с соответствием протоколу «DynamicProperty». Но иногда эффективнее оптимизация параметров построения типов представлений.

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

Например, когда в SwiftUI обновляется ContentView, если содержимое параметров построения SubView  —  name и age  —  изменяется, в SubView переоценивается body, то есть обновляется представление:

struct SubView{
let name:String
let age:Int

var body: some View{
VStack{
Text(name)
Text("\(age)")
}
}
}

struct ContentView {
var body: some View{
SubView(name: "fat" , age: 99)
}
}

Простая, но эффективная стратегия сравнения

За время существования представления экземпляры его типа обычно создаются в SwiftUI многократно. Основная цель при этом  —  проверка изменений экземпляра типа представления, обусловленных почти во всех случаях изменением значения параметра построения:

  • Создается новый экземпляр и сравнивается с имеющимся в SwiftUI.
  • Если обнаруживается изменение, имеющийся заменяется новым вместе с оцениваемым значением body.
  • Замена сущности не сказывается на существовании представления.

В SwiftUI не требуется соответствия типов представлений протоколу «Equatable», поэтому используется простая, но высокоэффективная операция сравнения на основе блоков, а не параметров или свойств.

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

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

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

Разбиваем на части

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

struct Student {
var name: String
var age: Int
}

struct RootView:View{
@State var student = Student(name: "fat", age: 88)
var body: some View{
VStack{
StudentNameView(student: student)
StudentAgeView(student: student)
Button("random age"){
student.age = Int.random(in: 0...99)
}
}
}
}
// Разделенное на представления поменьше
struct StudentNameView:View{
let student:Student
var body: some View{
let _ = Self._printChanges()
Text(student.name)
}
}
struct StudentAgeView:View{
let student:Student
var body: some View{
let _ = Self._printChanges()
Text(student.age,format: .number)
}
}

Хотя в коде выше реализуется визуализация SubView отображения данных студента, из-за проблемы выбора параметров построения повторные вычисления не сократились.

После нажатия кнопки random age («Случайный возраст») для изменения атрибута age, даже если он не применяется в StudentNameView, в SwiftUI все равно обновляются и StudentNameView, и StudentAgeView:

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

struct RootView:View{
@State var student = Student(name: "fat", age: 88)
var body: some View{
VStack{
StudentNameView(name: student.name) // Передаем только необходимые данные
StudentAgeView(age:student.age)
Button("random age"){
student.age = Int.random(in: 0...99)
}
}
}
}

struct StudentNameView:View{
let name:String // Необходимые данные
var body: some View{
let _ = Self._printChanges()
Text(name)
}
}
struct StudentAgeView:View{
let age:Int
var body: some View{
let _ = Self._printChanges()
Text(age,format: .number)
}
}

Итог этих корректировок: StudentNameView обновляется, только когда изменяется атрибут name, а StudentAgeView  —  когда изменяется атрибут age.

Настраиваем правила сравнения, приводя представления в соответствие с протоколом «Equatable»

Что, если оптимизировать параметры построения этим методом невозможно? В SwiftUI имеется другой  —  настройка правил сравнения:

  • Приводим представление в соответствие с протоколом «Equatable».
  • Настраиваем для представления правила сравнения.

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

Возьмем пример кода выше:

struct RootView: View {
@State var student = Student(name: "fat", age: 88)
var body: some View {
VStack {
StudentNameView(student: student)
StudentAgeView(student: student)
Button("random age") {
student.age = Int.random(in: 0...99)
}
}
}
}

struct StudentNameView: View, Equatable {
let student: Student
var body: some View {
let _ = Self._printChanges()
Text(student.name)
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.student.name == rhs.student.name
}
}
struct StudentAgeView: View, Equatable {
let student: Student
var body: some View {
let _ = Self._printChanges()
Text(student.age, format: .number)
}
static func== (lhs: Self, rhs: Self) -> Bool {
lhs.student.age == rhs.student.age
}
}

Этот метод не распространяется на обновление, генерируемое обертками свойств с соответствием протоколу «DynamicProperty», а только на сравнение экземпляров типов представлений.

Замыкание  —  решение, легко упускаемое из виду

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

Вот пример:

struct ClosureDemo: View {
@StateObject var store = MyStore()
var body: some View {
VStack {
if let currentID = store.selection {
Text("Current ID: \(currentID)")
}
List {
ForEach(0..<100) { i in
CellView(id: i){ store.sendID(i) } // Задаем действие кнопки для вложенного представления, используя последующее замыкание
}
}
.listStyle(.plain)
}
}
}

struct CellView: View {
let id: Int
var action: () -> Void
init(id: Int, action: @escaping () -> Void) {
self.id = id
self.action = action
}
var body: some View {
VStack {
let _ = print("update \(id)")
Button("ID: \(id)") {
action()
}
}
}
}
class MyStore: ObservableObject {
@Published var selection:Int?
func sendID(_ id: Int) {
self.selection = id
}
}

Когда нажимаем кнопку в представлении CellView, все представления CellView текущей области отображения List пересчитываются:

Но ведь мы не представили источник истины, которым бы вызывались обновления в CellView. Зато поместили store в замыкание, поэтому нажатием кнопки вызывается изменение store. В итоге при сравнении экземпляров CellView в SwiftUI распознается изменение:

CellView(id: i){ store.sendID(i) }

Проблема решается двумя способами:

  • Приводим CellView в соответствие с протоколом «Equatable» и не сравниваем параметр action:
struct CellView: View, Equatable {
let id: Int
var action: () -> Void
init(id: Int, action: @escaping () -> Void) {
self.id = id
self.action = action
}

var body: some View {
VStack {
let _ = print("update \(id)")
Button("ID: \(id)") {
action()
}
}
}
static func == (lhs: Self, rhs: Self) -> Bool { // Исключаем «action» из сравнения
lhs.id == rhs.id
}
}
ForEach(0..<100) { i in
CellView(id: i){ store.sendID(i) }
}
  • Чтобы исключить store из CellView, меняем определение функции в параметрах конструктора:
struct CellView: View {
let id: Int
var action: (Int) -> Void // меняем определение функции
init(id: Int, action: @escaping (Int) -> Void) {
self.id = id
self.action = action
}

var body: some View {
VStack {
let _ = print("update \(id)")
Button("ID: \(id)") {
action(id)
}
}
}
}
ForEach(0..<100) { i in
CellView(id: i, action: store.sendID) // напрямую передаем метод «sendID» из «store», исключая «store»
}

Источник событий

Чтобы полностью перейти к жизненному циклу SwiftUI, в Apple имеются модификаторы представления для прямой обработки событий внутри представлений: «onReceive», «onChange», «onOpenURL», «onContinueUserActivity» и т. д. Эти триггеры называются источниками событий и считаются источником истины, компонентом состояния представления.

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

Для сокращения повторных вычислений, вызываемых источниками событий, применяются такие идеи оптимизации:

  • Контроль жизненного цикла.

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

  • Сокращение области воздействия.

Чтобы минимизировать влияние триггера на обновления представления, для него создается отдельное представление:

struct EventSourceTest: View {
@State private var enable = false

var body: some View {
VStack {
let _ = Self._printChanges()
Button(enable ? "Stop" : "Start") {
enable.toggle()
}
TimeView(enable: enable) // Отдельное представление, «onReceive» чревато только обновлением «TimeView»
}
}
}

struct TimeView:View{
let enable:Bool
@State private var timestamp = Date.now
var body: some View{
let _ = Self._printChanges()
Text(timestamp, format: .dateTime.hour(.twoDigits(amPM: .abbreviated)).minute(.twoDigits).second(.twoDigits))
.background(
Group {
if enable { // Триггер загружается только при необходимости
Color.clear
.task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1000000000)
NotificationCenter.default.post(name: .test, object: Date())
}
}
.onReceive(NotificationCenter.default.publisher(for: .test)) { notification in
if let date = notification.object as? Date {
timestamp = date
}
}
}
}
)
}
}

extension Notification.Name {
static let test = Notification.Name("test")
}

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

Заключение

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

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи fatbobman ( 东坡肘子): How to Avoid Repeating SwiftUI View Updates

Предыдущая статьяПрощай, Ramda
Следующая статьяПродвинутые темы SQL для дата-инженеров