Search code examples
swiftsqlitecore-dataplistuserdefaults

What's the best way to implement data storage in Swift, how to deal with class change


I am a new swift programmer here, and I am practicing it with my own project, when I was implementing data storage, I found that there are so many methods to do it, such as UserDefaults, Plist, CoreData ... I chose Plist as my own data persistence method, instantly I found there is an issue, to store those custom classes, I need to make it following the Codable protocol.

For example, my custom class User has variables and functions


class User: Codable {
  
   var name: String
   var gender: Gender
   var avatar: Data
   var keys: Int
   var items: Array<Item>
   var vip: Bool
   
   var themeColorSetting: ThemeColor? = nil

   
   public init(name: String, gender: Gender, avatar: UIImage, keys: Int, items: Array<Item>, vip: Bool) {
       self.name = name
       self.gender = gender
       self.avatar = avatar.pngData() ?? Data()
       self.keys = keys
       self.items = items
       self.vip = vip
   }
   
   public func getAvatarImage() -> UIImage {
       return UIImage(data: avatar) ?? UIImage()
   }
   
   
}

This works fine when I store it to the Plist, But when I tried to add a function

public func setAvatarImage(_ image: UIImage) {

        avatar = image.pngData() ?? #imageLiteral(resourceName: "Test").pngData()!
    }

to the class, I found that the original data can't be read because it doesn't have the new function in the coded file, and this even leads to crushing when I upload a new build to TestFlight,

So what's the best way to store the data that still works even when I add new variables or functions in the future, or how do you deal with the Refactor or Extension to a class. Thank you so much

The crushing issue caused by updating User class:

I've got

Thread 1: EXC_BREAKPOINT (code=1, subcode=0x10355e3a8)
class AppManager {
  public static let shared = AppEngine()
public var currentUser: User = User(name: "User", gender: .undefined, avatar: #imageLiteral(resourceName: "Test"), keys: 3, items: [Item](), vip: false)
  public let dataFilePath: URL? = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("item.plist")

  init() {
    loadUser()
  }

 public func loadUser() {
        
        if let data = try? Data(contentsOf: dataFilePath!) {
           let decoder = JSONDecoder() //PropertyListDecoder()

           do {
                self.currentUser = try decoder.decode(User.self, from: data) 
           } catch {
               print(error)
           }
        }
        
        if self.currentUser.vip {
            print("Welcome back VIP!")
        }
    }

}

When I don't call loadUser(), it works fine as there is a stored default user in AppManager, this all happened when I just simply add a new function to the User Class, if I delete the App and reinstall it, It works fine with loadUser() called


Solution

  • Although your code and description suggest that you only added methods, not properties, simply adding a method to User should not alter the ability of PropertyListDecoder, or JSONDecoder to decode it. However, if you did add a stored property, or if you changed the other Codable properties, such as Item or Gender, you might very well be unable to decode it from data that was encoded from a previous version of those types.

    One way to track it down, and also provide some backward compatibility for future changes you might make to User's stored properties, would be to fully implement the Codable protocol in User rather than relying on the one Swift synthesizes for you.

    Codable is basically just a combination of two other protocols, Encodable and Decodable.

    Encodable requires a function, encode(to: Encoder) throws. Decodable requires an initializer, init(from: Decoder) throws.

    Encoders will serialize your class to an "object" type, which is essentially a dictionary. So you'll need a type to act as a key for that dictionary and it needs to conform to the CodingKey protocol. It should have a specific value for each property you want to serialize. Normally, it's implemented as an enum.

    For your User class you could add:

    enum CodingKeys: CodingKey
    {
        case name
        case gender
        case avatar
        case keys
        case items
        case vip
        case themeColor
    }
    

    With the CodingKey taken care of, you can now implement the required methods in User:

