Simple persistent storage in Swift
Asked Answered
D

2

20

I have an array of objects each with a number of properties. Here is some sample data taken by looping through the array of objects:

Name = Rent
Default Value 750
This Months Estimate = 750
Sum Of This Months Actuals = 0
Risk Factor = 0.0
Monthly Average = 750.0
--------------
Name = Bills
Default Value 250
This Months Estimate = 170
Sum Of This Months Actuals = 140
Risk Factor = 0.0
Monthly Average = 190.0
--------------
Name = Food
Default Value 240
This Months Estimate = 200
Sum Of This Months Actuals = 95
Risk Factor = 0.0
Monthly Average = 190.0
--------------
Name = Lunches
Default Value 100
This Months Estimate = 150
Sum Of This Months Actuals = 155
Risk Factor = 0.899999976158142
Monthly Average = 190.0

Its very little data so I want to avoid using core data. I need to be able to persist save the array then open it again and be able to loop through it. I was hoping to use a simple solution like NSUserDefaults or NSKeyedArchiver but in Swift I can get neither to work with this type of array (I've been going through documentation and forums and examples online for 24 hours now...)

How would you recommend I persist save an array of objects like the above? OR maybe persist saving this type of array is bad practice?

Thanks in advance for your help!

Adding the object class:

class costCategory : NSObject {
    var name : String
    var defaultValue : Int
    var thisMonthsEstimate : Int
    var sumOfThisMonthsActuals : Int
    var riskFactor : Float
    var monthlyAverage : Float


    init (name:String, defaultValue:Int, thisMonthsEstimate:Int, sumOfThisMonthsActuals:Int, riskFactor:Float, monthlyAverage:Float) {
        self.name = name
        self.defaultValue = defaultValue
        self.thisMonthsEstimate = thisMonthsEstimate
        self.sumOfThisMonthsActuals = sumOfThisMonthsActuals
        self.riskFactor = riskFactor
        self.monthlyAverage = monthlyAverage
    }

}

If I try to save the array to NSUserDefaults I'm getting the error:

Property list invalid for format: 200 (property lists cannot contain objects of type 'CFType')

I have tried using inheriting from the NSCoder class but I get an error which Im not able to resolve, as below:

class costCategory : NSObject, NSCoder {
    var name : String
    var defaultValue : Int
    var thisMonthsEstimate : Int
    var sumOfThisMonthsActuals : Int
    var riskFactor : Float
    var monthlyAverage : Float


    init (name:String, defaultValue:Int, thisMonthsEstimate:Int, sumOfThisMonthsActuals:Int, riskFactor:Float, monthlyAverage:Float) {
        self.name = name
        self.defaultValue = defaultValue
        self.thisMonthsEstimate = thisMonthsEstimate
        self.sumOfThisMonthsActuals = sumOfThisMonthsActuals
        self.riskFactor = riskFactor
        self.monthlyAverage = monthlyAverage
    }



    // MARK: NSCoding

    required convenience init(coder decoder: NSCoder) {
        self.init() //Error here "missing argument for parameter name in call
        self.name = decoder.decodeObjectForKey("name") as String
        self.defaultValue = decoder.decodeIntegerForKey("defaultValue")
        self.thisMonthsEstimate = decoder.decodeIntegerForKey("thisMonthsEstimate")
        self.sumOfThisMonthsActuals = decoder.decodeIntegerForKey("sumOfThisMonthsActuals")
        self.riskFactor = decoder.decodeFloatForKey("riskFactor")
        self.monthlyAverage = decoder.decodeFloatForKey("monthlyAverage")

    }

    func encodeWithCoder(coder: NSCoder) {
        coder.encodeObject(self.name, forKey: "name")
        coder.encodeInt(Int32(self.defaultValue), forKey: "defaultValue")
        coder.encodeInt(Int32(self.thisMonthsEstimate), forKey: "thisMonthsEstimate")
        coder.encodeInt(Int32(self.sumOfThisMonthsActuals), forKey: "sumOfThisMonthsActuals")
        coder.encodeFloat(self.riskFactor, forKey: "riskFactor")
        coder.encodeFloat(self.monthlyAverage, forKey: "monthlyAverage")

    }
}
Duel answered 7/10, 2014 at 9:42 Comment(6)
Is this class or value type?Stellarator
What about using NSDictionary instead of an Array? NSDictionary then can be stored using NSUserDefaults.Warbler
You might also want to include exactly how you attempted this solution.Avenue
They instantiations of a very simple class Kirsteins. I did try creating an array of dictionaries first but there were the same problems - an array of dictionaries of mixed object types were unable to be persist saved...Duel
@Duel please look at the answers, don't cast as an Int32 use the encodeInteger methods!Avenue
Thanks! Changed it, but the error I mention at the end of the main post is still there.Duel
S
33

One possibility is to convert your object properties:values to string:object store them to NSUserDefaults and then get and decode them back.

If you want to store your object using NSKeyedArchiver, your class needs to conform to NSCoding and be subclass of NSObject. Example:

class costCategory : NSObject, NSCoding {
    var name : String
    var defaultValue : Int
    var thisMonthsEstimate : Int
    var sumOfThisMonthsActuals : Int
    var riskFactor : Float
    var monthlyAverage : Float

    init (name:String, defaultValue:Int, thisMonthsEstimate:Int, sumOfThisMonthsActuals:Int, riskFactor:Float, monthlyAverage:Float) {
        self.name = name
        self.defaultValue = defaultValue
        self.thisMonthsEstimate = thisMonthsEstimate
        self.sumOfThisMonthsActuals = sumOfThisMonthsActuals
        self.riskFactor = riskFactor
        self.monthlyAverage = monthlyAverage
    }

    // MARK: NSCoding

    required init(coder decoder: NSCoder) {
        //Error here "missing argument for parameter name in call
        self.name = decoder.decodeObjectForKey("name") as String
        self.defaultValue = decoder.decodeIntegerForKey("defaultValue")
        self.thisMonthsEstimate = decoder.decodeIntegerForKey("thisMonthsEstimate")
        self.sumOfThisMonthsActuals = decoder.decodeIntegerForKey("sumOfThisMonthsActuals")
        self.riskFactor = decoder.decodeFloatForKey("riskFactor")
        self.monthlyAverage = decoder.decodeFloatForKey("monthlyAverage")
        super.init()
    }

    func encodeWithCoder(coder: NSCoder) {
        coder.encodeObject(self.name, forKey: "name")
        coder.encodeInt(Int32(self.defaultValue), forKey: "defaultValue")
        coder.encodeInt(Int32(self.thisMonthsEstimate), forKey: "thisMonthsEstimate")
        coder.encodeInt(Int32(self.sumOfThisMonthsActuals), forKey: "sumOfThisMonthsActuals")
        coder.encodeFloat(self.riskFactor, forKey: "riskFactor")
        coder.encodeFloat(self.monthlyAverage, forKey: "monthlyAverage")

    }
}

Then you can archive and save to NSDefaults:

let defaults = NSUserDefaults.standardUserDefaults()
let arrayOfObjectsKey = "arrayOfObjectsKey"

var arrayOfObjects = [costCategory]()
var arrayOfObjectsData = NSKeyedArchiver.archivedDataWithRootObject(arrayOfObjects)

defaults.setObject(arrayOfObjectsData, forKey: arrayOfObjectsKey)

// ...

var arrayOfObjectsUnarchivedData = defaults.dataForKey(arrayOfObjectsKey)!
var arrayOfObjectsUnarchived = NSKeyedUnarchiver.unarchiveObjectWithData(arrayOfObjectsUnarchivedData) as [costCategory]
Stellarator answered 7/10, 2014 at 9:52 Comment(6)
this doesn't compile with swift, you need to remove the automatic unwrapped declaration of the NSCoding parametersAvenue
Fixed. Sorry, didn't test the code that was working in previous Xcode 6 betas.Stellarator
I have tried that too. I had a lot of problems with calling the custom initialiser (with the values for the properties). The full code I tried but could not remove an error has been added to the main post (sorry I don't know how to format correctly in comments). It would be great if you could take a look and see if you can resolve the error?Duel
Thanks Kirstein -> That did it. It's a lot more complicated than using Objective C but I appreciate the help. Will probably look directly at using core data next time regardless of the amount of data. With Swift it seems like there are fewer simple solutions...Duel
How to save if one of the var is object? So if we hve var person = Person()Ardene
@YestayMuratov Make Person conform to NSCoding same way as costCategory. Then use encodeObject(,forKey:) and decodeObjectForKey to encode/decode it.Stellarator
A
6

Core Data is incredibly powerful and I highly recommend you try not be swayed by its intimidating appearance. When your app grows you will be incredibly grateful that you backed your data with Core Data as it scales incredibly well.

That being said, theres a nice article on NSHipster that covers the basics of using the NSKeyedArchiver. So the solution would be to have your objects be a subclass of NSObject and conform to the NSCoding protocol. This allows you to archive an unarchive the objects from the disk. You can the save your file to the documents directory

Your subclass would then need to implicitly unwrap all the encode-able variables, the result would yield:

class costCategory : NSObject, NSCoding {
var name : String!
var defaultValue : Int!
var thisMonthsEstimate : Int!
var sumOfThisMonthsActuals : Int!
var riskFactor : Float!
var monthlyAverage : Float!


init (name:String, defaultValue:Int, thisMonthsEstimate:Int, sumOfThisMonthsActuals:Int, riskFactor:Float, monthlyAverage:Float) {
    self.name = name
    self.defaultValue = defaultValue
    self.thisMonthsEstimate = thisMonthsEstimate
    self.sumOfThisMonthsActuals = sumOfThisMonthsActuals
    self.riskFactor = riskFactor
    self.monthlyAverage = monthlyAverage
}

override init() {
    super.init()
}

// MARK: NSCoding

required convenience init(coder decoder: NSCoder) {
    self.init()
    self.name = decoder.decodeObjectForKey("name") as String
    self.defaultValue = decoder.decodeIntegerForKey("defaultValue")
    self.thisMonthsEstimate = decoder.decodeIntegerForKey("thisMonthsEstimate")
    self.sumOfThisMonthsActuals = decoder.decodeIntegerForKey("sumOfThisMonthsActuals")
    self.riskFactor = decoder.decodeFloatForKey("riskFactor")
    self.monthlyAverage = decoder.decodeFloatForKey("monthlyAverage")
}

func encodeWithCoder(coder: NSCoder) {
    coder.encodeObject(self.name, forKey: "name")
    coder.encodeInteger((self.defaultValue), forKey: "defaultValue")
    coder.encodeInteger((self.thisMonthsEstimate), forKey: "thisMonthsEstimate")
    coder.encodeInteger((self.sumOfThisMonthsActuals), forKey: "sumOfThisMonthsActuals")
    coder.encodeFloat(self.riskFactor, forKey: "riskFactor")
    coder.encodeFloat(self.monthlyAverage, forKey: "monthlyAverage")
}
}

You could then add a way of finding the location to the documents directory:

func documentsDirectory() -> NSString {
    let paths = NSSearchPathForDirectoriesInDomains(.DocumentDirectory, .UserDomainMask, true)
    let documentDirectory = paths[0] as String
    return documentDirectory
}

So archiving would look like this:

var filePath = documentsDirectory().stringByAppendingPathComponent("fileName")
NSKeyedArchiver.archiveRootObject(receipts, toFile: filePath)

and later on you could read your array back from disk:

let receipts = NSKeyedUnarchiver.unarchiveObjectWithFile(filePath)

I would recommend against using User Defaults to store an array, its meant to store preferences, here take a look at the documentation:

The NSUserDefaults class provides a programmatic interface for interacting with the defaults system. The defaults system allows an application to customize its behavior to match a user’s preferences. For example, you can allow users to determine what units of measurement your application displays or how often documents are automatically saved. Applications record such preferences by assigning values to a set of parameters in a user’s defaults database.

Ideally you don't want to persist arrays because then you have to fetch the entire array into memory even if you need one object. Naturally Core Data will solve all that unnecessary heap mess for you:)

EDIT

To ease yourself into Core Data you could always look at Magical Record. They simplify a lot of the cruft needed to maintain the Core Data stack.

Good Luck

Core Data Evangelist

Avenue answered 7/10, 2014 at 9:49 Comment(7)
Thanks for your comments Daniel. I'm afraid that I had actually found that article and spent several hours going through it and trying each method it explains but there are errors at every point, no matter what I do. Really I wanted to avoid using core data simply because I'm trying (but failing pretty badly) to get up to speed with Swift and I wanted to test just what I'd studied so far. Maybe you're right, maybe I should just dive in to core data...Duel
@Duel please elaborate on the types of errors you are facing?Avenue
Ive added the class in the main post along with the error when trying to save to NSUser defaults.Thanks!Duel
don't use NSUSerDefaults to store an array. Its meant to store simple key value pairs. You want to store Objects. For that you are going to need to archive your array to a file on disk. See Kirsteins PostAvenue
I updated my answer:) You really should look into the NSHipster article because your object doesn't implement any of the methods he outlinedAvenue
Hehe thats because its all commented out in my code. I promise, I tried everything for hours. I have a question about "NSKeyedArchiver.archiveRootObject(receipts, toFile: "/path/to/archive")" ... Do I need to choose a filename and specify it there? I did try this but no file was ever created (that I could see in xcode)and I was unable to extract the object back out of the file if it had existed but was invisible.. I'll try again now...Duel
@Duel I updated my post to elaborate how you can archive to a file:)Avenue

© 2022 - 2024 — McMap. All rights reserved.