Mantle——iOS 模型 & 字典轉(zhuǎn)換框架

Mantle——iOS 模型 & 字典轉(zhuǎn)換框架

Mantle 是 iOS 和 Mac 平臺(tái)下基于 Objective-C 編寫的一個(gè)簡(jiǎn)單高效的模型層框架。

典型的模型對(duì)象

通常情況下,用 Objective-C 編寫模型對(duì)象的方式存在哪些問題?

讓我們用 GitHub API 進(jìn)行演示。在 Objective-C 中,如何用一個(gè)模型來表示 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

哇,這么簡(jiǎn)單的事情就編寫了很多樣板代碼!而且,即使如此,此示例仍無法解決一些問題:

  • 無法使用服務(wù)器的新數(shù)據(jù)更新 GHIssue 對(duì)象。
  • 無法反過來將 GHIssue 對(duì)象轉(zhuǎn)換回 JSON 模型。
  • GHIssueState 不應(yīng)原樣編碼。如果這個(gè)枚舉類型將來發(fā)生了變更,則現(xiàn)有的歸檔會(huì)崩潰(無法向下兼容)。
  • 如果 GHIssue 的接口未來發(fā)生變化,則現(xiàn)有的歸檔會(huì)崩潰(無法向下兼容)。

為什么不使用 Core Data?

Core Data 很好地解決了某些問題。如果你需要對(duì)數(shù)據(jù)執(zhí)行復(fù)雜的查詢,處理具有大量關(guān)系的巨大對(duì)象圖或支持撤消和重做,那么 Core Data 是一個(gè)很好的選擇。

但是,它確實(shí)也有一些痛點(diǎn):

  • 仍然需要編寫很多樣板代碼。管理對(duì)象減少了上面看到的一些樣板代碼,但是 Core Data 有很多自己的東西。正確設(shè)置 Core Data 堆棧(持久性存儲(chǔ)和持久性存儲(chǔ)協(xié)調(diào)器)并執(zhí)行提取操作可能也需要編寫不少代碼。
  • 它很難正確工作。即使是經(jīng)驗(yàn)豐富的開發(fā)人員,在使用 Core Data 時(shí)也會(huì)犯錯(cuò),并且該框架也讓人難以忍受。

如果你只是想嘗試訪問 JSON 對(duì)象,Core Data 可能需要耗費(fèi)很多功夫而收效甚微(投入大于收益,不劃算)。

盡管如此,如果你已經(jīng)在應(yīng)用程序中使用或想要使用 Core Data,Mantle 仍然可以是 API 和模型對(duì)象之間的便捷轉(zhuǎn)換層。

MTLModel

使用 MTLModel。這是繼承自 MTLModel 對(duì)象的 GHIssue 對(duì)象示例:

typedef enum : NSUInteger {
    GHIssueStateOpen,
    GHIssueStateClosed
} GHIssueState;

// !!!: 必須遵守 <MTLJSONSerializing> 協(xié)議
@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 對(duì)象實(shí)例
@property (nonatomic, copy, readonly) NSDate *updatedAt;  // JSON 日期字符串,轉(zhuǎn)換為 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 模型轉(zhuǎn)換,URL -> NSURL
+ (NSValueTransformer *)URLJSONTransformer {
    return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}

// 自定義 JSON 模型轉(zhuǎn)換,URL -> NSURL
+ (NSValueTransformer *)HTMLURLJSONTransformer {
    return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}

// 自定義 JSON 模型轉(zhuǎn)換,JSON 字符串 -> 枚舉類型
+ (NSValueTransformer *)stateJSONTransformer {
    return [NSValueTransformer mtl_valueMappingTransformerWithDictionary:@{
        @"open": @(GHIssueStateOpen),
        @"closed": @(GHIssueStateClosed)
    }];
}

// assignee 屬性是一個(gè) GHUser 對(duì)象實(shí)例
+ (NSValueTransformer *)assigneeJSONTransformer {
    return [MTLJSONAdapter dictionaryTransformerWithModelClass:GHUser.class];
}

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

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

    // 存儲(chǔ)需要在初始化時(shí)由本地確定的值
    _retrievedAt = [NSDate date];

    return self;
}

