Search code examples
swiftdecodeencodeuserdefaults

Add variables to an already saved User Defaults variable


I have downloaded a swift file that should help me with saving and loading custom variables:

import Foundation

protocol ObjectSavable {
func setToObject<Object>(_ object: Object, forKey: String) throws where Object: Encodable
func getToObject<Object>(forKey: String, castTo type: Object.Type) throws -> Object where Object: Decodable
}

extension UserDefaults: ObjectSavable {
func setToObject<Object>(_ object: Object, forKey: String) throws where Object: Encodable {
    let encoder = JSONEncoder()
    do {
        let data = try encoder.encode(object)
        set(data, forKey: forKey)
    } catch {
        throw ObjectSavableError.unableToEncode
    }
}

func getToObject<Object>(forKey: String, castTo type: Object.Type) throws -> Object where Object: Decodable {
    guard let data = data(forKey: forKey) else { throw ObjectSavableError.noValue }
    let decoder = JSONDecoder()
    do {
        let object = try decoder.decode(type, from: data)
        return object
    } catch {
        throw ObjectSavableError.unableToDecode
    }
}
}

enum ObjectSavableError: String, LocalizedError {
case unableToEncode = "Unable to encode object into data"
case noValue = "No data object found for the given key"
case unableToDecode = "Unable to decode object into given type"
}

And I have this Person struct:

struct Person: Encodable, Decodable {
    var firstName: String
    var lastName: String
    var birthday: Date

    init() {
        self.firstName = "Tim"
        self.lastName = "Cook"
        self.birthday = Date()
    }
}

And I also have this code for saving/loading a Person struct (that is using the code from above)

Saving:

print("Saving object...")
    let person: Person = Person()
    
    do {
        try UserDefaults.standard.setToObject(person, forKey: "person")
        print("Object saved successfully")
    } catch let err {
        print("Error while saving object:\n\(err.localizedDescription)")
    }

Loading:

print("Loading object...")
    
    do {
        self.person = try UserDefaults.standard.getToObject(forKey: "person", castTo: Person.self)
        print("Successfully load object:\n\(self.person!)")
    } catch let err {
        print("Error while loading object:\n\(err.localizedDescription)")
    }

Now, all of this does work. But, let's say that I release my app that way, and then I want to add a new variable to Person, for example, I'll add a favorite:

struct Person: Encodable, Decodable {
    var firstName: String
    var lastName: String
    var birthday: Date
    var favorite: Bool = false

    init() {
        self.firstName = "Tim"
        self.lastName = "Cook"
        self.birthday = Date()
    }
}

Before the update, the app (without the favorite variable in Person) would save without the favorite variable. And after the update, the app will try to load the previous saved Person with the favorite variable. And that is where it failes, because the older-version data doesn't have a favorite variable in it. So it throws an error.

And my question is, is there a way that when it decoding a Person from User Defaults, if it doesn't find any matching variable (for example: favorite), instead of throwing an error, it will try to auto-create it? (from var favorite = false)?

My project: https://github.com/orihpt/Encodable

Thanks in advance.


Solution

  • One way to do this is to add custom decoding code into Person:

    enum CodingKeys : CodingKey {
        case firstName
        case lastName
        case birthday
        case favorite
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        firstName = try container.decode(String.self, forKey: .firstName)
        lastName = try container.decode(String.self, forKey: .lastName)
        birthday = try container.decode(Date.self, forKey: .birthday)
        favorite = try container.decodeIfPresent(Bool.self, forKey: .favorite) ?? false
    }
    

    Note that for favorite, I used decodeIfPresent and defaults to false.


    Another way is to declare favorite as optional:

    var favorite: Bool?
    

    This will cause favorite to be set to nil if it doesn't exist in the data, not the false that you want. If you really want false, you can use an implicitly unwrapped optional Bool?, and you'd need to change the nil to false every time you decode:

    self.person = try UserDefaults.standard.getToObject(forKey: "person", castTo: Person.self)
    if self.person.favorite == nil {
        self.person.favorite = false
    }
    

    If you are afraid that you might forget to do this, you can make getToObject only accept objects that conform to this protocol:

    protocol HasDefaults {
        func changeNilsToDefaults()
    }
    
    
    extension UserDefaults {
        func getToObject<Object: HasDefaults>(forKey: String, castTo type: Object.Type) throws -> Object where Object: Decodable {
            guard let data = data(forKey: forKey) else { throw ObjectSavableError.noValue }
            let decoder = JSONDecoder()
            do {
                let object = try decoder.decode(type, from: data)
                object.changeNilsToDefaults() // notice this line!
                return object
            } catch {
                throw ObjectSavableError.unableToDecode
            }
        }
    }
    

    And you won't be able to do getToObject(forKey: "person", castTo: Person.self) unless you conform Person to HasDefaults:

    extension Person : HasDefaults {
        func changeNilsToDefaults() {
            if self.person.favorite == nil {
                self.person.favorite = false
            }
        }
    }