Подробный разбор фреймворка Observation. Новый способ повысить производительность SwiftUI

На WWDC 2023 компанией Apple представлена новинка стандартной библиотеки Swift  —  фреймворк «Observation». Ожидается, что с его появлением решится давняя проблема: сокращение лишних обновлений в представлениях SwiftUI.

Изучим этот фреймворк в формате вопросов и ответов: причины создания, методы применения, принципы работы, меры предосторожности.

Необходимость фреймворка «Observation»

До версии Swift 5.9 у нас не было унифицированного, эффективного механизма отслеживания изменений свойств ссылочного типа. KVO ограничен подклассами NSObject, в Combine нет точного отслеживания на уровне свойств, и нигде нет поддержки кроссплатформенности.

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

Фреймворк «Observation» призван устранить эти ограничения. Вот его преимущества перед KVO и Combine:

  1. Применимость ко всем ссылочным типам Swift, платформонезависимость.
  2. Точное отслеживание на уровне свойств без специальных аннотаций.
  3. Сокращение лишних обновлений в представлениях SwiftUI, повышение производительности приложений.

Объявление объекта «Observable»

С фреймворком «Combine» наблюдаемый ссылочный тип объявляется так:

class Store: ObservableObject {
@Published var firstName: String
@Published var lastName: String
var fullName: String {
firstName + " " + lastName
}

@Published private var count: Int = 0

init(firstName: String, lastName: String, count: Int) {
self.firstName = firstName
self.lastName = lastName
self.count = count
}
}

Когда меняются firstName, lastName и счетчик экземпляров count, с помощью @Published через objectWillChange или ObjectWillChangePublisher всем подписчикам отправляются уведомления об изменении текущего экземпляра.

С фреймворком «Observation» объявление совсем другое:

@Observable
class Store {
var firstName: String = "Yang"
var lastName: String = "Xu"
var fullName: String {
firstName + " " + lastName
}

private var count: Int = 0

init(firstName: String, lastName: String, count: Int) {
self.firstName = firstName
self.lastName = lastName
self.count = count
}
}
  • Перед объявлением класса добавляется аннотация @Observable, и нет необходимости указывать о соответствии типа Store протоколу.
  • Нет @Published, которым аннотируются свойства для запуска уведомлений. Отслеживается любое не аннотированное особо, сохраняемое свойство.
  • Отслеживаются вычисляемые свойства, в примере fullName тоже.
  • Неотслеживаемые свойства аннотированы как @ObservationIgnored:
// счетчик не отслеживается
@ObservationIgnored
private var count: Int = 0
  • У всех свойств  —  литеральные значения по умолчанию, даже если указывается пользовательский метод init.

Объявление объектов Observable во фреймворке «Observation» лаконичнее и интуитивно понятнее, к тому же с отслеживанием вычисляемых свойств.

Зачем здесь «@Observable»

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

Макросы  —  новый функционал Swift 5.9 для манипулирования и обработки кода Swift во время компиляции. Указывая выполняемое при компиляции определение макроса, разработчики меняют, добавляют код или удаляют его из исходного кода.

В Xcode 15, чтобы увидеть генерируемый макросом код, нажимаем правой кнопкой мыши на @Observable и выбираем Expand Macro («Развернуть макрос»):