@end

此版本中明顯沒有 <NSCoding>,
<NSCopying>,-isEqual:,和 -hash 的方法實(shí)現(xiàn)。通過檢查子類中的 @property 屬性聲明,MTLModel 可以為所有這些方法提供默認(rèn)實(shí)現(xiàn)。

原始示例中的問題也都被修復(fù)了:

無法使用服務(wù)器中的新數(shù)據(jù)更新 GHIssue 對(duì)象。

MTLModel 擴(kuò)展了一個(gè)的 -mergeValuesForKeys: FromModel:方法,可以與其他任何實(shí)現(xiàn)了<MTLModel> 協(xié)議的模型對(duì)象集成。

無法將 GHIssue 模型轉(zhuǎn)換回 JSON 對(duì)象。

這就是反向轉(zhuǎn)換器真正派上用場(chǎng)的地方。
+[MTLJSONAdapter JSONDictionaryFromModel:error:] 可以把任何遵守 <MTLJSONSerializing> 協(xié)議的模型對(duì)象轉(zhuǎn)換回 JSON 字典。
+[MTLJSONAdapter JSONArrayFromModels:error:] 是同樣的,但是它是將包含模型對(duì)象的數(shù)組轉(zhuǎn)換為 JSON 數(shù)組。

如果 GHIssue 的接口發(fā)生變化,則現(xiàn)有存檔可能會(huì)無法工作。

MTLModel 會(huì)自動(dòng)保存用于歸檔的模型對(duì)象的版本。當(dāng)解檔時(shí),如果覆寫了 -decodeValueForKey:withCoder:modelVersion: 方法,它會(huì)被自動(dòng)調(diào)用,從而為你提供方便的掛鉤(hook)來升級(jí)舊數(shù)據(jù)。

MTLJSONSerializing - 模型和 JSON 的相互轉(zhuǎn)換

為了將模型對(duì)象從 JSON 序列化或序列化為 JSON,你需要在自定義的 MTLModel 子類對(duì)象中聲明該子類對(duì)象遵守<MTLJSONSerializing> 協(xié)議。這樣就可以使用 MTLJSONAdapter 將模型對(duì)象從 JSON 轉(zhuǎn)換回來:

// 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 - 實(shí)現(xiàn)模型和 JSON 的自定義映射

此方法返回的 NSDictionary 字典用于指定如何將模型對(duì)象的屬性映射到 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 的自定義映射
// 將模型對(duì)象的屬性名稱與 JSON 對(duì)象的 key 名稱進(jìn)行映射。
+ (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 需要以不同方式處理的四個(gè)屬性:

  • name 屬性被映射到了 JSON 中相同名稱的鍵上。
  • createdAt 屬性映射到了其等效的 snack 語(yǔ)法格式的鍵上。
  • meUser 屬性沒有序列化為 JSON。
  • JSON 反序列化后,helper 屬性會(huì)在本地被初始化。

如果模型的父類還遵守了 <MTLJSONSerializing> 協(xié)議,則使用 -[NSDictionary mtl_dictionaryByAddingEntriesFromDictionary:] 來合并其映射。

如果你想將模型類的所有屬性映射到它們自己,則可以使用+[NSDictionary mtl_identityPropertyMapWithModel:] 輔助方法。

使用 +[MTLJSONAdapter modelOfClass:fromJSONDictionary:error:] 方法反序列化 JSON 時(shí),與屬性名稱不對(duì)應(yīng)或具有顯式映射的 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 字段將會(huì)被忽略,因?yàn)樗炔黄ヅ?XYUser 的屬性名稱,也不映射到+JSONKeyPathsByPropertyKey 中。

+JSONTransformerForKey: - 對(duì) JSON 和模型不同類型手動(dòng)進(jìn)行映射

從 JSON 反序列化時(shí),實(shí)現(xiàn)這個(gè) <MTLJSONSerializing> 協(xié)議中可選的方法以將屬性轉(zhuǎn)換為其他類型。

??

將 JSON 對(duì)象轉(zhuǎn)換為模型對(duì)象時(shí),如果 JSON 對(duì)象的數(shù)據(jù)類型和模型對(duì)象的數(shù)據(jù)類型不一致,或者無法實(shí)現(xiàn)自動(dòng)轉(zhuǎn)換時(shí),需要通過以下的方法進(jìn)行手動(dòng)轉(zhuǎn)換。

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

