禅与Objective-C编程艺术-3

编程和「禅」有关系吗?

Zen and the Art of the Objective-C Craftsmanship译文兼笔记

本章包含规范:Categories、Protocols、NSNotification、代码美化和代码组织

Categories

虽然很难看,但是 categories 通常会以小写前缀和下划线开头,比如-(id)zoc_myCategoryMethod。这种写法也是 苹果推荐的.

这是非常必要的因为如果在一个对象的扩展 category 方法或者其他 category 的实现中用了相同的方法名,会导致不可预期的后果。实际上,最终被调用的将会是最后被实现的方法。

这种情况下你想保证没有任何方法的实现被你自己的 category 覆盖的话,你可以把环境变量 OBJC_PRINT_REPLACED_METHODS设定为YES,这样会在控制台打印出来被覆盖掉的方法名。

现在 LLVM 5.1不会为此有任何警告或者错误提示,所以要小心不要在 categories 中重载方法。

在 category 名之前加前缀是一个好习惯。

例如:

@interface NSDate (ZOCTimeExtensions)
- (NSString *)zoc_timeAgoShort;
@end

不要这样:

@interface NSDate (ZOCTimeExtensions)
- (NSString *)timeAgoShort;
@end

在头文件中 category 可以被用来为相近功能的方法来分组。在 Apple 的 Framework (如下例子取自 NSDate头文件)中这是一种很常见的行为并且我们鼓励在你自己的代码中也这样做。

我们的经验是,创建一组分类对以后的重构很有帮助:一个类的接口增加的时候,可能意味着这个类做了太多事情,违背了单一功能原则,之前创造的分组可以帮助理解不同部分的功能,并且把这个类分成更多独立的部分。

@interface NSDate : NSObject <NSCopying, NSSecureCoding>
@property (readonly) NSTimeInterval timeIntervalSinceReferenceDate;
@end
@interface NSDate (NSDateCreation)
+ (instancetype)date;
+ (instancetype)dateWithTimeIntervalSinceNow:(NSTimeInterval)secs;
+ (instancetype)dateWithTimeIntervalSinceReferenceDate:(NSTimeInterval)ti;
+ (instancetype)dateWithTimeIntervalSince1970:(NSTimeInterval)secs;
+ (instancetype)dateWithTimeInterval:(NSTimeInterval)secsToBeAdded sinceDate:(NSDate *)date;
// ...
@end

Protocols

近些年,在 Objective-C 的世界中对抽象接口的理解有所偏差。Interface 术语通常指一个类的.h文件,但是对 Java 开发者来说还有领一个熟知的含义:用来描述一系列不必具体实现的方法。

在 Objective-C 中上述情况是通过使用 protocols 来实现的。由于历史原因,protocol (Java 中的接口) 并没有在 Objective-C 社区中广泛使用。主要原因是因为 Apple 大部分代码开发者没有使用这种方法的,几乎所有的开发者都倾向于遵从 Apple 的模式和引导。Apple 开发者几乎只在 delegation 模式下使用 protocols。

抽象接口的概念很强大,在计算机科学历史中早有起源,所以在 Objective-C 中也没理由拒绝使用。

这里会通过一个具体例子来展现 protocols (用作抽象接口)用法的强大:从一个非常糟糕的架构设计改造为非常优秀且可复用的代码。

这个例子是实现一个 RSS 订阅器(作为一个技术面试中的测试题来思考下)。

需求很简单:在一个 tableView 中展示远程订阅的 RSS。

一种很幼稚的实现方法可能是创建一个UITableViewController的子类,把订阅信息的检索,解析以及展示所有逻辑都写在一起然后展示在同一个地方,或者说是 “MVC” (Massive View Controller)。这样做是可以运行的,但是设计很糟糕,还是足以通过一些要求不高的面试。

最小步骤应该遵循单一功能原则,至少创建两部分来完成不同的任务:

  • 一个 feed 解析器来解析从终端上获取的结果
  • 一个 feed 阅读器来显示结果

这些类的接口应该是这样的:

@interface ZOCFeedParser : NSObject
@property (nonatomic, weak) id <ZOCFeedParserDelegate> delegate;
@property (nonatomic, strong) NSURL *url;
- (id)initWithURL:(NSURL *)url;
- (BOOL)start;
- (void)stop;
@end
@interface ZOCTableViewController : UITableViewController
- (instancetype)initWithFeedParser:(ZOCFeedParser *)feedParser;
@end

ZOCFeedParser 通过一个指向终端的 NSURL 初始化来抓取 RSS 订阅(实际上可能会用 NSXMLParser 和 NSXMLParserDelegate 来创建有意义的数据),ZOCTableViewController 通过 parser 来初始化。我们希望它显示 parser 取回的值,我们通过下方的 protocol 来实现 delegation:

