2

I'm writing a planner app, based on Apple's FoodTracker tutorial, that stores several strings, a Date, and a UIImage per Assignment. I'm encountering problems encoding/decoding the Date. I don't know for sure which it is because the console outputs change with every slight modification, but from what I can tell, my code saves the Date as nil, and then when it tries to load that Date, it unexpectedly finds nil and crashes. Because I'm relatively new to Swift and Swift 3 is a headache and a half, I have very little idea where the problem really is. Here's the code that I think should work:

class Assignment: NSObject, NSCoding {

//MARK: Properties

var name: String
var className: String
var assignmentDescription: String
var materials: String
var dueDate: Date?
var assignmentImage: UIImage?

//MARK: Archiving Paths

static let DocumentsDirectory = FileManager().urls(for: .documentDirectory, in: .userDomainMask).first!
static let ArchiveURL = DocumentsDirectory.appendingPathComponent("assignments")

//MARK: Types

struct PropertyKey {
    static let nameKey = "name"
    static let classNameKey = "className"
    static let assignmentDescriptionKey = "assignmentDescription"
    static let materialsKey = "materials"
    static let dueDateKey = "dueDate"
    static let assignmentImageKey = "assignmentImage"
}

//MARK: Initialization

init?(name: String, className: String, assignmentDescription: String, materials: String, dueDate: Date, assignmentImage: UIImage?) {
    //Initialize stored properties.
    self.name = name
    self.className = className
    self.assignmentDescription = assignmentDescription
    self.materials = materials
    self.dueDate = dueDate
    self.assignmentImage = assignmentImage

    super.init()

    //Initialization should fail if there is no name and no class.
    if name.isEmpty && className.isEmpty {
        print("Failed to initialize an assignment.")
        return nil
    }
}

//MARK: NSCoding

func encode(with aCoder: NSCoder) {
    aCoder.encode(name, forKey: PropertyKey.nameKey)
    aCoder.encode(className, forKey: PropertyKey.classNameKey)
    aCoder.encode(assignmentDescription, forKey: PropertyKey.assignmentDescriptionKey)
    aCoder.encode(materials, forKey: PropertyKey.materialsKey)
    aCoder.encode(dueDate, forKey: PropertyKey.dueDateKey)
    aCoder.encode(assignmentImage, forKey: PropertyKey.dueDateKey)
}

required convenience init?(coder aDecoder: NSCoder) {
    //Required fields.
    let name = aDecoder.decodeObject(forKey: PropertyKey.nameKey) as! String
    let className = aDecoder.decodeObject(forKey: PropertyKey.classNameKey) as! String

    //Optional fields.
    let assignmentDescription = aDecoder.decodeObject(forKey: PropertyKey.assignmentDescriptionKey) as? String
    let materials = aDecoder.decodeObject(forKey: PropertyKey.materialsKey) as? String
    let dueDate = aDecoder.decodeObject(forKey: PropertyKey.dueDateKey) as! Date
    let assignmentImage = aDecoder.decodeObject(forKey: PropertyKey.assignmentImageKey) as? UIImage

    //Must call designated initializer.
    self.init(name: name, className: className, assignmentDescription: assignmentDescription!, materials: materials!, dueDate: dueDate, assignmentImage: assignmentImage)
}

Any insight at all would be appreciated.

Edit:

With Duncan C's help and Xcode's fix-its, this is what required convenience init?(coder aDecoder: NSCoder) looks like now:

//Required fields.
    let newName = aDecoder.decodeObject(forKey: PropertyKey.nameKey) as! String
    let newClassName = aDecoder.decodeObject(forKey: PropertyKey.classNameKey) as! String

    //Optional fields.
    var newAssignmentImage: UIImage?
    var newDueDate: Date

    let newAssignmentDescription = aDecoder.decodeObject(forKey: PropertyKey.assignmentDescriptionKey) as! String
    let newMaterials = aDecoder.decodeObject(forKey: PropertyKey.materialsKey) as! String

