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
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
.
Encoder
s 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.