@protocol ZOCFeedParserDelegate <NSObject>
@optional
- (void)feedParserDidStart:(ZOCFeedParser *)parser;
- (void)feedParser:(ZOCFeedParser *)parser didParseFeedInfo:(ZOCFeedInfoDTO *)info;
- (void)feedParser:(ZOCFeedParser *)parser didParseFeedItem:(ZOCFeedItemDTO *)item;
- (void)feedParserDidFinish:(ZOCFeedParser *)parser;
- (void)feedParser:(ZOCFeedParser *)parser didFailWithError:(NSError *)error;
@end

用合适的 protocol 来处理 RSS 很完美。视图控制器要在公共接口中遵守上述 protocol 的协议:

@interface ZOCTableViewController : UITableViewController <ZOCFeedParserDelegate>

最后创建的代码应该是如下的样子:

NSURL *feedURL = [NSURL URLWithString:@"http://bbc.co.uk/feed.rss"];
ZOCFeedParser *feedParser = [[ZOCFeedParser alloc] initWithURL:feedURL];
ZOCTableViewController *tableViewController = [[ZOCTableViewController alloc] initWithFeedParser:feedParser];
feedParser.delegate = tableViewController;

到目前为止可能觉得新代码还不错,但是这些代码有多少可以有效复用呢?视图控制器只能处理 ZOCFeedParser 类型的对象:从这点来看,我们只是把代码拆成了两部分,却没做任何其他有价值的事情。

视图控制器的职责应该是”把某某提供的东西展示出来”,但是如果我们只允许其传递 ZOCFeedParser 对象这就无法解决了。

我们修改 parser 加入 ZOCFeedParserParotocol protocol (在 ZOCFeedParserProtocol.h 文件中,ZOCFeedParserDelegate 也在这)。

@protocol ZOCFeedParserProtocol <NSObject>
@property (nonatomic, weak) id <ZOCFeedParserDelegate> delegate;
@property (nonatomic, strong) NSURL *url;
- (BOOL)start;
- (void)stop;
@end
@protocol ZOCFeedParserDelegate <NSObject>
@optional
- (void)feedParserDidStart:(id<ZOCFeedParserProtocol>)parser;
- (void)feedParser:(id<ZOCFeedParserProtocol>)parser didParseFeedInfo:(ZOCFeedInfoDTO *)info;
- (void)feedParser:(id<ZOCFeedParserProtocol>)parser didParseFeedItem:(ZOCFeedItemDTO *)item;
- (void)feedParserDidFinish:(id<ZOCFeedParserProtocol>)parser;
- (void)feedParser:(id<ZOCFeedParserProtocol>)parser didFailWithError:(NSError *)error;
@end

注意,ZOCFeedParserDelegate protocol 现在会处理那些遵循新 protocol 的对象,ZOCFeedParser 的接口文件也更加简洁了:

@interface ZOCFeedParser : NSObject <ZOCFeedParserProtocol>
- (id)initWithURL:(NSURL *)url;
@end

由于 ZOCFeedParser 现在遵循 ZOCFeedParserProtocol 协议,它就必须实现所有 required 方法。

这样一来视图控制器可以接受任何遵循新 protocol 的对象,当然对象也会响应 startstop 方法,而且会通过 delegate 属性来传递信息, 视图控制器只需要知道相关对象并且不需要知道具体的实现细节。

@interface ZOCTableViewController : UITableViewController <ZOCFeedParserDelegate>
- (instancetype)initWithFeedParser:(id<ZOCFeedParserProtocol>)feedParser;
@end

上面代码片的变化看起不大,但是实际上有了很大的提升,因为视图控制器工作时面相抽象接口要比具体实现更好。这样有很多好处:

  • 视图控制器可以通过 delegate 属性接收到由任何对象发出的信息:可以是 RSS 远程订阅解析器,本地解析器,读取其他类型远程数据服务,还可能是抓取本地数据库中数据的服务。
  • 订阅解析器对象可以完整复用(对比第一步重构之前);
  • ZOCFeedParserZOCFeedParserDelegate 可以被其他部分复用;
  • ZOCViewController (UI 逻辑部分)可以被复用;
  • 测试更简单,因为可以用 mock 对象来达到 protocol 预期的效果。

当实现一个 protocol 你应该遵守 里氏替换原则。原则的内容是:你应该在不改变客户端或者相关实现的前提下,可以随意用其他实现来替换当前接口(Objective-C 中的 “protocol”)的实现。

换句话说,你的 protocol 不应该暴露任何其实现类的细节;当通过 protocol 来设计抽象表述的时候应该十分小心,时刻谨记它和低层实现是无关的,其真正作用是暴露给使用者的抽象概念。

所有设计的代码以后都可以复用以为着代码的高质量,这也是开发者一直追求的目标。程序设计是区分资深开发者和初级开发者的方法。

