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

Интерфейс хранилища

При работе с UserDefaults разработчика чаще всего интересуют три метода:

func set(_ value: Any?, forKey defaultName: String)
func removeObject(forKey defaultName: String)
func object(forKey defaultName: String) -> Any?

Поскольку этот компонент был представлен широкой публике еще с выходом iOS 2.0, интерфейс с полной уверенностью можно назвать устаревшим.

  1. Для вызова в качестве ключа нужно передавать строку.

  2. Работа происходит с объектом типа Any, а не с конкретным типом.

  3. Работа происходит с ограниченным списком типов. Даже перечисления и OptionSet вызывают crash. (Сразу сделаем оговорку, что Codable объекты в UserDefaults хранить не стоит).

Ниже предлагается решение всех этих недостатков в виде объекта UserDefaultsContainer со следующим интерфейсом:

public init(keyedBy: K.Type, userDefaults: UserDefaults = .standard)
public subscript<T>(key: K) -> T? { get set }
public subscript<T>(key: K) -> T? where T : RawRepresentable { get set }
import Foundation

public final class UserDefaultsContainer<K: CodingKey> {
	private let userDefaults: UserDefaults
	
	public init(keyedBy: K.Type, userDefaults: UserDefaults = .standard) {
		self.userDefaults = userDefaults
	}
	
	public subscript<T>(key: K) -> T? {
		get { getValue(forKey: key.stringValue) as? T }
		set { set(value: newValue, forKey: key.stringValue) }
	}
	
	public subscript<T>(key: K) -> T? where T: RawRepresentable {
		get {
			if let rawValue = getValue(forKey: key.stringValue) as? T.RawValue {
				return T(rawValue: rawValue)
			} else {
				return nil
			}
		}
		set {
			set(value: newValue?.rawValue, forKey: key.stringValue)
		}
	}
	
	private func set(value: Any?, forKey key: String) {
		if let value {
			userDefaults.set(value, forKey: key)
		} else {
			userDefaults.removeObject(forKey: key)
		}
	}
	
	private func getValue(forKey key: String) -> Any? {
		return userDefaults.object(forKey: key)
	}
}

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

Самое время определить требования для работы с хранилищем:

  1. Объект может быть опциональным, значение которого может отсутствовать.

  2. Объект может быть не опциональным, имеющим значение по умолчанию.

  3. Объект может быть перечислением или OptionSet.

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

struct UserSettings {
	
	// MARK: - Types
	
	private enum SettingsKey: CodingKey {
		case age
		case name
		case planet
	}
	
	// MARK: - Properties
	
	private let storage = UserDefaultsContainer(keyedBy: SettingsKey.self)
	
	var age: Int? {
		get { storage[.age] }
		set { storage[.age] = newValue }
	}
	
	var name: String? {
		get { storage[.name] }
		set { storage[.name] = newValue }
	}
	
	var planet: Planet {
		get { storage[.planet] ?? .earth }
		set { storage[.planet] = newValue }
	}
}

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

var settings = UserSettings()
settings.age = 20
settings.planet = .mars

Причем не имеет значение, будет ли это singleton, environmentObject или объект, создаваемый на каждом экране. Выше рассмотрен последний вариант.

Property Wrapper

Выполнив поиск существующих решений на просторах сети интернет были найдены следующие материалы:

  1. Property wrappers in Swift

  2. Create the Perfect UserDefaults Wrapper Using Property Wrapper

  3. A better approach to writing a UserDefaults Property Wrapper

Однако все они имеют несколько недостатков:

  1. Значение не может быть опциональным, вследствие чего требуется указывать значение по-умолчанию

  2. Значение не может быть перечислением или OptionSet

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

Но если обратиться к первоисточнику и изучить AppStorage от Apple, то единственный недостаток, присущий уже к данному решению является минимальная версия iOS 14, а также зависимость от framework SwiftUI.

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

import Foundation

@propertyWrapper
public struct SettingsStorage<Value> {
	private let get: () -> Value
	private let set: (Value) -> Void
	
	public var wrappedValue: Value {
		get { get() }
		set { set(newValue) }
	}
}

public extension SettingsStorage {
	init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == Bool {
		self.init(defaultValue: wrappedValue, key: key, store: store)
	}
	
	init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == Int {
		self.init(defaultValue: wrappedValue, key: key, store: store)
	}
	
	init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == Double {
		self.init(defaultValue: wrappedValue, key: key, store: store)
	}
	
	init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == String {
		self.init(defaultValue: wrappedValue, key: key, store: store)
	}
	
	init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == URL {
		self.init(defaultValue: wrappedValue, key: key, store: store)
	}
	
	init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value == Data {
		self.init(defaultValue: wrappedValue, key: key, store: store)
	}
	
	private init(defaultValue: Value, key: String, store: UserDefaults) {
		get = {
			let value = store.value(forKey: key) as? Value
			return value ?? defaultValue
		}
		
		set = { newValue in
			store.set(newValue, forKey: key)
		}
	}
}

public extension SettingsStorage where Value: ExpressibleByNilLiteral {
	init(_ key: String, store: UserDefaults = .standard) where Value == Bool? {
		self.init(wrappedType: Bool.self, key: key, store: store)
	}
	
	init(_ key: String, store: UserDefaults = .standard) where Value == Int? {
		self.init(wrappedType: Int.self, key: key, store: store)
	}

	init(_ key: String, store: UserDefaults = .standard) where Value == Double? {
		self.init(wrappedType: Double.self, key: key, store: store)
	}

	init(_ key: String, store: UserDefaults = .standard) where Value == String? {
		self.init(wrappedType: String.self, key: key, store: store)
	}

	init(_ key: String, store: UserDefaults = .standard) where Value == URL? {
		self.init(wrappedType: URL.self, key: key, store: store)
	}

	init(_ key: String, store: UserDefaults = .standard) where Value == Data? {
		self.init(wrappedType: Data.self, key: key, store: store)
	}
	
	private init<T>(wrappedType: T.Type, key: String, store: UserDefaults) {
		get = {
			let value = store.value(forKey: key) as? Value
			return value ?? nil
		}
		
		set = { newValue in
			let newValue = newValue as? Optional<T>
			
			if let newValue {
				store.set(newValue, forKey: key)
			} else {
				store.removeObject(forKey: key)
			}
		}
	}
}

public extension SettingsStorage where Value: RawRepresentable {
	init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value.RawValue == String {
		self.init(defaultValue: wrappedValue, key: key, store: store)
	}
	
	init(wrappedValue: Value, _ key: String, store: UserDefaults = .standard) where Value.RawValue == Int {
		self.init(defaultValue: wrappedValue, key: key, store: store)
	}
	
	private init(defaultValue: Value, key: String, store: UserDefaults) {
		get = {
			var value: Value?
			
			if let rawValue = store.value(forKey: key) as? Value.RawValue {
				value = Value(rawValue: rawValue)
			}
			
			return value ?? defaultValue
		}
		
		set = { newValue in
			let value = newValue.rawValue
			store.set(value, forKey: key)
		}
	}
}

public extension SettingsStorage {
	init<R>(_ key: String, store: UserDefaults = .standard) where Value == R?, R: RawRepresentable, R.RawValue == Int {
		self.init(key: key, store: store)
	}
	
	init<R>(_ key: String, store: UserDefaults = .standard) where Value == R?, R: RawRepresentable, R.RawValue == String {
		self.init(key: key, store: store)
	}
	
	private init<R>(key: String, store: UserDefaults) where Value == R?, R: RawRepresentable {
		get = {
			if let rawValue = store.value(forKey: key) as? R.RawValue {
				return R(rawValue: rawValue)
			} else {
				return nil
			}
		}
		 
		set = { newValue in
			let newValue = newValue as Optional<R>
			
			if let newValue {
				store.set(newValue.rawValue, forKey: key)
			} else {
				store.removeObject(forKey: key)
			}
		}
	}
}

Стоит отметить, что использование propertyWrapper намного удобней, так как кода становится существенно меньше, соответственно и читается он легче.

final class UserSettings {
	@SettingsStorage("age")
	var age: Int?
	
	@SettingsStorage("name")
	var name: String?
	
	@SettingsStorage("name")
	var planet: Planet = .earth
}

Заключение

В последнее время, с момента появления SwiftUI, все большей популярностью пользуются @propertyWrapper. Однако использование таких объектов накладывает дополнительные ограничения. К примеру, необходимо поднимать минимальную версию iOS и внедрять дополнительную зависимость от framework SwiftUI. Но это вовсе не означает, что нужно пользоваться тем, что есть, и пример тому был описан в этой статье.

  1. UserDefaults

  2. AppStorage

Комментарии (0)