PropertyWrappers: Lazy, AdaptiveColor gift.svg

16.08.2020
3 мин

Наконец-то добрался до относительно новой возможности языка Swift. И сейчас мы сначала разберемся на практике как можно написать свой собственный bender.svg модификатор lazy с помощью @propertyWrapper. А затем напишем действительно полезный модификатор для поддержки темной темы.

gift.svg Немного о @propertyWrapper

Дословно это обертка над свойством.

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

Модификатор @propertyWrapper позволяет обернуть свойство, и таким образом добавить логику выполнения его методов get и set. А что это будет за логика решать вам.

loupe.png Что такое lazy?

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

Но зачем нам это?

  • Если есть вероятность, что свойство может быть не использовано. Тогда нет смысла создавать его значение сразу;
  • Если создание значения свойства занимает длительное время, то его лучше отложить. Тогда сам объект, где объявлено свойство, будет создан быстро;
  • Значение свойства не может быть создано в момент создания объекта, где это свойство объявлено. К примеру, нужно дождаться готовности одной из зависимостей объекта;
  • Ваш вариант.

Более подробно вы можете почитать в официальной документации языка Swift или в одной из множества статей на эту тему. На этом закончим со скучной непонятной теорией и перейдем к практике.

cook.svg Подготовка

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

final class Game {
lazy var help: HelpProvider = {
print("Taking Fire, Need Assistance")
return HelpProvider()
}()
}
let game = Game() // Output:
game.help // Output: Taking Fire, Need Assistance
game.help // Output:

Отлично, у нас есть ленивое свойство help и его значение будет создано только в момент обращения к нему. Затем оно переиспользуется без пересоздания.

bender.svg То же самое, но без lazy

Теперь откажемся от стандартного модификатора lazy и напишем то же самое без него. Для этого нам потребуется ещё одна переменная для хранения значения свойства. А также придется написать всю ленивую логику.

final class Game {
var help: HelpProvider {
// Сначала проверяем, может значение уже создано
if let storage = helpStorage { return storage }
// Иначе создаем и сохраняем в отдельной переменной
print("Taking Fire, Need Assistance")
let newStorage = HelpProvider()
helpStorage = newStorage
return newStorage
}
private var helpStorage: HelpProvider?
}
let game = Game() // Output:
game.help // Output: Taking Fire, Need Assistance
game.help // Output:

Всё, теперь осталось завернуть это в @propertyWrapper.

gift.svg Заворачиваем в @Lazy

Для начала напишем самую простую версию. Затем оценим её минусы и попробуем улучшить. wrappedValue является обязательным и его тип должен совпадать с типом переменной, к которой применяется @propertyWrapper.

@propertyWrapper final class Lazy {
var wrappedValue: HelpProvider {
if let existStorage = storage { return existStorage }
print("Taking Fire, Need Assistance")
let newStorage = HelpProvider()
self.storage = newStorage
return newStorage
}
private var storage: HelpProvider?
}

Использование очень напоминает применение модификатора lazy. Правда создание значения свойства скрыто в @propertyWrapper.

final class Game {
@Lazy var help: HelpProvider
}
let game = Game() // Output:
game.help // Output: Taking Fire, Need Assistance
game.help // Output:

Какие минусы вы заметили? На самом деле пока это решение выглядит как один сплошной минус.

  • Можно использовать только для HelpProvider - и это ужасно;
  • Создание объекта не может быть изменено без изменения реализации @propertyWrapper.

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

force.svg Убираем минусы

Чтобы избавится от первого минуса, воспользуемся преимуществом Generics. Объявим @propertyWrapper как LazyOnce<T>, где T это тип, который будет подставляться на этапе компиляции в зависимости от контекста. Таким образом можно будет использовать любой тип.

Со вторым минусом всё сложнее. Нам нужно передать closure, в котором будет скрыто создание объекта. Но проблема в том, что тип свойства wrappedValue должен совпадать с типом в методе init.

Здесь на помощь приходит @autoclosure. Это позволяет передавать аргумент, который может быть как функцией () -> T, так и значением c типом T. Во втором случае значение будет завернуто в closure автоматически. Отсюда и название.

@propertyWrapper final class LazyOnce<T> {
var wrappedValue: T {
if let existStorage = storage { return existStorage }
let newStorage = lazyBlock()
self.storage = newStorage
return newStorage
}
private var storage: T?
private let lazyBlock: () -> T
init(wrappedValue: @escaping @autoclosure () -> T) {
self.lazyBlock = wrappedValue
}
}

Возможно вы обратили внимание, что я переименовал Lazy в LazyOnce. Это связанно с тем, что переменная помеченная таким модификатором будет создана лишь один раз. Нельзя присвоить ей другое значение за пределами @propertyWrapper.

На самом деле это баг bug.svg и мы могли бы реализовать и другую версию похожую на lazy. Но давайте оставим у нашей реализации такую особенность.

final class Game {
@LazyOnce var help = HelpProvider()
}
let game = Game()
// Cannot assign to property: 'help' is a get-only property
game.help = HelpProvider()

draw.svg Поддержка Dark Mode

И как обещал в самом начале, давайте рассмотрим более полезный пример применения @propertyWrapper в разработке.

Если вам нужно добавить поддержку темной темы, то вам необходимо описать логику выбора цвета. Для этого как раз подойдет @propertyWrapper. Я объявил аргумент dark опциональным, так как есть универсальные цвета, которые не меняются в зависимости от выбора светлой или темной темы.

@propertyWrapper struct AdaptiveColor {
private let light: UIColor
private let dark: UIColor
init(light: UIColor, dark: UIColor? = nil) {
self.light = light
self.dark = dark ?? light
}
var wrappedValue: UIColor {
UIColor {
// Возвращаем цвет в зависимости от выбранной темы
$0.userInterfaceStyle == .light ? self.light : self.dark
}
}
}

Затем можно создать палитру и объявить в ней необходимые цвета. Используя @AdaptiveColor получаем очень компактное и простое представление.

final class Pallete {
@AdaptiveColor(light: .white, dark: .black)
var backgroundColor: UIColor
@AdaptiveColor(light: .green)
var textColor: UIColor
}

В конце хотелось посоветовать не изобретать @propertyWrapper только ради его применения. Да, это интересная возможность языка Swift. Но также этот подход часто требует использования синглтонов и это чаще плохо, чем хорошо. К примеру, @Injected с использованием синглтон контейнера внутри выглядит очень спорно.

finish.svg Конец

  • В этот раз мы узнали что такое lazy и зачем это нужно;
  • На практике разобрались с @propertyWrapper;
  • Написали почти бесполезную обертку @LazyOnce, которая уже есть — lazy;
  • И даже одну полезную @AdaptiveColor.

На этом пока всё, я надеюсь пост был для вас интересным и полезным.
Подписывайтесь в Twitter и ещё увидимся.