Modernish UserDefaults Wrapper
January 8, 2024
Chronometer
TL;DR

A somewhat modernish UserDefaults wrapper, tailored to specific project needs, is presented. It's a blend of type safety, modularity, and a bit of creative procrastination, all aimed at making side projects more fun and manageable.

I was working on my side project, which had already missed like three deadlines, and I finally realized what the problem was that stopped me from shipping it. It's the fact that I don't have my own custom API wrapper which would allow me to save settings inside UserDefaults.

Naturally, every time I run the app, I need to go deep into certain screens, and the fact that many of the settings are not saved results in more clicks. All kidding aside regarding missing deadlines, which in reality are mostly due to other life factors, I was truly annoyed by the fact that I was performing certain steps over and over again to debug specific states.

While I could use the default (haha) UserDefaults API with its new and shiny partner AppStorage, I didn't want to go this route since those APIs don't match my requirements (plus, naturally, I want to procrastinate more on implementation details rather than working on my side project).

I remember reading a really nice blog post by Antoine van der Lee about AppStorage and its replacement, so I decided to give it a shot with my own requirements in mind.

The requirements are as follows:

  1. Compiler guidance and help: compiler, please guide me when I add new preferences or try to use old ones.
  2. Configuration type definition: I want to define all my preferences in one place where I can see all keys and so on. Additionally, I want to set default values and specify the location where each setting should be saved.
  3. Modern: I want to read preferences both with conventional style methods and with a property wrapper style API.
  4. Reactive: I know it's not considered so cool anymore, but I want the ability to reactively track preference changes using Combine.
  5. Modular: this is probably one of the most important requirements, as my whole app is divided into modules. I need to be able to read preferences from other modules without exposing implementation.

P.S. Since most of the code is pulled from a real app, please be aware of prefixes related to modules and so on. Personally, I just find reading real code a bit more interesting than a toy example.

Heading linkInterface

Let's start by addressing the modular aspect by defining the interface. This interface will be exposed by the InvoicyStorageInterface module to all other modules that wish to access the preferences.

import InvoicyDashboardFeatureInterface

public protocol InvoicyPreferences {
    var invoiceEditorWindowFrame: InvoicyPreference<String> { get }
    var selectedDashboardItem: InvoicyPreference<DashboardItem> { get }
}

public struct InvoicyPreference<T> {
    public let key: String
    public let defaultValue: T
    public let location: InvoicyPersistenceLocation

    public init(
        key: String,
        defaultValue: T,
        location: InvoicyPersistenceLocation
    ) {
        self.key = key
        self.defaultValue = defaultValue
        self.location = location
    }
}

The idea here is simple. I define how each preference should look so consumers know what they can expect. Now, let's define the repository interface for conventional types of access:

import Foundation
import Combine

public protocol PreferencesRepositoryInterface {
    // MARK: - Default access
    func get<Value: Decodable>(
        using keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>
    ) -> Value
    func set<Value: Encodable>(
        value: Value,
        for keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>
    )
    
    // MARK: - Reactive publishers
    func changesPublisher<Value>(
        for keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>
    ) -> AnyPublisher<Void, Never>
    
    func valuesPublisher<Value: Decodable>(
        for keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>,
        includingInitial: Bool
    ) -> AnyPublisher<Value, Never>
}

Again, it's nothing too complicated: an interface that allows consumers to set and get preferences in conventional ways, in addition to a few reactive-type publishers.

The only thing left for the interface is a property wrapper that could be used in other modules. While the code is not very complex, I feel it's more comprehensible to see it after understanding the repository implementation.

Heading linkImplementation

So now that we know what our API looks like, let's dive into the implementation. First, let's look at the configuration file that defines all preferences:

import InvoicyDashboardFeatureInterface

fileprivate struct Preferences: InvoicyPreferences {    
    let invoiceEditorWindowFrame: InvoicyPreference<String> = .init(
        key: "invoiceEditorWindowFrame",
        defaultValue: "",
        location: .app
    )
    
    let selectedDashboardItem: InvoicyPreference<DashboardItem> = .init(
        key: "selectedDashboardItem",
        defaultValue: .templates,
        location: .app
    )
}

Again, it's pretty straightforward. I didn't have many options here, as the compiler was strict and guided me on what I must define. What I like about this is that everything is in a single place and type-safe.

Now let's look at the actual implementation for the repository:

import Foundation
import Combine
import InvoicyStorageInterface

final class PreferencesRepository: PreferencesRepositoryInterface {
    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()
    
    private let appStorage: UserDefaults
    private let preferences = Preferences()
    private var changes = PassthroughSubject<AnyKeyPath, Never>()
    
    init(appStorage: UserDefaults) {
        self.appStorage = appStorage
    }
    
    // MARK: - Default access
    func get<Value: Decodable>(
        using keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>
    ) -> Value {
        let preference = preferences[keyPath: keyPath]
        let storage = getStorage(for: preference.location)
        
        guard let object = storage.object(forKey: preference.key) else {
            return preference.defaultValue
        }
        
        if let data = object as? Data {
            do {
                return try decoder.decode(Value.self, from: data)
            } catch {
                assertionFailure("Failed decoding preference")
                return preference.defaultValue
            }
        }
        
        return object as? Value ?? preference.defaultValue
    }
    
