Введение
Привет! Меня зовут Илья, я iOS разработчик в компании Банки.ру.
Так получилось, что уже больше двух лет наше приложение мы пишем на SwiftUI. Часто разработчики используют функционал фреймворка, практически не задумываясь, воспринимая некоторые вещи как магию. Например, @State, который является одной из ключевых составляющих и используется повсеместно. Так что сегодня предлагаю вместе разобраться в том, как он устроен.
P.S. Если вы уверены в своих знаниях SwiftUI, можете сразу переходить к главе “Привилегии @State”.
Собака спереди
Может показаться, что @State или @Binding — это какие-то ключевые слова, которые появились в языке специально, чтобы поддерживать SwiftUI. Но сами по себе эти @Штуки не являются частью языка, ею является property wrapper.
Эта конструкция используется не часто, появилась только в Swift 5.1. Поэтому тем, кто подзабыл, подробно можно почитать вот тут.
Если кратко, то property wrapper’ы позволяют разделить код на 2 части: одна определяет само свойство, другая — как это свойство хранится.
Чтобы создать property wrapper, формируем структуру, перечисление или класс, которые определяют свойство wrappedValue и будут помечены атрибутом @propertyWrapper. Если нужна дополнительная функциональность, в property wrapper’e можем определить projectedValue любого нужного нам типа. Обратиться к projectedValue можно так же, как и к wrappedValue, только в начале необходимо указать знак доллара ($).
Ниже приведен пример работы с property wrapper’ом, но если вы хотите как-следует разобраться, рекомендую перечитать документацию?
@propertyWrapper
struct Trimmed {
private var value: String = ""
// Геттер свойства возвращает значение свойства value
// Сеттер обрезает ненужные leading и trailing пробелы и каретки
var wrappedValue: String {
get { value }
set {
value = newValue.trimmingCharacters(
in: .whitespacesAndNewlines
)
projectedValue = (newValue != value)
}
}
// Свойство, показывающее пытались ли при последнем обращении
// присвоить строку, имеющую ненужные пробелы и каретки
var projectedValue: Bool = false
init(wrappedValue initialValue: String) {
self.wrappedValue = initialValue
}
}
struct User {
@Trimmed var username: String
@Trimmed var fullName: String
}
var newUser = User(username: " JD ", fullName: "John Doe")
// Обращение к wrappedValue
print(newUser.username) // Output: "JD"
print(newUser.fullName) // Output: "John Doe"
// Изменение значений
newUser.username = " HelloKitty "
newUser.fullName = "Jane Smith"
// Обращение к wrappedValue
print(newUser.username) // Output: "HelloKitty"
print(newUser.fullName) // Output: "Jane Smith"
// Обращение к projectedValue
print(newUser.$username) // Output: true
print(newUser.$fullName) // Output: false
Вернемся к SwiftUI.
Что если не ставить @State
Создадим вьюшку с флагом, от которого зависит, какой текст мы будем показывать, и попробуем убрать @State.
Ну не спроста ж он там стоит — получаем ожидаемую ошибку. Ведь наша ComputerView является структурой, а структуры являются value-типами, а мы помним, что по умолчанию свойства value-типов не могут быть изменены внутри методов.
struct ComputerView: View {
private var isOn = false
var body: some View {
VStack {
Text("Computer is ") + Text(isOn ? "on" : "off")
Button("Press the button") {
// ❌ Cannot assign to property: 'self' is immutable
self.isOn = !self.isOn
}
}
}
}
В документации нам предлагают использовать ключевое слово mutating.
However, if you need to modify the properties of your structure or enumeration within a particular method, you can opt in to mutating behavior for that method.
Попробуем в нашем примере добавить mutating функцию и менять значение там.
struct ComputerView: View {
private var isOn = false
var body: some View {
VStack {
Text("Computer is ") + Text(isOn ? "on" : "off")
Button("Press the button") {
// ❌ Cannot use mutating member on immutable value: 'self' is immutable
self.mutateIsOnWithValue(!isOn)
}
}
}
mutating func mutateIsOnWithValue(_ value: Bool) {
self.isOn = value
}
}
В геттерах self по умолчанию не может быть модифицирован, поэтому мы получаем ошибку.
Однако, возможны исключения. Например, если геттер специально помечен как mutating, то self может быть изменен. Кажется, мы близко! Пометим геттер свойства body ключевым словом mutating.
struct ComputerView: View {
var body: some View {
mutating get {
// ...
}
}
}
И снова мимо… К сожалению, получим ошибку “❌ Type 'ComputerView' does not conform to protocol 'View’”.
Причина по которой это происходит состоит в том, что в протоколе View геттер свойства body неявно помечен как nonmutating, ведь, как я и говорил ранее, в геттерах self по умолчанию не может быть модифицирован, то есть помечен как { nonmutating get }
public protocol View {
// ...
associatedtype Body : View
// ...
@ViewBuilder @MainActor var body: Self.Body { get }
}
В самом начале мы убрали @State для свойства isOn. Наши неудачные попытки изменить свойство привели нас к тому, что мы вспомнили особенности value-типов. Получается, что @State имеет некоторые “привилегии”.
Привилегии @State
Грубо говоря, можно заключить, что @State отвечает за две вещи:
Обманывает неизменяемость структур
Синхронизируется с UI и обновляет его автоматически при необходимости
Разберемся для начала с первым пунктом.
Обман неизменяемости структур
При изменении переменной, помеченной @State, сама переменная изнутри не может измениться из-за того, что находится в структуре.
@State решает эту проблему очень филигранно. Суть заключается в изменении переменной вне текущей структуры.
Чтобы проследить эту мысль, посмотрим на определение property wrapper’a State в документации.
@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
public init(wrappedValue value: Value)
public init(initialValue value: Value)
public var wrappedValue: Value { get nonmutating set }
public var projectedValue: Binding<Value> { get }
}
Сеттеры по умолчанию является mutating. Но в нашем случае, свойство wrappedValue помечено как nonmutating
Это ключевое слово указывает компилятору, что процесс присваивания не будет изменять саму структуру (но может изменяться что-то другое).
Внутри wrappedValue может использоваться какая-либо ссылочная форма хранения данных, в итоге сама обертка не будет подвергаться изменениям при присвоении нового значения.
К сожалению, реализация @State скрыта.
Поэтому предлагаю рассмотреть пример, который приблизит нас к пониманию происходящего.
@propertyWrapper
struct SuiState<T> {
private final class Storage {
var value: T
init(value: T) {
self.value = value
}
}
var wrappedValue: T {
get {
return storage.value
}
nonmutating set {
storage.value = newValue
}
}
private let storage: Storage
init(wrappedValue initialValue: T) {
self.storage = Storage(value: initialValue)
}
}
Тут wrappedValue модифицирует значение, которое хранится в Storage, который, в свою очередь, является ссылочным типом.
Использовать такой подход следует только в очень специфичных случаях, так как он позволяет как бы «обойти» основные аспекты value-семантики в Swift. А это чревато проблемами.
У нас как-раз тот самый специфичный случай, ведь вьюшки в SwiftUI являются структурами. А почему вьюшки — это структуры, можно прочитать вот тут.
Теперь, если мы вернемся к изначальному примеру с ComputerView и пометим свойство isOn как @SuiState, компилятор больше не будет жаловаться на неизменяемость структур.
С первой “привилегией” разобрались, переходим ко второму пункту.
Синхронизация с UI
Если провалиться в @State, то можно заметить, что он подписан под протокол DynamicProperty.
/// An interface for a stored variable that updates an external property of a
/// view.
///
/// The view gives values to these properties prior to recomputing the view's
/// ``View/body-swift.property``.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol DynamicProperty {
/// Updates the underlying value of the stored value.
///
/// SwiftUI calls this function before rendering a view's
/// ``View/body-swift.property`` to ensure the view has the most recent
/// value.
mutating func update()
}
В протоколе объявлен только метод update(), и для него уже предоставляет реализация по умолчанию.
An interface for a stored variable that updates an external property of a view.
Давайте попробуем: подпишем наш property wrapper под протокол DynamicProperty.
@propertyWrapper
struct SuiState<T> : DynamicProperty {
// ...
}
Наша вьюшка должна реагировать на изменение свойств и перерисовываться.
Однако мы жмем на кнопку, и ничего не происходит.
Чтобы все заработало, наш @SuiState должен уведомить ViewGraph, о том, что произошли изменения. Для этого немного изменим наш код.
@propertyWrapper
struct SuiState<T> : DynamicProperty {
private final class Storage: ObservableObject {
var value: T {
willSet {
objectWillChange.send()
}
}
init(value: T) {
self.value = value
}
}
var wrappedValue: T {
get {
return storage.value
}
nonmutating set {
storage.value = newValue
}
}
@StateObject private var storage: Storage
init(wrappedValue initialValue: T) {
self._storage = .init(wrappedValue: Storage(value: initialValue))
}
}
Итак, все работает. Однако остается вопрос: как же SwiftUI понимает, что конкретно изменилось? Ведь свойства могут быть произвольного типа, и @State не держит ViewGraph, чтобы выборочно перерисовывать определенные View.
Этот вопрос хорошо разобран вот в этой статье.
SwiftUI использует в своей работе паттерн Visitor, с помощью которого передает метаданные о полях, имеющихся во View. Эти метаданные содержат дескрипторы, есть среди них и так называемые дескрипторы полей.
Дескриптор поля имеет следующий формат:
type FieldRecord struct {
Flags uint32
MangledTypeName int32
FieldName int32
}
type FieldDescriptor struct {
MangledTypeName int32
Superclass int32
Kind uint16
FieldRecordSize uint16
NumFields uint32
FieldRecords []FieldRecord
}
Получая метаданные для списка полей, у SwiftUI появляется возможность отобразить на экране нужные данные.
У меня все.
Спасибо за внимание! ?
Источники
https://khorbushko.github.io/article/2021/01/08/dynamicProperty.html
https://kateinoigakukun.hatenablog.com/entry/2019/06/08/232142
https://github.com/apple/swift/blob/main/docs/ABI/TypeMetadata.rst#nominal-type-descriptor
https://betterprogramming.pub/the-inner-workings-of-state-properties-in-swiftui-8409ef39a7bd