cocoa_chen

(译)你可能不知道的Objective-C技巧

2014-11-25

如果你在阅读这些文章,你大概已经对iOS开发的基础知识有了很好的掌握,但还是有一些小的技巧和实践是很多开发者不熟悉的,即使是已经有几年开发经验的人。在这一章中,你会学到一些很重要的提示和技巧,但这还远远不够,你需要多多练习以使得你的代码更加健壮以及可维护性更好。

注:本文翻译自《iOS7 Pushing the limits》一书的第三章“You May Not Know”,翻译不当之处敬请谅解。

目录

  • 一、最好的命名实践
  • 二、属性(Property)和实例变量(Ivar)的最佳实践
  • 三、分类(Categories)
  • 四、关联引用(Associative References)
  • 五、Weak Collections
  • 六、NSCache
  • 七、NSURLComponents
  • 八、CFStringTransform
  • 九、instancetype
  • 十、Base64 and Percent Encoding
  • 十一、-[NSArray firstObject]
  • 十二、Summary
  • 十三、更多阅读

一、最好的命名实践

在iOS开发中,命名规范极其重要。在接下来的内容中,我们将学习如何正确命名各种条目,以及为什么这么命名。

1.自动变量

Cocoa是动态类型的语言,你很容易对所使用的类型感到困惑。集合(数组,字典等等)没有关联它们的类型,所以很容易发生这样的意外:

1
2
NSArray *dates = @[@"1/1/2000"];
NSDate *firstDate = [dates firstObject];

这样的代码没有编译警告,但是当你尝试使用firstDate时,很可能会报错(an unknown selector exception),错误原因是调用了一个字符串类型的数组dates。这个数组应该命名为dateStrings或者它包含的是NSDate类型的对象。这样小心的命名将会为你避免很多头疼的错误。

2.方法

1)方法名应该清晰的表明接收和返回的类型。例如:这个方法名就是令人困惑的:

1
- (void)add;  // 令人困惑

看起来add方法应该带个参数,但它没有。难道它是添加一些默认对象?
这样命名就清楚多了:

1
2
- (void)addEmptyRecord;
- (void)addRecord:(Record *)record;

现在addRecord:接收了一个Record参数,看起来就清楚多了。

2)对象的类型应该符合名称,如果不匹配,就会容易理解不当。例如,这个例子展示了一个常见的错误:

1
- (void)setURL:(NSString *)URL;  // 错误的

这不正确是因为方法名为setURL:,它应该接收一个NSURL,而不是一个NSString。如果你需要一个string,你应该添加一些指示让它更清晰:

1
2
- (void)setURLString:(NSString *)string;
- (void)setURL:(NSURL *)URL;

不过这个规则不应该过度使用,如果类型很明显,别添加类型信息到变量上。如果在项目中没有包含可能让阅读者困惑的Name类,一个叫做name的属性就比叫做nameString的属性会更好。

3)方法名也有与内存管理和KVC相关的特定原则。虽然ARC使得其中的一些规则不再重要,但在ARC与非ARC进行交互时(包括Apple框架的非ARC代码),不正确的方法命名仍会导致非常具有挑战性的错误。

方法名应该永远是以小写字母开头的驼峰结构

如果一个方法名以alloc,new,copy或者mutableCopy开头,调用者拥有返回的对象。如果你的property的名字像newRecord这样,这个规则会导致一些问题。重新命名这个property为nextRecord或者其他名字。

get开头的方法应该返回一个参照值,例如:

1
- (void)getPerson:(Person **)person;

不要使用get前缀作为property访问器的一部分,property name的getter应该是-name.

二、属性(Property)和实例变量(Ivar)的最佳实践

