Adam Stragner
Posted on December 4, 2023
Hey, everyone! A few days ago, my buddy, who recently delved into the world of SwiftUI, messaged me: 'Seen @AppStorage? Pretty neat stuff, but it doesn't support Codable types, gotta write some with RawRepresentable.' I was relaxing on the throne when my brain reacted to SwiftUI and pooped an idea: 'Let's create our own implementation but let's jazz it up with Codable and add some UIKit support.' Brilliant plan, right? So, I strolled back to my desk.
No hard feelings, folks! I'm just a dinosaur and a fan of UIKit. So, let's roll!
Intro
What's @AppStorage
and how does it work? From ancient times, there's been this thing called NSUserDefaults that can persistently store files. So, @AppStorage is a sleek and reactive wrapper around this well-known API. With a simple code snippet like @AppStorage("key") var property: Int?
, instead of the bulky let value = (UserDefaults.standard.value(forKey: "key") as? Int) ?? 0
, this propertyWrapper
lets you modify UserDefaults
by key. But the most convenient part? It instantly captures any changes, triggering a rebuild of our View
.
So, to recreate a similar functionality, we'll need knowledge about:
- UserDefaults (NSUserDefaults)
- Combine
- Objective-C (ha-ha, yes)
Writing the propertyWrapper
Let's kick things off with the simplest implementation that fetches and stores values within UserDefaults
.
@propertyWrapper
public struct CodableStroage<ValueType> {
public init(
_ valueKey: String,
defaultValue: ValueType,
userDefaults: UserDefaults = .standard
) {
self.valueKey = valueKey
self.defaultValue = defaultValue
self.userDefaults = userDefaults
}
public var defaultValue: ValueType
public var valueKey: String
public var userDefaults: UserDefaults
public var wrappedValue: ValueType {
get { userDefaults.value(forKey: valueKey) as? ValueType ?? defaultValue }
set { userDefaults.set(newValue, forKey: valueKey) }
}
}
Now, to ensure our SwiftUI View
can receive updates and rebuild, let's leverage the DynamicProperty
protocol and complete its implementation for our propertyWrapper
. According to the documentation, the update()
method in this protocol has a default implementation, so we won't have to write a lot.
extension CodableStorage: DynamicProperty {}
Next, let's make some updates to the CodableStorage
implementation to get that SwiftUI magic going. To achieve this, we'll use the standard @ObservedObject
and ObservableObject
.
First of all, we need to create a class responsible for data storage to ensure immutability in our structure. And since we want to support Codable
types for storage, let's immediately incorporate JSONEncoder & JSONDecoder
. Also, let's add an Equatable
constraint to our ValueType
to avoid unnecessarily triggering view hierarchy updates in the future.
internal final class SubscriptionStorage<ValueType>: ObservableObject
where
ValueType: Codable,
ValueType: Equatable
{
internal init(
encoder: JSONEncoder,
decoder: JSONDecoder,
valueKey: String,
defaultValue: ValueType,
userDefaults: UserDefaults
) {
self.userDefaults = userDefaults
self.valueKey = valueKey
self.defaultValue = defaultValue
// 1. Retrieving the initial value from UserDefaults
if let data = userDefaults.data(forKey: valueKey),
let decodedValue = try? decoder.decode(ValueType.self, from: data)
{
self.currentValue = decodedValue
} else {
self.currentValue = nil
}
self.encoder = encoder
self.decoder = decoder
}
}
Add an update
function to this class where the main magic will happen with ObservableObject
.
internal func update(_ currentValue: ValueType) {
guard let encodedValue = try? encoder.encode(currentValue)
else {
return
}
// 1. Checking if the value is different from the previous one
if value != encodedValue {
// 2. Notifying all observers about pending changes
objectWillChange.send()
}
// 3. Updating the value in persistent storage
userDefaults.setValue(encodedValue, forKey: valueKey)
// 4. Updating our in-memory storage
self.currentValue = currentValue
}
Now, let's update our main propertyWrapper
and make use of the SubscriptionStorage
we just created.
@propertyWrapper
public struct CodableStroage<ValueType>
where
// 1. Adding constraints on types to allow our SubscriptionStorage to work with them
ValueType: Codable,
ValueType: Equatable
{
// Including necessary parameters in .init
public init(
_ valueKey: String,
defaultValue: ValueType,
userDefaults: UserDefaults? = nil,
decoder: JSONDecoder? = nil,
encoder: JSONEncoder? = nil
) {
self.init(
valueKey,
defaultValue: defaultValue,
userDefaults: userDefaults ?? .standart,
decoder: decoder ?? JSONDecoder(),
encoder: encoder ?? JSONEncoder()
)
}
public var wrappedValue: ValueType {
get { storage.value }
// 2. Highlighting that our structure is now immutable even when values change
nonmutating set { storage.update(newValue) }
}
public var defaultValue: ValueType { storage.defaultValue }
public var valueKey: String { storage.valueKey }
// 3. Attaching `@ObservedObject` to our `ObservableObject`
@ObservedObject
private var storage: SubscriptionStorage<ValueType>
}
Also, we can add sugar to the CodableStorage
initializer to simplify the code for optional data types.
public init(
_ valueKey: String,
defaultValue: ValueType = nil,
userDefaults: UserDefaults? = nil,
decoder: JSONDecoder? = nil,
encoder: JSONEncoder? = nil
) where ValueType: ExpressibleByNilLiteral {
self.init(
valueKey,
defaultValue: defaultValue,
userDefaults: userDefaults ?? .standard,
decoder: decoder ?? JSONDecoder(),
encoder: encoder ?? JSONEncoder()
)
}
Now, there's no need to write @CodableStorage("key", defaultValue: nil)
each time; instead, we can simply use @CodableStorage("key")
.
First run
Our code is functioning, but as seen in the video, it's not quite as expected. The issue lies in the fact that two different CodableStorage
instances have separate stores and aren't synchronized at all. Let's fix that!
Synchronization
To synchronize different instances of our CodableStorage
, let's utilize the good old NSUserDefaultsDidChangeNotification API. We'll write our own Publisher
and Subscription
, which will respond to changes from UserDefaults
and send this information to their subscribers.
The Subscription object
internal final class UserDefaultsSubscription<ValueType, SubscriberType>:
Subscription
where
ValueType: Equatable,
SubscriberType: Subscriber,
SubscriberType.Input == ValueType,
SubscriberType.Failure == Never
{
internal func request(_ demand: Subscribers.Demand) {
guard demand > 0
else {
return
}
// 1. Immediately send new data to the subscriber
userDefaultsDidChange(userDefaults)
// 2. Subscribe to updates from all UserDefaults and wait for changes
subscription = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: OperationQueue(),
using: { [weak self] notification in
guard let userDefaults = notification.object as? UserDefaults
else {
return
}
// 3. Process updates
self?.userDefaultsDidChange(userDefaults)
}
)
}
private func userDefaultsDidChange(_ userDefaults: UserDefaults) {
// 4. Verify that the subscription exists and UserDefaults matches the one we created with
guard let subscriber, self.userDefaults == userDefaults
else {
return
}
let updatedValue = userDefaults.object(forKey: valueKey)
// 5. Check if the value has changed
guard let updatedValue = updatedValue as? ValueType, currentValue != updatedValue
else {
return
}
currentValue = updatedValue
// 6. Send
let _ = subscriber.receive(updatedValue)
}
}
The Publisher object
internal final class UserDefaultsPublisher<ValueType>:
Publisher
where
ValueType: Equatable
{
internal typealias Output = ValueType
internal typealias Failure = Never
internal func receive<S>(
subscriber: S
) where S: Subscriber, S.Failure == Failure, S.Input == Output {
subscriber.receive(subscription: UserDefaultsSubscription(
subscriber: subscriber,
userDefaults: userDefaults,
valueKey: valueKey,
currentValue: initialValue
))
}
}
Now, we'll need to update the SubscriptionStorage
to retrieve data directly from UserDefaults using Combine
and UserDefaultsPublisher
.
internal final class SubscriptionStorage<ValueType>: ObservableObject
where
ValueType: Codable,
ValueType: Equatable
{
private var cancellables: Set<AnyCancellable> = .init()
internal init(/* ... */) {
// 1. Create UserDefaultsPublisher and subscribe to changes in our UserDefaults
let publisher = UserDefaultsPublisher(
initialValue: try? encoder.encode(defaultValue),
userDefaults: userDefaults,
valueKey: valueKey.rawValue
).eraseToAnyPublisher()
publisher
.receive(on: DispatchQueue.main) // We can only dispatch updates to the view hierarchy from the main thread
.sink(receiveValue: { [weak self] value in
self?.receive(value)
})
.store(in: &cancellables)
}
internal func update(_ currentValue: ValueType) {
guard let encodedValue = try? encoder.encode(currentValue)
else {
return
}
// 3. Now, as we'll directly receive any changes from UserDefaults, here we just need to update the value on disk
userDefaults.setValue(encodedValue, forKey: valueKey)
}
private func receive(_ data: Data) {
guard let decodedValue = try? decoder.decode(ValueType.self, from: data)
else {
return
}
// 4. Verify that the value is updated and dispatch it to our subscribers
if value != decodedValue {
objectWillChange.send()
}
currentValue = decodedValue
}
}
Voilà, you're amazing! Let's conduct the next experiment and try running our code again.
And it's not working again. How come? Where did we make a mistake? Well, time to debug. And for that, we'll cover ourselves with print()
all over the program, haha.
Fortunately, debugging didn't take long, and the problem was quickly localized — it within UserDefaultsSubscription
. Who can guess where the trouble lies? Hint:
private func userDefaultsDidChange(_ userDefaults: UserDefaults) {
guard let subscriber, self.userDefaults == userDefaults
else {
return
}
// ...
}
Correct, you can't simply compare UserDefaults
like that. Even instances with the same suiteName
are not equal! So, it seems we need to figure out a way to identify UserDefaults
differently. Let's utilize Hopper Disassembler for this purpose.
If we were using the
UserDefaults
object passed in our initializer as the argument (asobject:
parameter) in theNotificationCenter.subscribe()
method, we wouldn't receive any notifications at all. :(
Here we discover that NSUserDefaults
actually has a certain _identifier_
. Well, let's try to retrieve it and make use of it. Remember when I mentioned Objective-C earlier? Well, the time has come. Now we'll be fetching the value of private properties, and for this, we'll need some runtime magic. Let's write a small extension for UserDefaults
!
import ObjectiveC
internal extension UserDefaults {
var identifier: String {
guard let ivar = class_getInstanceVariable(UserDefaults.self, "_identifier_"),
let value = object_getIvar(self, ivar) as? NSString
else {
#if DEBUG
fatalError("UserDefaults API has been changed")
#else
return UUID().uuidString
#endif
}
return value as String
}
}
Using the class_getInstanceVariable
function, we retrieve a reference to the ivar, then extract its value with object_getIvar
. Just to be safe, we'll make the app crash (only in DEBUG
mode) if the API has changed (although that's highly unlikely). Now, let's modify the code a bit and try running it again.
private func userDefaultsDidChange(_ userDefaults: UserDefaults) {
guard let subscriber, self.userDefaults.identifier == userDefaults.identifier
else {
return
}
// ...
}
Excuse me, sir! Do we love UIKit?
There won't be any rocket science here, just adding one method to SubscriptionStorage
and CodableStorage
.
extension SubscriptionStorage {
internal func eraseToAnyPublisher() -> AnyPublisher<ValueType, Never> {
let decoder = decoder
let publisher = UserDefaultsPublisher(
initialValue: encoder._encode(defaultValue),
userDefaults: userDefaults,
valueKey: valueKey.rawValue
)
return publisher.compactMap({ data in
decoder._decode(T.self, from: data)
}).eraseToAnyPublisher()
}
}
extension CodableStroage {
public var projectedValue: CodableStroage<ValueType> { self }
func eraseToAnyPublisher() -> AnyPublisher<ValueType, Never> {
storage.eraseToAnyPublisher()
}
}
Here, we've added projectedValue
, which allows us to access the propertyWrapper
by using $
before the wrapped variable's name instead. Now, if we're writing code with UIKit, we can do something like this:
class Subview: UIView {
init() {
super.init(frame: .zero)
$value.eraseToAnyPublisher().sink(receiveValue: { value in
print(value)
}).store(in: &cancellables)
}
@CodableStroage("key", defaultValue: 0)
private var value: Int
private var cancellables: Set<AnyCancellable> = .init()
}
It seems there might be a small bug
Because CodableStorage
can accept Optional as generic ValueType
, there's an issue when we save our data to disk. We pass our value to JSONEncoder, and it wraps nil
, turning it into null
. Therefore, there might be a small problem during the decoding stage. Let's fix that! To determine that the value received at runtime as ValueType
is actually nil
. But we can't simply add a check like value == nil
because the compiler will complain at us, ha-ha.
// 1. Let's write our protocol called `AnyOptional`, which will allow us to determine at runtime whether it's nil or not.
private protocol AnyOptional {
var isEmpty: Bool { get }
}
// 2. We'll make sure all Optionals are now of type `AnyOptional`.
extension Optional: AnyOptional {
var isEmpty: Bool { self == nil }
}
// 3. We'll write a simple function.
internal func isnil(_ instance: Any) -> Bool {
guard let _optional = instance as? AnyOptional
else {
return false
}
return _optional.isEmpty
}
And now, once again (for the last time, I promise!), let's update our SubscriptionStorage
.
internal func update(_ currentValue: ValueType) {
guard !isnil(currentValue)
else {
userDefaults.removeObject(forKey: valueKey)
return
}
guard let encodedValue = try? encoder.encode(currentValue)
else {
return
}
userDefaults.setValue(encodedValue, forKey: valueKey)
}
It looks like we're all set! Now you can use CodableStorage
to store Codable
types inside UserDefaults
.
Thanks for sticking around until the end! You can check out the code for the finished library here - 0xstragner/CodableStorage.
Posted on December 4, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.