为了代码可读性以及开发效率,我们往往会将数据抽象为数据模型,在开发过程中操作数据模型而不是数据本身。

在开发过程中,我们需要将key-value结构的数据,也就是字典,转化为数据模型。也就是字典转模型啦。

字典转模型主要应用在两个场景。网络请求(json解析为模型、模型转字典作为请求参数),模型的数据持久化存取。

下面我们来分别探讨一下,OC跟swift中几种主流的字典转模型方式。

1 swift中字典转模型的方式

1.1 Codable

Codable是swift4开始引入的类型,它包含两个协议Decodable Encodable

public typealias Codable = Decodable & Encodable

public protocol Encodable {
    func encode(to encoder: Encoder) throws
}

public protocol Decodable {
    init(from decoder: Decoder) throws
}
复制代码

使用Codable可以很方便的将数据解码为数据模型,将数据模型编码成数据。

让模型遵从 Codable,一切将都变得很简单

Encodable跟Decodable都有默认的实现。当我们让模型遵从Codable后,就可以可是编解码了。

class User : Codable {
	var name : String?
	...
}
复制代码

json转模型

let model = try? JSONDecoder().decode(User.self, from: jsonData)
复制代码

模型转json

let tdata = try? JSONEncoder().encode(model)
复制代码

而通常情况下,我们要处理的是字典转模型,将json解析步骤交给网络请求库。而Codable在实现json转模型过程中,也是先解析为字典再进行模型转化的。

所以通常情况我们的使用姿势是这样的:

// 字典转模型
func decode<T>(json:Any)->T? where T:Decodable{
    do {
        let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
        let model = try JSONDecoder().decode(T.self, from: data)
        return model
    } catch let err {
        print(err)
        return nil
    }
}

// 模型转字典
func encode<T>(model:T) ->Any? where T:Encodable {
    do {
        let tdata = try JSONEncoder().encode(model)
        let tdict  = try JSONSerialization.jsonObject(with: tdata, options: JSONSerialization.ReadingOptions.allowFragments)
        return tdict
    } catch let error {
        print(error)
        return nil
    }
}
复制代码
如何让我们的模型顺利遵从 Codable

当我们的模型申明遵循Codable协议时,经常会看到碰到does not conform to protocol的报错,那么如何才能让模型顺利准从Codable呢

已经遵从Codable的系统库中的类型:
  • 所有的基础类型,Int,Double, String...
  • swift Foundation中的大部分类型:URL、Data、Date、IndexPath等,不包含OC类型
  • 集合类型:Array,Dictionary<Key, Value>, Set等。需要注意的是: 其所有包含的子类型都要遵从Codable
在遵从Codable时,我们需要注意:
  • 所有属性的类型都必须遵从Codable。除了给我们我们自定义的子类都加上Codable申明外,其他类型必须是上述Codable类型之一
  • enum类型必须实现RawRepresentable,也就是需要定义原始值类型。而且原始值类型必须也遵循Codable
如果需要包含未遵循Codable属性如何处理

在项目当中,我们有时候需要在模型里申明一些非Codable属性,譬如CLLocation、AVAsset等。接下来,我们来探讨一下解决这类需求有那些可行方案

  • 计算属性不会形象对Codable协议的实现。对于任何计算属性我们不用关心它是否Codable
  • 通过extension为非Codable类型添加遵循Codable的申明
  • 首先在extension中申明继承Encodable协议,是支持的,除了class类型必须声明为final(Decodable不支持)外。
  • 但是它有个苛刻的条件,extension必须与类型申明在同一文件。

Implementation of 'Decodable' cannot be automatically synthesized in an extension in a different file to the type

- 这种方式显然是不可行的,除非我们可以修改相应类型的源码
复制代码
  • 实现CodingKeys,并且不包含非Codable属性。
  • 如果CodingKeys中不包含非Codable类型的case,是可以通过编译的:
struct Destination : Codable {
    var location : CLLocationCoordinate2D?
    var city : String?
    enum CodingKeys : String,CodingKey {
        case city
    }
}
复制代码
  • 自己实现Encodable和Decodable并做相应转化
  • 经验证,只要手动实现了Encodable和Decodable协议中的方法,任何非Codable属性的申明都不会报错。而我们需要做的是完成特定的数据结构与非Codable的类型属性的相互转化
什么情况下会转换失败
  • 类型不一致:当属性声明的类型与字典中相应字段的类型不一致时,会抛出异常
  • 当一个非空属性,在字典中找不到对应的key,或者value为空时,会抛出异常
key的特殊处理
  • 如果不希望所有key参与编解码,或者字典中key和属性名不同。

可以申明一个CodingKeys枚举。

enum Codingkeys : String,CodingKey {
    case id,name,age
    case userId = "user_id"
}
复制代码

这样做了之后,在转化过程中,会只处理所有已申明的case,并且根据原始值确定key与属性的对应关系。

如果继承Codable,系统默认会自动合成一个继承CodingKey协议的枚举类型CodingKeys,并且包含所有申明的属性值(不包含static变量),并且stringValue与属性名称一致。