最终代码可以在这里找到。

NSNotification

当你定义自己的 NSNotification 时你应该把你的通知名定义成一个字符串常量。就想任何你要暴露给其他类使用的字符串常量一样,应该在公共接口中用 extern 声明,并在对应的实现文件里定义。

因为你在头文件中暴露了这个符号,那你应该遵循命名空间规则,用其所属的类名作为该通知名的前缀。

用助动词 Did/Will 来为通知命名,以及用单词 “Notifications” 来作为通知名的后缀都是不错的做法。

// Foo.h
extern NSString * const ZOCFooDidBecomeBarNotification
// Foo.m
NSString * const ZOCFooDidBecomeBarNotification = @"ZOCFooDidBecomeBarNotification";

美化代码

空格

  • 缩进用 4 个空格,不要用 tab 缩进。确定你在 Xcode 中是这样设置的。
  • 方法的大括号和其他的大括号 (if/else/switch/while等等) 通常在当前行开始,在语句结束的时候另起一行。

推荐:

if (user.isHappy) {
//Do something
}
else {
//Do something else
}

不推荐:

if (user.isHappy)
{
//Do something
} else {
//Do something else
}
  • 两个方法之间应该有空行,这样有助于看起来更清晰且有组织。方法内的空格应该是用来分离功能的,但是通常新功能应该用新方法来实现。
  • 优先使用 auto-synthesis。如果一定要用 @synthesize@dynamic 的话应该在实现中另起一行。
  • 冒号对齐这种方法应该尽量避免使用。如果当一个方法表述包含超过 3 个冒号时,冒号对齐就会让代码可读性增强。即使包含 blocks 也要让冒号对齐。

推荐:

[UIView animateWithDuration:1.0
animations:^{
// something
}
completion:^(BOOL finished) {
// something
}];

不推荐:

[UIView animateWithDuration:1.0 animations:^{
// something
} completion:^(BOOL finished) {
// something
}];

如果自动对齐降低了可读性,那么应该提前把 block 定义为变量,或者重新考虑这个方法的格式设计。

换行

由于本指南关注代码的显示效果和可读性,所以换行是一个重要的主题。

例如:

self.productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];

依照本指南,像上述一行很长的代码应该以一个间隔(两个空格)另起一行。

self.productsRequest = [[SKProductsRequest alloc]
initWithProductIdentifiers:productIdentifiers];

括号

在如下场景使用 Egyptain 括号(又称 K&R 风格,代码段括号的开始位于一行的末尾,而不是另外起一行的风格):

  • 控制语句(if-else, for, switch)

非 Egyptain 括号在以下情况可以使用:

  • 类的实现(如果存在)
  • 方法的实现

代码组织

引用自 Mattt Thompson

代码组织就是卫生问题

非常赞成这句话。用整洁规范的方法来组织代码,是对自己和其他要读和修改你代码的人的尊重(包括以后你自己)。

利用代码块

GCC 有一个很隐蔽的特性,同时 Clang 也支持这个特性,就是在闭合的大括号内部,代码块会返回最后一行语句的值。

NSURL *url = ({
NSString *urlString = [NSString stringWithFormat:@"%@/%@", baseURLString, endpoint];
[NSURL URLWithString:urlString];
});

这个特性可以很有效的组织一段代码,这段代码通常只是为了实例化一个类这个目的而存在的。

这给了看代码的人一个重要的视觉提示,降低了视觉上的干扰,专注于函数最重要的变量。

此外,这个技巧的好处在于,所有在代码块中定义的变量,只在括号内部有效,这就意味着不会污染堆栈轨迹,你可以重复使用变量名而不会出现 duplicated symbols 错误。

Pragma

Pragma Mark

#pragma mark - 是类内部组织代码很有效的方法,帮助将方法实现进行分类。

我们建议用 #pragma mark - 来分隔:

  • 不同组的方法
  • protocols 的实现
  • 父类方法的重载
- (void)dealloc { /* ... */ }
- (instancetype)init { /* ... */ }
#pragma mark - View Lifecycle
- (void)viewDidLoad { /* ... */ }
- (void)viewWillAppear:(BOOL)animated { /* ... */ }
- (void)didReceiveMemoryWarning { /* ... */ }
#pragma mark - Custom Accessors
- (void)setCustomProperty:(id)value { /* ... */ }
- (id)customProperty { /* ... */ }
#pragma mark - IBActions
- (IBAction)submitData:(id)sender { /* ... */ }
#pragma mark - Public
- (void)publicMethod { /* ... */ }
#pragma mark - Private
- (void)zoc_privateMethod { /* ... */ }
#pragma mark - UITableViewDataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { /* ... */ }
#pragma mark - ZOCSuperclass
// ... overridden methods from ZOCSuperclass
#pragma mark - NSObject
- (NSString *)description { /* ... */ }