    if aDecoder.containsValue(forKey: PropertyKey.dueDateKey) {
        newDueDate = aDecoder.decodeObject(forKey: PropertyKey.dueDateKey) as! Date
    } else {
        newDueDate = Date()
        if aDecoder.containsValue(forKey: PropertyKey.assignmentImageKey) {
            newAssignmentImage = aDecoder.decodeObject(forKey: PropertyKey.assignmentImageKey) as? UIImage
        } else {
            newAssignmentImage = UIImage(named: "sampleAssignmentImage")
        }
    }

    //Must call designated initializer.
    self.init(name: newName, className: newClassName, assignmentDescription: newAssignmentDescription, materials: newMaterials, dueDate: newDueDate, assignmentImage: newAssignmentImage)!

It compiles, but it still throws fatal error: unexpectedly found nil while unwrapping an Optional value on the line newDueDate = aDecoder.decodeObject(forKey: PropertyKey.dueDateKey) as! Date

Edit 2:

After going back to my original code and changing ? to !, this is what my code looks like:

 //Required fields.
    let name = aDecoder.decodeObject(forKey: PropertyKey.nameKey) as! String
    let className = aDecoder.decodeObject(forKey: PropertyKey.classNameKey) as! String

    //Optional fields.
    let assignmentDescription = aDecoder.decodeObject(forKey: PropertyKey.assignmentDescriptionKey) as! String
    let materials = aDecoder.decodeObject(forKey: PropertyKey.materialsKey) as! String
    let dueDate = aDecoder.decodeObject(forKey: PropertyKey.dueDateKey) as! Date
    let assignmentImage = aDecoder.decodeObject(forKey: PropertyKey.assignmentImageKey) as! UIImage