CodingKeys的默认实现是private的,只能在类内部使用

'CodingKeys' is inaccessible due to 'private' protection level

Decodable跟Encodable的默认实现都是基于CodingKeys。如果我们自己将其申明了,系统就会使用我们申明的CodingKeys而不再自动生成。

值得注意的是:如过CodingKeys中包含与属性中没有的case,会报错 does not conform to protocol。

  • 仅仅是命名风格差异

直接设置通过JSONDecoder的设置就可以完成

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
复制代码

它可以讲所有带下划线分割符的key,转化为驼峰命名法

对value的特殊处理
一些可以自动转化的value类型
  • enum类型:可以自动将rawValue值转化为enum类型值
  • URL类型:可以自动将字符串转为URL
  • Date日期类型

默认情况下可以将Double类型,也就是2001年1月1日到现在的时间戳(timeIntervalSinceReferenceDate),转化为Date类型。 我们可以通过JSONDecoder的dateDecodingStrategy属性,来制定 Date 类型的解析策略

public enum DateDecodingStrategy {
    /// default strategy.
    case deferredToDate
    
    case secondsSince1970
    case millisecondsSince1970
    case formatted(DateFormatter)
    case custom((Decoder) throws -> Date)
    
    ///  ISO-8601-formatted string 
    case iso8601
}
复制代码
手动实现协议方法

做了上面这些已经能解决大部分业务场景,但是我们需要在编解码过程中对属性值进行转换,或者做多级映射,我们需要实现Encodable``Decodable两个协议方法,并对属性值做适当处理与转化

struct Destination:Codable {
    var  location : CLLocationCoordinate2D

    private enum CodingKeys:String,CodingKey{
        case latitude
        case longitude
    }
    
    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy:CodingKeys.self)
        try container.encode(location.latitude,forKey:.latitude)
        try container.encode(location.longitude,forKey:.longitude)
    }
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let latitude = try container.decode(CLLocationDegrees.self,forKey:.latitude)
        let longitude = try container.decode(CLLocationDegrees.self,forKey:.longitude)
        self.location = CLLocationCoordinate2D(latitude:latitude,longitude:longitude)
    }
    
}
复制代码
1.2 ObjectMapper

ObjectMapper是用swift编写的json转模型类库。它主要依赖Mappable所有实现了这个协议的类型,都可以轻松实现json与模型之间的转化

使用方式

首先在类中实现Mappable

class UserMap : Mappable {
    var name : String?
    var age : Int?
    var gender : Gender?
    var body : Body?
    
    required init?(map: Map) {}
    
    // Mappable
    func mapping(map: Map) {
        name    		<- map["name"]
        age         <- map["age"]
        gender      <- map["gender"]
        body        <- map["body"]
    }
    
}
复制代码

字典转模型

let u = UserMap(JSON:dict)
复制代码

模型转字典

let json = u?.toJSON()
复制代码
优缺点
  • 如果是swift项目,不用担心桥接问题,代码看起来更swifty。
  • 字典属性的增删改,对转化过程影响也很小。当字典中类型与属性类型不一致时,属性将赋予nil值,其他属性不受影响
  • 缺点也很明显,实现func mapping(map: Map)方法,确定属性与key映射关系,这一步骤会尤为繁琐,尤其是在属性值多时
自动化生成代码

当然,有人将这个繁琐的过程自动化了

  • ObjectMapper-Plugin一个让当前类型代码自动添加Mappable实现的插件
  • json4swift一个自动生成swift模型代码的网站。它是一个很好用的json转数据模型代码的工具。支持Codable、ObjectMapper、Classic Swift Key/Value Dictionary(通过硬编码方式,根据key-value通过属性的getter、setter方法赋值)
1.3 HandyJSON

与Codable用法相似,让模型遵循HandyJSON就可以进行字典转模型、json转模型了。

原理上,主要使用Reflection,依赖于从Swift Runtime源码中推断的内存规则

这是一个阿里的项目,感兴趣的同学可以移步他的github

2 OC中字典转模型的方式
2.1 KVC

KVC全称是Key Value Coding,定义在NSKeyValueCoding.h文件中,是一个非正式协议。KVC提供了一种间接访问其属性方法或成员变量的机制,可以通过字符串来访问对应的属性方法或成员变量。

其核心方法是:

- (void)setValue:(nullable id)value forKey:(NSString *)key;

- (nullable id)valueForKey:(NSString *)key;
复制代码

在字典转模型的方法,大家都很熟悉了:

Person *person = [[Person alloc] init];
[person setValuesForKeysWithDictionary:dic];
复制代码

它等同于

Person *p0 = [[Person alloc] init];
for (NSString *key in dic) {
    [p0 setValue:dic[key] forKey:key];
}
复制代码

唯一不同的是如果字典或json中存在null时,用setValue:forKey:等到的是NSNull的属性值,而setValuesForKeysWithDictionary:得到的是相应属性类型的nil值