Property应该代表一个对象的状态,Getter应该没有外部影响(它们可以具有内部影响,例如caching,但这些应该是对调用者不可见的)。通常,它们应该直接有效的调用且不应该有所限制。
避免直接访问实例变量,用访问器来替代。我们会过一会讨论一些例外的情况,不过还是先说下为什么要用访问器来访问。
在ARC出现之前,引起bug最常见的问题就是直接访问实例变量。开发者没有正确的retain和release实例变量,它们的程序就会内存泄露或者崩溃。由于ARC自动管理retain和release,一些开发者认为这个规则已经不再重要,但还有其他使用访问器的原因:

  • KVO —- 也许使用访问器最关键的原因是property可以被观察到。如果不用访问器,你需要在每次修改property里的实例变量时调用willChangeValueForKey:didChangeValueForKey:。而访问器会在需要时自动调用这些方法。

  • 其他作用 —- 你或者你的子类可能在setter方法中添加一些其他的逻辑。比如发送通知Notifications或者注册事件到NSUndoManager中,你不应该忽视这些作用,除非它们不重要。类似地,你或者你的子类可能会对getter方法添加了缓存导致将不会再访问实例变量。

  • 惰性加载 —- 如果一个property是惰性加载的,你应该使用访问器来确保它被正确的初始化。

  • 锁定 —- 如果你在处理多线程代码时对property加锁了,直接访问实例变量会绕开加锁机制,而且很可能会造成崩溃。

  • 一致性 —- 看了前面的内容,有人可能会说当我们需要操作property时应该只使用访问器,但这会使得代码很难维护。怀疑并解释每一个直接访问的实例变量,而不是不断地记住哪些变量需要用访问器,哪些不需要,这会使得代码更容易的审核,检查和维护。访问器,尤其是synthesized的访问器,已经在Objective-C里被高度优化,它们值得使用。

也就是说,你不应该在这几个地方使用访问器:

  • 访问器内部 —- 很明显,你不应该在访问器内部使用访问器自身。通常,你也不想在getter和setter内部使用它们自己(这可能会造成死循环),一个访问器应该访问它自身的实例变量。

  • Dealloc —- ARC极大的简化了dealloc的使用,但它有时仍会出现。最好在dealloc里不调用外部对象,因为这个对象可能会处于有歧义的状态。它很可能会让观察者感到困惑,因为观察者会接收到好几次属性值要改变的通知,但其他是这个对象要被销毁了。

  • 初始化 —- 跟dealloc类似,对象在初始化过程中也可能会处于不一致的状态,你不应该在这个期间内发送通知或者调用一些其他的逻辑。通常我们会在这里初始化readonly的变量(比如NSMutableArray)。这样,你可以初始化这个变量而不用将它声明为readwrite。

访问器在Objective-C中被高度优化,而且在可维护性和灵活性上有很重要的优点。一般上,对于所有属性甚至是你自己声明的,都应该使用访问器.

三、分类(Categories)

分类允许你在运行时为一个已存在的类添加方法,任何类,甚至是由Apple提供的Cocoa类,都可以通过分类来扩展,这些方法对这个类的所有实例都是可用的。声明一个分类很简单,它看起来如下所示:

1
2
3
@interface NSMutableString (PTLCapitalize)
- (void)ptl_capitalize;
@end

PTLCapitalize是分类的名称,注意这里没有声明任何实例变量。分类不能声明实例变量,也不能合成属性(synthesize properties)。这个PTLCapitalize分类不要求必须在某处实现ptl_capitalize方法。如果ptl_capitalize方法没有实现但是有对象尝试调用它,系统会抛出异常,编译器不会在这里提供任何保护。如果要实现ptl_capitalize方法,那么它看起来如下所示:

1
2
3
4
@implementation NSMutableString (PTLCapitalize)
- (void)ptl_capitalize {
[self setString:[self capitalizedString]];
}

这个在分类的implementation中定义,或者是这个分类的implementation必须跟分类的interface有同样的名字,并没有要求。但是,如果我们写了一个叫做PTLCapitalize的@implementation块,那么就必须实现叫做PTLCapitalize的@interface块中的所有的方法。

从技术上说,分类可以覆盖(override)方法,但是这么做是很危险也是不推荐的。如果两个分类实现了相同的方法,哪个方法会被调用是不确定的。如果后来为了维护而把一个类分成categories,那么对方法的覆盖(override)可能会导致一些不确定的问题(一些蛋疼的难以查到的bug)。此外,利用这个特性会使得代码难以理解。分类的覆盖(overrides)也会导致没办法调用原来的方法。

