Перевод статьи подготовлен в рамках онлайн-курса "iOS Developer. Professional". Если вам интересно узнать подробнее о курсе, приходите на День открытых дверей онлайн.


Property Wrappers (Обертки Свойств) в Swift позволяют извлекать общую логику в отдельный объект-обертку. С момента представления во время WWDC 2019 и появления в Xcode 11 со Swift 5 было много примеров, которыми поделились в сообществе. Это изящное дополнение к библиотеке Swift, позволяющее удалить много шаблонного кода, который, вероятно, все мы писали в своих проектах.

Историю об обертках свойств можно найти на форумах Swift для SE-0258. В то время как целесообразность их использования в основном говорит о том, что обертки свойств являются решением для @NSCopying свойств, есть общая закономерность, которая реализовывается ими, и вы, вероятно, скоро все узнаете.

Что такое обертка свойства?

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

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

extension UserDefaults {

    @UserDefault(key: "has_seen_app_introduction", defaultValue: false)
    static var hasSeenAppIntroduction: Bool
}

Оператор @UserDefault выполняет вызов обертки свойства. Как видите, мы можем задать ему несколько параметров, которые используются для настройки обертки свойства. Существует несколько способов взаимодействия с оберткой свойства, например, использование обернутого и прогнозируемого значения. Вы также можете настроить обертку с внедренными свойствами, о которых мы поговорим позже. Давайте сначала рассмотрим пример обертки свойства User Defaults.

Обертки свойств и UserDefaults

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

extension UserDefaults {

    public enum Keys {
        static let hasSeenAppIntroduction = "has_seen_app_introduction"
    }

    /// Indicates whether or not the user has seen the onboarding.
    var hasSeenAppIntroduction: Bool {
        set {
            set(newValue, forKey: Keys.hasSeenAppIntroduction)
        }
        get {
            return bool(forKey: Keys.hasSeenAppIntroduction)
        }
    }
}

Он позволяет устанавливать и получать значения из пользовательских настроек по умолчанию из любого места следующим образом:

UserDefaults.standard.hasSeenAppIntroduction = true

guard !UserDefaults.standard.hasSeenAppIntroduction else { return }
showAppIntroduction()

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

Использование оберток свойств для удаления шаблонного кода

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

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

@propertyWrapper
struct UserDefault<Value> {
    let key: String
    let defaultValue: Value
    var container: UserDefaults = .standard

    var wrappedValue: Value {
        get {
            return container.object(forKey: key) as? Value ?? defaultValue
        }
        set {
            container.set(newValue, forKey: key)
        }
    }
}

Обертка позволяет передать значение по умолчанию, если еще нет зарегистрированного значения. Мы можем передать любое значение, поскольку обертка определяется общим значением Value.

Теперь мы можем изменить нашу предыдущую имплементацию кода и создать следующее расширение для типа UserDefaults:

extension UserDefaults {

    @UserDefault(key: "has_seen_app_introduction", defaultValue: false)
    static var hasSeenAppIntroduction: Bool
}

Как видите, мы можем использовать сгенерированный по умолчанию инициализатор struct из обертки определяемого свойства. Мы передаем тот же ключ, что и раньше, и устанавливаем значение по умолчанию false. Использовать это новое свойство очень просто:

UserDefaults.hasSeenAppIntroduction = false
print(UserDefaults.hasSeenAppIntroduction) // Prints: false
UserDefaults.hasSeenAppIntroduction = true
print(UserDefaults.hasSeenAppIntroduction) // Prints: true

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

extension UserDefaults {
    static let groupUserDefaults = UserDefaults(suiteName: "group.com.swiftlee.app")!

    @UserDefault(key: "has_seen_app_introduction", defaultValue: false, container: .groupUserDefaults)
    static var hasSeenAppIntroduction: Bool
}

Добавление дополнительных свойств с помощью одной обертки

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

extension UserDefaults {

    @UserDefault(key: "has_seen_app_introduction", defaultValue: false)
    static var hasSeenAppIntroduction: Bool

    @UserDefault(key: "username", defaultValue: "Antoine van der Lee")
    static var username: String

    @UserDefault(key: "year_of_birth", defaultValue: 1990)
    static var yearOfBirth: Int
}

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

Хранение опционалов с помощью обертки свойств пользователя по умолчанию

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

/// Allows to match for optionals with generics that are defined as non-optional.
public protocol AnyOptional {
    /// Returns `true` if `nil`, otherwise `false`.
    var isNil: Bool { get }
}
extension Optional: AnyOptional {
    public var isNil: Bool { self == nil }
}

Мы можем расширить нашу обертку свойств UserDefault, чтобы она соответствовала этому протоколу:

extension UserDefault where Value: ExpressibleByNilLiteral {
    
    /// Creates a new User Defaults property wrapper for the given key.
    /// - Parameters:
    ///   - key: The key to use with the user defaults store.
    init(key: String, _ container: UserDefaults = .standard) {
        self.init(key: key, defaultValue: nil, container: container)
    }
}

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

Наконец, нам нужно настроить наш установщик значений обертки, чтобы позволить удалять объекты из пользовательских значений по умолчанию:

@propertyWrapper
struct UserDefault<Value> {
    let key: String
    let defaultValue: Value
    var container: UserDefaults = .standard

    var wrappedValue: Value {
        get {
            return container.object(forKey: key) as? Value ?? defaultValue
        }
        set {
            // Check whether we're dealing with an optional and remove the object if the new value is nil.
            if let optional = newValue as? AnyOptional, optional.isNil {
                container.removeObject(forKey: key)
            } else {
                container.set(newValue, forKey: key)
            }
        }
    }

    var projectedValue: Bool {
        return true
    }
}

Теперь это позволяет нам определять опционалы и принимать значения равными нулю:

extension UserDefaults {

    @UserDefault(key: "year_of_birth")
    static var yearOfBirth: Int?
}

UserDefaults.yearOfBirth = 1990
print(UserDefaults.yearOfBirth) // Prints: 1990
UserDefaults.yearOfBirth = nil
print(UserDefaults.yearOfBirth) // Prints: nil

Отлично! Теперь мы можем справиться со всеми сценариями с помощью обертки пользовательских настроек по умолчанию. Последнее, что нужно добавить, это прогнозируемое значение, которое будет преобразовано в Combine publisher, как и в обертке свойства @Published.

Прогнозирование значения из обертки свойства

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

Чтобы сделать это с помощью обертки свойства user defaults, мы должны добавить publisher, который будет субъектом сквозной передачи. Все дело в названии: он будет просто передавать изменения значений. Реализация выглядит следующим образом:

import Combine
 
 @propertyWrapper
 struct UserDefault<Value> {
     let key: String
     let defaultValue: Value
     var container: UserDefaults = .standard
     private let publisher = PassthroughSubject<Value, Never>()
     
     var wrappedValue: Value {
         get {
             return container.object(forKey: key) as? Value ?? defaultValue
         }
         set {
             // Check whether we're dealing with an optional and remove the object if the new value is nil.
             if let optional = newValue as? AnyOptional, optional.isNil {
                 container.removeObject(forKey: key)
             } else {
                 container.set(newValue, forKey: key)
             }
             publisher.send(newValue)
         }
     }

     var projectedValue: AnyPublisher<Value, Never> {
         return publisher.eraseToAnyPublisher()
     }
 } 
We can now start 

Теперь мы можем начать наблюдать за изменениями в нашем объекте следующим образом:

let subscription = UserDefaults.$username.sink { username in
     print("New username: \(username)")
 }
 UserDefaults.username = "Test"
 // Prints: New username: Test 

Это замечательно! Это позволяет нам реагировать на любые изменения. Поскольку до этого мы определяли наше свойство статически, теперь этот publisher будет работать во всем нашем приложении. Если вы хотите узнать больше о Combine, обязательно ознакомьтесь с моей статьей Начало работы с фреймворком Combine в Swift.

Определение образцов файлов с помощью обертки свойств

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

Возьмем следующую обертку свойств, в которой мы определяем файл-образец:

@propertyWrapper
struct SampleFile {

    let fileName: String

    var wrappedValue: URL {
        let file = fileName.split(separator: ".").first!
        let fileExtension = fileName.split(separator: ".").last!
        let url = Bundle.main.url(forResource: String(file), withExtension: String(fileExtension))!
        return url
    }

    var projectedValue: String {
        return fileName
    }
}

Мы можем использовать эту обертку для определения файлов-образцов, которые могут понадобиться для отладки или при выполнении тестов:

struct SampleFiles {
    @SampleFile(fileName: "sample-image.png")
    static var image: URL
}

Свойство projectedValue позволяет нам считывать имя файла, используемое в обертке свойства:

print(SampleFiles.image) // Prints: "../resources/sample-image.png"
print(SampleFiles.$image) // Prints: "sample-image.png"

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

Доступ к определенным приватным свойствам

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

В приведенном выше примере мы можем получить доступ и к имени файла, используя префикс подчеркивания. Это позволяет нам получить доступ к приватному свойству filename:

extension SampleFiles {
    static func printKey() {
        print(_image.fileName)
    }
}

Отнеситесь к этому со скептицизмом и посмотрите, не можете ли вы решить свои задачи, используя другие варианты решения.

Другие примеры использования

Обертки свойств используются и в стандартных API Swift. Особенно в SwiftUI вы найдете такие обертки свойств, как @StateObject и @Binding. Все они имеют нечто общее: упрощение доступа к часто используемым шаблонам.

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

@Option(shorthand: "m", documentation: "Minimum value", defaultValue: 0)
var minimum: Int

Или для представлений, макеты которых определены в коде:

final class MyViewController {
    @UsesAutoLayout
    var label = UILabel()
}

Этот последний пример я часто использую в своих проектах для представлений, которые используют автоматическую компоновку и требуют, чтобы translatesAutoresizingMaskIntoConstraints был установлен в false. Подробнее об этом примере вы можете прочитать в моей статье в блоге: Автоматическая компоновка в Swift: Программное написание ограничений.

Заключение

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

Если вы хотите желаете узнать больше советов по Swift, загляните на страницу категории swift. Не стесняйтесь связаться со мной или написать мне в Twitter, если у вас есть дополнительные рекомендации или отзывы. Спасибо!