    func set<Value: Encodable>(
        value: Value,
        for keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>
    ) {
        let preference = preferences[keyPath: keyPath]
        let storage = getStorage(for: preference.location)
        let isPlistCompatible = switch value {
        case is NSData, is NSString, is NSNumber, is NSDate, is Bool:
            // We could extend this to Array and Dictionary, but purposely not doing that
            true
        default: 
            false
        }
        
        if isPlistCompatible {
            storage.set(value, forKey: preference.key)
        } else {
            do {
                let data = try encoder.encode(value)
                storage.set(data, forKey: preference.key)
            } catch {
                return assertionFailure("Failed encoding data for \(value)")
            }
        }
        
        changes.send(keyPath)
    }
    
    // MARK: - Reactive publishers
    func valuesPublisher<T: Decodable>(
        for keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<T>>,
        includingInitial: Bool
    ) -> AnyPublisher<T, Never> {
        let changesPublisher = changes
            .filter { $0 == keyPath }
            .compactMap { [weak self] _ in self?.get(using: keyPath) }
            .eraseToAnyPublisher()
        
        return includingInitial
            ? changesPublisher.prepend(get(using: keyPath)).eraseToAnyPublisher()
            : changesPublisher
    }
    
    func changesPublisher<T>(
        for keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<T>>
    ) -> AnyPublisher<Void, Never> {
        changes
            .filter { $0 == keyPath }
            .map { _ in () }
            .eraseToAnyPublisher()
    }
    
    private func getStorage(for location: InvoicyPersistenceLocation) -> UserDefaults {
        switch location {
        case .app:
            appStorage
        }
    }
}

The implementation here is pretty clean, apart from a few hoops we have to jump through to properly support non-primitive types. We could clean this up by treating primitive types as Codable, but I don't like that approach as it seems quite inefficient and a bit dirty.

The trade-offs we made here are:

  1. Special handling for Data.
  2. Special handling for primitive types.

While in a completely perfect world, I probably wouldn't make those trade-offs, I feel like this is the point where I draw the line between trying to achieve something perfectly clean and something that works correctly in my real-life project.

Heading linkSwiftUI

The only remaining thing that is left is writing a property wrapper that can be used within the SwiftUI environment. Again, it's nothing fancy and basically wraps our PreferencesRepository in a nice way:

import Combine
import SwiftUI
import InvoicyModularInterface

@propertyWrapper
public struct Preference<Value: Codable>: DynamicProperty {
    private let keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>
    private let preferencesRepository: PreferencesRepositoryInterface
    @ObservedObject private var updater: Updater
    
    public init(
        for keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>,
        in preferencesRepository: PreferencesRepositoryInterface
    ) {
        self.keyPath = keyPath
        self.preferencesRepository = preferencesRepository
        self.updater = Updater(
            publisher: preferencesRepository.changesPublisher(for: keyPath)
        )
    }
    
    public var wrappedValue: Value {
        get {
            preferencesRepository.get(using: keyPath)
        }
        nonmutating set {
            preferencesRepository.set(value: newValue, for: keyPath)
        }
    }
    
    public var projectedValue: Binding<Value> {
        Binding(
            get: { wrappedValue },
            set: { wrappedValue = $0 }
        )
    }
    
    private final class Updater: ObservableObject {
        private var subscription: AnyCancellable?
        
        init(publisher: AnyPublisher<Void, Never>) {
            subscription = publisher.sink { [weak self] in
                self?.objectWillChange.send()
            }
        }
    }
}

Again, it's pretty standard Swift code, apart from the fact that the property wrapper conforms to DynamicProperty. This is to inform SwiftUI that the view needs to be re-evaluated when the ObservedObject publishes changes.

In addition to the code above, I have an additional convenience initializer that uses my dependency injection system. This allows for nicer usage:

extension Preference {
    public init(for keyPath: KeyPath<InvoicyPreferences, InvoicyPreference<Value>>) {
        self.init(
            for: keyPath,
            in: DI.resolve(PreferencesRepositoryInterface.self)
        )
    }
}

Heading linkUsage

Now that we have both the interface and implementation, usage is pretty straightforward. In the case of using the property wrapper, we simply define it as follows:

import SwiftUI
import InvoicyStorageInterface
import InvoicyDashboardFeatureInterface

struct DashboardView: View {
    @Preference(for: \.selectedDashboardItem) var selectedItem
    
    var body: some View {
        HStack(spacing: 0) {
            DashboardSidebarView(selectedItem: $selectedItem)
            // more secret code
        }
    }
}

The compiler suggests available options when we type the key path, and the value of the type is DashboardItem, which is expected since we defined it this way with the default value.

In cases where we want to access preferences in a more conventional way (which I do in ViewModel and other places where I spend my time), you would write:

let dashboardItem = preferencesRepository.get(using: \.selectedDashboardItem)

Again, it's pretty clean and concise.

No matter how uncool this makes me, I still like to write reactive streams sometimes, and using the reactive API is again very simple:

subscription = preferencesRepository.valuesPublisher(for: \.selectedDashboardItem, includingInitial: true)
    .sink { dashboardItem in
        print("WOW, you changed the value to: \(dashboardItem)")
    }

You can control includingInitial, which becomes quite useful for streams that are related to UI.

Heading linkConclusion

That's pretty much it. Is it worth it? For me, the answer is yes. This is because:

  1. It perfectly matches all the requirements I have for my project
  2. I understand every single bit of the code
  3. I'm free to change and morph it in any way I like
  4. I enjoy creating building blocks that are perfectly shaped for my tiny hands
  5. It gave me a chance to procrastinate even more, instead of just working on features for my side project

P.S. If anything I wrote doesn't make sense, I suggest reading Antoine van der Lee's blog post. It goes much deeper into the explanation part.

© Edvinas Byla, 2024

(my lovely corner on the internetâ„¢)