升级 Xcode 11 踩坑归档解档

5,509 阅读4分钟

背景

由于4月之后苹果要求不能使用老本版的Xcode打包提审,因此最近一次上线更新升级成了Xcode 11.3.1版本。iOS13适配要点总结有一些大佬已经总结很全面了,这里补充记录一个归档解档的坑。

问题代码

- (void)updateCache {
    NSMutableDictionary *cache = [NSMutableDictionary dictionary];
    if (self.viewModel.data1) {
        [cache setObject:self.viewModel.data1 forKey:@"data1"];
    }
    if (self.viewModel.data2) {
        [cache setObject:self.viewModel.data2 forKey:@"data2"];
    }
    if (self.viewModel.data3) {
        [cache setObject:self.viewModel.data3 forKey:@"data3"];
    }
    NSData *archiverData = [NSKeyedArchiver archivedDataWithRootObject:[cache copy]];
    NSString *archiverString = [archiverData base64EncodedStringWithOptions:0];
    [[NSUserDefaults standardUserDefaults] setObject:archiverString forKey:@"cache"];
    [[NSUserDefaults standardUserDefaults] synchronize];
}

- (void)loadCache {
    NSString *archiverString = [[NSUserDefaults standardUserDefaults] objectForKey:@"cache"];
    if (archiverString) {
        @try {
            NSData *archiverData = [[NSData alloc] initWithBase64EncodedString:archiverString options:0];
            NSDictionary *cacheDic = [NSKeyedUnarchiver unarchiveObjectWithData:archiverData];
            self.viewModel.data1 = cacheDic[@"data1"];
            self.viewModel.data2 = cacheDic[@"data2"];
            self.viewModel.data3 = cacheDic[@"data3"];
        } @catch (NSException *exception) {
            [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"cache"];
        }
    }
}

我们的缓存策略是第一次进入页面返回数据后进行updateCache操作,后续刷新接口时比对数据MD5跟之前是否一致,不一致使用新数据展示并重新进行updateCache,一致的话加载之前缓存数据loadCache

问题就是在loadCache方法中解档出来的cacheDic虽热归档进去的每个对象都存在,但是对象对应的属性值全部都为nil

寻找原因很痛苦毕竟除了升级Xcode其他什么都没改。最后在官方方法中看到了端倪。

+ (NSData *)archivedDataWithRootObject:(id)rootObject API_DEPRECATED("Use +archivedDataWithRootObject:requiringSecureCoding:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));

+ (nullable id)unarchiveObjectWithData:(NSData *)data API_DEPRECATED("Use +unarchivedObjectOfClass:fromData:error: instead", macosx(10.2,10.14), ios(2.0,12.0), watchos(2.0,5.0), tvos(9.0,12.0));

iOS12之后两个归档解档的方法被废弃了,iOS11之后提供了新的方法。

+ (nullable NSData *)archivedDataWithRootObject:(id)object requiringSecureCoding:(BOOL)requiresSecureCoding error:(NSError **)error API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0));
+ (nullable id)unarchivedObjectOfClass:(Class)cls fromData:(NSData *)data error:(NSError **)error API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0)) NS_REFINED_FOR_SWIFT;
+ (nullable id)unarchivedObjectOfClasses:(NSSet<Class> *)classes fromData:(NSData *)data error:(NSError **)error API_AVAILABLE(macos(10.13), ios(11.0), watchos(4.0), tvos(11.0)) NS_REFINED_FOR_SWIFT;

注意到官方新的API中归档方法里面有个requiringSecureCoding参数,对应归档数据是否遵循NSSecureCoding协议。可以看出新的API更加安全。

替换后代码

