標籤:
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架構)
參考 MagicalRecord配合Mantle
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
字典轉模型架構 Mantle的使用:國外程式員最常用的iOS模型