Mantle——iOS 模型 & 字典转换框架

Mantle——iOS 模型 & 字典转换框架

Mantle 是 iOS 和 Mac 平台下基于 Objective-C 编写的一个简单高效的模型层框架。

典型的模型对象

通常情况下,用 Objective-C 编写模型对象的方式存在哪些问题?

让我们用 GitHub API 进行演示。在 Objective-C 中,如何用一个模型来表示 GitHub
issue

typedef enum : NSUInteger {
    GHIssueStateOpen,
    GHIssueStateClosed
} GHIssueState;

@interface GHIssue : NSObject <NSCoding, NSCopying>

@property (nonatomic, copy, readonly) NSURL *URL;
@property (nonatomic, copy, readonly) NSURL *HTMLURL;
@property (nonatomic, copy, readonly) NSNumber *number;
@property (nonatomic, assign, readonly) GHIssueState state;
@property (nonatomic, copy, readonly) NSString *reporterLogin;
@property (nonatomic, copy, readonly) NSDate *updatedAt;
@property (nonatomic, strong, readonly) GHUser *assignee;
@property (nonatomic, copy, readonly) NSDate *retrievedAt;

@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *body;

- (id)initWithDictionary:(NSDictionary *)dictionary;

@end
@implementation GHIssue

+ (NSDateFormatter *)dateFormatter {
    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    dateFormatter.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
    dateFormatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'";
    return dateFormatter;
}

- (id)initWithDictionary:(NSDictionary *)dictionary {
    self = [self init];
    if (self == nil) return nil;

    _URL = [NSURL URLWithString:dictionary[@"url"]];
    _HTMLURL = [NSURL URLWithString:dictionary[@"html_url"]];
    _number = dictionary[@"number"];

    if ([dictionary[@"state"] isEqualToString:@"open"]) {
        _state = GHIssueStateOpen;
    } else if ([dictionary[@"state"] isEqualToString:@"closed"]) {
        _state = GHIssueStateClosed;
    }

    _title = [dictionary[@"title"] copy];
    _retrievedAt = [NSDate date];
    _body = [dictionary[@"body"] copy];
    _reporterLogin = [dictionary[@"user"][@"login"] copy];
    _assignee = [[GHUser alloc] initWithDictionary:dictionary[@"assignee"]];

    _updatedAt = [self.class.dateFormatter dateFromString:dictionary[@"updated_at"]];

    return self;
}

- (id)initWithCoder:(NSCoder *)coder {
    self = [self init];
    if (self == nil) return nil;

    _URL = [coder decodeObjectForKey:@"URL"];
    _HTMLURL = [coder decodeObjectForKey:@"HTMLURL"];
    _number = [coder decodeObjectForKey:@"number"];
    _state = [coder decodeUnsignedIntegerForKey:@"state"];
    _title = [coder decodeObjectForKey:@"title"];
    _retrievedAt = [NSDate date];
    _body = [coder decodeObjectForKey:@"body"];
    _reporterLogin = [coder decodeObjectForKey:@"reporterLogin"];
    _assignee = [coder decodeObjectForKey:@"assignee"];
    _updatedAt = [coder decodeObjectForKey:@"updatedAt"];

    return self;
}

- (void)encodeWithCoder:(NSCoder *)coder {
    if (self.URL != nil) [coder encodeObject:self.URL forKey:@"URL"];
    if (self.HTMLURL != nil) [coder encodeObject:self.HTMLURL forKey:@"HTMLURL"];
    if (self.number != nil) [coder encodeObject:self.number forKey:@"number"];
    if (self.title != nil) [coder encodeObject:self.title forKey:@"title"];
    if (self.body != nil) [coder encodeObject:self.body forKey:@"body"];
    if (self.reporterLogin != nil) [coder encodeObject:self.reporterLogin forKey:@"reporterLogin"];
    if (self.assignee != nil) [coder encodeObject:self.assignee forKey:@"assignee"];
    if (self.updatedAt != nil) [coder encodeObject:self.updatedAt forKey:@"updatedAt"];

    [coder encodeUnsignedInteger:self.state forKey:@"state"];
}

