DEV Community

Adam Stragner
Adam Stragner

Posted on

Recreating @AppStorage, but for Codable types

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) }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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()
    )
}
Enter fullscreen mode Exit fullscreen mode

Now, there's no need to write @CodableStorage("key", defaultValue: nil) each time; instead, we can simply use @CodableStorage("key").

First run

screenshot

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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
        ))
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

Voilà, you're amazing! Let's conduct the next experiment and try running our code again.

screenshot

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.

meme

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
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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 (as object: parameter) in the NotificationCenter.subscribe() method, we wouldn't receive any notifications at all. :(

hopper

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

screenshot

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
_mkowalski profile image
Michał Kowalski

Seems very convenient to use but I prefer to store Codable objects directly in file on disk.