此方法支持批量的自定義映射!通過判斷屬性名 key 的不同,可以實(shí)現(xiàn)多個(gè)屬性的自定義映射操作。

// 注意:該方法中的局部參數(shù) key 指的是「模型對(duì)象」中的屬性名稱。
+ (NSValueTransformer *)JSONTransformerForKey:(NSString *)key {
    if ([key isEqualToString:@"createdAt"]) {
        // 當(dāng)處理 createdAt 屬性的映射時(shí),執(zhí)行自定義轉(zhuǎn)換
        return [NSValueTransformer valueTransformerForName:XYDateValueTransformerName];
    }

    return nil;
}

key 是應(yīng)用于模型對(duì)象的屬性名;不是原始的 JSON 中的鍵。如果你使用 +JSONKeyPathsByPropertyKey 轉(zhuǎn)換時(shí),請(qǐng)記住這一點(diǎn)。

為了更加方便,如果你實(shí)現(xiàn)了 +<key>JSONTransformer 方法,那么 MTLJSONAdapter 將改用該方法的結(jié)果。例如,JSON 中通常表示為字符串的日期可以轉(zhuǎn)換為 NSDate,如下所示:

// 自定義 JSON 模型轉(zhuǎn)換,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];
    }];
}

如果轉(zhuǎn)換器是可逆的,則在將對(duì)象序列化為 JSON 時(shí)也將使用它。

??

也就是說,屬性的自定義轉(zhuǎn)換支付兩種方法,一種是:

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

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

還有一種是單個(gè)屬性的自定義映射方法,即:

+<key>JSONTransformer;

這邊的 <key> 是模型對(duì)象中屬性的名字。以上面的 GHIssue 例子來說,GHIssue 對(duì)象中的第一個(gè)屬性 URLNSURL 類型的屬性,而 JSON 模型返回的 URL 鏈接是一個(gè)字符串類型,它們之間的數(shù)據(jù)類型不一致,因此這個(gè)屬性無法實(shí)現(xiàn)自動(dòng)轉(zhuǎn)換,需要手動(dòng)實(shí)現(xiàn),即:

// 自定義 JSON 模型轉(zhuǎn)換,URL -> NSURL
// 這個(gè)方法中的 <key> 就是 URL,即模型中的 URL 屬性。
+ (NSValueTransformer *)URLJSONTransformer {
    return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}

也就是說每個(gè)單獨(dú)實(shí)現(xiàn)的自定義轉(zhuǎn)換方法名是通過模型屬性名與 JSONTransformer 拼接而來的。

另外,這個(gè) “拼接形式” 的自定義模型轉(zhuǎn)換方法的優(yōu)先級(jí)比 JSONTransformerForKey: 要高!也就是說,如果兩個(gè)方法中都實(shí)現(xiàn)了某一個(gè)屬性的自定義 JSON 模型轉(zhuǎn)換,則以 +<key>JSONTransformer; 方法的實(shí)現(xiàn)為準(zhǔn)!

+classForParsingJSONDictionary:

如果你使用了類簇,請(qǐng)實(shí)現(xiàn)此可選方法,classForParsingJSONDictionary 可以讓你選擇使用哪一個(gè)類進(jìn)行 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 會(huì)根據(jù)你傳入的 JSON 字典自動(dòng)選擇類:

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 持久化存儲(chǔ)

Mantle 不會(huì)自動(dòng)為你保留對(duì)象。但是,MTLModel 默認(rèn)實(shí)現(xiàn)了 NSCoding 協(xié)議,可以利用 NSKeyedArchiver 方便的對(duì)對(duì)象進(jìn)行歸檔和解檔。

如果你需要更強(qiáng)大的功能,或者想要避免一次將整個(gè)模型保留在內(nèi)存中,那么 Core Data 可能是更好的選擇。

最低系統(tǒng)要求

Mantle supports the following platform deployment targets:

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

導(dǎo)入 Mantle

手動(dòng)導(dǎo)入

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!

參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容