- (id)copyWithZone:(NSZone *)zone {
    GHIssue *issue = [[self.class allocWithZone:zone] init];
    issue->_URL = self.URL;
    issue->_HTMLURL = self.HTMLURL;
    issue->_number = self.number;
    issue->_state = self.state;
    issue->_reporterLogin = self.reporterLogin;
    issue->_assignee = self.assignee;
    issue->_updatedAt = self.updatedAt;

    issue.title = self.title;
    issue->_retrievedAt = [NSDate date];
    issue.body = self.body;

    return issue;
}

- (NSUInteger)hash {
    return self.number.hash;
}

- (BOOL)isEqual:(GHIssue *)issue {
    if (![issue isKindOfClass:GHIssue.class]) return NO;

    return [self.number isEqual:issue.number] && [self.title isEqual:issue.title] && [self.body isEqual:issue.body];
}

@end

哇,这么简单的事情就编写了很多样板代码!而且,即使如此,此示例仍无法解决一些问题:

  • 无法使用服务器的新数据更新 GHIssue 对象。
  • 无法反过来将 GHIssue 对象转换回 JSON 模型。
  • GHIssueState 不应原样编码。如果这个枚举类型将来发生了变更,则现有的归档会崩溃(无法向下兼容)。
  • 如果 GHIssue 的接口未来发生变化,则现有的归档会崩溃(无法向下兼容)。

为什么不使用 Core Data?

Core Data 很好地解决了某些问题。如果你需要对数据执行复杂的查询,处理具有大量关系的巨大对象图或支持撤消和重做,那么 Core Data 是一个很好的选择。

但是,它确实也有一些痛点:

  • 仍然需要编写很多样板代码。管理对象减少了上面看到的一些样板代码,但是 Core Data 有很多自己的东西。正确设置 Core Data 堆栈(持久性存储和持久性存储协调器)并执行提取操作可能也需要编写不少代码。
  • 它很难正确工作。即使是经验丰富的开发人员,在使用 Core Data 时也会犯错,并且该框架也让人难以忍受。

如果你只是想尝试访问 JSON 对象,Core Data 可能需要耗费很多功夫而收效甚微(投入大于收益,不划算)。

尽管如此,如果你已经在应用程序中使用或想要使用 Core Data,Mantle 仍然可以是 API 和模型对象之间的便捷转换层。

MTLModel

使用 MTLModel。这是继承自 MTLModel 对象的 GHIssue 对象示例:

typedef enum : NSUInteger {
    GHIssueStateOpen,
    GHIssueStateClosed
} GHIssueState;

// !!!: 必须遵守 <MTLJSONSerializing> 协议
@interface GHIssue : MTLModel <MTLJSONSerializing>

@property (nonatomic, copy, readonly) NSURL *URL;     // URL 类型
@property (nonatomic, copy, readonly) NSURL *HTMLURL; // URL 类型
@property (nonatomic, copy, readonly) NSNumber *number;
@property (nonatomic, assign, readonly) GHIssueState state; // 枚举类型
@property (nonatomic, copy, readonly) NSString *reporterLogin;
@property (nonatomic, strong, readonly) GHUser *assignee; // 该属性指向 GHUser 对象实例
@property (nonatomic, copy, readonly) NSDate *updatedAt;  // JSON 日期字符串,转换为 NSDate

@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *body;

@property (nonatomic, copy, readonly) NSDate *retrievedAt;

@end
@implementation GHIssue

+ (NSDateFormatter *)dateFormatter {
    NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init];
    dateFormatter.locale = [[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"];
    dateFormatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'";
    return dateFormatter;
}

// 模型和 JSON 的自定义映射
+ (NSDictionary *)JSONKeyPathsByPropertyKey {
    return @{
        @"URL"           : @"url",
        @"HTMLURL"       : @"html_url",
        @"number"        : @"number",
        @"state"         : @"state",
        @"reporterLogin" : @"user.login",
        @"assignee"      : @"assignee",
        @"updatedAt"     : @"updated_at"
    };
}

// 自定义 JSON 模型转换,URL -> NSURL
+ (NSValueTransformer *)URLJSONTransformer {
    return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}

// 自定义 JSON 模型转换,URL -> NSURL
+ (NSValueTransformer *)HTMLURLJSONTransformer {
    return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}

// 自定义 JSON 模型转换,JSON 字符串 -> 枚举类型
+ (NSValueTransformer *)stateJSONTransformer {
    return [NSValueTransformer mtl_valueMappingTransformerWithDictionary:@{
        @"open": @(GHIssueStateOpen),
        @"closed": @(GHIssueStateClosed)
    }];
}

// assignee 属性是一个 GHUser 对象实例
+ (NSValueTransformer *)assigneeJSONTransformer {
    return [MTLJSONAdapter dictionaryTransformerWithModelClass:GHUser.class];
}

// 自定义 JSON 模型转换,JSON 字符串 -> NSDate
+ (NSValueTransformer *)updatedAtJSONTransformer {
    return [MTLValueTransformer transformerUsingForwardBlock:^id(NSString *dateString, BOOL *success, NSError *__autoreleasing *error) {
        // 自定义 JSON 转模型方式
        return [self.dateFormatter dateFromString:dateString];
    } reverseBlock:^id(NSDate *date, BOOL *success, NSError *__autoreleasing *error) {
        // 自定义模型转 JSON 方式
        return [self.dateFormatter stringFromDate:date];
    }];
}

- (instancetype)initWithDictionary:(NSDictionary *)dictionaryValue error:(NSError **)error {
    self = [super initWithDictionary:dictionaryValue error:error];
    if (self == nil) return nil;

    // 存储需要在初始化时由本地确定的值
    _retrievedAt = [NSDate date];

    return self;
}

@end

此版本中明显没有 <NSCoding>
<NSCopying>-isEqual:,和 -hash 的方法实现。通过检查子类中的 @property 属性声明,MTLModel 可以为所有这些方法提供默认实现。

原始示例中的问题也都被修复了:

无法使用服务器中的新数据更新 GHIssue 对象。

MTLModel 扩展了一个的 -mergeValuesForKeys: FromModel:方法,可以与其他任何实现了<MTLModel> 协议的模型对象集成。

无法将 GHIssue 模型转换回 JSON 对象。

这就是反向转换器真正派上用场的地方。
+[MTLJSONAdapter JSONDictionaryFromModel:error:] 可以把任何遵守 <MTLJSONSerializing> 协议的模型对象转换回 JSON 字典。
+[MTLJSONAdapter JSONArrayFromModels:error:] 是同样的,但是它是将包含模型对象的数组转换为 JSON 数组。

如果 GHIssue 的接口发生变化,则现有存档可能会无法工作。

MTLModel 会自动保存用于归档的模型对象的版本。当解档时,如果覆写了 -decodeValueForKey:withCoder:modelVersion: 方法,它会被自动调用,从而为你提供方便的挂钩(hook)来升级旧数据。

MTLJSONSerializing - 模型和 JSON 的相互转换

为了将模型对象从 JSON 序列化或序列化为 JSON,你需要在自定义的 MTLModel 子类对象中声明该子类对象遵守<MTLJSONSerializing> 协议。这样就可以使用 MTLJSONAdapter 将模型对象从 JSON 转换回来:

// JSON -> Model
NSError *error = nil;
XYUser *user = [MTLJSONAdapter modelOfClass:XYUser.class fromJSONDictionary:JSONDictionary error:&error];
// Model -> JSON
NSError *error = nil;
NSDictionary *JSONDictionary = [MTLJSONAdapter JSONDictionaryFromModel:user error:&error];

+JSONKeyPathsByPropertyKey - 实现模型和 JSON 的自定义映射

此方法返回的 NSDictionary 字典用于指定如何将模型对象的属性映射到 JSON 的键上。

@interface XYUser : MTLModel <MTLJSONSerializing>

@property (readonly, nonatomic, copy) NSString *name;
@property (readonly, nonatomic, strong) NSDate *createdAt;

@property (readonly, nonatomic, assign, getter = isMeUser) BOOL meUser;
@property (readonly, nonatomic, strong) XYHelper *helper;

@end

@implementation XYUser

// 模型和 JSON 的自定义映射
// 将模型对象的属性名称与 JSON 对象的 key 名称进行映射。
+ (NSDictionary *)JSONKeyPathsByPropertyKey {
    return @{
        @"name": @"name",
        @"createdAt": @"created_at"
    };
}

- (instancetype)initWithDictionary:(NSDictionary *)dictionaryValue error:(NSError **)error {
    self = [super initWithDictionary:dictionaryValue error:error];
    if (self == nil) return nil;

    _helper = [XYHelper helperWithName:self.name createdAt:self.createdAt];

    return self;
}

@end

在此示例中,XYUser 类声明了 Mantle 需要以不同方式处理的四个属性:

  • name 属性被映射到了 JSON 中相同名称的键上。
  • createdAt 属性映射到了其等效的 snack 语法格式的键上。
  • meUser 属性没有序列化为 JSON。
  • JSON 反序列化后,helper 属性会在本地被初始化。

如果模型的父类还遵守了 <MTLJSONSerializing> 协议,则使用 -[NSDictionary mtl_dictionaryByAddingEntriesFromDictionary:] 来合并其映射。

如果你想将模型类的所有属性映射到它们自己,则可以使用+[NSDictionary mtl_identityPropertyMapWithModel:] 辅助方法。

使用 +[MTLJSONAdapter modelOfClass:fromJSONDictionary:error:] 方法反序列化 JSON 时,与属性名称不对应或具有显式映射的 JSON 将被忽略:

NSDictionary *JSONDictionary = @{
    @"name": @"john",
    @"created_at": @"2013/07/02 16:40:00 +0000",
    @"plan": @"lite"
};

NSError *error = nil;
XYUser *user = [MTLJSONAdapter modelOfClass:XYUser.class
                         fromJSONDictionary:JSONDictionary
                                      error:&error];
/**
 <XYUser: 0x280d99170> {
    helper = <XYHelper: 0x2803c99e0> {
    name = john,
    createdAt = 2013-07-02 16:40:00 +0000
}
*/

该示例中, plan 字段将会被忽略,因为它既不匹配 XYUser 的属性名称,也不映射到+JSONKeyPathsByPropertyKey 中。

+JSONTransformerForKey: - 对 JSON 和模型不同类型手动进行映射

从 JSON 反序列化时,实现这个 <MTLJSONSerializing> 协议中可选的方法以将属性转换为其他类型。

💡

将 JSON 对象转换为模型对象时,如果 JSON 对象的数据类型和模型对象的数据类型不一致,或者无法实现自动转换时,需要通过以下的方法进行手动转换。

+ (NSValueTransformer *)JSONTransformerForKey:(NSString *)key;

此方法支持批量的自定义映射!通过判断属性名 key 的不同,可以实现多个属性的自定义映射操作。

// 注意:该方法中的局部参数 key 指的是「模型对象」中的属性名称。
+ (NSValueTransformer *)JSONTransformerForKey:(NSString *)key {
    if ([key isEqualToString:@"createdAt"]) {
        // 当处理 createdAt 属性的映射时,执行自定义转换
        return [NSValueTransformer valueTransformerForName:XYDateValueTransformerName];
    }

    return nil;
}

key 是应用于模型对象的属性名;不是原始的 JSON 中的键。如果你使用 +JSONKeyPathsByPropertyKey 转换时,请记住这一点。

为了更加方便,如果你实现了 +<key>JSONTransformer 方法,那么 MTLJSONAdapter 将改用该方法的结果。例如,JSON 中通常表示为字符串的日期可以转换为 NSDate,如下所示:

// 自定义 JSON 模型转换,JSON 字符串 -> NSDate
+ (NSValueTransformer *)updatedAtJSONTransformer {
    return [MTLValueTransformer transformerUsingForwardBlock:^id(NSString *dateString, BOOL *success, NSError *__autoreleasing *error) {
        return [self.dateFormatter dateFromString:dateString];
    } reverseBlock:^id(NSDate *date, BOOL *success, NSError *__autoreleasing *error) {
        return [self.dateFormatter stringFromDate:date];
    }];
}

如果转换器是可逆的,则在将对象序列化为 JSON 时也将使用它。

💡

也就是说,属性的自定义转换支付两种方法,一种是:

+ (NSValueTransformer *)JSONTransformerForKey:(NSString *)key;

它支持批量的自定义映射操作。

还有一种是单个属性的自定义映射方法,即:

+<key>JSONTransformer;

这边的 <key> 是模型对象中属性的名字。以上面的 GHIssue 例子来说,GHIssue 对象中的第一个属性 URLNSURL 类型的属性,而 JSON 模型返回的 URL 链接是一个字符串类型,它们之间的数据类型不一致,因此这个属性无法实现自动转换,需要手动实现,即:

// 自定义 JSON 模型转换,URL -> NSURL
// 这个方法中的 <key> 就是 URL,即模型中的 URL 属性。
+ (NSValueTransformer *)URLJSONTransformer {
    return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}

也就是说每个单独实现的自定义转换方法名是通过模型属性名与 JSONTransformer 拼接而来的。

另外,这个 “拼接形式” 的自定义模型转换方法的优先级比 JSONTransformerForKey: 要高!也就是说,如果两个方法中都实现了某一个属性的自定义 JSON 模型转换,则以 +<key>JSONTransformer; 方法的实现为准!

+classForParsingJSONDictionary:

如果你使用了类簇,请实现此可选方法,classForParsingJSONDictionary 可以让你选择使用哪一个类进行 JSON 反序列化。

@interface XYMessage : MTLModel

@end

@interface XYTextMessage: XYMessage

@property (readonly, nonatomic, copy) NSString *body;

@end

@interface XYPictureMessage : XYMessage

@property (readonly, nonatomic, strong) NSURL *imageURL;

@end

@implementation XYMessage

+ (Class)classForParsingJSONDictionary:(NSDictionary *)JSONDictionary {
    if (JSONDictionary[@"image_url"] != nil) {
        return XYPictureMessage.class;
    }

    if (JSONDictionary[@"body"] != nil) {
        return XYTextMessage.class;
    }

    NSAssert(NO, @"No matching class for the JSON dictionary '%@'.", JSONDictionary);
    return self;
}

@end

然后,MTLJSONAdapter 会根据你传入的 JSON 字典自动选择类:

NSDictionary *textMessage = @{
    @"id": @1,
    @"body": @"Hello World!"
};

NSDictionary *pictureMessage = @{
    @"id": @2,
    @"image_url": @"http://example.com/lolcat.gif"
};

XYTextMessage *messageA = [MTLJSONAdapter modelOfClass:XYMessage.class fromJSONDictionary:textMessage error:NULL];

XYPictureMessage *messageB = [MTLJSONAdapter modelOfClass:XYMessage.class fromJSONDictionary:pictureMessage error:NULL];

Persistence 持久化存储

Mantle 不会自动为你保留对象。但是,MTLModel 默认实现了 NSCoding 协议,可以利用 NSKeyedArchiver 方便的对对象进行归档和解档。

如果你需要更强大的功能,或者想要避免一次将整个模型保留在内存中,那么 Core Data 可能是更好的选择。

最低系统要求

Mantle supports the following platform deployment targets:

  • macOS 10.10+
  • iOS 8.0+
  • tvOS 9.0+
  • watchOS 2.0+

导入 Mantle

手动导入

To add Mantle to your application:

  1. Add the Mantle repository as a submodule of your application's repository.
  2. Run git submodule update --init --recursive from within the Mantle folder.
  3. Drag and drop Mantle.xcodeproj into your application's Xcode project.
  4. On the "General" tab of your application target, add Mantle.framework to the "Embedded Binaries".

If you’re instead developing Mantle on its own, use the Mantle.xcworkspace file.

Carthage 方式

Simply add Mantle to your Cartfile:

github "Mantle/Mantle"

CocoaPods 方式

Add Mantle to your Podfile under the build target they want it used in:

target 'MyAppOrFramework' do
  pod 'Mantle'
end

Then run a pod install within Terminal or the CocoaPods app.

License

Mantle is released under the MIT license. See
LICENSE.md.

More Info

Have a question? Please open an issue!

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342