- (void)updateCache {
    NSMutableDictionary *cache = [NSMutableDictionary dictionary];
    if (self.viewModel.data1) {
        [cache setObject:self.viewModel.data1 forKey:@"data1"];
    }
    if (self.viewModel.data2) {
        [cache setObject:self.viewModel.data2 forKey:@"data2"];
    }
    if (self.viewModel.data3) {
        [cache setObject:self.viewModel.data3 forKey:@"data3"];
    }
    NSData *archiverData = nil;
    if (@available(iOS 11.0, *)) {
        NSError *error = nil;
        archiverData = [NSKeyedArchiver archivedDataWithRootObject:[cache copy] requiringSecureCoding:YES error:&error];
    } else {
        archiverData = [NSKeyedArchiver archivedDataWithRootObject:[cache copy]];
    }
    NSString *archiverString = [archiverData base64EncodedStringWithOptions:0];
    [[NSUserDefaults standardUserDefaults] setObject:archiverString forKey:@"cacheData"];
    [[NSUserDefaults standardUserDefaults] synchronize];
}
- (void)loadCache
{
    NSString *archiverString = [[NSUserDefaults standardUserDefaults] objectForKey:@"cacheData"];
    if (archiverString) {
        @try {
            NSData *archiverData = [[NSData alloc] initWithBase64EncodedString:archiverString options:0];
            NSDictionary *cacheDic = nil;
            NSError *error = nil;
            if (@available(iOS 11.0, *)) {
                NSSet *set = [[NSSet alloc] initWithArray:@[[Data1Class class], [Data2Class class], [Data3Class class], [Data3Class class], [NSArray class], [NSDictionary class]]];
                cacheDic = [NSKeyedUnarchiver unarchivedObjectOfClasses:set fromData:archiverData error:&error];
            } else {
               cacheDic = [NSKeyedUnarchiver unarchiveObjectWithData:archiverData];
            }
            self.viewModel.data1 = homeCacheDic[@"data1"];
            self.viewModel.data2 = homeCacheDic[@"data2"];
            self.viewModel.data3 = homeCacheDic[@"data3"];
        } @catch (NSException *exception) {
            [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"cacheData"];
        }
    }
}

最初的更改是直接替换了API,发现不管requiringSecureCoding设为truefalse都还是之前的效果(解档出来的每个对象都存在,但是对象对应的属性值全部都为nil)。

最终的解决方案是归档时requiringSecureCoding设为true,归档的自定义数据都遵守NSSecureCoding协议,并实现对应方法。解档时unarchivedObjectOfClasses对应的NSSet要包括归档时数据结构的所有类名。

NSSecureCoding协议对应要实现的方法有3个:

public protocol NSSecureCoding : NSCoding {
    static var supportsSecureCoding: Bool { get }
}

public protocol NSCoding {
    func encode(with coder: NSCoder)
    init?(coder: NSCoder)
}

由于我们是OCSwift混编,并使用了ObjectMapper做数据模型转换。所以伪代码大概是这样:

import UIKit
import ObjectMapper
@objc(Data1)
@objcMembers class Data1: NSObject, Mappable, NSSecureCoding {
    required init?(coder: NSCoder) {
        param1 = coder.decodeObject(forKey: "param1") as? String
        param2 = coder.decodeObject(forKey: "param2") as? String
        param3 = coder.decodeObject(forKey: "param3") as? String
        param4 = coder.decodeBool(forKey: "param4")
    }
    
    static var supportsSecureCoding: Bool {
        return true
    }
    
    func encode(with coder: NSCoder) {
        coder.encode(param1, forKey: "param1")
        coder.encode(param2, forKey: "param2")
        coder.encode(param3, forKey: "param3")
        coder.encode(param4, forKey: "param4")
    }
    
    var param1: String?
    var param2: String?
    var param3: String?
    var param4: Bool = false
    
    required init?(map: Map) { }
    
    func mapping(map: Map) {
        param1 <- map["param1"]
        param2 <- map["param2"]
        param3 <- map["param3"]
        param4 <- map["param4"]
    }
}

API在归档中用到的所有自定义数据模型类全部实现NSSecureCoding之后,发现解档出来的对象对应的属性已经有正确的值了。

总结

踩这个坑感觉有几个点需要注意:

  1. 本地存储归档字符串的Key需要更改一下,防止新代码读取老缓存失败的问题。
  2. 如果归档时传入的数据结构包含了NSArrayNSDictionary,那么在解档时unarchivedObjectOfClasses对应的NSSet中也应该添加对应的类名,否则解档出来的值为nil
  3. APIiOS11之后出的,所以要做好之前系统版本的兼容。
  4. 如果你的数据模型类是多个自定义类嵌套的话,记得解档时所有涉及的所有类都要添加在unarchivedObjectOfClasses对应的NSSet中。