因为代码冲突的可能性,建议为分类方法添加一个前缀,然后是一个下划线,就像例子中的ptl_capitalize一样。Cocoa通常不这么用,但是在这个例子中,它比其他的方式更清晰。

一种比较好的分类用法是对已存在的类添加方法,当我们这么做时,建议命名头文件和实现文件时用原来类的名称加上扩展的名称。比如,你可能会对NSDate创建一个简单的PTLExtensions分类:

1
2
3
4
5
6
7
8
9
10
11
12
13
NSDate+PTLExtensions.h
——————————————————————
@interface NSDate (PTLExtensions)
- (NSTimeInterval)ptl_timeIntervalUntilNow;
@end

NSDate+PTLExtensions.m
——————————————————————
@implementation NSDate (PTLExtensions)
- (NSTimeInterval)ptl_timeIntervalUntilNow {
return -[self timeIntervalSinceNow];
}
@end

如果你只添加几个实用的方法,建议你把他们放到一个分类文件,可以命名为PTLExtensions或者其他你想起的名称。这么做可以使得添加这些扩展类到每个工程时很方便。当然,这会造成代码膨胀,所以在向”utility”分类中添加方法时要注意数量,不要太多。Objective-C不能像C/C++那样有效的做到死代码剥离。

+load

分类是在运行时附加到类上的,这可能定义分类为动态加载,所以分类可以很晚才被添加(虽然你不能在iOS里编写自己的动态库,但系统库包括分类都是动态加载的)。Objective-C提供了一个叫做+load的hook技术,可以在分类首次附加时运行。和+initialize一样,你可以使用它来实现分类具体的设定,比如初始化静态变量。你不能在分类中安全的使用+initialize,因为类可能已经实现它了。如果多个分类都实现了initialize,那个正在运行的是那个分类是不确定的.

你可能会问:“如果分类不能用+initialize,因为它们可能与其他分类冲突,那么多个分类实现+load呢?”。这正是Objective-C runtime神奇的地方之一,+load方法是runtime的特例,每个分类都能实现它并且所有的实现都会运行。不过并不能确保执行的先后顺序,而且你不应该手动调用+load方法。

不管分类是静态还是动态的加载+load都可以被调用,分类被添加到runtime时+load会被调用,这往往是程序加载的时候,在main之前,不过也可能更晚一些。

类也可以有自己的+load方法(不在分类中定义的),它们被调用时也是在类被添加到runtime时。不过这很少用到,除非你是动态的加载类。

你不用像防止+initialize多次运行那样来对待+load+load消息只会发送给真正实现了它的类,所以你不会意外的从子类得到调用的消息,就像+initialize那样。每个+load方法只会调用一次,而且你不应该使用[super load]。

四、关联引用(Associative References)

关联引用允许你附加key-value数据到任何对象上,这个能力有很多用途,但最常用的是允许分类的属性添加数据。
假设我们有个Person类,你想使用分类来添加一个叫emailAddress的新属性。也许你在其他程序中也使用Person类,并且有时需要使用emailAddress有时不需要,因此当你不需要时使用分类会是避免开销的好的解决办法。或者,你不拥有Person类,并且维护者不会为你添加property,你该怎么解决这个问题?首先,我们先看看基础的Person类:

1
2
3
4
5
6
@interface Person : NSObject
@property (nonatomic, readwrite, copy) NSString *name;
@end

@implementation Person
@end

现在,你可以在分类中用关联引用添加一个叫emailAddress的新属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#import <objc/runtime.h>
@interface Person (EmailAddress)
@property (nonatomic, readwrite, copy) NSString *emailAddress;
@end

@implementation Person (EmailAddress)

static char emailAddressKey;
- (NSString *)emailAddress {
return objc_getAssociatedObject(self, &emailAddressKey);
}
- (void)setEmailAddress:(NSString *)emailAddress {
objc_setAssociatedObject(self, &emailAddressKey,
emailAddress,
OBJC_ASSOCIATION_COPY);
}
@end

