admin管理员组

文章数量:1244397

I've learned how to read/write application data from/to a file in my iOS application. I've also learned how to put data in the @Environment for use my various view objects. I just can't seem to figure out how to link those together. I have some app data (e.g. some configuration values):

@Observable class Config: Codable {
    var someConfigVal: Bool = false
}

And have defined a "store" to read and write this info.

@Observable class ConfigStore {
    var configuration: Config = Config()
    
    private static func fileURL() throws -> URL {
        try FileManager.default.url(
            for: .documentDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: false
        )
        .appendingPathComponent("config.data")
    }
    
    func load() async throws {
        let task = Task<Config, Error> {
            let fileURL = try Self.fileURL()

            guard let data = try? Data(contentsOf: fileURL) else {
                return Config()
            }
            let storedConfig = try JSONDecoder().decode(Config.self, from: data)
            
            print("loaded", storedConfig.someConfigVal)
            return storedConfig
        }
        
        let appConfig = try await task.value

        self.configuration = appConfig
        print("load", self.configuration.someConfigVal)
    }
    
    func save(appConfig: Config) async throws {
        print("Some val on save", appConfig.someConfigVal)
        let task = Task {
            let data = try JSONEncoder().encode(appConfig)
            let outfile = try Self.fileURL()
            
            try data.write(to: outfile)
        }
        
        _ = try await task.value
    }
}

extension EnvironmentValues {
    var configurationStore: ConfigStore {
        get { self[ConfigStoreKey.self] }
        set { self[ConfigStoreKey.self] = newValue }
    }
}

private struct ConfigStoreKey: EnvironmentKey {
    static let defaultValue: ConfigStore = ConfigStore()
}

I'm currently creating this store as a @State object in the main application object along with the async code to do the file storage management.

@main
struct StoreTestApp: App {
    @State private var configStore = ConfigStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView(configStore: configStore) {
                Task {
                    print("In save task", configStore.configuration.someConfigVal)
                    do {
                        try await configStore.save(appConfig: configStore.configuration)
                    } catch {
                        fatalError(error.localizedDescription)
                    }
                }
            }
                .task {
                    do {
                        try await configStore.load()
                    } catch {
                        fatalError(error.localizedDescription)
                    }
                }
//            .environment(configStore)
        }
    }
}

For the UI, I've created a simple tabbed view: 1 to show the value and 1 to edit it.

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase
//    @Environment(\.configurationStore) private var configStore

    var configStore: ConfigStore
    
    let saveAction: () -> Void
    
    var body: some View {
        TabView {
            ShowView()
                .tabItem {
                    Label("Show", systemImage: "magnifyingglass")
                }
            ParentEditView()
                .tabItem {
                    Label("Edit", systemImage: "pencil")
                }
        }
        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .inactive {
                print("Scene is inactive", configStore.configuration.someConfigVal)
                saveAction()
            }
        }
        .environment(configStore)
    }
}

struct ShowView: View {
    @Environment(\.configurationStore) private var configStore

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(String(configStore.configuration.someConfigVal))
        }
        .padding()
    }
}

struct ParentEditView: View {
    @Environment(\.configurationStore) private var configStore

    var body: some View {
        EditView(configStore: configStore)
    }
}

struct EditView: View {
    @Bindable var configStore: ConfigStore
    
    var body: some View {
        Toggle("Some val", isOn: $configStore.configuration.someConfigVal)
            .onChange(of: configStore.configuration.someConfigVal, { print(configStore.configuration.someConfigVal) })
    }
}

When I run the app in the simulator, change the value to true and click the "Home" button, I get the following in the output window:

loaded false
load false
true
Scene is inactive false
In save task false
Some val on save false

Any ideas where I am going wrong or what I should do differently? I'm kind of invested in the @Observable functionality at this point (lots of classes built with it), but if changing that's the only to "solve" this, I'll do it but please try to work with me on that. Thanks!

I've learned how to read/write application data from/to a file in my iOS application. I've also learned how to put data in the @Environment for use my various view objects. I just can't seem to figure out how to link those together. I have some app data (e.g. some configuration values):

@Observable class Config: Codable {
    var someConfigVal: Bool = false
}

And have defined a "store" to read and write this info.

@Observable class ConfigStore {
    var configuration: Config = Config()
    
    private static func fileURL() throws -> URL {
        try FileManager.default.url(
            for: .documentDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: false
        )
        .appendingPathComponent("config.data")
    }
    
    func load() async throws {
        let task = Task<Config, Error> {
            let fileURL = try Self.fileURL()

            guard let data = try? Data(contentsOf: fileURL) else {
                return Config()
            }
            let storedConfig = try JSONDecoder().decode(Config.self, from: data)
            
            print("loaded", storedConfig.someConfigVal)
            return storedConfig
        }
        
        let appConfig = try await task.value

        self.configuration = appConfig
        print("load", self.configuration.someConfigVal)
    }
    
    func save(appConfig: Config) async throws {
        print("Some val on save", appConfig.someConfigVal)
        let task = Task {
            let data = try JSONEncoder().encode(appConfig)
            let outfile = try Self.fileURL()
            
            try data.write(to: outfile)
        }
        
        _ = try await task.value
    }
}