    //Must call designated initializer.
    self.init(name: name, className: className, assignmentDescription: assignmentDescription, materials: materials, dueDate: dueDate, assignmentImage: assignmentImage)

It compiles, but it still throws fatal error: unexpectedly found nil while unwrapping an Optional value on the line let dueDate = aDecoder.decodeObject(forKey: PropertyKey.dueDateKey) as! Date

Edit 3 (Working):

For anyone interested, here are the sections of the original code that were modified to make it work:

Properties:

//You can't encode optional values, so you don't use `?` here.
var dueDate: Date
var assignmentImage: UIImage

Initializer:

//Removed the `?` after UIImage because it isn't an Optional.
init?(name: String, className: String, assignmentDescription: String, materials: String, dueDate: Date, assignmentImage: UIImage)

Decoder (required convenience init?(coder aDecoder: NSCoder)):

//Required fields.
let name = aDecoder.decodeObject(forKey: PropertyKey.nameKey) as! String
let className = aDecoder.decodeObject(forKey: PropertyKey.classNameKey) as! String

//Optional fields.
let assignmentDescription = aDecoder.decodeObject(forKey: PropertyKey.assignmentDescriptionKey) as! String //This String should use `!` instead of `?`.
let materials = aDecoder.decodeObject(forKey: PropertyKey.materialsKey) as! String //This String should use `!` instead of `?`.
let dueDate = aDecoder.decodeObject(forKey: PropertyKey.dueDateKey) as! Date
//Check for the UIImage being nil. If it is, assign some default image to it so that it doesn't unwrap nil and crash.
var assignmentImage: UIImage!
if aDecoder.decodeObject(forKey: PropertyKey.assignmentImageKey) == nil {
    assignmentImage = UIImage(named: "SampleAssignmentImage")
}
else {
    assignmentImage = aDecoder.decodeObject(forKey: PropertyKey.assignmentImageKey) as! UIImage
}

//Must call designated initializer.
//`materials` and `assignmentDescription` don't need `!` now because they're already unwrapped.
self.init(name: name, className: className, assignmentDescription: assignmentDescription, materials: materials, dueDate: dueDate, assignmentImage: assignmentImage) 

It isn't perfect, but it works.

H. Martin
  • 45
  • 1
  • 9
  • No, don't change `?` to `!`. change your properties to be `var dueDate: Date` and `var assignmentImage: UIImage` with no question mark or exclamation point. And then you'll need to delete the app from your devices and re-install it, since your saved objects will have missing keys which will cause a crash. – Duncan C Nov 22 '16 at 01:26
  • You need to stop flailing around and go read up on optionals. You're not going to get anywhere until you understand them. – Duncan C Nov 22 '16 at 01:27
  • I've figured it out. Thank you for your help, Duncan. – H. Martin Nov 22 '16 at 01:59
  • Good. Can you edit your question or post an answer explaining what you did, exactly, so others can learn from it? – Duncan C Nov 22 '16 at 03:18
  • I already edited my question with the pieces of code that differ from what I originally posted. I'll go back and try to add useful explanations in comments in the code. – H. Martin Nov 22 '16 at 03:25
  • If my explanations are too vague, incorrect, or anything like that, let me know, please. – H. Martin Nov 22 '16 at 03:50

2 Answers2

9

As Matt, says, you can't encode an optional. Rather than force-unwrapping it, though, I would suggest adding an if let and only adding the optionals to the archive if they contain a value:

func encode(with aCoder: NSCoder) {
        aCoder.encode(name, forKey: PropertyKey.nameKey)
        aCoder.encode(className, forKey: PropertyKey.classNameKey)
        aCoder.encode(assignmentDescription, forKey: PropertyKey.assignmentDescriptionKey)
        aCoder.encode(materials, forKey: PropertyKey.materialsKey)
        if let date = dueDate {
            aCoder.encode(date, forKey: PropertyKey.dueDateKey)

        }
        if let image = assignmentImage {
            aCoder.encode(image, forKey: PropertyKey.dueDateKey)
        }
    }

And then in your init(coder:) method, check to see if the keys exist before decoding:

required convenience init?(coder aDecoder: NSCoder) {
    //Required fields.
    name = aDecoder.decodeObject(forKey: PropertyKey.nameKey) as! String
    className = aDecoder.decodeObject(forKey: PropertyKey.classNameKey) as! String

    //Optional fields.
    assignmentDescription = aDecoder.containsValue(forKey: PropertyKey.assignmentDescriptionKey) as? String
    materials = aDecoder.decodeObject(forKey: PropertyKey.materialsKey) as? String

    if aDecoder.containsValue(forKey: PropertyKey.dueDateKey) {
        dueDate = aDecoder.decodeObject(forKey: PropertyKey.dueDateKey) as! Date
    } else {
       dueDate = nil
    if aDecoder.containsValue(forKey: PropertyKey.assignmentImageKey) {
        assignmentImage = aDecoder.decodeObject(forKey: PropertyKey.assignmentImageKey) as? UIImage
    } else {
      assignmentImage = nil
    }

    //Must call designated initializer.
    self.init(name: name, className: className, assignmentDescription: assignmentDescription!, materials: materials!, dueDate: dueDate, assignmentImage: assignmentImage)
}

EDIT:

I just created a sample project called Swift3PhoneTest on Github (link) that demonstrates using NSSecureCoding to save a custom data container object.

It has both a non-optional and an optional property, and properly manages archiving and unarchiving when the optional property is nil.

Duncan C
  • 128,072
  • 22
  • 173
  • 272
  • If you are unwrapping with `if let date = dueDate`, shouldn't it be `aCoder.encode(date...` instead of `aCoder.encode(dueDate...`? – Nicolas Miari Nov 21 '16 at 03:58
  • Yup. An editing mistake. Same with the image property. Thanks for catching it. (Fixed.) – Duncan C Nov 21 '16 at 04:02
  • I get several errors using this code. The first batch is about aDecoder.containsObject(forKey: String) because there is no such method. The closest is .containsValue(forKey: String), which I subbed in. The next is that in the initializer, dueDate was not unwrapped, and I went along with the fix-it and added '!'. Then, I get two more errors in the initializer: "Use of 'self' in property access 'assignmentImage'/'dueDate' before self.init initializes self." Could this be that if the if statements are false, those two values never get set, so it tries to initialize with values that don't exist? – H. Martin Nov 21 '16 at 13:47
  • Sorry, I typed that code on an iPad from memory. I should know better. I've edited it on my Mac, and the new code should work. Note that your `init(coder:)` method was assigning the values it read in to local constants. That's wrong. (you had lines like `let name = aDecoder...`. They should have been `name = aDecoder...`, without the `let`) – Duncan C Nov 21 '16 at 16:13
  • While your explanations make sense, the code still produces errors when pasted in, like "Cannot force unwrap of non-optional type 'String'", "Expected declaration", and something about being unable to assign nil to Date and UIImage. I fixed the "Expected declaration" error by adjusting the curly braces around the if statements. After that, I did my best to go along with the fix-its, and I renamed the instance variables to be separate from the local ones. After I finally got it to where it would compile, the app crashed with the same nil error on the line where it tries to decode the date. – H. Martin Nov 21 '16 at 19:37
  • Ok, I'm not trying to give you code that you can copy-paste into place. I'm trying to explain how to do it. I got code that works exactly like I explained working in a test project, and then tried to apply that same syntax to your project. There might be minor syntax problems that you need to fix. If you can't get it to work append your new code to the bottom of your question along with info about the specific line that's crashing. – Duncan C Nov 21 '16 at 20:08
  • Note that your date and image properties should probably not be optionals. If you make them required properties then the need for this special case code goes away. – Duncan C Nov 21 '16 at 20:09
  • I've appended my question with the code that compiles but crashes. About your last comment, I now understand why those two properties shouldn't be optionals, but I don't know how they become optionals. The date is taken from a datePicker, which is always set to the current date by default, and then the image is taken from a UIImage that the user can change, and that also has a default image. So if they have "default" values, how can they become nil? – H. Martin Nov 22 '16 at 00:31
  • So get rid of the `?` declarations of those 2 properties and ignore my code. The code I posted is how you would handle encoding optional properties and dealing with their nil/not nil states, but if the values can never be nil then you should make them non-optional. – Duncan C Nov 22 '16 at 00:33
  • If I do that, it still throws that error. Should I just come up with a workaround, like storing the Date in a String and encoding that? – H. Martin Nov 22 '16 at 00:38
  • 1
    NO!!!! Do not store the date in a string. Figure out and fix your errors. You say "it still throws that error." What throws WHAT error? Post your new code, along with the error it throws. (You need to learn how to deal with Optionals or you're not going to get out of first gear in Swift.) – Duncan C Nov 22 '16 at 00:44
  • See the edit to my answer. I posted a demo project that shows how to handle archiving objects with optional properties. I also use `NSSecureCoding`, which you should really be using for new development. – Duncan C Nov 22 '16 at 17:13
1

You can only encode an Objective-C object, and an Optional Date? is not an Objective-C object. Try saying:

aCoder.encode(dueDate! as NSDate, forKey: PropertyKey.dueDateKey)
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Although if it's an optional it would be better/safer to only encode the date if it's not nil, and add a check in the decode to see if that key exists. – Duncan C Nov 21 '16 at 03:44
  • @DuncanC Maybe in general, but in this case we know it's not `nil` because the initializer sets it. There seems no good reason why this is an Optional. – matt Nov 21 '16 at 04:13
  • Hmm. Then it shouldn't be optional then. It's a var, so it could be set to nil during the lifetime of the object. It should either be non-optional, or the `init(coder:)` and `decode(coder:)` methods should be written to handle the case where it's nil/the key is missing (same goes for the image property) – Duncan C Nov 21 '16 at 04:16