NSKeyedArchiver and sharing a custom class between targets
Asked Answered
P

1

2

My app uses a custom class as its data model:

class Drug: NSObject, NSCoding {
    // Properties, methods etc...
}

I have just created a Today extension and need to access the user’s data from it, so I use NSCoding to persist my data in both the app container and the shared container. These are the save and load functions in the main app:

func saveDrugs() {
    // Save to app container
    let isSuccessfulSave = NSKeyedArchiver.archiveRootObject(drugs, toFile: Drug.ArchiveURL.path)
    if isSuccessfulSave {
        print("Drugs successfully saved locally")
    } else {
        print("Error saving drugs locally")
    }

    // Save to shared container for extension
    let isSuccessfulSaveToSharedContainer = NSKeyedArchiver.archiveRootObject(drugs, toFile: Drug.SharedArchiveURL.path)
    if isSuccessfulSaveToSharedContainer {
        print("Drugs successfully saved to shared container")
    } else {
        print("Error saving drugs to shared container")
    }
}

func loadDrugs() -> [Drug]? {
    return NSKeyedUnarchiver.unarchiveObject(withFile: Drug.ArchiveURL.path) as? [Drug]
}

I encountered the problem of class namespacing where the NSKeyedUnarchiver in my Today extension could not decode the object properly, so I used this answer and added @objc before the class definition:

@objc(Drug)
class Drug: NSObject, NSCoding {
    // Properties, methods etc...
}

This solved the problem perfectly. However, this will be version 1.3 of my app, and it seems this breaks the unarchiving process for pre-existing data (as I thought it might).

What is the best way to handle this scenario, as if I just make this change, the new version of the app will crash for existing users!

I cannot find any other answers about this, and I am not sure that the NSKeyedArchiver.setClass() method is relevant, nor am I sure where to use it.

Any help would be gratefully received. Thanks.

Primateship answered 2/2, 2018 at 13:30 Comment(2)
Why does the app crash?Preventer
Hi @Preventer It crashes as the old Drug class (myApp.Drug) cannot be decoded by NSKeyedUnarchiver once I have added the @objc(Drug) line - the class now becomes *.Drug, which works when sharing between the app and extension, but does not decode old user data. New users would be fine.Primateship
I
9

This is exactly the use case for NSKeyedUnarchiver.setClass(_:forClassName:) — you can migrate old classes forward by associating the current classes for their old names:

let unarchiver = NSKeyedUnarchiver(...)
unarchiver.setClass(Drug.self, forClassName: "myApp.Drug")
// unarchive as appropriate

Alternatively, you can provide a delegate conforming to NSKeyedUnarchiverDelegate and providing a unarchiver(_:cannotDecodeObjectOfClassName:originalClasses:), but that's likely overkill for this scenario.


Keep in mind that this works for migrating old data forward. If you have newer versions of the app that need to send data to old versions of the app, what you'll need to do is similar on the archive side — keep encoding the new class with the old name with NSKeyedArchiver.setClassName(_:for:):

let archiver = NSKeyedArchiver(...)
archiver.setClassName("myApp.Drug", for: Drug.self)
// archive as appropriate

If you have this backwards compatibility issue, then unfortunately, it's likely you'll need to keep using the old name as long as there are users of the old version of the app.

Ice answered 2/2, 2018 at 15:48 Comment(9)
Thank you for this! I only need to unarchive as my Today extension only needs to read data and not write it. Once a user has upgraded the app and loaded their stored data, it will be re-archived in the new way. Just to clarify, will your implementation unarchive both the new (@objc(Drug)) class and also the old one? Do I still need to use @objc?Primateship
I’m not at my Mac now but I’ll test this later and mark yours as correct answer. Thanks.Primateship
@Ampoule You'll still need to give the class an @objc name to keep it stable between both targets, so keep @objc(Drug). Setting a class for the existing class name is additive — it adds a mapping from "myApp.Drug" to Drug and will allow you to unarchive "Drug" as itself.Ice
I tried this but it won’t compile as I’m trying to use the unArchiveObject method of the unarchiver instance, but it is a static method so needs to be called on NSKeyedUnarchiver. However, how then do I use setClass? Could I do NSKeyedUnarchiver.setClass(...) then NSKeyedUnarchiver.unarchiveObject(...) ?Primateship
@Primateship That's one way to do it. There's the equivalently-named class function that allows you to set it across all instances of NSKeyedUnarchiver. So you'd write NSKeyedUnarchiver.setClass(Drug.self, forClassName: "myApp.Drug") and then unarchive with NSKeyedUnarchiver.unarchiveObject(with: ...).Ice
Ah I see now. Just checked the Apple documentation and I see there are both class and instance methods. I was clearly only selecting the instance method in XCode. Thanks again!Primateship
@Primateship Happy to have helped.:)Ice
Excuse me sir, where are you putting the unarchiver method? on the class ? or appdelegate?, im talking about this: let archiver = NSKeyedArchiver(...) archiver.setClassName("myApp.Drug", for: Drug.self)Sheepdog
@AlejandroViquez Wherever you are about to actually go ahead and archive or unarchive the data. So, in whichever routines handle either saving the data to disk, or coordinating that. That will vary based on how you have this set up in your app. Alternatively, NSKeyedArchiver and NSKeyedUnarchiver have class methods to do the same thing (e.g. NSKeyedArchiver.setClass(_:forClassName:) which affect all archives in the app, and you can call from the AppDelegate or similarIce

© 2022 - 2024 — McMap. All rights reserved.