extension EnvironmentValues {
    var configurationStore: ConfigStore {
        get { self[ConfigStoreKey.self] }
        set { self[ConfigStoreKey.self] = newValue }
    }
}

private struct ConfigStoreKey: EnvironmentKey {
    static let defaultValue: ConfigStore = ConfigStore()
}

I'm currently creating this store as a @State object in the main application object along with the async code to do the file storage management.

@main
struct StoreTestApp: App {
    @State private var configStore = ConfigStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView(configStore: configStore) {
                Task {
                    print("In save task", configStore.configuration.someConfigVal)
                    do {
                        try await configStore.save(appConfig: configStore.configuration)
                    } catch {
                        fatalError(error.localizedDescription)
                    }
                }
            }
                .task {
                    do {
                        try await configStore.load()
                    } catch {
                        fatalError(error.localizedDescription)
                    }
                }
//            .environment(configStore)
        }
    }
}

For the UI, I've created a simple tabbed view: 1 to show the value and 1 to edit it.

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase
//    @Environment(\.configurationStore) private var configStore

    var configStore: ConfigStore
    
    let saveAction: () -> Void
    
    var body: some View {
        TabView {
            ShowView()
                .tabItem {
                    Label("Show", systemImage: "magnifyingglass")
                }
            ParentEditView()
                .tabItem {
                    Label("Edit", systemImage: "pencil")
                }
        }
        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .inactive {
                print("Scene is inactive", configStore.configuration.someConfigVal)
                saveAction()
            }
        }
        .environment(configStore)
    }
}

struct ShowView: View {
    @Environment(\.configurationStore) private var configStore

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(String(configStore.configuration.someConfigVal))
        }
        .padding()
    }
}

struct ParentEditView: View {
    @Environment(\.configurationStore) private var configStore

    var body: some View {
        EditView(configStore: configStore)
    }
}

struct EditView: View {
    @Bindable var configStore: ConfigStore
    
    var body: some View {
        Toggle("Some val", isOn: $configStore.configuration.someConfigVal)
            .onChange(of: configStore.configuration.someConfigVal, { print(configStore.configuration.someConfigVal) })
    }
}

When I run the app in the simulator, change the value to true and click the "Home" button, I get the following in the output window:

loaded false
load false
true
Scene is inactive false
In save task false
Some val on save false

Any ideas where I am going wrong or what I should do differently? I'm kind of invested in the @Observable functionality at this point (lots of classes built with it), but if changing that's the only to "solve" this, I'll do it but please try to work with me on that. Thanks!

Share Improve this question asked Feb 15 at 22:07 GeneGene 5644 silver badges7 bronze badges 3
  • Maybe @AppStorage can help: hackingwithswift/quick-start/swiftui/… – koen Commented Feb 16 at 6:23
  • Change class Config to struct. developer.apple/documentation/swift/… – malhal Commented Feb 16 at 6:46
  • FYI Using State with class instead of struct brings risk of memory leaks, check for leaks, your objects probably aren’t being deinit and are being init too often. – malhal Commented Feb 16 at 6:48
Add a comment  | 

2 Answers 2

Reset to default 1

To be able to read your config.data file and use @Observable class Config: Codable to decode the json data in your ConfigStore func load(), you need to have:

@Observable class Config: Codable {
    var someConfigVal: Bool = false
    
    enum CodingKeys: String, CodingKey {
        case _someConfigVal = "someConfigVal"   //<-- here
    }
}

This is because the @Observable macro changes the property name to _someConfigVal for use by SwiftUI.

EDIT-1:

instead of loading @Observable class using the environment object, try using the normal passing of @Observable class to Views using @Environment(ConfigStore.self) private var configStore.

With the changes as shown in this example code, all works well for me.

@Observable class Config: Codable {
    var someConfigVal: Bool = false
    
    enum CodingKeys: String, CodingKey {
        case _someConfigVal = "someConfigVal"   //<-- here
    }
}

@Observable class ConfigStore {
    var configuration: Config = Config()
    
    private static func fileURL() throws -> URL {
        try FileManager.default.url(
            for: .documentDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: false
        )
        .appendingPathComponent("config.data")
    }

    // why you want to use Task I don't know.
    func load() async throws {
        let task = Task<Config, Error> {
            let fileURL = try Self.fileURL()
            guard let data = try? Data(contentsOf: fileURL) else {
                return Config()
            }
            let storedConfig = try JSONDecoder().decode(Config.self, from: data)
            return storedConfig
        }
        let appConfig = try await task.value
        self.configuration = appConfig
        print("----> load storedConfig: ", self.configuration.someConfigVal)
    }
    
    // --- here no Task, no async throws
    func save() {
        do {
            print("----> saving: ", configuration.someConfigVal)
            let data = try JSONEncoder().encode(configuration)  //<-- here
            let outfile = try Self.fileURL()
            try data.write(to: outfile)
        } catch {
            print(error)
        }
    }
}

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase
    @Environment(ConfigStore.self) private var configStore // <--- here
 
    let saveAction: () -> Void

    var body: some View {
        TabView() {
            ShowView()
                .tabItem {
                    Label("Show", systemImage: "magnifyingglass")
                }
            ParentEditView()
                .tabItem {
                    Label("Edit", systemImage: "pencil")
                }
        }
        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .inactive || newPhase == .background {  // <--- for testing
                print("--> Scene is inactive or background: ", configStore.configuration.someConfigVal)
                saveAction()
            }
        }
    }
}