    public required init(from decoder: Decoder) throws
    {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        self.name = try container.decode(String.self, forKey: .name)
        self.gender = try container.decode(Gender.self, forKey: .gender)
        self.avatar = try container.decode(Data.self, forKey: .avatar)
        self.keys = try container.decode(Int.self, forKey: .keys)
        self.items = try container.decode([Item].self, forKey: .items)
        self.vip = try container.decode(Bool.self, forKey: .vip)
        self.themeColorSetting = try container.decode(ThemeColor?.self, forKey: .themeColor)
    }
    
    public func encode(to encoder: Encoder) throws
    {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.name, forKey: .name)
        try container.encode(self.gender, forKey: .gender)
        try container.encode(self.avatar, forKey: .avatar)
        try container.encode(self.keys, forKey: .keys)
        try container.encode(self.items, forKey: .items)
        try container.encode(self.vip, forKey: .vip)
        try container.encode(self.themeColorSetting, forKey: .themeColor)
    }
    

    Note that this does mean you'll have to maintain your CodingKeys and these methods when you add properties. However, it does a few things for you that you don't get from the automatically synthesized implementation.

    For starters, if you find that you are failing to decode a previously encoded User, you can enable a breakpoint on Swift.Error in Xcode. The debugger will stop on the specific property that's failing to decode. You don't get that with Swift's synthesized Codable implementation.

    Another thing explicitly implementing Codable yourself gives you is the ability to deal with the possibility of adding fields that might be missing in previously encoded instances. For example, let's say you add an isModerator: Bool property, but you want to be able to decode existing User instances that didn't have that property. No problem.

    First you add the isModerator property itself to User:

    var isModerator: Bool
    

    Then you update your CodingKeys:

    enum CodingKeys: CodingKey
    {
        case name
        case gender
        case avatar
        case keys
        case items
        case vip
        case themeColor
        case isModerator // <- Added this
    }
    

    Then you update encode(to: Encoder) throws:

    public func encode(to encoder: Encoder) throws
    {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(self.name, forKey: .name)
        try container.encode(self.gender, forKey: .gender)
        try container.encode(self.avatar, forKey: .avatar)
        try container.encode(self.keys, forKey: .keys)
        try container.encode(self.items, forKey: .items)
        try container.encode(self.vip, forKey: .vip)
        try container.encode(self.themeColorSetting, forKey: .themeColor)
        try container.encode(self.isModerator, forKey: .isModerator) // <- Added this
    }
    

    And then finally, in init(from: Decoder) throws:

    public required init(from decoder: Decoder) throws
    {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        self.name = try container.decode(String.self, forKey: .name)
        self.gender = try container.decode(Gender.self, forKey: .gender)
        self.avatar = try container.decode(Data.self, forKey: .avatar)
        self.keys = try container.decode(Int.self, forKey: .keys)
        self.items = try container.decode([Item].self, forKey: .items)
        self.vip = try container.decode(Bool.self, forKey: .vip)
        self.themeColorSetting = try container.decode(ThemeColor?.self, forKey: .themeColor)
    
        // Add the following line, using nil coalescing to provide a default value 
        // for old serialized Users that didn't have this property.
        self.isModerator = try container.decodeIfPresent(Bool.self, forKey: .isModerator) ?? false
    }
    

    Note: If you're adding a property to a class that was encoded using the synthesized implementation and are now supplying your own version, the enum cases you give for CodingKeys have to match the previous property names exactly, although Swift does provide some conversion from JSON-style "snake case" to Swift-style "camel case". You can also try making String extension conform to CodingKey, and use String literals for your coding key instead. If you want to see what keys are being used, convert the Data from the encoder to a String and print it. Unfortunately converting to a string will fail by default with Data produced by PropertyListEncoder, because its default output format is binary. In that case, save it to a file, and open it in Plist Editor to see the keys being used.

    Providing your own Codable implementation also means you don't have to encode every property. For example, you might have some property on User that is transient - it only is meaningful during a session, and doesn't need to be stored. Since you're already explicitly encoding your properties, and you don't want to encode that transient one, there is nothing to do in encode(to: Encoder) throws. You can initialize it in all of your initializers, including init(from: Decoder) throws just as you would in any initializer... or initialize at the point of declaration and you don't even have to touch initializers.