上述的标记可以有效的分离和组织代码。还可以 cmd+click 点击 mark 跳转到符号定义的地方。

要注意的是,用 pragma mark 也是一种手艺,这不是你在类中过度增加方法的原因:类里面有太多说明方法说明这个类做了太多,需要重构了。

Pragma 注意

http://raptureinvenice.com/pragmas-arent-just-for-marks 这里有关于 pragma 的一次非常棒的讨论,这里做部分说明。

大部分 iOS 开发者平时不太会关注编译器选项,很多选项对于控制检查(或者不检查)代码错误的严格度十分有效。有时你想要用 pragma 在代码中直接抛出异常,用来临时打断编译器的行为。

当在用 ARC 的时候,编译器替你插入内存管理调用方法,有些情况下会让人混淆。比如当你使用 NSSelectorFromString 来动态产生一个 selector 调用的时候,由于 ARC 不知道这个方法是什么,也不知道应该用什么类型的内存管理方式,就会被警告performSelector may cause a leak because its selector is unknown(由于 selector 未知,执行可能导致内存泄露)。

如果你清楚你的代码不会内存泄漏,你可以通过用这些代码忽略警告

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[myObj performSelector:mySelector withObject:name];
#pragma clang diagnostic pop

注意我们是如何在代码前后用 push 和 pop 来停用 -Warc-performSelector-leaks 检查的。确保我们没有全局禁用,全局禁用会导致错误。

打开和关闭的所有选项可以在The Clang User’s Manual 找到和学习。

忽略没有使用变量的编译警告

提示一个定义但未被使用的变量是很有用的。大部分情况下,你想移除这些引用来提高一些性能,但是有时你又想保留他们。为什么?可能以后会用,或者只是暂时移除功能。无论如何,除了粗暴的注释掉相关的代码行之外,消除这些警告的有效办法是用 #pragma unused():

- (NSInteger)giveMeFive
{
NSString *foo;
#pragma unused (foo)
return 5;
}

现在就可以在编译器不会编译这些代码的情况下保留他们了。同时,pragma 要标记在问题代码的下方。

明确编译器警告和错误

编译器毕竟是个机器人,他会用 Clang 定义的一些规则来标记你代码中的错误。但是你你总是比编译器要聪明的。通常你会发现有些烦人的代码会出问题,但是因为某种原因你暂时没办法修复。你可以明确的像下面这样标记 error:

- (NSInteger)divide:(NSInteger)dividend by:(NSInteger)divisor
{
#error Whoa, buddy, you need to check for zero here!
return (dividend / divisor);
}

类似的你可以这样标记一个 warning:

- (float)divide:(float)dividend by:(float)divisor
{
#warning Dude, don't compare floating point numbers like this!
if (divisor != 0.0) {
return (dividend / divisor);
}
else {
return NAN;
}
}

字符串文档

所有重要的方法、接口、分类和协议的定义应该伴有描述来表明他们的用途和使用方法。更多例子可以看 Google Style Guide 中 File and Declaration Comments.

总而言之,有两种字符串文档注释,长字符串文档和短字符串文档。

短字符串文档适合只有一行的情况,这其中包括斜杠。它适用于简单的函数,尤其是(不仅仅)非 public API 中。

// Return a user-readable form of a Frobnozz, html-escaped.

注意文本描述应该用 “return” 这种行为动词而非 “returns”。

如果描述超过一行,你应该用长字符串文档:

  • /**开始
  • 换行写一句总结的话,以?或者!或者.结尾。
  • 空一行
  • 在与第一行对齐的位置开始写剩下的注释
  • 最后用*/结束。
/**
This comment serves to demonstrate the format of a docstring.
Note that the summary line is always at most one line long, and
after the opening block comment, and each line of text is preceded
by a single space.
*/

一个函数必须有字符串文档除非下述所有条件:

  • 非共有
  • 很短
  • 功能明确

注释文档应该描述调用的时机和其使用场景,而不是具体实现。

注释

注释存在的意义是解释特定的代码的用途。所有的注释都应该保持最新的版本,否则就直接删掉。

通常要避免块注释,因为代码本身就应该尽可能的表达语意,就算要写注释也尽可能几行搞定。例外:不包括那些要抽离出来生成文档的注释。

头文档

类的头文档应该只在 .h 文件中遵守 Doxygen/AppleDoc 的语法书写。方法和属性都应该提供文档。

例如:

/**
* Designated initializer.
*
* @param store The store for CRUD operations.
* @param searchService The search service used to query the store.
*
* @return A ZOCCRUDOperationsStore object.
*/
- (instancetype)initWithOperationsStore:(id<ZOCGenericStoreProtocol>)store
searchService:(id<ZOCGenericSearchServiceProtocol>)searchService;