Hi all,
I've spent the better part of a day and half now trying to debug this issue I'm seeing when trying to unarchive a Data blob stored locally (this issue also appears when retrieving it via iCloud, but since they run through the same codepath, I assume they're related).
Background
I originally built this app four years ago, and for reasons that have since been lost to time (but probably because I was more of a novice back then), I relied on the AutoCoding library to get objects in my data model to automagically adopt NSCoding (although I did implement the protocol myself in some places -- like I said, I was an novice) and FCFileManager for saving these objects to the local documents directory. The data model itself is fairly simple: custom NSObjects that have various properties of NSString, NSArray, and other custom NSObject classes (but I will note there are a number of circular references; most of them declared as strong and nonatomic in header files). This combination has (and still does) work well in the production version of the app.
However, in a future update, I'm planning on adding saving/loading files from iCloud. While I've been building that out, I've been looking to trim down my list of third-party dependencies and update older code to iOS 13+ APIs. It so happens that FCFileManager relies on the now-deprecated +[NSKeyedUnarchiver unarchiveObjectWithFile:] and +[NSKeyedArchiver archiveRootObject: toFile:], so I've focused on rewriting what I need from that library using more modern APIs.
I was able to get saving files working pretty easily using this:
@objc static func save(_ content: NSCoding, at fileName: String, completion: ((Bool, Error?) -> ())?) {
CFCSerialQueue.processingQueue.async { // my own serial queue
measureTime(operation: "[LocalService Save] Saving") { // just measures the time it takes for the logic in the closure to process
do {
let data: Data = try NSKeyedArchiver.archivedData(withRootObject: content, requiringSecureCoding: false)
// targetDirectory here is defined earlier in the class as the local documents directory
try data.write(to: targetDirectory!.appendingPathComponent(fileName), options: .atomicWrite)
if (completion != nil) {
completion!(true, nil)
}
} catch {
if (completion != nil) {
completion!(false, error)
}
}
}
}
}
And this works great -- pretty fast and can still be loaded by FCFileManager's minimal wrapper around +[NSKeyedUnarchiver unarchiveObjectWithFile:].
Problem
But loading this file _back_ from the local documents directory has proved to be a massive challenge. Here's what I'm working with right now:
@objc static func load(_ fileName: String, completion: @escaping ((Any?, Error?) -> ())) {
CFCSerialQueue.processingQueue.async {// my own serial queue
measureTime(operation: "[LocalService Load] Loading") {
do {
// targetDirectory here is defined earlier in the class as the local documents directory
let combinedUrl: URL = targetDirectory!.appendingPathComponent(fileName)
if (FileManager.default.fileExists(atPath: combinedUrl.path)) {
let data: Data = try Data(contentsOf: combinedUrl)
let obj: Any? = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data)
completion(obj, nil)
} else {
completion(nil, ServiceError.generic(message: "Data not found at URL \(combinedUrl)"))
}
} catch {
completion(nil, error)
}
}
}
}
I've replaced FCFileManager's +[NSKeyedUnarchiver unarchiveObjectWithFile:] with the new +[NSKeyedUnarchiver unarchiveTopLevelObjectWithData:], but I'm running into EXC_BAD_ACCESS code=2 crashes when getting execution flowing through that line. The stacktrace is never particularly helpful; it's usually ~1500 frames long and jumping between various custom -[NSObject initWithCoder:] implementations. Here's an example (comments added for context, clarity, and conciseness):
@implementation Game
@synthesize AwayKStats,AwayQBStats,AwayRB1Stats,AwayRB2Stats,AwayWR1Stats,AwayWR2Stats,AwayWR3Stats,awayTOs,awayTeam,awayScore,awayYards,awayQScore,awayStarters,gameName,homeTeam,hasPlayed,homeYards,HomeKStats,superclass,HomeQBStats,HomeRB1Stats,HomeRB2Stats,homeStarters,HomeWR1Stats,HomeWR2Stats,HomeWR3Stats,homeScore,homeQScore,homeTOs,numOT,AwayTEStats,HomeTEStats, gameEventLog,HomeSStats,HomeCB1Stats,HomeCB2Stats,HomeCB3Stats,HomeDL1Stats,HomeDL2Stats,HomeDL3Stats,HomeDL4Stats,HomeLB1Stats,HomeLB2Stats,HomeLB3Stats,AwaySStats,AwayCB1Stats,AwayCB2Stats,AwayCB3Stats,AwayDL1Stats,AwayDL2Stats,AwayDL3Stats,AwayDL4Stats,AwayLB1Stats,AwayLB2Stats,AwayLB3Stats,homePlays,awayPlays,playEffectiveness, homeStarterSet, awayStarterSet;
-(id)initWithCoder:(NSCoder *)aDecoder {
self = [super init];
if (self) {
// ...lots of other decoding...
// stack trace says the BAD_ACCESS is flowing through these decoding lines
// @property (atomic) Team *homeTeam;
homeTeam = [aDecoder decodeObjectOfClass:[Team class] forKey:@"homeTeam"];
// @property (atomic) Team *awayTeam;
// there's no special reason for this line using a different decoding method;
// I was just trying to test out both
awayTeam = [aDecoder decodeObjectForKey:@"awayTeam"];
// ...lots of other decoding...
}
return self;
}
Each Game object has a home and away Team; each team has an NSMutableArray of Game objects called gameSchedule, defined as such:
@property (strong, atomic) NSMutableArray<game*> *gameSchedule;
Here's Team's initWithCoder: implementation:
-(id)initWithCoder:(NSCoder *)coder {
self = [super initWithCoder:coder];
if (self) {
if (teamHistory.count > 0) {
if (teamHistoryDictionary == nil) {
teamHistoryDictionary = [NSMutableDictionary dictionary];
}
if (teamHistoryDictionary.count < teamHistory.count) {
for (int i = 0; i < teamHistory.count; i++) {
[teamHistoryDictionary setObject:teamHistory[i] forKey:[NSString stringWithFormat:@"%ld",(long)([HBSharedUtils currentLeague].baseYear + i)]];
}
}
}
if (state == nil) {
// set the home state here
}
if (playersTransferring == nil) {
playersTransferring = [NSMutableArray array];
}
if (![coder containsValueForKey:@"projectedPollScore"]) {
if (teamOLs != nil && teamQBs != nil && teamRBs != nil && teamWRs != nil && teamTEs != nil) {
FCLog(@"[Team Attributes] Adding Projected Poll Score to %@", self.abbreviation);
projectedPollScore = [self projectPollScore];
} else {
projectedPollScore = 0;
}
}
if (![coder containsValueForKey:@"teamStrengthOfLosses"]) {
[self updateStrengthOfLosses];
}
if (![coder containsValueForKey:@"teamStrengthOfSchedule"]) {
[self updateStrengthOfSchedule];
}
if (![coder containsValueForKey:@"teamStrengthOfWins"]) {
[self updateStrengthOfWins];
}
}
return self;
}
Pretty simple other than for the backfilling of some properties. However, this class imports AutoCoding, which hooks into -[NSObject initWithCoder:] like so:
- (void)setWithCoder:(NSCoder *)aDecoder
{
BOOL secureAvailable = [aDecoder respondsToSelector:@selector(decodeObjectOfClass:forKey:)];
BOOL secureSupported = [[self class] supportsSecureCoding];
NSDictionary *properties = self.codableProperties;
for (NSString *key in properties)
{
id object = nil;
Class propertyClass = properties[key];
if (secureAvailable)
{
object = [aDecoder decodeObjectOfClass:propertyClass forKey:key]; // where the EXC_BAD_ACCESS seems to be coming from
}
else
{
object = [aDecoder decodeObjectForKey:key];
}
if (object)
{
if (secureSupported && ![object isKindOfClass:propertyClass] && object != [NSNull null])
{
[NSException raise:AutocodingException format:@"Expected '%@' to be a %@, but was actually a %@", key, propertyClass, [object class]];
}
[self setValue:object forKey:key];
}
}
}
- (instancetype)initWithCoder:(NSCoder *)aDecoder
{
[self setWithCoder:aDecoder];
return self;
}
I did some code tracing and found that execution flows through line 12 of this snippet. Based on some logging I added, it seems like propertyClass somehow gets deallocated before getting passed to -[NSCoder decodeObjectOfClass:forKey:]. However, Xcode shows that propertyClass has a value when the crash occurs (see screenshot: https://imgur.com/a/J0mgrvQ
The property in question in that frame is defined:
@property (strong, nonatomic) Record *careerFgMadeRecord;
and has the following properties itself:
@interface Record : NSObject
@property (strong, nonatomic) NSString *title;
@property (nonatomic) NSInteger year;
@property (nonatomic) NSInteger statistic;
@property (nonatomic) Player *holder;
@property (nonatomic) HeadCoach *coachHolder;
// … some functions
@end
This class also imports AutoCoding, but has no custom initWithCoder: or setWithCoder: implementation.
Curiously, replacing the load method I’ve written with FCFileManager’s version also crashes in the same fashion, so this could be more of an issue with how the data is being archived than how it’s being retrieved. But again, this all works fine when using FCFileManager’s methods to load/save files, so my guess is that there’s some lower-level difference between the implementation of archives in iOS 11 (when FCFileManager was last updated) and iOS 12+ (when the NSKeyedArchiver APIs were updated).
Per some suggestions I've found online (like this one), I also tried this:
@objc static func load(_ fileName: String, completion: @escaping ((Any?, Error?) -> ())) {
CFCSerialQueue.processingQueue.async {
measureTime(operation: "[LocalService Load] Loading") {
do {
let combinedUrl: URL = targetDirectory!.appendingPathComponent(fileName)
if (FileManager.default.fileExists(atPath: combinedUrl.path)) {
let data: Data = try Data(contentsOf: combinedUrl)
let unarchiver: NSKeyedUnarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
unarchiver.requiresSecureCoding = false;
let obj: Any? = try unarchiver.decodeTopLevelObject(forKey: NSKeyedArchiveRootObjectKey)
completion(obj, nil)
} else {
completion(nil, ServiceError.generic(message: "Data not found at URL \(combinedUrl)"))
}
} catch {
completion(nil, error)
}
}
}
}
However, this still throws the same EXC_BAD_ACCESS when trying to decode.
Does anyone have any insight into where I might be going wrong here? I’m sure it’s something simple, but I just can’t seem to figure it out. I have no problem providing more source code if needed to dive deeper.
Thanks for your help!