Clément Cardonnel
Clément Cardonnel

Reputation: 5277

@AppStorage fails to sync when used in multiple ObservableObject

I've been using @AppStorage for a long time, but it came to my attention that there seems to be an issue with how @AppStorage properties work with ObservableObject, or rather with multiple ObservableObject.

Basically, when using a shared @AppStorage property in two or more ObservableObjects, if the property is updated in one of them, the other doesn't receive the information and is effectively desynced with the actual value. Updating the desynced property triggers an update on the Views, but doesn't sync the value with the other ObservableObjects.

I recorded a video of this behavior: https://youtube.com/shorts/kbFM2W0IvRo

Here's my sample project:

struct ContentView: View {
    var body: some View {
        TabView {
            TabAView()
                .tabItem {
                    Label {
                        Text("From View")
                    } icon: {
                        Image(systemName: "square")
                    }
                }

            TabAView()
                .tabItem {
                    Label {
                        Text("From View 2")
                    } icon: {
                        Image(systemName: "circle")
                    }
                }

            TabBView()
                .tabItem {
                    Label {
                        Text("From ObservableObj")
                    } icon: {
                        Image(systemName: "triangle")
                    }
                }

            TabBView()
                .tabItem {
                    Label {
                        Text("From ObservableObj 2")
                    } icon: {
                        Image(systemName: "fireworks")
                    }
                }
        }
    }
}

struct TabAView: View {

    @AppStorage("test") private var increment = 0

    var body: some View {
        VStack {
            Text("\(increment)")

            Button {
                increment += 1
            } label: {
                Text("Increment from View")
            }
        }
    }
}

final class TabBModel: ObservableObject {
    @AppStorage("test") var increment = 0
}

struct TabBView: View {

    @StateObject private var model = TabBModel()

    var body: some View {
        VStack {
            Text("\(model.increment)")

            Button {
                model.increment += 1
            } label: {
                Text("Increment from ObservableObject")
            }
        }
    }
}

I filed a feedback to Apple over this issue: FB13250915.

This issue seems to have started from the moment I started building my projects with Xcode 15. I've tried building on 15.1 Beta 1, and also 14.3.1 with the same results.

I have two questions:

  1. Am I doing anything wrong? I'm at the point of wondering if it's somehow a bad practice to use @AppStorage properties in more than one ObservableObject. Or have I missed a glaring issue in the code shown above?
  2. Is anyone affected by this issue as well? What are you doing as of consequence? I'm considering not using @AppStorage anymore, but I'm not sure what the best alternative would be.

Upvotes: 0

Views: 545

Answers (1)

Clément Cardonnel
Clément Cardonnel

Reputation: 5277

So, I don't have a direct answer to the problem I described, but here's the workaround I found:

Storage Controller

This ObservableObject is my replacement to @AppStorage. It's not as simple, but it works. You add it as a @StateObject to your app and you can pass it down via EnvironmentObject.

@StateObject private var storageController = StorageController.shared

You can also use it as a Singleton, and this is the way to use it in other ObservableObjects.

Here it is:

final class StorageController: ObservableObject {

    // MARK: - Properties

    private var tasks = [String: Task<Void,Error>]()

    @Published var codableItem: ChargingSession? = decodeFromUserDefaults(type: SomeCodable.self, key: "codableItem") {
        didSet {
            saveDataToUserDefaults(codableItem, key: "codableItem")
        }
    }

    @Published var booleanItem: Bool = UserDefaults.standard.bool(forKey: "booleanItem") {
        didSet {
            saveToUserDefaults(booleanItem, key: "booleanItem")
        }
    }

    // MARK: - Singleton

    static let shared = StorageController()
    private init() { }

    // MARK: - Helpers

    /// Save a Codable type to UserDefaults.
    ///
    /// The saving will be performed in a background queue.
    private func saveDataToUserDefaults<Value: Codable>(_ value: Value, key: String) {
        tasks[key]?.cancel()
        tasks[key] = Task(priority: .background) {
            try Task.checkCancellation()
            let data = try JSONEncoder().encode(value)
            UserDefaults.standard.set(data, forKey: key)
        }
    }

    private func saveToUserDefaults(_ value: Any?, key: String) {
        tasks[key]?.cancel()
        tasks[key] = Task(priority: .background) {
            try Task.checkCancellation()
            UserDefaults.standard.set(value, forKey: key)
        }
    }

}

/// Decode a Codable conforming type from UserDefaults.
func decodeFromUserDefaults<DecodedType: Codable>(type: DecodedType.Type, key: String) -> DecodedType? {
    guard let data = UserDefaults.standard.data(forKey: UserDefaultsKey.chargingSession()),
          let decoded = try? JSONDecoder.extended8601Decoder.decode(type, from: data) else {
        return nil
    }
    return decoded
}

How to use it

In your Views, you can still rely on @AppStorage, no need to bother with the following.

But in your ObservableObjects, you can do this:

Step 1: Declare a property mirroring your storage value

@Published private var booleanItem: Bool = StorageController.shared.booleanItem {
    willSet {
        StorageController.shared.booleanItem = newValue
    }
}

Here, we do three things:

  1. We set the initial value of the property with the latest value of our StorageController
  2. We use willSet to update the StorageController.
  3. We mark it as @Published whether this is private or not. This will come in handy in step 2.

Step 2: Link the property with Combine in init

StorageController.shared.$booleanItem
    .receive(on: RunLoop.main)
    .filter { [weak self] newValue in
        newValue != self?.booleanItem
    }
    .assign(to: &$notificationsEnabled)

This code ensures our property always has the latest value. The filter part is crucial because it prevents an infinite loop that the willSet above would trigger.

Discussion and limits

  • This is obviously much more work than @AppStorage was.
  • However, Codable support is way easier, we don't have to parse anything to RawRepresentable
  • It works well for iOS 16 (and below, I suppose?)
  • I don't think this is thread-safe. I wouldn't recommend you using it as it is if you believe that could be an issue.
  • Storage reads could be heavy and could slow the launch of the app since the controller basically reads everything as soon as it is instantiated.
  • However, storage writes should be efficient thanks to background Tasks and Task cancellation.

Upvotes: 1

Related Questions