Mantle簡介
Mantle是iOS和Mac平台下基於Objective-C編寫的一個簡單高效的模型層框架。
Mantle能做什么
Mantle可以輕松把JSON數據、字典(Dictionary)和模型(即Objective對象)之間的相互轉換,支持自定義映射,並且內置實現了NSCoding和NSCoping,大大簡化歸檔操作。
為什么要使用Mantle
傳統的模型層方案遇到的問題
通常我們用Objective-C寫的模型層遇到了什么問題?
我們可以用Github API來舉例。現在假設我們想用Objective-C展現一個Github Issue,應該怎么做?
目前我們可以想到
-
直接解析JSON數據字典,然后展現給UI
-
將JSON數據轉換為模型,在賦值給UI
關於1,弊端有很多,可以參考我的這篇文章:在iOS開發中使用字典轉模型,現在假設我們選擇了2,我們大致會定義下面的GHIssue模型:
GHIssue.h
#import <Foundation/Foundation.h>
typedef enum : NSUInteger {
GHIssueStateOpen,
GHIssueStateClosed
} GHIssueState;
@class GHUser;
@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;
- (instancetype)initWithDictionary:(NSDictionary *)dictionary;
@end
GHIssue.m
#import "GHIssue.h"
#import "GHUser.h"
@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;
}
- (instancetype)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;
}
- (instancetype)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 decodeIntegerForKey:@"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 encodeInteger:self.state forKey:@"state"];
}
- (instancetype)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];
}
GHUser.h
@interface GHUser : NSObject <NSCoding, NSCopying>
@property (nonatomic, copy) NSString *login;
@property (nonatomic, assign) NSUInteger id;
@property (nonatomic, copy) NSString *avatarUrl;
@property (nonatomic, copy) NSString *gravatarId;
@property (nonatomic, copy) NSString *url;
@property (nonatomic, copy) NSString *htmlUrl;
@property (nonatomic, copy) NSString *followersUrl;
@property (nonatomic, copy) NSString *followingUrl;
@property (nonatomic, copy) NSString *gistsUrl;
@property (nonatomic, copy) NSString *starredUrl;
@property (nonatomic, copy) NSString *subscriptionsUrl;
@property (nonatomic, copy) NSString *organizationsUrl;
@property (nonatomic, copy) NSString *reposUrl;
@property (nonatomic, copy) NSString *eventsUrl;
@property (nonatomic, copy) NSString *receivedEventsUrl;
@property (nonatomic, copy) NSString *type;
@property (nonatomic, assign) BOOL siteAdmin;
- (id)initWithDictionary:(NSDictionary *)dictionary;
@end
你會看到,如此簡單的事情卻有很多弊端。甚至,還有一些其他問題,這個例子里面沒有展示出來。
- 無法使用服務器的新數據來更新這個
GHIssue - 無法反過來將
GHIssue轉換成JSON - 對於
GHIssueState,如果枚舉改編了,現有的歸檔會崩潰 - 如果
GHIssue接口改變了,現有的歸檔會崩潰。
使用MTLModel
如果使用MTLModel,我們可以這樣,聲明一個類繼承自MTLModel
typedef enum : NSUInteger {
GHIssueStateOpen,
GHIssueStateClosed
} GHIssueState;
@interface GHIssue : MTLModel <MTLJSONSerializing>
@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, strong, readonly) GHUser *assignee;
@property (nonatomic, copy, readonly) NSDate *updatedAt;
@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;
}
+ (NSDictionary *)JSONKeyPathsByPropertyKey {
return @{
@"URL": @"url",
@"HTMLURL": @"html_url",
@"number": @"number",
@"state": @"state",
@"reporterLogin": @"user.login",
@"assignee": @"assignee",
@"updatedAt": @"updated_at"
};
}
+ (NSValueTransformer *)URLJSONTransformer {
return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}
+ (NSValueTransformer *)HTMLURLJSONTransformer {
return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}
+ (NSValueTransformer *)stateJSONTransformer {
return [NSValueTransformer mtl_valueMappingTransformerWithDictionary:@{
@"open": @(GHIssueStateOpen),
@"closed": @(GHIssueStateClosed)
}];
}
+ (NSValueTransformer *)assigneeJSONTransformer {
return [MTLJSONAdapter dictionaryTransformerWithModelClass:GHUser.class];
}
+ (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];
}];
}
- (instancetype)initWithDictionary:(NSDictionary *)dictionaryValue error:(NSError **)error {
self = [super initWithDictionary:dictionaryValue error:error];
if (self == nil) return nil;
// Store a value that needs to be determined locally upon initialization.
_retrievedAt = [NSDate date];
return self;
}
@end
很明顯,我們不需要再去實現<NSCoding>, <NSCopying>, -isEqual:和-hash。在你的子類里面生命屬性,MTLModel可以提供這些方法的默認實現。
最初例子里面的問題,在這里都得到了很好的解決。
-
MTLModel提供了一個
- (void)mergeValueForKey:(NSString *)key fromModel:(id<MTLModel>)model{},可以與其他任何實現了MTLModel協議的模型對象集成。 -
+[MTLJSONAdapter JSONDictionaryFromModel:error:]可以把任何遵循MTLJSONSerializing>``協議的對象轉換成JSON字典,+[MTLJSONAdapter JSONArrayFromModels:error:]```類似,不過轉換的是一個數組。
MTLJSONAdapter中的fromJSONDictionary和JSONDictionaryFromModel可以實現模型和JSON的相互轉化。
JSONKeyPathsByPropertyKey可以實現模型和JSON的自定義映射。
JSONTransformerForKey可以對JSON和模型不同類型進行映射。
classForParsingJSONDictionary 如果你使用了類簇(關於類簇,請參考:類簇在iOS開發中的應用),classForParsingJSONDictionary可以讓你選擇使用哪一個類進行JSON反序列化。
- MTLModel可以用歸檔很好的存儲模型而不需要去實現令人厭煩的NSCoding協議。
-decodeValueForKey:withCoder:modelVersion:方法在解碼時會自動調用,如果重寫,可以方便的進行自定義。
持久化
Mantle配合歸檔
MTLModel默認實現了 NSCoding協議,可以利用NSKeyedArchiver方便的對對象進行歸檔和解檔。
Mantle配合Core Data
除了SQLite、FMDB之外,如果你想在你的數據里面執行復雜的查詢,處理很多關系,支持撤銷恢復,Core Data非常適合。
然而,這樣也帶來了一些痛點:
- 仍然有很多弊端
Managed objects解決了上面看到的一些弊端,但是Core Data自生也有他的弊端。正確的配置Core Data和獲取數據需要很多行代碼。 - 很難保持正確性。甚至有經驗的人在使用Core Data時也會犯錯,並且這些問題框架是無法解決的。
如果你想獲取JSON對象,Core Data需要做很多工作,但是卻只能得到很少的回報。
但是,如果你已經在你的APP里面使用了Core Data,Mantle將仍然會是你的API和你的managed model objects之間一個很方便的轉換層。
Mantle配合MagicRecord(一個Core Data框架)
Mantle為我們帶來的好處
-
實現了NSCopying protocol,子類可以直接copy是多么爽的事情
-
實現了NSCoding protocol,跟NSUserDefaults說拜拜
-
提供了-isEqual:和-hash的默認實現,model作NSDictionary的key方便了許多
-
支持自定義映射,這在接口改變的情況下很有用
-
簡單且把一件事情做好,不摻雜網絡相關的操作
合理選擇
雖然上面說了一系列的好處,但如果你的App的代碼規模只有幾萬行,或者API只有十幾個,或者沒有遇到上面這些問題, 建議還是不要引入了,殺雞用指甲刀就夠了。但是,Mantle的實現和思路是值得每位iOS工程師學習和借鑒的。
代碼
https://github.com/terwer/MantleDemo
參考
https://github.com/mantle/mantle
http://segmentfault.com/a/1190000002431365
http://yyny.me/ios/Mantle%E3%80%81JSONModel%E3%80%81MJExtension%E6%80%A7%E8%83%BD%E6%B5%8B%E8%AF%95/
PS: 本文由我們iOS122的小伙伴@TerwerGreen整理編輯,歡迎大家到他的個人博客terwer共同論道!
