@State тепер макрос

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Гортав реліз-ноути до iOS 27 і зачепився оком за одне реально важливе покращення в SwiftUI.

Тепер @State під капотом реалізований як макрос. І це не просто косметика — завдяки цьому нарешті полагодили одну з тих проблем, через які в проєктах іноді доводилося тягнути @StateObject замість @State.

Проблема вилазила під час ініціалізації @State дефолтним значенням:

struct PageckageView: View {
  @State private var page = PageckageModel()
}

Раніше кожного разу, коли SwiftUI обчислював PackageView, модель PackageModel створювалася заново. Причому SwiftUI брав до роботи тільки найперший інстанс, а всі наступні просто створювалися і зависали в пам’яті аж до наступного обчислення PackageView. Тобто ти платиш алокацією за об’єкт, який ніхто навіть не подивиться.

А тепер згадаймо, що SwiftUI спокійно може перераховувати body кожен фрейм. І от таке безглузде накопичення непотрібних алокацій, зрозуміло, б’є по перформансу. Тому це начебто дрібне виправлення насправді помітно розвантажує SwiftUI. Маленький крок для @State, і трошки ближче до заповітних 120 fps.

До речі, макрос підтримує back deployment аж до iOS 17. А враховуючи, що Observation доступний з тієї ж версії — це прям хороший привід підняти мінімальний таргет і не озиратися назад.

У цьому пості розберемо, як влаштований новий макрос State(). Нагадаю: він прийшов на заміну property-wrapper’у @State, щоб полагодити ту саму «жадібну» алокацію початкових значень. Деталі можна почитати в TN3211: Resolving SwiftUI source incompatibilities for State and ContentBuilder.

TL;DR: сам @State нікуди не подівся — він просто обзавівся лінивою версією у вигляді LazyState.

Почнемо з простішого кейсу — State без початкового значення:

@State private var text: String

Тут генерується доволі компактне обчислюване властивість (computed property). Воно майже один в один повторює те, що раніше робив state property wrapper:

private var text: String {
  @storageRestrictions(initializes: _text)
  init(initialValue) {
    _text = SwiftUICore.State(initialValue: initialValue)
  }
  get {
    _text.wrappedValue
  }
  nonmutating set {
    _text.wrappedValue = newValue
  }
}
private var _text: SwiftUICore.State<String>
private var $text: SwiftUICore.Binding<String> {
  _text.projectedValue
}

Якщо комусь цікаво, що це за @storageRestrictions на init — це називається init accessors. Вони дозволяють перевизначити ініціалізатор для обчислюваного властивості. Саме тому два ініціалізатори нижче працюють абсолютно однаково:

struct ValueStateTest: View {
  // ...
  
  init(text: String) {
    self.text = text // Backed by @storageRestrictions(initializes: _text)
  }
  init(text: String) {
    self._text = SwiftUI.State(wrappedValue: text)
  }
}

Детальніше про init accessors можна почитати в пропозалі SE-0400: Init Accessors.

А тепер найсмачніше — State() з початковим значенням:

@State private var text: String = "Hello, World!"

Ось тут макрос уже розганяється і генерує помітно більше коду. Ось коротка версія:

private var text: String {
  get {
    __text.wrappedValue
  }
  nonmutating set {
    __text.wrappedValue = newValue
  }
}
private var __text = SwiftUICore.State._makeStorage(({
  "Hello, World!"
}))
  
@SwiftUICore._StateProjectedValue
private var $text = SwiftUICore.State<String>(initialValue: "Hello, World!").projectedValue
@SwiftUICore._StatePropertyWrapperStorage(initialValue: "...")
private var _text: SwiftUICore.State<_>! = SwiftUICore._stateNil(of: {
  SwiftUICore.State<String>(initialValue: "Hello, World!")
})
@SwiftUICore._StateInitialStoredValue("...")
private static var $base64_initial_value = (SwiftUICore.State._makeStorage(initialValue: {
  "Hello, World!"
}()))

Розбираймося по порядку. Перше, що впадає в око — це основне сховище для стейту, __text. Воно створюється через функцію SwiftUICore.State._makeStorage, яка потрібна лише для одного: уніфікувати точку входу для різних версій iOS:

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
static func _makeStorage(_ makeInitialValue: @escaping () -> Value) -> LazyState<Value> {
  LazyState(initialValue: makeInitialValue)
}
@_disfavoredOverload
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
static func _makeStorage(_ makeInitialValue: @escaping () -> Value) -> State<Value> {
  State<Value>(wrappedValue: makeInitialValue())
}

