Mantle: a Model Framework for Objective-C
Lately, we’ve been shipping more in GitHub for Mac than ever before. Now that username autocompletion and Notification Center support are out the door, we’re releasing the two frameworks that…
Lately, we’ve been shipping more in GitHub for Mac than ever before. Now that
username
autocompletion
and Notification Center
support are out the
door, we’re releasing the two frameworks that helped make it happen.
This post talks about Mantle, our framework
that makes it dead simple to create a flexible and easy-to-use model layer in
Cocoa or Cocoa Touch. In our next blog
post,
we’ll talk about Rebel, our framework for
improving AppKit.
First, let’s explore why you would even want such a framework. What’s wrong with
the way model objects are usually written in Objective-C?
The Typical Model Object
Let’s use the GitHub API for demonstration. How
would one typically represent a GitHub
issue in
Objective-C?
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) NSString *assigneeLogin;
@property (nonatomic, copy, readonly) NSDate *updatedAt;
@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];
_body = [dictionary[@"body"] copy];
_reporterLogin = [dictionary[@"user"][@"login"] copy];
_assigneeLogin = [dictionary[@"assignee"][@"login"] copy];
_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"];
_body = [coder decodeObjectForKey:@"body"];
_reporterLogin = [coder decodeObjectForKey:@"reporterLogin"];
_assigneeLogin = [coder decodeObjectForKey:@"assigneeLogin"];
_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.assigneeLogin != nil) [coder encodeObject:self.assigneeLogin forKey:@"assigneeLogin"];
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->_assigneeLogin = self.assigneeLogin;
issue->_updatedAt = self.updatedAt;
issue.title = self.title;
issue.body = self.body;
}
- (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
Whew, that’s a lot of boilerplate for something so simple! And, even then, there
are some problems that this example doesn’t address:
- If the
url
orhtml_url
field is missing,+[NSURL URLWithString:]
will throw an exception. - There’s no way to update a
GHIssue
with new data from the server. - There’s no way to turn a
GHIssue
back into JSON. GHIssueState
shouldn’t be encoded as-is. If the enum changes in the future, existing archives might break.- If the interface of
GHIssue
changes down the road, existing archives might break.
Why Not Use Core Data?
Core Data solves certain problems very well. If you need to execute complex
queries across your data, handle a huge object graph with lots of relationships,
or support undo and redo, Core Data is an excellent fit.
It does, however, come with some pain points:
- Concurrency is a huge headache. It’s particularly difficult to pass managed
objects between threads. TheNSManagedObjectContextConcurrencyTypes
introduced in OS X 10.7 and iOS 5 don’t really address this problem. Instead,
object IDs have to be passed around and translated back and forth, which is
highly inconvenient. - There’s still a lot of boilerplate. Managed objects reduce some of the
boilerplate seen above, but Core Data has plenty of its own. Correctly
setting up a Core Data stack (with a persistent store and persistent store
coordinator) and executing fetches can take many lines of code. - It’s hard to get right. Even experienced developers can make mistakes
when using Core Data, and the framework is not forgiving.
If you’re just trying to access some JSON objects, Core Data can be a lot of
work for little gain.
MTLModel
Enter
MTLModel.
This is what GHIssue
looks like inheriting from MTLModel
:
typedef enum : NSUInteger {
GHIssueStateOpen,
GHIssueStateClosed
} GHIssueState;
@interface GHIssue : MTLModel
@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) NSString *assigneeLogin;
@property (nonatomic, copy, readonly) NSDate *updatedAt;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *body;
@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 *)externalRepresentationKeyPathsByPropertyKey {
return [super.externalRepresentationKeyPathsByPropertyKey mtl_dictionaryByAddingEntriesFromDictionary:@{
@"URL": @"url",
@"HTMLURL": @"html_url",
@"reporterLogin": @"user.login",
@"assigneeLogin": @"assignee.login",
@"updatedAt": @"updated_at"
}];
}
+ (NSValueTransformer *)URLTransformer {
return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}
+ (NSValueTransformer *)HTMLURLTransformer {
return [NSValueTransformer valueTransformerForName:MTLURLValueTransformerName];
}
+ (NSValueTransformer *)stateTransformer {
NSDictionary *states = @{
@"open": @(GHIssueStateOpen),
@"closed": @(GHIssueStateClosed)
};
return [MTLValueTransformer reversibleTransformerWithForwardBlock:^(NSString *str) {
return states[str];
} reverseBlock:^(NSNumber *state) {
return [states allKeysForObject:state].lastObject;
}];
}
+ (NSValueTransformer *)updatedAtTransformer {
return [MTLValueTransformer reversibleTransformerWithForwardBlock:^(NSString *str) {
return [self.dateFormatter dateFromString:str];
} reverseBlock:^(NSDate *date) {
return [self.dateFormatter stringFromDate:date];
}];
}
@end
Notably absent from this version are implementations of <NSCoding>
,
<NSCopying>
, -isEqual:
, and -hash
. By inspecting the @property
declarations you have in your subclass, MTLModel
can provide default
implementations for all these methods.
The problems with the original example all happen to be fixed as well:
- If the
url
orhtml_url
field is missing,+[NSURL URLWithString:]
will throw an exception.
The URL transformer we used (included in Mantle) returns nil
if given a nil
string.
- There’s no way to update a
GHIssue
with new data from the server.
MTLModel
has an extensible -mergeValuesForKeysFromModel:
method, which makes
it easy to specify how new model data should be integrated.
- There’s no way to turn a
GHIssue
back into JSON.GHIssueState
shouldn’t be encoded as-is. If the enum changes in the future, existing archives might break.
Both of these issues are solved by using reversible transformers.
-[GHIssue externalRepresentation]
will return a JSON dictionary, which is also
what gets encoded in -encodeWithCoder:
. No saving fragile enum values!
- If the interface of
GHIssue
changes down the road, existing archives might break.
MTLModel
automatically saves the version of the model object that was used for
archival. When unarchiving, +migrateExternalRepresentation:fromVersion:
will
be invoked if migration is needed, giving you a convenient hook to upgrade old
data.
Other Extensions
Mantle also comes with miscellaneous cross-platform extensions meant to
make your life easier, including:
- Higher-order functions (
map
,filter
,fold
) for
NSArray,
NSDictionary,
NSOrderedSet,
and NSSet. - Weak notification center observers
There will assuredly be more, as we run into other common pain points!
Getting Involved
Mantle is still new and moving fast, so we may make breaking changes from
time-to-time, but it has excellent unit test coverage and is already being used
in GitHub for Mac’s production code.
We heartily encourage you to check it out
and file any issues that you find. If you’d like to contribute code, take a look
at the README.
Enjoy!
Written by
Related posts
Apply now for GitHub Universe 2023 micro-mentoring
As part of our ongoing commitment to accelerate human progress through Social Impact initiatives, we’re offering students 30-minute, 1:1 micro-mentoring sessions with GitHub employees ahead of Universe.
The 2023 Open Source Program Office (OSPO) Survey is live!
Help quantify the state of enterprise open source by taking the 2023 OSPO survey.
Godot 4.0 Release Party 🎉
We are delighted to host the Godot 4.0 Release Party at GitHub HQ on Wednesday, March 22 from 6:30 pm to 9:30 pm. And you’re invited!