模型转字典,可以用:

- (NSDictionary<NSString *, id> *)dictionaryWithValuesForKeys:(NSArray<NSString *> *)keys;
复制代码

在使用时,需要注意的是:

  • 字典中的key必须与对象属性名一一对应
  • 为了避免字典中存在多余的键值而导致崩溃,往往会在模型类的实现中加上:
- (void)setValue:(id)value forUndefinedKey:(NSString *)key{}
复制代码
  • 对于值类型属性,一般我们直接声明为对应的值类型,如int、double、BOOL、CGRect等,而不必声明为NSNumber或NSValue。而不管用那种声明方式都是可以正确转换的。
嵌套类型

对于嵌套类型kvc是不支持直接转化的,但是我们可用通过重写相应属性的setter方法来实现

- (void)setBody:(Body *)body
{
    if (![body isKindOfClass:[Body class]] && [body isKindOfClass:[NSDictionary class]]) {
        _body = [[Body alloc] init];
        [_body setValuesForKeysWithDictionary:(NSDictionary *)body];
    }else{
        _body = body;
    }
}
复制代码

如果value要做一定转化时,也可以用类似方法

key值转化

当字典中key值与属性名存在差异时,可以通过重写setValue:forUndefinedKey实现

- (void)setValue:(id)value forUndefinedKey:(NSString *)key {
    if ([key isEqualToString:@"id"]) {
        self.ID = value;
    }
}
复制代码
在swift中使用

如果需要在swift中使用,首先模型类必须继承自NSObject,所有需要的属性必须加上@objc修饰符

2.2 MJExtension
2.2.1 使用方法

MJExtension是OC中字典转模型最常用的第三方库。他的使用很简单。

// 字典转模型
User *user = [User mj_objectWithKeyValues:dict];

// 模型转字典
NSDictionary *userDict = user.mj_keyValues;
复制代码

而相较于直接使用kvc,它还还支持很多独有的特性

嵌套模型

它支持下述这种嵌套模型。

@interface Body : NSObject
@property (nonatomic,assign)double weight;
@property (nonatomic,assign)double height;
@end

@interface Person : NSObject
@property (nonatomic,strong)NSString *ID;
@property (nonatomic,strong)NSString *userId;
@property (nonatomic,strong)NSString *oldName;
@property (nonatomic,strong)Body *body;
@end

复制代码
key值转化:

如果字典与模型的命名风格不一致,或者需要多级映射。需要在模型类的实现中添加下列方法的实现

+ (NSDictionary *)mj_replacedKeyFromPropertyName
{
    return @{
             @"ID" : @"id",
             @"userId" : @"user_id",
             @"oldName" : @"name.oldName"
             };
}
复制代码

值得注意的是,除了这个字典中的key做相应转化之外,其他属性和key是不受任何影响的。

过滤规则
+ (NSArray *)mj_ignoredPropertyNames
{
    return @[@"selected"];
}
复制代码
2.2.1 原理

MJExtension主要运用了KVC和OC反射机制

字典转模型

字典转模型时,其核心是KVC,主要运用:

- (void)setValue:(nullable id)value forKey:(NSString *)key;
复制代码

在为每一个属性赋值之前,它使用runtime函数,运用oc反射机制,获取所有属性的属性名及类型。在对所有属性名进行白名单和黑名单的过滤,通过对属性类型推断,对键值进行相应转换,保证键值的安全有效,也对嵌套类型做相应处理。还包含一些缓存策略。

核心代码可归纳如下:

- (id)objectWithKeyValues:(NSDictionary *)keyValues type:(Class)type
{
    id obj = [[type alloc] init];
    unsigned int outCount = 0;
    objc_property_t *properties = class_copyPropertyList([Person class],&outCount);
    for (int i = 0; i < outCount;i++) {
        objc_property_t property = properties[i];
        // 获取属性名
        NSString *name = @(property_getName(property));
        // 获取成员类型
        NSString *attrs = @(property_getAttributes(property));
        
        NSString *code = [self codeWithAttributes:attrs];
        // 类型转换为class
        Class propertyClass = [self classWithCode:code];
        
        id value = keyValues[name];
        
        if (!value || value == [NSNull null]) continue;
        
        if (![self isFoundation:propertyClass] && propertyClass) {
            // 模型属性
            value = [self objectWithKeyValues:value type:propertyClass];
        } else {
            value = [self convertValue:value propertyClass:propertyClass code:code];
        }
        // kvc设置属性值
        [obj setValue:value forKey:name];
    }
    return obj;
}
复制代码
模型转字典

主要流程是通过反射获取属性名称(key)的列表,再通过valueForKey:获取属性值(value),然后对key进行过滤转换,value进行转换,最后把所有键值对放入字典中得到结果。

在swift中使用

如果需要在swift中使用,首先模型类必须继承自NSObject,所有需要的属性必须加上@objc修饰符

避免使用Bool类型(官方文档的提示。而Swift5、Xcode10中实测,除了转成字典时bool变为0和1外,无其他异常)