Тобто для iOS 17 і новіше в діло йде LazyState, який по суті нічим не відрізняється від StateObject — крім того, що не обмежує себе тільки класами:

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
@frozen struct LazyState<Value>: DynamicProperty {
  enum Storage: @unchecked Sendable {
    case thunk(() -> Value)
    case value(Value)
  }
  internal var _storage: Storage
  internal var _location: AnyLocation<Value>?
  
  var wrappedValue: Value {
    get
    nonmutating set
  }
  var projectedValue: Binding<Value> {
    get
  }
}

Виглядає так, ніби LazyState збиралися зробити публічним property-wrapper’ом, а потім у якийсь момент передумали. Чому — розберемося трохи далі. А поки що зауважу: LazyState цілком спокійно можна юзати всередині Viewнапряму:

let _text = LazyState(initialValue: { "Hello, World!"})
var text: String {
  get { _text.wrappedValue }
  nonmutating set { _text.wrappedValue = newValue}
}

На $text довго не зупинятимемося — це просто обгортка над __text.projectedValue.

А от де стає цікаво — це властивість _text:

@SwiftUICore._StatePropertyWrapperStorage(initialValue: "...")
var _text: SwiftUICore.State<_>! = SwiftUICore._stateNil(of: {
  SwiftUICore.State<String>(initialValue: "Hello, World!")
})

І ще цікавіше стає, коли зазираєш у публічні хедери і бачиш, що його початкове значення завжди дорівнює nil:

@_alwaysEmitIntoClient @_transparent
func _stateNil<T>(of _: () -> T) -> T? { nil }

По факту ця функція існує тільки заради одного — зберегти тип початкового значення. А макрос, у який вона загорнута, у параметрі initialValue тримає base64-рядок, що посилається на статичне властивість із самим початковим значенням:

@SwiftUICore._StateInitialStoredValue("...")
static var $base64_initial_value = (SwiftUICore.State._makeStorage(initialValue: {
  "Hello, World!"
}()))

Це властивість потрібне, щоб можна було дотягнутися до початкового значення зсередини реалізації _text. Повна імплементація виглядає так:

private var _text: SwiftUICore.State<_>! {
  @storageRestrictions(initializes: __text)
  init(initialValue) {
    if initialValue == nil {
      __text = Self.$base64_initial_value
    } else {
      __text = SwiftUICore.State._makeStorage(initialValue: initialValue.wrappedValue)
    }
  }
  get {
    SwiftUICore.State(initialValue: __text.wrappedValue)
  }
  set {
    if newValue != nil {
      __text = SwiftUICore.State._makeStorage(initialValue: newValue.wrappedValue)
    }
  }
}

Тепер очевидно, що _text — це просто обгортка над __text. А щоб зрозуміти, навіщо вона взагалі потрібна, пропоную позалипати над таким прикладом. Яке значення в підсумку отримає text?

@State private var text: String = "Hello, World!"
init(text: String) {
  self._text = State(initialValue: text)
}

Правильна відповідь — значення з text, а "Hello, World!" не використається взагалі ніколи.

Отже, стає ясно: _text існує рівно для того, щоб зберегти зворотну сумісність зі старою реалізацією @State. Те, що подібний код компілюється і чесно працює, якраз і пояснює, чому Apple заюзали тут макрос — щоб максимально не поламати API-сумісність і не зачепити занадто багато вже написаного коду. З цікавого: для frozen API LazyStateдоступний з iOS 26, а не 27.

Але в такого рішення є і зворотний бік. А саме — купа згенерованих властивостей і способів ініціалізації стейту, які в підсумку дають абсолютно різну і не завжди очевидну поведінку. Особливо якщо ти не в темі того, що цей макрос насправді робить під капотом:

@State private var text: String = "Hello, World!"
init(text: String) {
  self.text = text // "Hello, World!"
  self._text = nil // "Hello, World!"
  self._text = State(initialValue: text) // Value of `text`.
  self.__text = LazyState(initialValue: { text }) // Value of `text`.
}

Apple навіть випустили окремі technotes, де радять не мішати State() з початковим значенням і ініціалізацію через init. Але вони описали далеко не всі кейси — тож, схоже, скоро на співбесідах з’явиться ще одне душне питання 😅

Щодо того, чи можна таку поведінку бекпортнути: так, можна — через @StateObject для старіших осей. Приклад того, як це зробити, є в статті Lazy Initialization @State in SwiftUI: Overcoming Premature Object Creation.

👍ПодобаєтьсяСподобалось1
До обраногоВ обраному0
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

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