@Observable
class Store {
@ObservationTracked
var firstName: String = "Yang" {
get {
access(keyPath: \.firstName)
return _firstName
}
set {
withMutation(keyPath: \.firstName) {
_firstName = newValue
}
}
}
@ObservationTracked // Этот код тоже развертывается.
var lastName: String = "Xu"
var fullName: String {
firstName + " " + lastName
}
@ObservationIgnored
private var count: Int = 0
init(firstName: String, lastName: String, count: Int) {
self.firstName = firstName
self.lastName = lastName
self.count = count
}
@ObservationIgnored private let _$observationRegistrar = ObservationRegistrar()
internal nonisolated func access<Member>(
keyPath: KeyPath<Store, Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, T>(
keyPath: KeyPath<Store, Member>,
_ mutation: () throws -> T
) rethrows -> T {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
@ObservationIgnored private var _firstName: String = "Yang"
@ObservationIgnored private var _lastName: String = "Xu"
}
extension Store: Observable {}

Исходное объявление макросом Observable изменено. Чтобы поддерживать и контролировать отношения между свойствами Observable и наблюдателями, в Store объявлена структура ObservationRegistrar. Сохраняемые свойства переписываются как вычисляемые, а исходное значение сохраняется в той же версии с префиксом _ prefix. Наблюдатели регистрируются и уведомляются в методах get и set через _$observationRegistrar. Наконец, для соответствия объекта Observable протоколу Observable  —  аналогично Sendable это всего лишь идентификатор без реализации  —  макросом добавляется код.

Применение объектов «Observable» в представлениях

Объявление объектов «Observable» в представлениях

В отличие от источника истины с соответствием протоколу «ObservableObject», для обеспечения жизненного цикла объектов Observable в представлениях применяется @State:

@Observable
class Store {
....
}

struct ContentView: View {
@State var store = Store()
var body: some View {
...
}
}

Внедрение объектов «Observable» в иерархию представлений с помощью окружения

В отличие от источника истины с соответствием протоколу ObservableObject, у объектов Observable, объявленных с фреймворком «Observation», возможности внедрения окружения разнообразнее и гибче.

  • Внедрение экземпляров через окружение:
@Observable
class Store {
....
}

struct ObservationTest: App {
@State var store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}

struct ContentView: View {
@Environment(Store.self) var store // Внедряем через окружение в представлении
var body: some View {
...
}
}
  • Настройка EnvironmentKey:
struct StoreKey: EnvironmentKey {
static var defaultValue = Store()
}

extension EnvironmentValues {
var store: Store {
get { self[StoreKey.self] }
set { self[StoreKey.self] = newValue }
}
}

struct ContentView: View {
@Environment(\.store) var store // Внедряем через окружение в представлении
var body: some View {
...
}
}
  • Внедрение дополнительных значений:
struct ObservationTest: App {
@State var store = Store()
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}

struct ContentView: View {
@Environment(Store.self) var store:Store? // Внедряем через окружение в представлении
var body: some View {
if let firstName = store?.firstName {
Text(firstName)
}
}
}

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

Но почему метод внедрения объектов Observable, объявленных с фреймворком «Observation», аналогичен методу типов значений, хотя ссылочным типам с соответствием протоколу ObservableObject требуется применение методов, которыми указывается объект для внедрения  —  StateObject, EnvironmentObject? Не случится ли путаницы?

Сценариев, где объекты Observable с соответствием протоколу ObservableObject появляются одновременно с объектами Observable, объявленными с фреймворком «Observation», при разработке приложений iOS 17+ ожидается все меньше. Поэтому скоро ссылочные типы и типы значений будут сильно унифицированы в своих формах внедрения, практически не останется сценариев с environmentObject и StateObject.

Передача объектов «Observable» в представлениях

struct ContentView: View {
@State var store = Store()
var body: some body {
SubView(store: store)
}
}

struct SubView:View {
let store:Store
var body: some body {
....
}
}

Применяются и let, и var.

Создание типа привязки

Типом Binding в SwiftUI реализуется двунаправленная привязка данных. С фреймворком «Observation» такой тип для свойства создается тремя способами.

Первый:

struct ContentView: View {
@State var store = Store()
var body: some body {
SubView(store: store)
}
}

struct SubView:View {
@Bindale var store:Store
var body: some body {
TextField("",text:$store.name)
}
}

Второй:

struct SubView:View {
var store:Store
var body: some body {
@Bindable var store = store
TextField("",text:$store.name)
}
}

Третий:

struct SubView:View {
var store:Store
var name:Binding<String>{
.init(get: { store.name }, set: { store.name = $0 })
}
var body: some body {
TextField("",text:name)
}
}

Поддерживаются ли фреймворком «Observation» более старые версии SwiftUI? Нет.

Отслеживание объектов «Observable»

Во фреймворке «Observation» имеется глобальная функция withObservationTracking для отслеживания того, изменились ли свойства объекта Observable.

Сигнатура функции:

func withObservationTracking<T>(
_ apply: () -> T,
onChange: @autoclosure () -> () -> Void
) -> T

Тест 1:

@Observable
class Store {
var a = 10
var b = 20
var c = 20
}

let sum = withObservationTracking {
store.a + store.b
} onChange: {
print("Store Changed a:\(store.a) b:\(store.b) c:\(store.c)")
}
store.c = 100
// Нет вывода
store.b = 100
// Вывод
// «Store» изменен a:10 b:20 c:100
store.a = 100
// Нет вывода

Тест 2:

withObservationTracking {
print(store)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3){
store.a = 100
}
} onChange: {
print("Store Changed")
}

store.b = 100
// Нет вывода
store.a = 100
// Нет вывода

В официальной документации Apple о функции withObservationTracking написано:

  • apply  —  замыкание со свойствами для отслеживания.
  • onChange  —  замыкание, вызываемое при изменении значения свойства.
  • Returns  —  значение, возвращаемое замыканием apply, если такое значение в нем имеется; в противном случае значение не возвращается.

Хотя описание простое, непонятки остаются:

  • Как в withObservationTracking определяется, какие в замыкании apply отслеживаются свойства?
  • Почему после модификации некоторыми свойствами Observable в замыкании apply не запускаются обратные вызовы? См. тест 2.
  • Создаваемое с withObservationTracking поведение отслеживания одноразовое или постоянное?
  • Когда вызывается замыкание onChange? «При изменении значения свойства»  —  это до или после его изменения?

Но фреймворк «Observation»  —  часть стандартной библиотеки Swift 5.9, подробнее о нем  —  в исходном коде.

Каков принцип отслеживания фреймворка «Observation»?

Процесс создания отслеживаний с withObservationTracking понятен из кода. Резюмируем его.

Создание фазы отслеживания

  • С withObservationTracking в _ThreadLocal.value текущего потока создается _AccessList.
  • Выполняется замыкание apply.
  • Когда вызывается свойство Observable объекта Observable, запускаемое замыканием apply, чтобы сохранить соответствие между свойством Observable и замыканием обратного вызова в ObservationRegistrar экземпляра объекта Observable, применяется метод access. Замыкание обратного вызова здесь используется для вызова замыкания onChange в withObservationTracking.
  • С withObservationTracking сохраняется соответствие между свойством Observable и замыканием обратного вызова onChange в _AccessList.

Когда отслеживаемое свойство меняется

  • Отслеживаемым свойством вызывается метод willSet в ObservationRegistrar, а также находится замыкание обратного вызова с соответствием текущему свойству KeyPath.
  • Вызовом этого замыкания вызывается замыкание onChange в потоке, инициируемом withObservationTracking.
  • После вызова onChange соответственная _AccessList информация в текущем потоке withObservationTracking удаляется.
  • В ObservationRegistrar удаляется соответствие между свойствами и замыканиями обратного вызова, связанными с этой операцией отслеживания.

Заключение

Разобравшись, приходим к таким выводам:

  • Отслеживаются только свойства Observable, считываемые в замыкании apply посредством вызова их метода get. Этим объясняется проблема в тесте 2.
  • Создаваемая с withObservationTracking операция отслеживания  —  одноразовое поведение. После вызова функции onChange любые изменения свойств Observable чреваты завершением этого отслеживания.
  • Замыкание onChange вызывается до изменения значения свойства  —  в методе willSet.
  • За одну операцию отслеживания отслеживается несколько свойств Observable. Любые изменения значения свойства чреваты завершением отслеживания.
  • Поведение отслеживания потокобезопасно: когда withObservationTracking запускается в другом потоке, замыкание onChange запускается в инициированном этой функцией потоке.
  • Отслеживаются только свойства Observable. Объектами Observable, которые появляются только в замыкании apply, операции отслеживания не создаются. Этим объясняется тест 2.

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

Отслеживание изменений свойств в представлениях SwiftUI

Судя по принципам работы фреймворка «Observation», в SwiftUI создается связь между свойствами Observable и обновлениями представлений, причем применяются такие методы:

struct A:View {
var body: some View {
...
}
}

let bodyValue = withObservationTracking {
viewA.body
} onChange: {
PreparingToRe-evaluateTheBodyValue()
}

Поскольку отслеживаются только свойства Observable, считываемые в замыкании apply посредством вызова их метода get, приходим к такому выводу:

Text(store.a) // Изменениями «store.a» запустится переоценка «body».

Button("Hi"){
store.b = "abc" // Изменениями «store.b» переоценка «body» не запустится.
}

Класс, аннотированный «@Observable», соответствует протоколу «ObservableObject»?

Да, но это чревато конфликтами между оберткой свойства @Published и макросом @Observable. Проблема решается с помощью withObservationTracking:

@Observable
final class Store: ObservableObject {
var name = ""
var age = 0

init(name: String = "", age: Int = 0) {
self.name = name
self.age = age
observeProperties()
}
private func observeProperties() {
withObservationTracking {
let _ = name
let _ = age
} onChange: { [weak self] in
guard let self else { return }
objectWillChange.send()
observeProperties()
}
}
}

Введение всех свойств «Observable» в методе «observeProperties» при необходимости автоматизируется пользовательскими макросами.

Могут ли «@Obervable» и «ObservableObject» сосуществовать в представлении?

Да, хотя объекты Observable объявляются в представлении по-разному: исходя из того, как они в него внедряются, в SwiftUI выбирается соответственный метод отслеживания.

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

@State var store = Store() // Определяем, переоценивать ли «body» на основании изменений свойств.

@StateObject var store = Store() // Всякий раз, когда свойство «@Published» меняется, «body» переоценивается.

Поддерживается ли «Observable» вложенность, т. е. когда свойство одного «Observable»  —  это свойство другого «Observable»?

Да. @Published поддерживаются только типы значений, поэтому вложенную логику для объектов Observable с соответствием протоколу «ObservableObject» реализовать сложно:

class A:ObservableObject {
@Published var b = B()
}

class B:ObservableObject {
@Published var a = 10
}
let a = A()
a.b.a = 100 // Обновление представления не запускается

Для решения этой проблемы написана обертка свойства @PublishedObject.

Суть такова: чтобы уведомлять подписчиков A об изменении свойства B, этим @PublishedObject используется objectWillChange внешнего объекта A, то есть внешний экземпляр. Другими словами, для достижения вложенности объектов Observable применяется подход с высокой связанностью.

Куда проще вложенность объектов Observable во фреймворке «Observation». При создании операции отслеживания с withObservationTracking каждым считываемым свойством Observable активно создается отношение с подписчиком. Оно корректно отслеживается независимо от положения в цепочке отношений или от формы его существования: массивы, словари и т. д.

Пример:

@Observable
class A {
var a = 1
var b = B()
}

@Observable
class B {
var b = 1
}

let a = A()

withObservationTracking {
let _ = a.b.b
} onChange: {
print("update")
}

В коде выше замыкание onChange вызывается в обоих этих методах, но только однажды:

a.b.b = 100

// или

a.b = B()

В строке let _ = a.b.b отслеживания создаются для двух свойств Observable из разных объектов и уровней: a.b и b.b. В этом преимущество фреймворка «Observation».

Решена ли в «Observation» проблема производительности «ObservableObject»?

Да, во фреймворке «Observation» доработана производительность объектов Observable в SwiftUI:

  • Лишние обновления сокращаются значительно, когда вместо свойств Observable в представлениях отслеживаются объекты Observable.
  • Механизм обратных вызовов Observation эффективнее модели Combine «издатель  —  подписчик».

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

Скажется ли фреймворк «Observation» на привычках программирования в SwiftUI?

На моих  —  да. Например, чтобы построить модель состояния приложения, разработчики обычно используют структуры. С фреймворком «Observation», чтобы реализовать отслеживание на уровне свойств, создаются объекты Observable, и такая модель строится даже с вложенными объектами Observable.

Кроме того, изменятся и многие применяемые в представлениях методы оптимизации. Например, при использовании ObservableObject мы сократим лишние обновления, вводя только полезные для текущего представления данные.

Подробнее о методах оптимизации для представлений  —  в статье.

class Store:ObservableObject {
@Published var a = 1
@Published var b = "hello"
}

struct Root:View {
@StateObject var store = Store()
var body: some View {
VStack{
A(a: store.a)
B(b: store.b)
}
}
}
struct A:View {
let a:Int // получаем только «a(Int)»
var body:some View {
Text("\(store.a)")
}
}
struct B:View { // получаем только «b(String)»
let b:String
var body:some View {
Text(store.b)
}
}

При изменении store.b переоцениваются только представления «Root» и «B».

После перехода на фреймворк «Observation» упомянутая выше стратегия оптимизации  —  это уже не оптимальное решение. Для новых объектов Observable оптимальнее не рекомендованный ранее метод:

@Observabl
class Store {
var a = 1
var b = "hello"
}

struct Root:View {
@State var store = Store()
var body: some View {
VStack{
A(store: store)
B(store: store)
}
}
}

struct A:View {
let store: Store
var body:some View {
Text("\(store.a)")
}
}

struct B:View {
let store: Store
var body:some View {
Text(store.b)
}
}

Обновление представления запускается только считываемыми свойствами, которые появляются в body. После модификации, когда изменяется store.b, переоценивается только представление «B».

Фреймворк «Observation» еще довольно новый, API для него постоянно развивается. Все больше приложений SwiftUI переводятся на Observation, опыт его использования разработчиками обобщается.

Заключение

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

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

Читайте нас в Telegram, VK и Дзен


Перевод статьи fatbobman ( 东坡肘子): A Deep Dive Into Observation: A New Way to Boost SwiftUI Performance

Предыдущая статьяОбработка ошибок в TypeScript без try/catch
Следующая статьяКак организовать свою систему обработки данных: кейс mondayDB