@State тепер макрос
Гортав реліз-ноути до 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.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів