A simple way to have persisted data in different storages (like Keychain or UserDefaults) that can be easily extended or tested.
We can have different types of storages (UserDefaults, NSCache, Keychain etc) for storing some simple data by key. But each of these stores has its own API.
This is the moment when PersistedStorage
and PersistedValue
can help. Caller side of this protocol knows nothing about the type of storage as the API will be the same. It also solved a problem with persisted type and you can store any type you want without changing API.
PersistedValue
has a number of operators that can simplify usage. Here an example some of them:
let persistedValue = storage.persistedValue(forKey: "key")
// store Codable types
.codable(Model.self)
// provides default value
.default(Model())
// print setters and getter in console
.print()
// reads current value
let model = persistedValue.wrappedValue
// writes a new value
persistedValue.wrappedValue = Model()
Highly recommend wrapping persisted values in separate service. It'll be single source of truth and can be easily injected.
protocol PersistedValuesServiceProtocol {
var userToggle: AnyPersistedValue<Bool> { get }
var userId: AnyPersistedValue<String?> { get }
var token: PersistedValues.Subject<Token?> { get }
}
final class PersistedValuesService: PersistedValuesServiceProtocol {
let userToggle: AnyPersistedValue<Bool>
let userId: AnyPersistedValue<String?>
let token: PersistedValues.Subject<Token?>
init(
keychain: PersistedStorage,
userDefaults: PersistedStorage
) {
self.userToggle = userDefaults.persistedValue(forKey: "user_toogle")
.bool()
.default(false)
.eraseToAnyPersistedValue()
self.userId = keychain.persistedValue(forKey: "user_id")
.string()
.eraseToAnyPersistedValue()
self.token = keychain.persistedValue(forKey: "token")
.codable(Token.self)
.subject(didChage: keychain.didChange(forKey: "token"))
}
}
struct Token: Codable {
let access: String
}
struct SomeViewModel {
private let persistedValues: PersistedValuesServiceProtocol
init(persistedValues: PersistedValuesServiceProtocol) {
self.persistedValues = persistedValues
}
func logOut() {
self.persistedValues.userId.wrappedValue = nil
}
}
The AnyPersistedValue
provides a type-erasing wrapper for the PersistedValue
protocol, that helps to avoid introducing generics in your code in some cases like this:
class SomeViewModel<PV> where PV: PersistedValue, Value == String? {
private let userId: PV
init(userId: PV) {
self.userId = userId
}
func logOut() {
self.userId.wrappedValue = nil
}
}
This generic SomeViewModel<PersistedValue>
will be hard to have as a property and you don't get benefits by this generic model. So it can be refactored with AnyPersistedValue
in this way:
class SomeViewModel {
private let userId: AnyPersistedValue<String?>
init<PV>(userId: PV) where PV: PersistedValue, Value == String? {
self.userId = userId.eraseToAnyPersistedValue()
}
func logOut() {
self.userId.wrappedValue = nil
}
}
For testing proposals you can use MockPersistedStorage
and MockPersistedValue
from PersistedValueTestingUtilities
target.
import PersistedValueTestingUtilities
final class PersistedValuesServiceTests: XCTestCase {
private var keychain: MockPersistedStorage!
private var userDefaults: MockPersistedStorage!
private var service: PersistedValuesService!
override func setUp() {
self.keychain = .init()
self.userDefaults = .init()
self.service = .init(keychain: keychain, userDefaults: userDefaults)
}
override func tearDown() { ... }
func testUserId() {
self.serive.userId.wrappedValue = "ID"
XCTAssertEqual(self.storage.sets["user_id"], 1)
}
}
- Xcode 12.4 and higher
- Swift 5.3 and higher
You can add PersistedValue to an Xcode project by adding it as a package dependency.
- File › Add Packages…
- Enter "https://github.com/DimaKoroliov/PersistedValue" into the package URL text field