注意关联引用是基于key的内存地址,而不是它的值。emailAddressKey里存储的是什么并不重要,它只需要有一个唯一的、不变的地址。这就是为什么它通常使用未分配的static char作为key。

关联引用有很好的内存管理,根据传给objc_setAssociatedObject的参数正确的处理copy,assign或者retain。当相关的对象被销毁时它们会被release掉。这意味着你可以在另一个对象被销毁时,使用关联的对象进行追踪。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const char kWatcherKey;

@interface Watcher : NSObject
@end

#import <objc/runtime.h>

@implementation Watcher
- (void)dealloc {
NSLog(@"HEY! The thing I was watching is going away!");
}
@end

...

NSObject *something = [NSObject new];
objc_setAssociatedObject(something, &kWatcherKey,
[Watcher new],
OBJC_ASSOCIATION_RETAIN);

这个技术对于调试很有用,同时也可以用在非调试任务,比如执行清理。

使用关联引用是附加相关对象到alert panel或者controller的好方法.比如你附加一个“represented object”到alert panel,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
ViewController.m(AssocRef)
——————————————————————————
id interestingObject = ...;
UIAlertView *alert = [[UIAlertView alloc]
initWithTitle:@"Alert" message:nil
delegate:self
cancelButtonTitle:@"OK"
otherButtonTitles:nil];
objc_setAssociatedObject(alert, &kRepresentedObject,
interestingObject,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[alert show];

现在,当alert panel要消失的时候,你可以获取到相关的内容:

1
2
3
4
5
- (void)alertView:(UIAlertView *)alertView
clickButtonAtIndex:(NSInteger)buttonIndex {
UIButton *sender = objc_getAssociatedObject(alertView,&kRepresentedObject);
self.buttonLabel.text = [[sender titleLabel] text];
}

很多程序在调用里使用实例变量来处理,但关联引用更简洁。对于那些熟悉Mac的开发者,这些代码类似于representedObject,但却更加灵活。

关联引用(或者其他为category添加数据的方式)的一个限制是它们没有与encodeWithCoder:整合,所以它们很难通过一个分类来序列化。

五、Weak Collections

Cocoa集合中最常用的是NSArray,NSSetNSDictionary,也是使用最多的,但是在某些情况下它们并不适用。NSArrayNSSet会持有你存储进去的对象,NSDictionary会持有你存储在里面的value同时拷贝对应的key。这些行为通常是你想要的,但对于某些情况它们并不适合。幸运的是,从iOS6开始我们可以使用其他的集合类:NSPointerArray,NSHashTableNSMapTable。在Apple的文档中它们统称为指针集合类(pointer collection classes),并且有时用NSPointerFunctions类来配置。

NSPointerArray类似于NSArrayNSHashTable类似于NSSet,而NSMapTable类似于NSDictionary。每个新的集合类都可以配置为保持弱引用,指向空对象或者其他异常情况。NSPointerArray的额外好处是它还可以存储NULL值,而在这NSArray是个常见的问题。

指针集合类可以使用NSPointerFunctions来广泛的配置,但大多数情况下,它只是简单的传一个NSPointerFunctionsOptions flag到-initWithOptions:中。最常见的情况,比如+weakObjectsPointerArray,有它们自己的构造函数。

六、NSCache

一个使用weak collection最常见的理由便是实现缓存。不过,大多数情况下我们可以使用Foundation的缓存对象NSCache来替代。大多数时,你可以像使用NSDictionary那样使用它,调用objectForKey:setObject:forKey:removeObjectForKey:方法即可。

NSCache有几个被低估的特性,比如事实上它是线程安全的。你可以在任务无锁的线程中改变一个NSCache。NSCache也被设计来整合遵从了的对象。

最常见的类型就是NSPurgeableData。通过调用beginContentAccess和endContentAccess,你可以控制何时安全的废弃这个对象。这不仅在你的应用运行时提供自动缓存管理,它甚至在你的应用暂停时提供帮助。通常,当内存紧张时且内存警告没有释放出足够的内存,iOS会开始杀死暂停在后台的应用。在这种情况下,你的应用还没有得到delegate的消息就被杀死了。但是如果你使用NSPurgeableData,iOS会为你释放这块内存,甚至是应用被暂停了。

想了解更多关于NSCache的信息,请参考Xcode的文档中的和NSPurgeableData。

七、NSURLComponents

有时候Apple会低调的添加一些有趣的类。在iOS7里,Apple增加了NSURLComponents,但却没有相关的参考文档,但是你可以去NSURL.h里查看它。

NSURLComponents会让分离URL的各个部分变得容易,例如:

1
2
3
NSString *URLString = @"http://en.wikipedia.org/wiki/Special:Search?search=ios";
NSURLComponents *components = [NSURLComponents componentsWithString:URLString];
NSString *host = components.host;

你也可以用NSURLComponents来组成或修改URL:

1
2
components.host = @"es.wikipedia.org";
NSURL *esURL = [components URL];

在iOS7中,NSURL.h添加了几个在处理URL时有用的分类。比如:你可以使用[NSCharacterSet URLPathAllowedCharacterSet]来获取path中允许的字符集。NSURL.h还添加了[NSString stringByAddingPercentEncodingWithAllowedCharacters:]方法来让你控制哪些字符应该被percent-encoded。以前,你只可以用CoreFoundation中的CFURLCreateStringByReplacingPercentEscapes来处理。

你可以查看官网文档中的NSURL.h来了解这些新方法。

八、CFStringTransform

CFStringTransform是个你很难相信自己以前竟然不知道它的函数。它可以以神奇的方式来音译字符串。例如,你可以使用kCFStringTransformStripCombiningMarks:选项来删除重音符号:

1
2
3
4
CFMutableStringRef string = CFStringCreateMutableCopy(NULL, 0, CFSTR("Schläger"));
CFStringTransform(string, NULL, kCFStringTransformStripCombiningMarks,false);
... => string is now "Schlager"
CFRelease(string);

当你在处理非拉丁文字系统时(例如中文和阿拉伯语),CFStringTransform功能更强大。它可以转换许多书写系统为拉丁字母,使得标准化更加简单。例如,你可以转换中文字母为拉丁字母:

1
2
3
4
5
6
CFMutableStringRef string = CFStringCreateMutableCopy(NULL, 0, CFSTR("你好"));
CFStringTransform(string, NULL, kCFStringTransformToLatin, false);
...=> string is now "nˇı hˇao"
CFStringTransform(string, NULL, kCFStringTransformStripCombiningMarks, false);
...=> string is now "ni hao"
CFRelease(string);

注意只需要简单的将option设置为kCFStringTransformToLatin即可,而不用管被转换的语言是什么。CFStringTransform也可以将拉丁字母转换为其他书写系统,比如阿拉伯语,韩文,希伯来语和泰语。
如果你想了解更多的信息,可以在developer.apple.com上查看CFMutableString和CFStringTokenizer的相关内容。

九、instancetype

Objective-C早就有一些微妙的子类化问题,比如下面这样的情况:

1
2
3
4
5
6
7
8
@interface Foo : NSObject
+ (Foo *)fooWithInt:(int)x;
@end

@interface SpecialFoo : Foo
@end
...
SpecialFoo *sf = [SpecialFoo fooWithInt:1];

这段代码会产生一个警告:”Incompatible pointer types initializing ‘SpecialFoo *’ with an expression of type ‘Foo *’”。问题在于fooWithInt返回一个Foo对象,而编译器不知道返回的类型是一个更具体的类(SpecialFoo)。这种情况很常见,比如[NSMutableArray array],编译器不会在你需要把值赋给NSMutableArray而它却返回一个NSArray的时候不生成警告。

有几种解决这个问题的方案。

方案一:你可能会重载fooWithInt:,如下所示:

1
2
3
4
5
6
7
8
@interface SpecialFoo : Foo
+ (SpecialFoo *)fooWithInt:(int)x;
@end

@implementation SpecialFoo
+ (SpecialFoo *)fooWithInt:(int)x {
return (SpecialFoo *)[super fooWithInt:x];
}

这种方法虽然能解决问题,但很不方便,你得为了添加类型转换重载很多方法。

方案二:你也可以在调用的时候进行类型转换:

1
SpecialFoo *sf = (SpecialFoo *)[SpecialFoo fooWithInt:1];

这种方法会更有效,不过它对于调用者很不方便。加入大量的类型转换也会消除类型检查,因此它更容易出错。

方案三:最常见的解决方法是返回id类型:

1
2
3
4
5
6
7
8
@interface Foo : NSObject
+ (id)fooWithInt:(int)x;
@end

@interface SpecialFoo : Foo
@end
...
SpecialFoo *sf = [SpecialFoo fooWithInt:1];

这种方法相当方便,而且消除了类型检查的警告。这是上面三种方案中最好用的,这也就是为什么Cocoa中大量的构造函数都返回id类型。

Cocoa有着极其一致的命名惯例。任何以init开头的方法都应该返回那种类型的对象,难道编译器不能强制这样做吗?答案是yes,最新版本的Clang编译器就是这么做的。所以现在,如果你有一个叫做initWithFoo:的方法返回了id类型,编译器假设返回类型是这个对象真正的类,如果你的类型不匹配它会给出警告。

对于init方法来讲这个自动转换很强大,但是这个例子中是一个便利的构造器fooWithInt:。在这个问题上编译器也能解决吗?答案也是yes,不过它不是自动的。对于便利构造器的命名转换不如init方法那么有效,SpecialFoo可能还有一个叫+fooWithInt:specialThing:的便利构造器。编译器没有好的办法从命名上找出是不是应该返回SpecialFoo类型,所以它不会这么去做。事实上,Clang添加了一种叫instancetype的新类型。作为返回类型,instancetype表示了它真正的类。所以,新的解决方案来了。

方案四:你可以像这样声明你的方法:

1
2
3
4
5
6
7
8
@interface Foo : NSObject
+ (instancetype)fooWithInt:(int)x;
@end

@interface SpecialFoo : Foo
@end
...
SpecialFoo *sf = [SpecialFoo fooWithInt:1];

为了保持一致性,最好使用instancetype作为init方法和便利构造器的返回类型。

十、Base64 and Percent Encoding

Cocoa早就需要方便的使用Base64编码和解码。Base64是很多web的标准协议,而且在很多情况下很有用,比如你需要存储任意的data到一个字符串的时候。

在iOS7中,新的NSData方法比如initWithBase64EncodedString:options:和base64EncodedStringWithOptions:可以用来在Base64和NSData间相互转换。

percent编码在web协议中也很重要,特别是URL。你现在可以使用[NSString stringByRemovingPercentEncoding]来解码percent编码过的字符串。尽管已经有了stringByAddingPercentEscapesUsingEncoding:方法来进行percent编码,iOS7还是添加了stringByAddingPercentEncodingWithAllowedCharacters:方法来允许你控制哪些字符要被percent编码。

十一、-[NSArray firstObject]

这是一个小改变,但我还是要提到它,因为我们等待它很久了:多年来,很多开发者实现自己的分类来获取数组的首个对象,现在Apple终于添加了firstObject方法,就像lastObject一样,如果数组是空的,firstObject会返回nil,而不是像使用objectAtIndex:0那样崩溃。

十二、Summary

Cocoa有很长的历史,充满了传统和惯例。Cocoa也是一个发展的、活跃的框架。在这个章节里面,你已经学到一些数十年里Objective-C开发的最佳实践。你学会了怎么为类,方法和变量更好的命名;学到了怎么更好的用一些并不熟知的特性,比如关联引用(associative references)和NSURLComponents。即使是对于有经验的Objective-C开发者,仍希望你学到了一些之前并不知道的Cocoa技巧。

十三、更多阅读

1.官方文档:下面的内容的相关文档可以在developer.apple.com或者在Xcode文档找到

  • CFMutableString Reference
  • CFStringTokenizer Reference
  • Collections Programming Topics
  • Collections Programming Topics,”Pointer Function Options”
  • Programming with Objective-C

2.其他资源

Tags: iOS
使用支付宝打赏
使用微信打赏

若你觉得我的文章对你有帮助,欢迎点击上方按钮对我打赏

扫描二维码,分享此文章