iOS开发读书笔记:Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法 - 篇1/4
iOS开发读书笔记:Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法 - 篇2/4
iOS开发读书笔记:Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法 - 篇3/4
iOS开发读书笔记:Effective Objective-C 2.0 编写高质量iOS与OS X代码的52个有效方法 - 篇4/4
- 第六章 块与大中枢派发
- 第37条:理解“块”这一概念
- 第38条:为常用的块类型创建typedef
- 第39条:用handler块降低代码分散程度
- 第40条:用块引用其所属对象时不要出现保留环
- 第41条:多用派发队列,少用同步锁
- 第42条:多用GCD,少用performSelector系列方法
- 第43条:掌握GCD及操作队列的使用时机
- 第44条:通过Dispatch Group机制,根据系统资源状况来执行任务
- 第45条:使用DIspatch_once来执行只需一次的线程安全代码
- 第46条:不要使用dispatch_get_current_queue
- 第七章 系统框架
- 第47条:熟悉系统框架
- 第48条:多用块枚举,少用for循环
- 第49条:对自定义其内存管理语义的collection使用无缝桥接
- 第50条:构建缓存时选用NSCache而非NSDictionary
- 第51条:精简initialize与load的实现代码
- 第52条:别忘了NSTimer会保留其目标对象
第六章 块与大中枢派发
当前多线程编程的核心就是“块"(block)与“大中枢派发”(GrandCentral Dispatch,GCD)。这虽然是两种不同的技术,但它们是一并引入的。
GCD是一种与块有关的技术,它提供了对线程的抽象,而这种抽象则基于“派发队列” (dispatch queue) 。
第37条:理解“块”这一概念
块的基础知识
“块”是一种可在C、C++及Objective-C代码中使用的“词法闭包"(lexical closure)。
- 块是一种代替函数指针的语法结构,所以它是匿名的;
- 可将块像对象一样传递;
- 在定义“块”的范围内,它可以访问到其中的全部变量;
块可以实现闭包。块用^
符号(脱字符)来表示,后面跟着一对花括号,括号里面是块的实现代码。 例如,下面就是个简单的块
^ {
//Block implementation here
}
块其实就是个值。与int、float或Objective-C对象一样,也可以把块赋给变量,然后像使用其他变量那样使用它。块类型的语法与函数指针近似。
块类型的语法结构如下:
return_type (block_name)(parameters)
int (^addBlock) (int a, int b) = ^(int a, int b) {
return a + b;
};
定义好之后,就可以像函数那样使用了。比方说,addBlock块可以这样用:
int add = addBlock (2 + 5) ; // add = 7
块的强大之处是:在声明它的范围里,所有变量都可以为其所捕获。这也就是说,那个范围里的全部变量,在块里依然可用。
默认情况下,为块所捕获的变量,是不可以在块里修改的,编译器会报错。不过,声明变量的时候可以加上__block
修饰符,这样就可以在块内修改了。
如果块所捕获的变量是对象类型,那么就会自动保留它。系统在释放这个块的时候,也会将其一并释放。这就引出了一个与块有关的重要问题。块本身可视为对象。实际上,在其他Objective-C对象所能响应的选择子中,有很多是块也可以响应的。而最重要之处则在于,块本身也和其他对象一样,有引用计数。当最后一个指向块的引用移走之后,块就回收了。 回收时也会释放块所捕获的变量,以便平衡捕获时所执行的保留操作。
块总能修改实例变量,所以在声明时无须加__block。不过,如果通过读取或写入操作捕获了实例变量,那么也会自动把self变量一并捕获了,因为实例变量是与self所指代的实例关联在一起的。直接访问实例变量和通过self来访问是等效的。
self->_anInstanceVariable = @"Something";
之所以要捕获self变量,原因正在于此。然而,一定要记住:self也是个对象,因而块在捕获它时也会将其保留。如果self所指代 的那个对象同时也保留了块,那么这种情况通常就会导致‘保留环’。
块的内部结构
每个Objective-C对象都占据着某个内存区域。因为实例变量的个数及对象所包含的关联数据互不相同,所以每个对象所占的内存区域也有大有小。块本身也是对象,在存放块对象的内存区域中,首个变量是指向Class对象的指针,该指针叫做isa(参见第14条)。其余内存里含有块对象正常运转所需的各种信息。
在内存布局中,最重要的就是invoke变量,这是个函数指针,指向块的实现代码。函数原型至少要接受一个void*型的参数,此参数代表块。刚才说过,块其实就是一种代替函数指针的语法结构,原来使用函数指针时,需要用“不透明的void指针”来传递状态。而改用块之后,则可以把原来用标准C语言特性所编写的代码封装成简明且易用的接口。
descriptor变量
是指向结构体的指针,每个块里都包含此结构体,其中声明了块对象的总体大小,还声明了copy
与dispose
这两个辅助函数所对应的函数指针。辅助函数在拷贝及丟弃块对象时运行,其中会执行一些操作,比方说,前者要保留捕获的对象,而后者则将之释放。
块还会把它所捕获的所有变量都拷贝一份。捕获了多少个变量,就要占据多少内存空间。请注意,拷贝的并不是对象本身,而是指向这些对象的指针变量。invoke函数为何需要把块对象作为参数传进来呢?原因就在于,执行块时,要从内存中把这些捕获到的变量读出来。
全局块、栈块及堆块
定义块的时候,其所占的内存区域是分配在栈中的。这就是说,块只在定义它的那个范围内有效。为解决此问题,可给块对象发送copy消息以拷贝之。这样的话,就可以把块从栈复制到堆了。拷贝后的块,可以在定义它的那个范围之外使用。而且,一旦复制到堆上,块就成了带引用计数的对象了。后续的复制操作都不会真的执行复制,只是递增块对象的引用计数。如果不再使用这个块,那就应将其释放,在ARC环境下会自动释放,而手动管理引用计数时则需要自己来调用release方法。当引用计数降为0后,“分配在堆上的块”(heap block)会像其他对象一样,为系统所回收。而“分配在栈上的块”(stack block)则无须释放,因为栈内存本来就会自动回收。
除了“桟块”和“堆块”之外,还有一类块叫做“全局块”(global block)。这种块不会捕捉任何状态(比如外围的变量等),运行时也无须有状态来参与。块所使用的整个内存区域,在编译期已经完全确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候于栈中创建。另外,全局块的拷贝操作是个空操作,因为全局块决不可能为系统所回收。这种块实际上相当于单例。
由于运行该块所需的全部信息都能在编译期确定,所以可把它做成全局块。
要点:
- 块是C、C++、Objective-C中的词法闭包。
- 块可接受参数,也可返回值。
- 块可以分配在栈或堆上,也可以是全局的。分配在栈上的块可拷贝到堆里,这样的话,就和标准的Objective-C对象一样,具备引用计数了。
第38条:为常用的块类型创建typedef
每个块都具备其“固有类型”(inherenttype),因而可将其赋给适当类型的变量。如果想把块赋给变量,则需注意其类型。变量类型及相关赋值语句如下:
int (^variableName) (BOOL flag, int value) = ^(BOOL flag, int value) {
//Implementation return somelnt;
}
这个类型似乎和普通的类型大不相同,然而如果习惯函数指针的话,那么看上去就会觉得眼熟了。
为了隐藏复杂的块类型,需要用到C语言中名为“类型定义”(type definition)的特性。 typedef关键字用于给类型起个易读的别名。比如:
typedef int(^EOCSomeBlock)(BOOL flag, int value);
上面这条语句向系统中新增了一个名为EOCSomeBlock的类型。此后,不用再以复杂的块类型来创建变量了,直接使用新类型即可:
EOCSomeBlock block = ^(BOOL flag, int value){
// Implementation
};
若不定义别名,则方法签名会像下面这样:
- (void)startWithCompletionHandler:(void(^)(NSData *data, NSError *error))completion;
- (void)startWithCompletionHandler:(EOCCompletionHandler)completion;
要点:
- 以typedef重新定义块类型,可令块变量用起来更加简单。
- 定义新类型时应遵从现有的命名习惯,勿使其名称与别的类型相冲突。
- 不妨为同一个块签名定义多个类型别名。如果要重构的代码使用了块类型的某个别名,那么只需修改相应typedef中的块签名即可,无须改动其他typedef。
第39条:用handler块降低代码分散程度
要点:
- 在创建对象时,可以使用内联的handler块将相关业务逻辑一并声明。
- 在有多个实例需要监控时,如果采用委托模式,那么经常需要根据传入的对象来切换,而若改用handler块来实现,则可直接将块与相关对象放在一起。
- 设计API时如果用到了handler块,那么可以增加一个参数,使调用者可通过此参数来决定应该把块安排在哪个队列上执行。
第40条:用块引用其所属对象时不要出现保留环
使用块来编程时,很容易导致“保留环”(retain cycle)。要想清楚块可能会捕获并保留哪些对象。如果这些对象又直接或间接保留了块,那么就要考虑怎样在适当的时机解除保留环。
要点:
- 如果块所捕获的对象直接或间接地保留了块本身,那么就得当心保留环问题。
- 一定要找个适当的时机解除保留环,而不能把责任推给API的调用者。
第41条:多用派发队列,少用同步锁
在Objective-C中,如果有多个线程要执行同一份代码,那么有时可能会出问题。这种情况下,通常要使用锁来实现某种同步机制。在GCD出现之前,有两种办法,第一种是采用内置的“同步块”(synchronization block):
- (void)synchronizedMethod {
@synchronized(self) {
//Safe
}
}
这种写法会根据给定的对象,自动创建一个锁,并等待块中的代码执行完毕。执行到这段代码结尾处,锁就释放了。在本例中,同步行为所针对的对象是self。这么写通常没错, 因为它可以保证每个对象实例都能不受干扰地运行synchronizedMethod
方法。然而,滥用@synchronized(self)
则会降低代码效率,因为共用同一个锁的那些同步块,都必须按顺序执行。若是在self对象上频繁加锁,那么程序可能要等另一段与此无关的代码执行完毕,才能继续执行当前代码,这样做其实并没有必要。
另一个办法是直接使用NSLock对象:
_lock = [[NSLock alloc] init];
- (void)synchronizedMethod {
[_lock lock];
//Safe
[_lock unlock];
}
也可以使用NSRecursiveLock
这种“递归锁’(recursive lock) ,线程能够多次持有该锁,而不会出现死锁(dead lock)现象。
这两种方法都很好,不过也有其缺陷。比方说,在极端情况下,同步块会导致死锁,另外,其效率也不见得很高,而如果直接使用锁对象的话,一旦遇到死锁,就会非常麻烦。
-(NSString*)someString {
@synchronized(self) {
return _someString;
}
}
- (void)setSomeString:(NSString*)someString {
@synchronized(self) {
_someString = someString;
}
}
刚才说过,滥用@synchronized(self)
会很危险,因为所有同步块都会彼此抢夺同一个锁。要是有很多个属性都这么写的话,那么每个属性的同步块都要等其他所有同步块执行完毕才能执行,这也许并不是开发者想要的效果。我们只是想令每个属性各自独立地同步。
使用GCD栅栏(barrier),下列函数可以向队列中派发块,将其作为栅栏使用:
void dispatch_barrier_async(dispatch_queue_t queue,dispatch_block_t block);
void dispatch_barrier_sync(dispatch_queue_t queue,dispatch_block_t block);
在队列中,栅栏块必须单独执行,不能与其他块并行。这只对并发队列有意义,因为串行队列中的块总是按顺序逐个来执行的。并发队列如果发现接下来要处理的块是个栅栏块 (barrier block) ,那么就一直要等当前所有并发块都执行完毕,才会单独执行这个栅栏块。待栅栏块执行过后,再按正常方式继续向下处理。
可以用栅栏块来实现属性的设置方法。在设置方法中使用了栅栏块之后,对属性的读取操作依然可以并发执行,但是写入操作却必须单独执行了。
_syncQueue = dispatch_qet_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT,0);
- (NSString *)someString {
__block NSString *localSomeString;
dispatch_sync(_syncQueuef ^{
localSomeString = _someString;
});
return localSomeString;
};
- (void)setSomeString: (NSString*)someString {
dispatch_barrier_async(_syncQueue,^{
_someString = someString;
});
}
在这个并发队列中,读取操作是用普通的块来实现的,而写入操作则是用栅栏块来实现的。 读取操作可以并行,但写入操作必须单独执行,因为它是栅栏块。
这种做法肯定比使用串行队列要快。注意,设置函数也可以改用同步的栅栏块(synchronous barrier)来实现,那样做可能会更髙效。
要点:
- 派发队列可用来表述同步语义(synchronization semantic),这种做法要比使用
@synchronized块
或NSLock对象
更简单。- 将同步与异步派发结合起来,可以实现与普通加锁机制一样的同步行为,而这么做却不会阻塞执行异步派发的线程。
- 使用同步队列及栅栏块,可以令同步行为更加髙效。
第42条:多用GCD,少用performSelector系列方法
NSObject定义了几个方法, 令开发者可以随意调用任何方法。
[object performSelector: selector];
这种方式看上去似乎多余。如果某个方法只是这么来调用的话,那么此方式确实多余。 然而,如果选择子是在运行期决定的,那么就能体现出此方式的强大之处了。这就等于在动态绑定之上再次使用动态绑定。
performSelector系列方法还有个功能,就是可以延后执行选择子,或将其放在另一个线程上执行。下面列出了此方法中一些更为常用的版本:
- (void)performSelector:(SEL)selector withObject:(id)argument afterDelay:(NSTimelnterval)delay
- (void)performSelector:(SEL)selector onThread:(NSThread *)thread withObject:(id)argument waitUntilDone:(BOOL)wait
- (void)performSelectorOnMainThread:(SEL)selector withObject:(id)argument waitUntilDone:(BOOL)wait
要点:
- performSelector系列方法在内存管理方面容易有疏失。它无法确定将要执行的选择子具体是什么,因而ARC编译器也就无法插入适当的内存管理方法。
- performSelector系列方法所能处理的选择子太过局限了,选择子的返回值类型及发送 给方法的参数个数都受到限制。
- 如果想把任务放在另一个线程上执行,那么最好不要用performSelector系列方法,而是应该把任务封装到块里,然后调用大中枢派发机制的相关方法来实现。
第43条:掌握GCD及NSOperationQueue(操作队列)的使用时机
在执行后台任务时,GCD并不一定是最佳方式。还有一种技术叫做NSOperationQueue,“操作队列” (operation queue)在GCD之前就有了,其中某些设计原理因操作队列而流行,GCD就是基于这些原理构建的。实际上,从iOS4与Mac OSX 10.6开始,操作队列在底层是用GCD来实现的。
在两者的诸多差别中,首先要注意:GCD是纯C的API,而操作队列则是Objective-C 的对象。在GCD中,任务用块来表示,而块是个轻量级数据结构(参见第37条)。与之相 反,“操作"(operation)则是个更为重量级的Objective-C对象。虽说如此,但GCD并不总是最佳方案。有时候采用对象所带来的开销微乎其微,使用完整对象所带来的好处反而大大超过其缺点。
使用NSOperation及NSOperationQueue的好处如下:
- 取消某个操作。运行任务之前, 可以在NSOperation对象上调用cancel方法,该方法会设置对象内的标志位,用以表明此任务不需执行,不过,已经启动的任务无法取消。若是不使用操作队列,而是把块安排到GCD队列,那就无法取消了。那套架构是“安排好任务之后就不管了"(fire and forget)。
- 指定操作间的依赖关系。使特定的操作必须在另外一个操作顺利执行完毕后方可执行。
- 通过键值观测机制监控NSOperation对象的属性。NSOperation对象有许多属性都适合通过键值观测机制(简称KVO)来监听,比如可以通过isCancelled属性来判断任务是否已取消,又比如可以通过isFinished属性来判断任务是否已完成。如果想在某个任务变更其状态时得到通知,或是想用比GCD更为精细的方式来控制所要执行的任务,那么键值观测机制会很有用。
- 指定操作的优先级。操作的优先级表示此操作与队列中其他操作之间的优先关系。优先级高的操作先执行,优先级低的后执行。操作队列的调度算法(scheduling algorithm)虽“不透明"(opaque),但必然是经过一番深思熟虑才写成的。反之,GCD 则没有直接实现此功能的办法。GCD的队列确实有优先级,不过那是针对整个队列来说的,而不是针对每个块来说的。
- 重用 NSOperation 对象。系统内置了一些 NSOperation 的子类(比如 NSBlockOperation) 供开发者调用,要是不想用这些固有子类的话,那就得自己来创建了。这些类就是普通的Objective-C对象,能够存放任何信息。对象在执行时可以充分利用存于其中的信息,而且还可以随意调用定义在类中的方法。这就比派发队列中那些简单的块要强大许多。这些NSOperation类可以在代码中多次使用,它们符合软件开发中的“不重复” (Don ’ t Repeat Yourself, DRY )原则。
有一个API选用了操作队列而非派发队列,这就是NSNotificationCemer,开发者可通过其中的方法来注册监听器,以便在发生相关事件时得到通知,而这个方法接受的参数是块, 不是选择子。方法原型如下:
- (id)addObserverForName:(NSString *)name object:(id)object queue:(NSOperationQueue *)queue usingBlock: (void(^)(NSNotification *))block
本来这个方法也可以不使用操作队列,而是把处理通知事件所用的块安排在派发队列里。但实际上并没有这样做,其设计者显然使用了高层的Objective-C API。在这种情况下, 两套方案的运行效率没多大差距。设计这个方法的人可能不想使用派发队列,因为那样做将依赖于GCD,而这种依赖没有必要,前面说过,块本身和GCD无关,所以如果仅使用块的话,就不会引入对GCD的依赖了。也有可能是编写这个方法的人想全部用Objective-C来描述,而不想使用纯C的东西。
经常会有人说:应该尽可能选用高层API,只在确有必要时才求助于底层。笔者也同意这个说法,但我并不盲从。某些功能确实可以用高层的Objective-C方法来做,但这并不等于说它就一定比底层实现方案好。要想确定哪种方案更佳,最好还是测试一下性能。
要点:
- 在解决多线程与任务管理问题时,派发队列并非唯一方案。
- 操作队列提供了一套高层的Objective-C API,能实现纯GCD所具备的绝大部分功能, 而且还能完成一些更为复杂的操作,那些操作若改用GCD来实现,则需另外编写代码。
第44条:通过Dispatch Group机制,根据系统资源状况来执行任务
dispatch group 是GCD的一项特性,能够把任务分组。调用者可以等待这组任务执行完毕,也可以在提供回调函数之后继续往下执行,这组任务完成时,调用者会得到通知。这个功能有许多用途,其中最重要、最值得注意的用法,就是把将要并发执行的多个任务合为一组,于是调用者就可以知道这些任务何时才能全部执行完毕。
创建dispatch group:
dispatch_group_dispatch_group_create();
想把任务编组,有两种办法:
第一种:
void dispatch_group_async(dispatch_group_t group,dispatch_queue_t queue, dispatch_block_t block);
第二种:
void dispatch_group_enter(dispatch_group_t group); //使分组里正要执行的任务数递增
void dispatch_group_leave(dispatch_group_t group);//使之递减
调用了 dispatch_group_enter以后,必须有与之对应的dispatch group leave才行。这与引用计数(参见第29条)相似。在使用dispatch group时,如果调用enter之后,没有相应的leave操作,那么这一组任务就永远执行不完。
下面这个函数可用于等待dispatch group执行完毕:
long dispatch_group_wait(dispatch_group_t group,dispatch_time_t timeout);
timeout参数表示函数在等待dispatch group执行完毕时,应该阻塞多久。如果执行dispatch group所需的时间小于timeout,则返回0,则返回非0值。此参数也可以取常量DISPATCH_ TIME_FOREVER
,这表示函数会一直等着dispatch group执行完,而不会超时(time out)。
若当前线程不应阻塞,则可用notify函数来取代wait:
void dispatch_group_notify(dispatch_group_t group,dispatch_queue_t queue, dispatch_block_t block);
模拟多线程耗时操作(比如异步请求网络):
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t globalQueue = dispatch_get_global_queue(0, 0);
dispatch_group_enter(group);
dispatch_group_async(group, globalQueue, ^{
sleep(3);
NSLog(@"thread:%@ block1结束",[NSThread currentThread]);
dispatch_group_leave(group);
});
dispatch_group_enter(group);
dispatch_group_async(group, globalQueue, ^{
sleep(3);
NSLog(@""thread:%@ block2结束",[NSThread currentThread]);
dispatch_group_leave(group);
});
dispatch_group_notify(group, dispatch_get_global_queue(0, 0), ^{
//全部结束
});
开发者未必总需要使用dispatch group。有时候采用单个队列搭配标准的异步派发,也可实现同样效果。
遍历某个collection,并在其每个元素上执行任务,dispatcl_apply会持续阻塞,直到所有任务都执行完毕为止。
void dispatch_apply(size_t iterations,dispatch_queue_t queue, void(^block)(size_t));
要点:
- 一系列任务可归入一个dispatch group之中。开发者可以在这组任务执行完毕时获得通知。
- 通过dispatch group,可以在并发式派发队列里同时执行多项任务。此时GCD会根据系统资源状况来调度这些并发执行的任务。开发者若自己来实现此功能,则需编写大量代码。
第45条:使用dispatch_once来执行只需运行一次的线程安全代码
void dispatch_once (dispatch_once_t *token,dispatch_block_t block);
此函数接受类型为dispatch_once_t的特殊参数,笔者称其为“标记”(token),此外还接受块参数。对于给定的标记来说,该函数保证相关的块必定会执行,且仅执行一次。首次调用该函数时,必然会执行块中的代码,最重要的一点在于,此操作完全是线程安全的。请注意,对于只需执行一次的块来说,每次调用函数时传入的标记都必须完全相同。因此,开发者通常将标记变量声明在static或global作用域里,可以保证编译器在每次执行sharedlnstance方法时都会复用这个变量,而不会创建新变量。
+ (id)sharedlnstance {
static EOCClass *sharedlnstance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sharedlnstance = [[self alloc] init];
});
return sharedlnstance;
}
此外,dispatch_once更高效。它没有使用重量级的同步机制,若是那样做的话,每次运行代码前都要获取锁,相反,此函数采用“原子访问”(atomic access)来査询标记,以判断其所对应的代码原来是否已经执行过。笔者在自己装有64位Mac OS X 10.8.2系统的电脑上简单测试了性能,分别采用@synchronized
方式及dispatch once
方式来实现sharedlnstance
方法,结果显示,后者的速度几乎是前者的两倍。
要点:
- 经常需要编写“只需执行一次的线程安全代码”(thread-safe single-code execution)。通过GCD所提供的dispatch_once函数,很容易就能实现此功能。
- 标记应该声明在static或global作用域中,这样的话,在把只需执行一次的块传给dispatch once函数时,传进去的标记也是相同的。
第46 条:不要使用dispatch_get_current_queue (待补充)
使用GCD时,经常需要判断当前代码正在哪个队列上执行,向多个队列派发任务时, 更是如此。文档中说,此函数返回当前正在执行代码的队列,该函数有种典型的错误用法(antipattem,“反模式”)。
要点:
- dispatch_get_current_queue函数的行为常常与开发者所预期的不同。此函数已经废弃, 只应做调试之用。
- 由于派发队列是按层级来组织的,所以无法单用某个队列对象来描述“当前队列”这一概念。
- dispatch_get_current_queue函数用于解决由不可重入的代码所引发的死锁,然而能用此函数解决的问题,通常也能改用“队列特定数据”来解决。
第七章 系统框架
虽说不使用系统框架也能编写Objective-C代码,但几乎没人这么做。即便是NSObject这个标准的根类,也属于Foundation框架,而非语言本身。若不使用Foundation,就必须自己编写根类。
第47条:熟悉系统框架
将一系列代码封装为动态库(dynamic library),并在其中放入描述其接口的头文件,这样做出来的东西就叫框架。有时为iOS平台构建的第三方框架所使用的是静态库(static library),这是因为iOS应用程序不允许在其中包含动态库。这些东西严格来讲并不是真正的框架,然而也经常视为框架。不过,所有iOS平台的系统框架仍然使用动态库。
在为Mac OS X或iOS系统开发“带图形界面的应用程序"(graphical application)时,会用到名为Cocoa的框架,在iOS上称为Cocoa Touch。其实Cocoa本身并不是框架,但是里面集成了一批创建应用程序时经常会用到的框架。
开发者会碰到的主要框架就是Foundation,像是NSObject、NSArray、NSDictionary等类都在其中。Foundation框架中的类,使用NS这个前缀,此前缀是在Objective-C语言用作NextStep操作系统
的编程语言时首度确定的。Foundation框架真可谓所有Objective-C应用程序的“基础”,若是没有它,那么本书大部分内容就不知所云了。
还有个与Foundation相伴的框架,叫做Core Foundation。虽然从技术上讲,Core Foundation框架不是Objective-C框架,但它却是编写Objectve-C应用程序时所应熟悉的重要框架, Foundation框架中的许多功能,都可以在此框架中找到对应的C语言API。Core Foundation 与Foundation不仅名字相似,而且还有更为紧密的联系。有个功能叫做“无缝桥接”(toll- free bridging ), 可以把Core Foundation中的C语言数据结构平滑转换为Foundation中的Objective-C对象,也可以反向转换。比方说,Foundation框架中的字符串是NSString,而它可以转换为Core Foundation里与之等效的CFString对象。无缝桥接技术是用某些相当复杂的代码实现出来的,这些代码可以使运行期系统把Core Foundation框架中的对象视为普通的Objective-C对象。但是,像无缝桥接这么复杂的技术,想自己编写代码实现它,可不太容易。开发程序时可以使用此功能。
除了 Foundation与Core Foundation之外,还有很多系统库,其中包括但不限于下面列出的这些:
-
CFNetwork
此框架提供了C语言级别的网络通信能力 -
CoreAudio
该框架所提供的C语言API可用来操作设备上的音频硬件。 -
AVFoundation
此框架所提供的Objective-C对象可用来回放并录制音频及视频 -
CoreData
此框架所提供的Objective-C接口可将对象放入数据库 -
CoreText
此框架提供的C语言接口可以高效执行文字排版及渲染操作。
可以看出Objective-C编程的一项重要特点,那就是:经常需要使用底层的C语言级API。用C语言来实现API的好处是,可以绕过Objective-C的运行期系统,从而提升执行速度。当然,由于ARC只负责 Objective-C的对象(参见第30条),所以使用这些API时尤其需要注意内存管理问题。若想使用这种框架,一定得熟悉C语言基础才行。
读者可能会编写使用UI框架的Mac OS X或iOS应用程序。这两个平台的核心UI框架分别叫做AppKit及UIKit,它们都提供了构建在Foundation与Core Foundation之上的Objective-C类。在这些主要的UI框架之下,是Core Animation
与Core Graphics
框架。
Core Animation
是用Objective-C
语言写成的,它提供了一些工具,而UI框架则用这些工具来渲染图形并播放动画。Core Animation
本身并不是框架,它是QuartzCore
框架的一部分。
Core Graphics
框架以C语言
写成,其中提供了2D渲染所必备的数据结构与函数,例如, 其中定义了CGPoint、CGSize、CGRect
等数据结构,比如UlKit框架中的UlView类在确定视图控件之间的相对位置时,这些数据结构都要用到。
要点:
- 许多系统框架都可以直接使用。其中最重要的是Foundation与Core Foundation,这两个框架提供了构建应用程序所需的许多核心功能。
- 很多常见任务都能用框架来做,例如音频与视频处理、网络通信、数据管理等。
- 请记住:用纯C写成的框架与用Objective-C写成的一样重要,若想成为优秀的Objective-C开发者,应该掌握C语言的核心概念。
第48条:多用块枚举,少用for循环
// 数组
NSArray *anArray = /* ••• */;
[anArray enumerateObjectsUsingBlock:^(id object, NSUInteger idx, BOOL *stop){
//Do something with object
if (shouldStop) {
*stop = YES;
}
}];
// Dictionary
NSDictionary *aDictionary = /* ••• */;
[aDictionary enumerateObjectsUsingBlock:^(id key, id object, BOOL *stop){
//Do something
if (shouldStop) {
*stop = YES;
}
}];
//Set
NSSet *anSet = /* ••• */;
[anSet enumerateObjectsUsingBlock:^(id object, BOOL *stop){
//Do something with object
if (shouldStop) {
*stop = YES;
}
}];
优点:
- 遍历时可以直接从块里获取更多信息;
- 可以通过设定stop变量值来终止循环,当然,使用其他几种遍历方式时,也可以通过break来终止循环;
- 能够修改块的方法签名,以免进行类型转换操作,如果能够确知某collection里的对象是什么类型, 那就应该使用这种方法指明其类型。
反向遍历
向其传入 NSEnumerationReverse
“选项掩码”(option mask):
- (void)enumerateObjectsWithOptions:(NSEnumerationOptions)options usingBlock:(void(^)(id obj, NSUInteger idx, BOOL *stop))block
- (void)enumerateKeysAndObjectsWithOptions:(NSEnumerationOptions)options usingBlock:(void(^) (id key, id obj, BOOL *stop) ) block
NSEnumerationOptions类型是个enum,其各种取值可用“按位或”(bitwise OR)连接,用以表明遍历方式。例如,开发者可以请求以并发方式执行各轮迭代。如果使用此选项,那么底层会通过GCD来处理并发执行事宜,具体实现时很可能会用到dispatch group (参见第44条)。不过,到底如何来实现,不是本条所要讨论的内容。
要注意:只有在遍历数组或有序set等有顺序的collection时,这么做才有意义。
要点:
- 遍历collection有四种方式。最基本的办法是for循环,其次是NSEnumerator遍历法 及快速遍历法,最新、最先进的方式则是“块枚举法”。
- “块枚举法”本身就能通过GCD来并发执行遍历操作,无须另行编写代码。而采用其 他遍历方式则无法轻易实现这一点。
- 若提前知道待遍历的collection含有何种对象,则应修改块签名,指出对象的具体类型。
第49条:对自定义其内存管理语义的collection使用无缝桥接
使用“无缝桥接”技术,可以在定义于Foundation框架中的Objective-C类和定义于 Core Foundation框架中的C数据结构之间互相转换。笔者将C语言级别的API称为数据结构,而没有称其为类或对象,这是因为它们与Objective-C中的类或对象并不相同。
NSArray *anNSArray = @[@1, @2, @3, @4, @5];
CFArrayRef aCFArray = (__bridge CFArrayRef)anNSArray;
NSLog(@"Size of array = %li",, CFArrayGetCount (aCFArray));
//Output: Size of array = 5
转换操作中的__bridge
告诉ARC (参见第30条)如何处理转换所涉及的Objective-C对象。__bridge
本身的意思是:ARC仍然具备这个Objective-C对象的所有权。而_bridge_retained
则与之相反,意味着ARC将交出对象的所有权。若是前面那段代码改用它来实现, 那么用完数组之后就要加上CFRelease(aCFArray)
以释放其内存。与之相似,反向转换可通过_bridge_transfer
来实现。比方说,想把CFArrayRef转换为NSArray *,并且想令ARC获得对象所有权,那么就可以采用此种转换方式。这三种转换方式称为“桥式转换"(bridged cast)。
在使用Foundation框架中的字典对象时会遇到一个大问题,那就是其键的内存管理语义为“拷贝”,而值的语义却是“保留”。除非使用强大的无缝桥接技术,否则无法改变其语义。
要点:
- 通过无缝桥接技术,可以在Foundation框架中的Objective-C对象与Core Foundation框架中的C语言数据结构之间来回转换。
- 在Core Foundation层面创建collection时,可以指定许多回调函数,这些函数表示此collection应如何处理其元素。然后,可运用无缝桥接技术,将其转换成具备特殊内存管理语义的Objective-C collection。
第50条:构建缓存时选用NSCache而非NSDictionary
NSCache类是Foundation框架专为处理内存缓存而设计的。
NSCache胜过NSDictionary之处在于以下几个方面:
- 当系统资源将要耗尽时,NSCache可以自动删减缓存,还会先行删减“最久未使用的"(lease recently used)对象。 如果采用普通的字典,那么就要自己编写挂钩,在系统发出“低内存”(low memory)通知时手工删减缓存,十分复杂。
- NSCache并不会“拷贝”键,而是会“保留”它。NSCache对象不拷贝键的原因在于:很多时候,键都是由不支持拷贝操作的对象来充当的。因此,NSCache不会自动拷贝键,所以说, 在键不支持拷贝操作的情况下,该类用起来比字典更方便。
- NSCache是线程安全的。 而NSDictionary则绝对不具备此优势,意思就是:在开发者自己不编写加锁代码的前提下,多个线程便可以同时访问NSCache。对缓存来说,线程安全通常很重要,因为开发者可能要在某个线程中读取数据,此时如果发现缓存里找不到指定的键,那么就要下载该键所对应的数据了。而下载完数据之后所要执行的回调函数,有可能会放在背景线程中运行,这样的话,就等于是用另外一个线程来写入缓存了。
- 开发者可以操控缓存删减其内容的时机。有两个与系统资源相关的尺度可供调整,其一是缓存中的对象总数,其二是所有对象的“总开销"(overall cost)。开发者在将对象加入缓存时,可为其指定“开销值”。当对象总数或总开销超过上限时,缓存就可能会删减其中的对象了,在可用的系统资源趋于紧张时,也会这么做。然而要注意,“可能”会删减某个对象, 并不意味着“一定”会删减这个对象。删减对象时所遵照的顺序,由具体实现来定。这尤其说明:想通过调整“开销值”来迫使缓存优先删减某对象,不是个好主意。
注意:向缓存中添加对象时,只有在能很快计算出“开销值”的情况下,才应该考虑采用这个尺度。若计算过程很复杂,那么照这种方式来使用缓存就达不到最佳效果了,而缓存的本意则是要增加应用程序响应用户操作的速度。
如何使用:比如下载数据所用的URL,就是缓存的键。若缓存未命中(cache miss) ,则下载数据并将其放入缓存。而数据的“开销值”则设为其长度。创建NSCache时,将其中可缓存的总对象数目上限设为100,将“总开销’上限设为5MB,不过,由于‘开销值’以‘字节’ 为单位,所以要通过算式将MB换箅成字节。
还有个类叫做NSPurgeableData
,和NSCache搭配起来用,效果很好,此类是NSMutiableData的子类,而且实现了NSDiscardableContent协议
。如果某个对象所占的内存能够根据需要随时丢弃,那么就可以实现该协议所定义的接口。这就是说,当系统资源紧张时,可以把保存NSPurgeableData
对象的那块内存释放掉。NSDiscardableContent
协议里定义了名为isContentDiscarded
的方法,可用来査询相关内存是否已释放。
要点:
- 实现缓存时应选用NSCache而非NSDictionary对象。因为NSCache可以提供优雅的自动删减功能,而且是“线程安全的”,此外,它与字典不同,并不会拷贝键。
- 可以给NSCache对象设置上限,用以限制缓存中的对象总个数及“总成本”,而这些尺度则定义了缓存删减其中对象的时机。但是绝对不要把这些尺度当成可靠的“硬限制”(hard limit),它们仅对NSCache起指导作用。
- 将
NSPurgeableData
与NSCache
搭配使用,可实现自动清除数据的功能,也就是说,当NSPurgeableData
对象所占内存为系统所丢弃时,该对象自身也会从缓存中移除。- 如果缓存使用得当,那么应用程序的响应速度就能提高。只有那种“重新计算起来很费事的”数据,才值得放入缓存,比如那些需要从网络获取或从磁盘读取的数据。
第51条:精简initialize与load的实现代码
在Objective-C中,绝大多数类都继承自NSObject这个根类,而该类有两个方法,可用来实现这种初始化操作:
+ (void)load
+ (void)initialize
首先要讲的是load方法:
对于加入运行期系统中的每个类(class)及分类(category)来说,必定会调用此方法,而且仅调用一次。当包含类或分类的程序库载入系统时,就会执行此方法,而这通常就是指应用程序启动的时候,若程序是为iOS平台设计的,则肯定会在此时执行。Mac OS X应用程序更自由一些,它们可以使用“动态加载”(dynamic loading)之类的特性,等应用程序启动好之后再去加载程序库。如果分类和其所属的类都定义了load方法,则先调用类里的,再调用分类里的。
load方法的问题在于,执行该方法时,运行期系统处于“脆弱状态"(fragilestate)。在执行子类的load方法之前,必定会先执行所有超类的load方法,而如果代码还依赖了其他程序库,那么程序库里相关类的load方法也必定会先执行。然而,根据某个给定的程序库,却无法判断出其中各个类的载入顺序。因此,在load方法中使用其他类是不安全的。
有个重要的事情需注意,那就是load方法并不像普通的方法那样,它并不遵从那套继承规则。如果某个类本身没实现load方法,那么不管其各级超类是否实现此方法,系统都不会调用。此外,分类和其所属的类里,都可能出现load方法。此时两种实现代码都会调用,类的实现要比分类的实现先执行。
而且load方法务必实现得精简一些,也就是要尽量减少其所执行的操作,因为整个应用程序在执行load方法时都会阻塞。如果load方法中包含繁杂的代码,那么应用程序在执行期间就会变得无响应。 其真正用途仅在于调试程序,比如可以在分类里编写此方法,用来判断该分类是否已经正确载入系统中。
想执行与类相关的初始化操作,还有个办法,就是覆写initialize方法:
对于每个类来说,该方法会在程序首次用该类之前调用,且只调用一次。它是由运行期系统来调用的,绝不应该通过代码直接调用。
其虽与load相似,但却有几个非常重要的区别:
- 它是“惰性调用的”,也就是说,只有当程序用到了相关的类时,才会调用。 因此,如果某个类一直都没有使用,那么其initialize方法就一直不会运行。这也就等于说,应用程序无须先把每个类的initialize都执行一遍,这与load方法不同,对于load来说,应 用程序必须阻塞并等着所有类的load都执行完,才能继续。
- 此方法与load还有个区别,就是运行期系统在执行该方法时,是处于正常状态的,因此,从运行期系统完整度上来讲,此时可以安全使用并调用任意类中的任意方法。而且,运行期系统也能确保initialize方法一定会在“线程安全的环境”(thread-safe environment)中执行。
- initialize方法与其他消息一样,如果某个类未实现它,而其超类实现了,那么就会运行超类的实现代码。
#import <Foundation/Foundation.h>
@interface EOCBaseClass : NSObject
@end
@implementation EOCBaseClass
+ (void)initialize {
NSLog (@"%@ initialize", self);
}
@end
@interface EOCSubClass : EOCBaseClass
@end
@implementation EOCSubClass
@end
即便EOCSubClass类没有实现initialize方法,它也会收到这条消息。由各级超类所实现的initialize也会先行调用。所以,首次使用EOCSubClass时,控制台会输出如下消息:
EOCBaseClass initialize
EOCSubClass initialize
与其他方法(除去load) —样, initialize也遵循通常的继承规则,所以,当初始化基类EOCBaseClass时,EOCBaseClass中定义的initialize方法要运行一遍,而当初始化子类EOCSubClass时,由于该类并未覆写此方法,因而还要把父类的实现代码再运行一遍。鉴于此,通常都会这么来实现initialize方法:
+ (void)initialize {
if(self == [EOCBaseClass class]) {
NSLog (@"%@ initialize", self);
}
}
加上这条检测语句之后,只有当开发者所期望的那个类载入系统时,才会执行相关的初 始化操作。如果把刚才的例子照此改写,那就不会打印出两条记录信息了,这次只输出一条:
EOCBaseClass initialize
若某个全局状态无法在编译期初始化,则可以放在initialize里来做:
//EOCClass.h
#import <Foundation/Foundation.h>
@interface EOCClass : NSObject
@end
//EOCClass .m
#import "EOCClass.h"
static const int klnterval = 10;
static NSMutableArray *kSomeObjects;
@implementation EOCClass
+ (void)initialize {
if (self == [EOCClass class]) {
kSomeObjects = [NSMutableArray new];
}
}
整数可以在编译期定义,然而可变数组不行,因为它是个Objective-C对象,所以创建实例之前必须先激活运行期系统。注意,某些Objective-C对象也可以在编译期创建,例如NSString实例。然而,创建下面这种对象会令编译器报错:
static NSMutableArray *kSomeObjects = [NSMutableArray new];
编写load或initialize方法时,把代码实现得简单一些,能节省很多调试时间。除了初始化全局状态之外,如果还有其他事情要做,那么可以专门创建一个方法来执行这些操作,并要求该类的使用者必须在使用本类之前调用此方法。比如说,如 果“单例类"(singleton class)在首次使用之前必须执行一些操作,那就可以采用这个办法。
要点:
- 在加载阶段,如果类实现了load方法,那么系统就会调用它。分类里也可以定义此方法,类的load方法要比分类中的先调用。与其他方法不同,load方法不参与覆写机制。
- 首次使用某个类之前,系统会向其发送initialize消息。由于此方法遵从普通的覆写规则,所以通常应该在里面判断当前要初始化的是哪个类。
- load与initialize方法都应该实现得精简一些,这有助于保持应用程序的响应能力,也能减少引入“依赖环”(interdependency cycle) 的几率。
- 无法在编译期设定的全局常量,可以放在initialize方法里初始化。
第52条:别忘了NSTimer会保留其目标对象
计时器要和“运行循环"(runloop)相关联,运行循环到时候会触发任务。
方法1:创建计时器,并将其预先安排在当前运行循环中:
+ (NSTimer *)scheduledTimerWithTimelnterval:(NSTimelnterval)seconds target:(id)target selector:(SEL)selector userlnfo:(id)userinfo repeats:(BOOL)repeats;
方法2:先创建timer,然后再将其加入到当前的runloop中,并指定mode:
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo;
[[NSRunLoop currentRunLoop] addTimer:timer forMode:NSRunLoopCommonModes];
方法3:iOS10.0及之后的系统才可以使用的,解决了内存泄露的问题:
+ (NSTimer *)timerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block
无论采用哪种方式,只有把计时器放在运行循环里,它才能正常触发任务。
target
与selector
参数表示计时器将在哪个对象上调用哪个方法。计时器会保留其目标对象,等到自身“失效”时再释放此对象。调用invalidate
方法可令计时器失效。
由于计时器会保留其目标对象,所以反复执行任务通常会导致应用程序出问题。也就是说, 设置成重复执行模式的那种计时器,很容易引入“保留环”。
这个问题可通过“块”来解决。虽然计时器当前并不直接支持块,但是可以用下面这段代码为其添加此功能:
#import <Foundation/Foundation.h>
@interface NSTimer (EOCBlocksSupport)
+ (NSTimer*)eoc_scheduledTimerWithTimelnterval:(NSTimelnterval)interval block:(void(^)())block repeats:(BOOL)repeats;
@end
@implementation NSTimer (EOCBlocksSupport)
+ (NSTimer*)eoc_scheduledTimerWithTimelnterval:(NSTimelnterval)interval block:(void(^)())block repeats:(BOOL)repeats {
return [self scheduledTimerWithTimelnterval:interval target:self selector:@selector(eoc_blocklnvoke:) userlnfo:[block copy] repeats:repeats];
}
+ (void)eoc_blocklnvoke:(NSTimer *timer {
void (^block)() = timer.userlnfo;
if (block) {
block();
}
}
@end
这个办法为何能解决“保留环”问题呢?这段代码将计时器所应执行的任务封装成“块”,在调用计时器函数时,把它作为userlnfo参数传进去。该参数可用来存放“不透明值”(opaque value) ,只要计时器还有效,就会一直保留着它。传入参数时要通过copy方法将block拷贝到“堆”上(参见第37条),否则等到稍后要执行它的时候,该块可能已经无效了。计时器现在的target是NSTimer类对象,这是个单例,因此计时器是否会保留它,其实都无所谓。此处依然有保留环,然而因为类对象(classobject)无须回收,所以不用担心。
还没有圆满的完成,还有两个问题要解决:还是存在保留环+需要停止计时器:
- (void)startPolling {
weak EOCClass *weakSelf = self;
_pollTimer = [NSTimer eoc_scheduledTimerWithTimeInterval:5.0 block:^{
EOCClass *strongSelf = weakSelf;
[strongSelf p_doPoll];
} repeats:YES];
};
- (void)dealloc {
[_pollTimer invalidate];
}
这段代码采用了一种很有效的写法,它先定义了一个弱引用,令其指向self,然后使块捕获这个引用,而不直接去捕获普通的self变量。也就是说,self不会为计时器所保留。当块开始执行时,立刻生成strong引用,以保证实例在执行期间持续存活。
采用这种写法之后,如果外界指向EOCClass实例的最后一个引用将其释放,则该实例就可为系统所回收了。回收过程中还会调用计时器的invalidate方法,这样的话,计时器就不会再执行任务了。此处使用weak引用还能令程序更加安全,因为有时开发者可能在编写dealloc时忘了调用计时器的invalidate方法,从而导致计时器再次运行,若发生此类情况,则块里的weakSelf会变成nil。
要点:
- NSTimer对象会保留其目标,直到计时器本身失效为止,调用invalidate方法可令计时器失效,另外,一次性的计时器在触发完任务之后也会失效。
- 反复执行任务的计时器(repeating timer),很容易引入保留环,如果这种计时器的目标对象又保留了计时器本身,那肯定会导致保留环。这种环状保留关系,可能是直接发生的,也可能是通过对象图里的其他对象间接发生的。
- 可以扩充NSTimer的功能,用“块”来打破保留环。不过,除非NSTimer将来在公共接口里提供此功能,否则必须创建分类,将相关实现代码加入其中。