struct ShowView: View {
    @Environment(ConfigStore.self) private var configStore  // <--- here

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(String(configStore.configuration.someConfigVal))
        }
        .padding()
    }
}

struct ParentEditView: View {
    @Environment(ConfigStore.self) private var configStore  // <--- here

    var body: some View {
        EditView()
    }
}

struct EditView: View {
    @Environment(ConfigStore.self) private var configStore  // <--- here

    var body: some View {
        @Bindable var configStore = configStore  // <--- here
        Toggle("Some val", isOn: $configStore.configuration.someConfigVal)
            .onChange(of: configStore.configuration.someConfigVal, {
                print("-----> EditView: \(configStore.configuration.someConfigVal)")
            })
    }
}

@main
struct StoreTestApp: App {
    @State private var configStore = ConfigStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView() {
                print("=======> App saving: \(configStore.configuration.someConfigVal)")
                configStore.save()
            }
            .task {
                do {
                    try await configStore.load()
                } catch {
                    print(error)
                }
            }
            .environment(configStore) // <-- here
        }
    }
}

Note the content of my test congig.data is just this json data {"someConfigVal":false}

You are mixing up the two different Environment-related APIs.

There is the @Observable-based APIs, where you use .environment(_:) to write to the environment, and @Environment(SomeObservable.self) to read the environment.

There is the key-based APIs, where you use .environment(\.someKey, value: someValue) to write to the environment, and @Environment(\.someKey) to read the environment.

You are using the key-based API to read the environment, and the @Observable-based API to write to the environment!

Instead of:

@Environment(\.configurationStore) private var configStore

Write:

@Environment(ConfigStore.self) private var configStore

You should not use the key-based APIs for @Observable objects, because it is very hard to provide a defaultValue in a concurrency-safe way. Also regarding concurrency, your using Task { ... } everywhere for no apparent reason is going to give you a lot of trouble on Swift 6. If your intention is to do the loading and saving off of the main actor, you can, but this is not how you should do it. In any case, that's a separate question.

For now, here is some working code with some additional stylistic changes.

@Observable class Config: Codable {
    var someConfigVal: Bool = false
}

@Observable class ConfigStore {
    var configuration: Config = Config()
    
    private static func fileURL() throws -> URL {
        try FileManager.default.url(
            for: .documentDirectory,
            in: .userDomainMask,
            appropriateFor: nil,
            create: false
        )
        .appendingPathComponent("config.data")
    }
    
    func load() throws {
        let fileURL = try Self.fileURL()

        let appConfig = if let data = try? Data(contentsOf: fileURL) {
            try JSONDecoder().decode(Config.self, from: data)
        } else {
            Config()
        }
        
        print("loaded", appConfig.someConfigVal)

        self.configuration = appConfig
        print("load", self.configuration.someConfigVal)
    }
    
    func save(appConfig: Config) throws {
        print("Some val on save", appConfig.someConfigVal)
        let data = try JSONEncoder().encode(appConfig)
        let outfile = try Self.fileURL()
        
        try data.write(to: outfile)
    }
}

@main
struct YourApp: App {
    @State private var configStore = ConfigStore()
    
    var body: some Scene {
        WindowGroup {
            ContentView {
                print("In save task", configStore.configuration.someConfigVal)
                do {
                    try configStore.save(appConfig: configStore.configuration)
                } catch {
                    print(error)
                    fatalError()
                }
            }
            .task {
                do {
                    try configStore.load()
                } catch {
                    print(error)
                    fatalError()
                }
            }
            .environment(configStore)
        }
    }
}

struct ContentView: View {
    @Environment(\.scenePhase) private var scenePhase
    @Environment(ConfigStore.self) private var configStore
    
    let saveAction: () -> Void
    
    var body: some View {
        TabView {
            ShowView()
                .tabItem {
                    Label("Show", systemImage: "magnifyingglass")
                }
            EditView()
                .tabItem {
                    Label("Edit", systemImage: "pencil")
                }
        }
        .onChange(of: scenePhase) { _, newPhase in
            if newPhase == .inactive {
                print("Scene is inactive", configStore.configuration.someConfigVal)
                saveAction()
            }
        }
    }
}

struct ShowView: View {
    @Environment(ConfigStore.self) private var configStore

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text(String(configStore.configuration.someConfigVal))
        }
        .padding()
    }
}

struct EditView: View {
    @Environment(ConfigStore.self) private var configStore
    
    var body: some View {
        @Bindable var configStore = self.configStore
        Toggle("Some val", isOn: $configStore.configuration.someConfigVal)
            .onChange(of: configStore.configuration.someConfigVal, { print(configStore.configuration.someConfigVal) })
    }
}

本文标签: swiftuiHow do I add a loaded object to the environmentStack Overflow